Commit 53e9a1b1 authored by Nick Kipling's avatar Nick Kipling

Added skeleton loaders to package list

Created new skeleton loader component
Added to packages list component
Removed loader from packages list app
Updated tests
parent 434e3cec
...@@ -34,6 +34,7 @@ import { ...@@ -34,6 +34,7 @@ import {
import { TrackingActions } from '../../shared/constants'; import { TrackingActions } from '../../shared/constants';
import { packageTypeToTrackCategory } from '../../shared/utils'; import { packageTypeToTrackCategory } from '../../shared/utils';
import PackageTags from '../../shared/components/package_tags.vue'; import PackageTags from '../../shared/components/package_tags.vue';
import PackagesListLoader from './packages_list_loader.vue';
export default { export default {
components: { components: {
...@@ -47,6 +48,7 @@ export default { ...@@ -47,6 +48,7 @@ export default {
GlModal, GlModal,
GlIcon, GlIcon,
PackageTags, PackageTags,
PackagesListLoader,
}, },
directives: { GlTooltip: GlTooltipDirective }, directives: { GlTooltip: GlTooltipDirective },
mixins: [Tracking.mixin()], mixins: [Tracking.mixin()],
...@@ -63,6 +65,7 @@ export default { ...@@ -63,6 +65,7 @@ export default {
isGroupPage: state => state.config.isGroupPage, isGroupPage: state => state.config.isGroupPage,
orderBy: state => state.sorting.orderBy, orderBy: state => state.sorting.orderBy,
sort: state => state.sorting.sort, sort: state => state.sorting.sort,
isLoading: 'isLoading',
}), }),
...mapGetters({ list: 'getList' }), ...mapGetters({ list: 'getList' }),
currentPage: { currentPage: {
...@@ -99,7 +102,7 @@ export default { ...@@ -99,7 +102,7 @@ export default {
key: LIST_KEY_PROJECT, key: LIST_KEY_PROJECT,
label: LIST_LABEL_PROJECT, label: LIST_LABEL_PROJECT,
orderBy: LIST_KEY_PROJECT, orderBy: LIST_KEY_PROJECT,
class: ['text-center'], class: ['text-left'],
}, },
{ {
key: LIST_KEY_VERSION, key: LIST_KEY_VERSION,
...@@ -183,11 +186,12 @@ export default { ...@@ -183,11 +186,12 @@ export default {
</script> </script>
<template> <template>
<div class="d-flex flex-column align-items-end"> <div class="d-flex flex-column">
<slot v-if="isListEmpty" name="empty-state"></slot> <slot v-if="isListEmpty && !isLoading" name="empty-state"></slot>
<template v-else> <template v-else>
<gl-sorting <gl-sorting
class="my-3" class="my-3 align-self-end"
:text="sortText" :text="sortText"
:is-ascending="isSortAscending" :is-ascending="isSortAscending"
@sortDirectionChange="onDirectionChange" @sortDirectionChange="onDirectionChange"
...@@ -202,7 +206,18 @@ export default { ...@@ -202,7 +206,18 @@ export default {
</gl-sorting-item> </gl-sorting-item>
</gl-sorting> </gl-sorting>
<gl-table :items="list" :fields="headerFields" :no-local-sorting="true" stacked="md"> <gl-table
:items="list"
:fields="headerFields"
:no-local-sorting="true"
:busy="isLoading"
stacked="md"
class="package-list-table"
>
<template #table-busy>
<packages-list-loader :is-group="isGroupPage" />
</template>
<template #cell(name)="{value, item}"> <template #cell(name)="{value, item}">
<div <div
class="flex-truncate-parent d-flex align-items-center justify-content-end justify-content-md-start" class="flex-truncate-parent d-flex align-items-center justify-content-end justify-content-md-start"
......
<script> <script>
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState } from 'vuex';
import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { GlEmptyState } from '@gitlab/ui';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import PackageList from './packages_list.vue'; import PackageList from './packages_list.vue';
export default { export default {
components: { components: {
GlEmptyState, GlEmptyState,
GlLoadingIcon,
PackageList, PackageList,
}, },
computed: { computed: {
...mapState({ ...mapState({
isLoading: 'isLoading',
resourceId: state => state.config.resourceId, resourceId: state => state.config.resourceId,
emptyListIllustration: state => state.config.emptyListIllustration, emptyListIllustration: state => state.config.emptyListIllustration,
emptyListHelpUrl: state => state.config.emptyListHelpUrl, emptyListHelpUrl: state => state.config.emptyListHelpUrl,
totalItems: state => state.pagination.total,
}), }),
emptyListText() { emptyListText() {
return sprintf( return sprintf(
...@@ -46,9 +45,7 @@ export default { ...@@ -46,9 +45,7 @@ export default {
</script> </script>
<template> <template>
<gl-loading-icon v-if="isLoading" class="mt-2" />
<package-list <package-list
v-else
@page:changed="onPageChanged" @page:changed="onPageChanged"
@package:delete="onPackageDeleteRequest" @package:delete="onPackageDeleteRequest"
@sort:changed="requestPackagesList" @sort:changed="requestPackagesList"
......
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
export default {
components: {
GlSkeletonLoader,
},
props: {
isGroup: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
desktopShapes() {
return this.isGroup ? this.$options.shapes.groups : this.$options.shapes.projects;
},
desktopHeight() {
return this.isGroup ? 38 : 54;
},
mobileHeight() {
return this.isGroup ? 160 : 170;
},
},
shapes: {
groups: [
{ type: 'rect', width: '100', height: '10', x: '0', y: '15' },
{ type: 'rect', width: '100', height: '10', x: '195', y: '15' },
{ type: 'rect', width: '60', height: '10', x: '475', y: '15' },
{ type: 'rect', width: '60', height: '10', x: '675', y: '15' },
{ type: 'rect', width: '100', height: '10', x: '900', y: '15' },
],
projects: [
{ type: 'rect', width: '220', height: '10', x: '0', y: '20' },
{ type: 'rect', width: '60', height: '10', x: '305', y: '20' },
{ type: 'rect', width: '60', height: '10', x: '535', y: '20' },
{ type: 'rect', width: '100', height: '10', x: '760', y: '20' },
{ type: 'rect', width: '30', height: '30', x: '970', y: '10', ref: 'button-loader' },
],
},
rowsToRender: {
mobile: 5,
desktop: 20,
},
};
</script>
<template>
<div>
<div class="d-xs-flex flex-column d-md-none">
<gl-skeleton-loader
v-for="index in $options.rowsToRender.mobile"
:key="index"
:width="500"
:height="mobileHeight"
preserve-aspect-ratio="xMinYMax meet"
>
<rect width="500" height="10" x="0" y="15" rx="4" />
<rect width="500" height="10" x="0" y="45" rx="4" />
<rect width="500" height="10" x="0" y="75" rx="4" />
<rect width="500" height="10" x="0" y="105" rx="4" />
<rect v-if="isGroup" width="500" height="10" x="0" y="135" rx="4" />
<rect v-else width="30" height="30" x="470" y="135" rx="4" />
</gl-skeleton-loader>
</div>
<div class="d-none d-md-flex flex-column">
<gl-skeleton-loader
v-for="index in $options.rowsToRender.desktop"
:key="index"
:width="1000"
:height="desktopHeight"
preserve-aspect-ratio="xMinYMax meet"
>
<component
:is="r.type"
v-for="(r, rIndex) in desktopShapes"
:key="rIndex"
rx="4"
v-bind="r"
/>
</gl-skeleton-loader>
</div>
</div>
</template>
...@@ -2,3 +2,10 @@ ...@@ -2,3 +2,10 @@
border: 0; border: 0;
border-left: 3px solid $white-dark; border-left: 3px solid $white-dark;
} }
.package-list-table[aria-busy='true'] {
td {
padding-bottom: 0;
padding-top: 0;
}
}
...@@ -18,7 +18,6 @@ describe('packages_list_app', () => { ...@@ -18,7 +18,6 @@ describe('packages_list_app', () => {
const emptyListHelpUrl = 'helpUrl'; const emptyListHelpUrl = 'helpUrl';
const findListComponent = () => wrapper.find(PackageList); const findListComponent = () => wrapper.find(PackageList);
const findLoadingComponent = () => wrapper.find(GlLoadingIcon);
const mountComponent = () => { const mountComponent = () => {
wrapper = shallowMount(PackageListApp, { wrapper = shallowMount(PackageListApp, {
...@@ -44,6 +43,7 @@ describe('packages_list_app', () => { ...@@ -44,6 +43,7 @@ describe('packages_list_app', () => {
}, },
}); });
store.dispatch = jest.fn(); store.dispatch = jest.fn();
mountComponent();
}); });
afterEach(() => { afterEach(() => {
...@@ -55,23 +55,6 @@ describe('packages_list_app', () => { ...@@ -55,23 +55,6 @@ describe('packages_list_app', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
describe('when isLoading is true', () => {
beforeEach(() => {
store.state.isLoading = true;
mountComponent();
});
it('shows the loading component', () => {
const loader = findLoadingComponent();
expect(loader.exists()).toBe(true);
});
});
describe('when isLoading is false', () => {
beforeEach(() => {
mountComponent();
});
it('generate the correct empty list link', () => { it('generate the correct empty list link', () => {
const emptyState = findListComponent(); const emptyState = findListComponent();
const link = emptyState.find('a'); const link = emptyState.find('a');
...@@ -98,5 +81,4 @@ describe('packages_list_app', () => { ...@@ -98,5 +81,4 @@ describe('packages_list_app', () => {
list.vm.$emit('sort:changed'); list.vm.$emit('sort:changed');
expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList');
}); });
});
}); });
import { mount } from '@vue/test-utils';
import PackagesListLoader from 'ee/packages/list/components/packages_list_loader.vue';
describe('PackagesListLoader', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = mount(PackagesListLoader, {
propsData: {
...props,
},
});
};
const getShapes = () => wrapper.vm.desktopShapes;
const findSquareButton = () => wrapper.find({ ref: 'button-loader' });
beforeEach(createComponent);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when used for projects', () => {
it('should return 5 rects with last one being a square', () => {
expect(getShapes()).toHaveLength(5);
expect(findSquareButton().exists()).toBe(true);
});
});
describe('when used for groups', () => {
beforeEach(() => {
createComponent({ isGroup: true });
});
it('should return 5 rects with no square', () => {
expect(getShapes()).toHaveLength(5);
expect(findSquareButton().exists()).toBe(false);
});
});
});
...@@ -5,6 +5,7 @@ import Tracking from '~/tracking'; ...@@ -5,6 +5,7 @@ import Tracking from '~/tracking';
import { mount, createLocalVue } from '@vue/test-utils'; import { mount, createLocalVue } from '@vue/test-utils';
import PackagesList from 'ee/packages/list/components/packages_list.vue'; import PackagesList from 'ee/packages/list/components/packages_list.vue';
import PackageTags from 'ee/packages/shared/components/package_tags.vue'; import PackageTags from 'ee/packages/shared/components/package_tags.vue';
import PackagesListLoader from 'ee/packages/list/components/packages_list_loader.vue';
import * as SharedUtils from 'ee/packages/shared/utils'; import * as SharedUtils from 'ee/packages/shared/utils';
import { TrackingActions } from 'ee/packages/shared/constants'; import { TrackingActions } from 'ee/packages/shared/constants';
import stubChildren from 'helpers/stub_children'; import stubChildren from 'helpers/stub_children';
...@@ -16,12 +17,11 @@ localVue.use(Vuex); ...@@ -16,12 +17,11 @@ localVue.use(Vuex);
describe('packages_list', () => { describe('packages_list', () => {
let wrapper; let wrapper;
let store; let store;
let state;
let getListSpy;
const GlSortingItem = { name: 'sorting-item-stub', template: '<div><slot></slot></div>' }; const GlSortingItem = { name: 'sorting-item-stub', template: '<div><slot></slot></div>' };
const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' }; const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' };
const findPackagesListLoader = () => wrapper.find(PackagesListLoader);
const findFirstActionColumn = () => wrapper.find({ ref: 'action-delete' }); const findFirstActionColumn = () => wrapper.find({ ref: 'action-delete' });
const findPackageListTable = () => wrapper.find(GlTable); const findPackageListTable = () => wrapper.find(GlTable);
const findPackageListSorting = () => wrapper.find(GlSorting); const findPackageListSorting = () => wrapper.find(GlSorting);
...@@ -32,31 +32,17 @@ describe('packages_list', () => { ...@@ -32,31 +32,17 @@ describe('packages_list', () => {
const findPackageTags = () => wrapper.findAll(PackageTags); const findPackageTags = () => wrapper.findAll(PackageTags);
const findEmptySlot = () => wrapper.find({ name: 'empty-slot-stub' }); const findEmptySlot = () => wrapper.find({ name: 'empty-slot-stub' });
const mountComponent = options => { const createStore = (isGroupPage, packages, isLoading) => {
wrapper = mount(PackagesList, { const state = {
localVue, isLoading,
store, packages,
stubs: {
...stubChildren(PackagesList),
GlTable,
GlSortingItem,
},
...options,
});
};
beforeEach(() => {
getListSpy = jest.fn();
getListSpy.mockReturnValue(packageList);
state = {
packages: [...packageList],
pagination: { pagination: {
perPage: 1, perPage: 1,
total: 1, total: 1,
page: 1, page: 1,
}, },
config: { config: {
isGroupPage: false, isGroupPage,
}, },
sorting: { sorting: {
orderBy: 'version', orderBy: 'version',
...@@ -66,22 +52,65 @@ describe('packages_list', () => { ...@@ -66,22 +52,65 @@ describe('packages_list', () => {
store = new Vuex.Store({ store = new Vuex.Store({
state, state,
getters: { getters: {
getList: getListSpy, getList: () => packages,
}, },
}); });
store.dispatch = jest.fn(); store.dispatch = jest.fn();
};
const mountComponent = ({
isGroupPage = false,
packages = packageList,
isLoading = false,
...options
} = {}) => {
createStore(isGroupPage, packages, isLoading);
wrapper = mount(PackagesList, {
localVue,
store,
stubs: {
...stubChildren(PackagesList),
GlTable,
GlSortingItem,
},
...options,
}); });
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('when is isGroupPage', () => { describe('when is loading', () => {
beforeEach(() => {
mountComponent({
packages: [],
isLoading: true,
});
});
it('shows skeleton loader when loading', () => {
expect(findPackagesListLoader().exists()).toBe(true);
});
});
describe('when is not loading', () => {
beforeEach(() => { beforeEach(() => {
state.config.isGroupPage = true;
mountComponent(); mountComponent();
}); });
it('does not show skeleton loader when not loading', () => {
expect(findPackagesListLoader().exists()).toBe(false);
});
});
describe('when is isGroupPage', () => {
beforeEach(() => {
mountComponent({ isGroupPage: true });
});
it('has project field', () => { it('has project field', () => {
const projectColumn = findFirstProjectColumn(); const projectColumn = findFirstProjectColumn();
expect(projectColumn.exists()).toBe(true); expect(projectColumn.exists()).toBe(true);
...@@ -177,8 +206,8 @@ describe('packages_list', () => { ...@@ -177,8 +206,8 @@ describe('packages_list', () => {
describe('when the list is empty', () => { describe('when the list is empty', () => {
beforeEach(() => { beforeEach(() => {
getListSpy.mockReturnValue([]);
mountComponent({ mountComponent({
packages: [],
slots: { slots: {
'empty-state': EmptySlotStub, 'empty-state': EmptySlotStub,
}, },
......
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