Commit d79c0e3a authored by Phil Hughes's avatar Phil Hughes

Merge branch '201889-design-skeleton-loaders-content-for-the-package-stage' into 'master'

Add skeleton loaders to container registry

See merge request gitlab-org/gitlab!25994
parents a0ffe720 077d58fe
<script>
import { mapState, mapActions } from 'vuex';
import { mapState, mapActions, mapGetters } from 'vuex';
import {
GlTable,
GlFormCheckbox,
......@@ -8,10 +8,10 @@ import {
GlTooltipDirective,
GlPagination,
GlModal,
GlLoadingIcon,
GlSprintf,
GlEmptyState,
GlResizeObserverDirective,
GlSkeletonLoader,
} from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { n__, s__ } from '~/locale';
......@@ -42,7 +42,7 @@ export default {
ClipboardButton,
GlPagination,
GlModal,
GlLoadingIcon,
GlSkeletonLoader,
GlSprintf,
GlEmptyState,
},
......@@ -51,6 +51,11 @@ export default {
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [timeagoMixin, Tracking.mixin()],
loader: {
repeat: 10,
width: 1000,
height: 40,
},
data() {
return {
selectedItems: [],
......@@ -61,15 +66,16 @@ export default {
};
},
computed: {
...mapState(['tags', 'tagsPagination', 'isLoading', 'config']),
...mapGetters(['tags']),
...mapState(['tagsPagination', 'isLoading', 'config']),
imageName() {
const { name } = decodeAndParse(this.$route.params.id);
return name;
},
fields() {
return [
{ key: LIST_KEY_CHECKBOX, label: '' },
{ key: LIST_KEY_TAG, label: LIST_LABEL_TAG },
{ key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' },
{ key: LIST_KEY_TAG, label: LIST_LABEL_TAG, class: 'w-25' },
{ key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID },
{ key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE },
{ key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED },
......@@ -209,122 +215,142 @@ export default {
</gl-sprintf>
</h4>
</div>
<gl-loading-icon v-if="isLoading" />
<template v-else-if="tags.length > 0">
<gl-table :items="tags" :fields="fields" :stacked="!isDesktop">
<template v-if="isDesktop" #head(checkbox)>
<gl-form-checkbox
ref="mainCheckbox"
:checked="selectAllChecked"
@change="onSelectAllChange"
/>
</template>
<template #head(actions)>
<gl-button
ref="bulkDeleteButton"
v-gl-tooltip
:disabled="!selectedItems || selectedItems.length === 0"
class="float-right"
variant="danger"
:title="s__('ContainerRegistry|Remove selected tags')"
:aria-label="s__('ContainerRegistry|Remove selected tags')"
@click="deleteMultipleItems()"
>
<gl-icon name="remove" />
</gl-button>
</template>
<template #cell(checkbox)="{index}">
<gl-form-checkbox
ref="rowCheckbox"
class="js-row-checkbox"
:checked="selectedItems.includes(index)"
@change="updateSelectedItems(index)"
/>
</template>
<template #cell(name)="{item}">
<span ref="rowName">
{{ item.name }}
</span>
<clipboard-button
v-if="item.location"
ref="rowClipboardButton"
:title="item.location"
:text="item.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</template>
<template #cell(short_revision)="{value}">
<span ref="rowShortRevision">
{{ value }}
</span>
</template>
<template #cell(total_size)="{item}">
<span ref="rowSize">
{{ formatSize(item.total_size) }}
<template v-if="item.total_size && item.layers">
&middot;
</template>
{{ layers(item.layers) }}
</span>
</template>
<template #cell(created_at)="{value}">
<span ref="rowTime">
{{ timeFormatted(value) }}
</span>
</template>
<template #cell(actions)="{index, item}">
<gl-button
ref="singleDeleteButton"
:title="s__('ContainerRegistry|Remove tag')"
:aria-label="s__('ContainerRegistry|Remove tag')"
:disabled="!item.destroy_path"
variant="danger"
:class="['js-delete-registry float-right btn-inverted btn-border-color btn-icon']"
@click="deleteSingleItem(index)"
<gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty>
<template v-if="isDesktop" #head(checkbox)>
<gl-form-checkbox
ref="mainCheckbox"
:checked="selectAllChecked"
@change="onSelectAllChange"
/>
</template>
<template #head(actions)>
<gl-button
ref="bulkDeleteButton"
v-gl-tooltip
:disabled="!selectedItems || selectedItems.length === 0"
class="float-right"
variant="danger"
:title="s__('ContainerRegistry|Remove selected tags')"
:aria-label="s__('ContainerRegistry|Remove selected tags')"
@click="deleteMultipleItems()"
>
<gl-icon name="remove" />
</gl-button>
</template>
<template #cell(checkbox)="{index}">
<gl-form-checkbox
ref="rowCheckbox"
class="js-row-checkbox"
:checked="selectedItems.includes(index)"
@change="updateSelectedItems(index)"
/>
</template>
<template #cell(name)="{item}">
<span ref="rowName">
{{ item.name }}
</span>
<clipboard-button
v-if="item.location"
ref="rowClipboardButton"
:title="item.location"
:text="item.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</template>
<template #cell(short_revision)="{value}">
<span ref="rowShortRevision">
{{ value }}
</span>
</template>
<template #cell(total_size)="{item}">
<span ref="rowSize">
{{ formatSize(item.total_size) }}
<template v-if="item.total_size && item.layers">
&middot;
</template>
{{ layers(item.layers) }}
</span>
</template>
<template #cell(created_at)="{value}">
<span ref="rowTime">
{{ timeFormatted(value) }}
</span>
</template>
<template #cell(actions)="{index, item}">
<gl-button
ref="singleDeleteButton"
:title="s__('ContainerRegistry|Remove tag')"
:aria-label="s__('ContainerRegistry|Remove tag')"
:disabled="!item.destroy_path"
variant="danger"
class="js-delete-registry float-right btn-inverted btn-border-color btn-icon"
@click="deleteSingleItem(index)"
>
<gl-icon name="remove" />
</gl-button>
</template>
<template #empty>
<template v-if="isLoading">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
:width="$options.loader.width"
:height="$options.loader.height"
preserve-aspect-ratio="xMinYMax meet"
>
<gl-icon name="remove" />
</gl-button>
<rect width="15" x="0" y="12.5" height="15" rx="4" />
<rect width="250" x="25" y="10" height="20" rx="4" />
<circle cx="290" cy="20" r="10" />
<rect width="100" x="315" y="10" height="20" rx="4" />
<rect width="100" x="500" y="10" height="20" rx="4" />
<rect width="100" x="630" y="10" height="20" rx="4" />
<rect x="960" y="0" width="40" height="40" rx="4" />
</gl-skeleton-loader>
</template>
</gl-table>
<gl-pagination
ref="pagination"
v-model="currentPage"
:per-page="tagsPagination.perPage"
:total-items="tagsPagination.total"
align="center"
class="w-100"
/>
<gl-modal
ref="deleteModal"
modal-id="delete-tag-modal"
ok-variant="danger"
@ok="onDeletionConfirmed"
@cancel="track('cancel_delete')"
>
<template #modal-title>{{ modalAction }}</template>
<template #modal-ok>{{ modalAction }}</template>
<p v-if="modalDescription">
<gl-sprintf :message="modalDescription.message">
<template #item>
<b>{{ modalDescription.item }}</b>
</template>
</gl-sprintf>
</p>
</gl-modal>
</template>
<gl-empty-state
v-else
:title="s__('ContainerRegistry|This image has no active tags')"
:svg-path="config.noContainersImage"
:description="
s__(
`ContainerRegistry|The last tag related to this image was recently removed.
<gl-empty-state
v-else
:title="s__('ContainerRegistry|This image has no active tags')"
:svg-path="config.noContainersImage"
:description="
s__(
`ContainerRegistry|The last tag related to this image was recently removed.
This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
If you have any questions, contact your administrator.`,
)
"
class="mx-auto my-0"
)
"
class="mx-auto my-0"
/>
</template>
</gl-table>
<gl-pagination
ref="pagination"
v-model="currentPage"
:per-page="tagsPagination.perPage"
:total-items="tagsPagination.total"
align="center"
class="w-100"
/>
<gl-modal
ref="deleteModal"
modal-id="delete-tag-modal"
ok-variant="danger"
@ok="onDeletionConfirmed"
@cancel="track('cancel_delete')"
>
<template #modal-title>{{ modalAction }}</template>
<template #modal-ok>{{ modalAction }}</template>
<p v-if="modalDescription">
<gl-sprintf :message="modalDescription.message">
<template #item>
<b>{{ modalDescription.item }}</b>
</template>
</gl-sprintf>
</p>
</gl-modal>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import {
GlLoadingIcon,
GlEmptyState,
GlPagination,
GlTooltipDirective,
......@@ -10,6 +9,7 @@ import {
GlModal,
GlSprintf,
GlLink,
GlSkeletonLoader,
} from '@gitlab/ui';
import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
......@@ -20,7 +20,6 @@ export default {
name: 'RegistryListApp',
components: {
GlEmptyState,
GlLoadingIcon,
GlPagination,
ProjectEmptyState,
GroupEmptyState,
......@@ -30,11 +29,17 @@ export default {
GlModal,
GlSprintf,
GlLink,
GlSkeletonLoader,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [Tracking.mixin()],
loader: {
repeat: 10,
width: 1000,
height: 40,
},
data() {
return {
itemToDelete: {},
......@@ -104,74 +109,81 @@ export default {
</gl-empty-state>
<template v-else>
<gl-loading-icon v-if="isLoading" size="md" class="prepend-top-16" />
<template v-else>
<div v-if="images.length" ref="imagesList">
<h4>{{ s__('ContainerRegistry|Container Registry') }}</h4>
<p>
<gl-sprintf
:message="
s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
<div>
<h4>{{ s__('ContainerRegistry|Container Registry') }}</h4>
<p>
<gl-sprintf
:message="
s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
project can have its own space to store its Docker images.
%{docLinkStart}More Information%{docLinkEnd}`)
"
>
<template #docLink="{content}">
<gl-link :href="config.helpPagePath" target="_blank">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</p>
"
>
<template #docLink="{content}">
<gl-link :href="config.helpPagePath" target="_blank">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</p>
</div>
<div class="d-flex flex-column">
<div v-if="isLoading" class="mt-2">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
:width="$options.loader.width"
:height="$options.loader.height"
preserve-aspect-ratio="xMinYMax meet"
>
<rect width="500" x="10" y="10" height="20" rx="4" />
<circle cx="525" cy="20" r="10" />
<rect x="960" y="0" width="40" height="40" rx="4" />
</gl-skeleton-loader>
</div>
<template v-else>
<div v-if="images.length" ref="imagesList" class="d-flex flex-column">
<div
v-for="(listItem, index) in images"
:key="index"
ref="rowItem"
:class="{ 'border-top': index === 0 }"
class="d-flex justify-content-between align-items-center py-2 border-bottom"
>
<div>
<router-link
ref="detailsLink"
:to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
>
{{ listItem.path }}
</router-link>
<clipboard-button
v-if="listItem.location"
ref="clipboardButton"
:text="listItem.location"
:title="listItem.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</div>
<div
v-for="(listItem, index) in images"
:key="index"
ref="rowItem"
:class="[
'd-flex justify-content-between align-items-center py-2 border-bottom',
{ 'border-top': index === 0 },
]"
v-gl-tooltip="{ disabled: listItem.destroy_path }"
class="d-none d-sm-block"
:title="
s__('ContainerRegistry|Missing or insufficient permission, delete button disabled')
"
>
<div>
<router-link
ref="detailsLink"
:to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
>
{{ listItem.path }}
</router-link>
<clipboard-button
v-if="listItem.location"
ref="clipboardButton"
:text="listItem.location"
:title="listItem.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</div>
<div
v-gl-tooltip="{ disabled: listItem.destroy_path }"
class="d-none d-sm-block"
:title="
s__(
'ContainerRegistry|Missing or insufficient permission, delete button disabled',
)
"
<gl-button
ref="deleteImageButton"
v-gl-tooltip
:disabled="!listItem.destroy_path"
:title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')"
class="btn-inverted"
variant="danger"
@click="deleteImage(listItem)"
>
<gl-button
ref="deleteImageButton"
v-gl-tooltip
:disabled="!listItem.destroy_path"
:title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')"
class="btn-inverted"
variant="danger"
@click="deleteImage(listItem)"
>
<gl-icon name="remove" />
</gl-button>
</div>
<gl-icon name="remove" />
</gl-button>
</div>
</div>
<gl-pagination
......@@ -182,6 +194,7 @@ export default {
class="w-100 mt-2"
/>
</div>
<template v-else>
<project-empty-state v-if="!config.isGroupPage" />
<group-empty-state v-else />
......
......@@ -68,31 +68,28 @@ export const requestDeleteTag = ({ commit, dispatch, state }, { tag, params }) =
.delete(tag.destroy_path)
.then(() => {
createFlash(DELETE_TAG_SUCCESS_MESSAGE, 'success');
dispatch('requestTagsList', { pagination: state.tagsPagination, params });
return dispatch('requestTagsList', { pagination: state.tagsPagination, params });
})
.catch(() => {
createFlash(DELETE_TAG_ERROR_MESSAGE);
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) => {
commit(types.SET_MAIN_LOADING, true);
const { id } = decodeAndParse(params);
const url = `/${state.config.projectPath}/registry/repository/${id}/tags/bulk_destroy`;
const { tags_path } = decodeAndParse(params);
const url = tags_path.replace('?format=json', '/bulk_destroy');
return axios
.delete(url, { params: { ids } })
.then(() => {
createFlash(DELETE_TAGS_SUCCESS_MESSAGE, 'success');
dispatch('requestTagsList', { pagination: state.tagsPagination, params });
return dispatch('requestTagsList', { pagination: state.tagsPagination, params });
})
.catch(() => {
createFlash(DELETE_TAGS_ERROR_MESSAGE);
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
......
// eslint-disable-next-line import/prefer-default-export
export const tags = state => {
// to show the loader inside the table we need to pass an empty array to gl-table whenever the table is loading
// this is to take in account isLoading = true and state.tags =[1,2,3] during pagination and delete
return state.isLoading ? [] : state.tags;
};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
......@@ -9,6 +10,7 @@ Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
state,
getters,
actions,
mutations,
});
......
......@@ -54,7 +54,7 @@
.mh-50vh { max-height: 50vh; }
.font-size-inherit { font-size: inherit; }
.gl-w-16 { width: px-to-rem($grid-size * 2); }
.gl-w-64 { width: px-to-rem($grid-size * 8); }
.gl-h-32 { height: px-to-rem($grid-size * 4); }
.gl-h-64 { height: px-to-rem($grid-size * 8); }
......
......@@ -6,7 +6,6 @@
.col-12
- if Feature.enabled?(:vue_container_registry_explorer, @project.group)
#js-container-registry{ data: { endpoint: project_container_registry_index_path(@project),
project_path: @project.full_path,
"help_page_path" => help_page_path('user/packages/container_registry/index'),
"two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'),
"personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'),
......
import { mount } from '@vue/test-utils';
import { GlTable, GlPagination, GlLoadingIcon } from '@gitlab/ui';
import { GlTable, GlPagination, GlSkeletonLoader } from '@gitlab/ui';
import Tracking from '~/tracking';
import stubChildren from 'helpers/stub_children';
import component from '~/registry/explorer/pages/details.vue';
......@@ -14,8 +14,7 @@ describe('Details Page', () => {
const findDeleteModal = () => wrapper.find(GlModal);
const findPagination = () => wrapper.find(GlPagination);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findTagsTable = () => wrapper.find(GlTable);
const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
const findMainCheckbox = () => wrapper.find({ ref: 'mainCheckbox' });
const findFirstRowItem = ref => wrapper.find({ ref });
const findBulkDeleteButton = () => wrapper.find({ ref: 'bulkDeleteButton' });
......@@ -33,7 +32,7 @@ describe('Details Page', () => {
...stubChildren(component),
GlModal,
GlSprintf: false,
GlTable: false,
GlTable,
},
mocks: {
$route: {
......@@ -53,18 +52,19 @@ describe('Details Page', () => {
});
describe('when isLoading is true', () => {
beforeAll(() => store.commit(SET_MAIN_LOADING, true));
beforeEach(() => {
store.dispatch('receiveTagsListSuccess', { ...tagsListResponse, data: [] });
store.commit(SET_MAIN_LOADING, true);
});
afterAll(() => store.commit(SET_MAIN_LOADING, false));
it('has a loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
it('has a skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
it('does not have a main content', () => {
expect(findTagsTable().exists()).toBe(false);
expect(findPagination().exists()).toBe(false);
expect(findDeleteModal().exists()).toBe(false);
it('does not have list items', () => {
expect(findFirstRowItem('rowCheckbox').exists()).toBe(false);
});
});
......
import VueRouter from 'vue-router';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { GlPagination, GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
import Tracking from '~/tracking';
import component from '~/registry/explorer/pages/list.vue';
import store from '~/registry/explorer/stores/';
......@@ -17,7 +17,7 @@ describe('List Page', () => {
const findDeleteBtn = () => wrapper.find({ ref: 'deleteImageButton' });
const findDeleteModal = () => wrapper.find(GlModal);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
const findImagesList = () => wrapper.find({ ref: 'imagesList' });
const findRowItems = () => wrapper.findAll({ ref: 'rowItem' });
const findEmptyState = () => wrapper.find(GlEmptyState);
......@@ -71,7 +71,7 @@ describe('List Page', () => {
});
it('should not show the loading or default state', () => {
expect(findLoadingIcon().exists()).toBe(false);
expect(findSkeletonLoader().exists()).toBe(false);
expect(findImagesList().exists()).toBe(false);
});
});
......@@ -81,8 +81,8 @@ describe('List Page', () => {
afterAll(() => store.commit(SET_MAIN_LOADING, false));
it('shows the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
it('shows the skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
it('imagesList is not visible', () => {
......
......@@ -180,10 +180,7 @@ describe('Actions RegistryExplorer Store', () => {
{
tagsPagination: {},
},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[{ type: types.SET_MAIN_LOADING, payload: true }],
[
{
type: 'requestTagsList',
......@@ -220,13 +217,11 @@ describe('Actions RegistryExplorer Store', () => {
});
describe('request delete multiple tags', () => {
const id = 1;
const params = window.btoa(JSON.stringify({ id }));
const projectPath = 'project-path';
const url = `${projectPath}/registry/repository/${id}/tags/bulk_destroy`;
const url = `project-path/registry/repository/foo/tags`;
const params = window.btoa(JSON.stringify({ tags_path: `${url}?format=json` }));
it('successfully performs the delete request', done => {
mock.onDelete(url).replyOnce(200);
mock.onDelete(`${url}/bulk_destroy`).replyOnce(200);
testAction(
actions.requestDeleteTags,
......@@ -235,15 +230,9 @@ describe('Actions RegistryExplorer Store', () => {
params,
},
{
config: {
projectPath,
},
tagsPagination: {},
},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[{ type: types.SET_MAIN_LOADING, payload: true }],
[
{
type: 'requestTagsList',
......@@ -267,9 +256,6 @@ describe('Actions RegistryExplorer Store', () => {
params,
},
{
config: {
projectPath,
},
tagsPagination: {},
},
[
......
import * as getters from '~/registry/explorer/stores/getters';
describe('Getters RegistryExplorer store', () => {
let state;
const tags = ['foo', 'bar'];
describe('tags', () => {
describe('when isLoading is false', () => {
beforeEach(() => {
state = {
tags,
isLoading: false,
};
});
it('returns tags', () => {
expect(getters.tags(state)).toEqual(state.tags);
});
});
describe('when isLoading is true', () => {
beforeEach(() => {
state = {
tags,
isLoading: true,
};
});
it('returns empty array', () => {
expect(getters.tags(state)).toEqual([]);
});
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment