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,9 +215,8 @@ 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">
<gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty>
<template v-if="isDesktop" #head(checkbox)>
<gl-form-checkbox
ref="mainCheckbox"
......@@ -280,13 +285,47 @@ export default {
: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']"
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"
>
<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-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"
/>
</template>
</gl-table>
<gl-pagination
ref="pagination"
v-model="currentPage"
......@@ -295,6 +334,7 @@ export default {
align="center"
class="w-100"
/>
<gl-modal
ref="deleteModal"
modal-id="delete-tag-modal"
......@@ -312,19 +352,5 @@ export default {
</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.
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"
/>
</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,10 +109,7 @@ 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">
<div>
<h4>{{ s__('ContainerRegistry|Container Registry') }}</h4>
<p>
<gl-sprintf
......@@ -124,16 +126,29 @@ export default {
</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="[
'd-flex justify-content-between align-items-center py-2 border-bottom',
{ 'border-top': index === 0 },
]"
:class="{ 'border-top': index === 0 }"
class="d-flex justify-content-between align-items-center py-2 border-bottom"
>
<div>
<router-link
......@@ -154,9 +169,7 @@ export default {
v-gl-tooltip="{ disabled: listItem.destroy_path }"
class="d-none d-sm-block"
:title="
s__(
'ContainerRegistry|Missing or insufficient permission, delete button disabled',
)
s__('ContainerRegistry|Missing or insufficient permission, delete button disabled')
"
>
<gl-button
......@@ -173,7 +186,6 @@ export default {
</gl-button>
</div>
</div>
</div>
<gl-pagination
v-model="currentPage"
:per-page="pagination.perPage"
......@@ -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