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> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { import {
GlLoadingIcon,
GlEmptyState, GlEmptyState,
GlPagination, GlPagination,
GlTooltipDirective, GlTooltipDirective,
...@@ -10,6 +9,7 @@ import { ...@@ -10,6 +9,7 @@ import {
GlModal, GlModal,
GlSprintf, GlSprintf,
GlLink, GlLink,
GlSkeletonLoader,
} from '@gitlab/ui'; } from '@gitlab/ui';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
...@@ -20,7 +20,6 @@ export default { ...@@ -20,7 +20,6 @@ export default {
name: 'RegistryListApp', name: 'RegistryListApp',
components: { components: {
GlEmptyState, GlEmptyState,
GlLoadingIcon,
GlPagination, GlPagination,
ProjectEmptyState, ProjectEmptyState,
GroupEmptyState, GroupEmptyState,
...@@ -30,11 +29,17 @@ export default { ...@@ -30,11 +29,17 @@ export default {
GlModal, GlModal,
GlSprintf, GlSprintf,
GlLink, GlLink,
GlSkeletonLoader,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
mixins: [Tracking.mixin()], mixins: [Tracking.mixin()],
loader: {
repeat: 10,
width: 1000,
height: 40,
},
data() { data() {
return { return {
itemToDelete: {}, itemToDelete: {},
...@@ -104,74 +109,81 @@ export default { ...@@ -104,74 +109,81 @@ export default {
</gl-empty-state> </gl-empty-state>
<template v-else> <template v-else>
<gl-loading-icon v-if="isLoading" size="md" class="prepend-top-16" /> <div>
<h4>{{ s__('ContainerRegistry|Container Registry') }}</h4>
<template v-else> <p>
<div v-if="images.length" ref="imagesList"> <gl-sprintf
<h4>{{ s__('ContainerRegistry|Container Registry') }}</h4> :message="
<p> s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
<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. project can have its own space to store its Docker images.
%{docLinkStart}More Information%{docLinkEnd}`) %{docLinkStart}More Information%{docLinkEnd}`)
" "
> >
<template #docLink="{content}"> <template #docLink="{content}">
<gl-link :href="config.helpPagePath" target="_blank"> <gl-link :href="config.helpPagePath" target="_blank">
{{ content }} {{ content }}
</gl-link> </gl-link>
</template> </template>
</gl-sprintf> </gl-sprintf>
</p> </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 <div
v-for="(listItem, index) in images" v-gl-tooltip="{ disabled: listItem.destroy_path }"
:key="index" class="d-none d-sm-block"
ref="rowItem" :title="
:class="[ s__('ContainerRegistry|Missing or insufficient permission, delete button disabled')
'd-flex justify-content-between align-items-center py-2 border-bottom', "
{ 'border-top': index === 0 },
]"
> >
<div> <gl-button
<router-link ref="deleteImageButton"
ref="detailsLink" v-gl-tooltip
:to="{ name: 'details', params: { id: encodeListItem(listItem) } }" :disabled="!listItem.destroy_path"
> :title="s__('ContainerRegistry|Remove repository')"
{{ listItem.path }} :aria-label="s__('ContainerRegistry|Remove repository')"
</router-link> class="btn-inverted"
<clipboard-button variant="danger"
v-if="listItem.location" @click="deleteImage(listItem)"
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 <gl-icon name="remove" />
ref="deleteImageButton" </gl-button>
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>
</div> </div>
</div> </div>
<gl-pagination <gl-pagination
...@@ -182,6 +194,7 @@ export default { ...@@ -182,6 +194,7 @@ export default {
class="w-100 mt-2" class="w-100 mt-2"
/> />
</div> </div>
<template v-else> <template v-else>
<project-empty-state v-if="!config.isGroupPage" /> <project-empty-state v-if="!config.isGroupPage" />
<group-empty-state v-else /> <group-empty-state v-else />
......
...@@ -68,31 +68,28 @@ export const requestDeleteTag = ({ commit, dispatch, state }, { tag, params }) = ...@@ -68,31 +68,28 @@ export const requestDeleteTag = ({ commit, dispatch, state }, { tag, params }) =
.delete(tag.destroy_path) .delete(tag.destroy_path)
.then(() => { .then(() => {
createFlash(DELETE_TAG_SUCCESS_MESSAGE, 'success'); createFlash(DELETE_TAG_SUCCESS_MESSAGE, 'success');
dispatch('requestTagsList', { pagination: state.tagsPagination, params }); return dispatch('requestTagsList', { pagination: state.tagsPagination, params });
}) })
.catch(() => { .catch(() => {
createFlash(DELETE_TAG_ERROR_MESSAGE); createFlash(DELETE_TAG_ERROR_MESSAGE);
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false); commit(types.SET_MAIN_LOADING, false);
}); });
}; };
export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) => { export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) => {
commit(types.SET_MAIN_LOADING, true); commit(types.SET_MAIN_LOADING, true);
const { id } = decodeAndParse(params); const { tags_path } = decodeAndParse(params);
const url = `/${state.config.projectPath}/registry/repository/${id}/tags/bulk_destroy`;
const url = tags_path.replace('?format=json', '/bulk_destroy');
return axios return axios
.delete(url, { params: { ids } }) .delete(url, { params: { ids } })
.then(() => { .then(() => {
createFlash(DELETE_TAGS_SUCCESS_MESSAGE, 'success'); createFlash(DELETE_TAGS_SUCCESS_MESSAGE, 'success');
dispatch('requestTagsList', { pagination: state.tagsPagination, params }); return dispatch('requestTagsList', { pagination: state.tagsPagination, params });
}) })
.catch(() => { .catch(() => {
createFlash(DELETE_TAGS_ERROR_MESSAGE); createFlash(DELETE_TAGS_ERROR_MESSAGE);
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false); 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 Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import * as actions from './actions'; import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations'; import mutations from './mutations';
import state from './state'; import state from './state';
...@@ -9,6 +10,7 @@ Vue.use(Vuex); ...@@ -9,6 +10,7 @@ Vue.use(Vuex);
export const createStore = () => export const createStore = () =>
new Vuex.Store({ new Vuex.Store({
state, state,
getters,
actions, actions,
mutations, mutations,
}); });
......
...@@ -54,7 +54,7 @@ ...@@ -54,7 +54,7 @@
.mh-50vh { max-height: 50vh; } .mh-50vh { max-height: 50vh; }
.font-size-inherit { font-size: inherit; } .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-w-64 { width: px-to-rem($grid-size * 8); }
.gl-h-32 { height: px-to-rem($grid-size * 4); } .gl-h-32 { height: px-to-rem($grid-size * 4); }
.gl-h-64 { height: px-to-rem($grid-size * 8); } .gl-h-64 { height: px-to-rem($grid-size * 8); }
......
...@@ -6,7 +6,6 @@ ...@@ -6,7 +6,6 @@
.col-12 .col-12
- if Feature.enabled?(:vue_container_registry_explorer, @project.group) - if Feature.enabled?(:vue_container_registry_explorer, @project.group)
#js-container-registry{ data: { endpoint: project_container_registry_index_path(@project), #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'), "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'), "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'), "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'),
......
import { mount } from '@vue/test-utils'; 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 Tracking from '~/tracking';
import stubChildren from 'helpers/stub_children'; import stubChildren from 'helpers/stub_children';
import component from '~/registry/explorer/pages/details.vue'; import component from '~/registry/explorer/pages/details.vue';
...@@ -14,8 +14,7 @@ describe('Details Page', () => { ...@@ -14,8 +14,7 @@ describe('Details Page', () => {
const findDeleteModal = () => wrapper.find(GlModal); const findDeleteModal = () => wrapper.find(GlModal);
const findPagination = () => wrapper.find(GlPagination); const findPagination = () => wrapper.find(GlPagination);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
const findTagsTable = () => wrapper.find(GlTable);
const findMainCheckbox = () => wrapper.find({ ref: 'mainCheckbox' }); const findMainCheckbox = () => wrapper.find({ ref: 'mainCheckbox' });
const findFirstRowItem = ref => wrapper.find({ ref }); const findFirstRowItem = ref => wrapper.find({ ref });
const findBulkDeleteButton = () => wrapper.find({ ref: 'bulkDeleteButton' }); const findBulkDeleteButton = () => wrapper.find({ ref: 'bulkDeleteButton' });
...@@ -33,7 +32,7 @@ describe('Details Page', () => { ...@@ -33,7 +32,7 @@ describe('Details Page', () => {
...stubChildren(component), ...stubChildren(component),
GlModal, GlModal,
GlSprintf: false, GlSprintf: false,
GlTable: false, GlTable,
}, },
mocks: { mocks: {
$route: { $route: {
...@@ -53,18 +52,19 @@ describe('Details Page', () => { ...@@ -53,18 +52,19 @@ describe('Details Page', () => {
}); });
describe('when isLoading is true', () => { 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)); afterAll(() => store.commit(SET_MAIN_LOADING, false));
it('has a loading icon', () => { it('has a skeleton loader', () => {
expect(findLoadingIcon().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(true);
}); });
it('does not have a main content', () => { it('does not have list items', () => {
expect(findTagsTable().exists()).toBe(false); expect(findFirstRowItem('rowCheckbox').exists()).toBe(false);
expect(findPagination().exists()).toBe(false);
expect(findDeleteModal().exists()).toBe(false);
}); });
}); });
......
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import { shallowMount, createLocalVue } from '@vue/test-utils'; 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 Tracking from '~/tracking';
import component from '~/registry/explorer/pages/list.vue'; import component from '~/registry/explorer/pages/list.vue';
import store from '~/registry/explorer/stores/'; import store from '~/registry/explorer/stores/';
...@@ -17,7 +17,7 @@ describe('List Page', () => { ...@@ -17,7 +17,7 @@ describe('List Page', () => {
const findDeleteBtn = () => wrapper.find({ ref: 'deleteImageButton' }); const findDeleteBtn = () => wrapper.find({ ref: 'deleteImageButton' });
const findDeleteModal = () => wrapper.find(GlModal); const findDeleteModal = () => wrapper.find(GlModal);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
const findImagesList = () => wrapper.find({ ref: 'imagesList' }); const findImagesList = () => wrapper.find({ ref: 'imagesList' });
const findRowItems = () => wrapper.findAll({ ref: 'rowItem' }); const findRowItems = () => wrapper.findAll({ ref: 'rowItem' });
const findEmptyState = () => wrapper.find(GlEmptyState); const findEmptyState = () => wrapper.find(GlEmptyState);
...@@ -71,7 +71,7 @@ describe('List Page', () => { ...@@ -71,7 +71,7 @@ describe('List Page', () => {
}); });
it('should not show the loading or default state', () => { it('should not show the loading or default state', () => {
expect(findLoadingIcon().exists()).toBe(false); expect(findSkeletonLoader().exists()).toBe(false);
expect(findImagesList().exists()).toBe(false); expect(findImagesList().exists()).toBe(false);
}); });
}); });
...@@ -81,8 +81,8 @@ describe('List Page', () => { ...@@ -81,8 +81,8 @@ describe('List Page', () => {
afterAll(() => store.commit(SET_MAIN_LOADING, false)); afterAll(() => store.commit(SET_MAIN_LOADING, false));
it('shows the loading icon', () => { it('shows the skeleton loader', () => {
expect(findLoadingIcon().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(true);
}); });
it('imagesList is not visible', () => { it('imagesList is not visible', () => {
......
...@@ -180,10 +180,7 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -180,10 +180,7 @@ describe('Actions RegistryExplorer Store', () => {
{ {
tagsPagination: {}, tagsPagination: {},
}, },
[ [{ type: types.SET_MAIN_LOADING, payload: true }],
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[ [
{ {
type: 'requestTagsList', type: 'requestTagsList',
...@@ -220,13 +217,11 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -220,13 +217,11 @@ describe('Actions RegistryExplorer Store', () => {
}); });
describe('request delete multiple tags', () => { describe('request delete multiple tags', () => {
const id = 1; const url = `project-path/registry/repository/foo/tags`;
const params = window.btoa(JSON.stringify({ id })); const params = window.btoa(JSON.stringify({ tags_path: `${url}?format=json` }));
const projectPath = 'project-path';
const url = `${projectPath}/registry/repository/${id}/tags/bulk_destroy`;
it('successfully performs the delete request', done => { it('successfully performs the delete request', done => {
mock.onDelete(url).replyOnce(200); mock.onDelete(`${url}/bulk_destroy`).replyOnce(200);
testAction( testAction(
actions.requestDeleteTags, actions.requestDeleteTags,
...@@ -235,15 +230,9 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -235,15 +230,9 @@ describe('Actions RegistryExplorer Store', () => {
params, params,
}, },
{ {
config: {
projectPath,
},
tagsPagination: {}, tagsPagination: {},
}, },
[ [{ type: types.SET_MAIN_LOADING, payload: true }],
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[ [
{ {
type: 'requestTagsList', type: 'requestTagsList',
...@@ -267,9 +256,6 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -267,9 +256,6 @@ describe('Actions RegistryExplorer Store', () => {
params, params,
}, },
{ {
config: {
projectPath,
},
tagsPagination: {}, 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