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, mapGetters } from 'vuex';
import { import {
GlTable, GlTable,
GlFormCheckbox, GlFormCheckbox,
...@@ -8,10 +8,10 @@ import { ...@@ -8,10 +8,10 @@ import {
GlTooltipDirective, GlTooltipDirective,
GlPagination, GlPagination,
GlModal, GlModal,
GlLoadingIcon,
GlSprintf, GlSprintf,
GlEmptyState, GlEmptyState,
GlResizeObserverDirective, GlResizeObserverDirective,
GlSkeletonLoader,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { n__, s__ } from '~/locale'; import { n__, s__ } from '~/locale';
...@@ -42,7 +42,7 @@ export default { ...@@ -42,7 +42,7 @@ export default {
ClipboardButton, ClipboardButton,
GlPagination, GlPagination,
GlModal, GlModal,
GlLoadingIcon, GlSkeletonLoader,
GlSprintf, GlSprintf,
GlEmptyState, GlEmptyState,
}, },
...@@ -51,6 +51,11 @@ export default { ...@@ -51,6 +51,11 @@ export default {
GlResizeObserver: GlResizeObserverDirective, GlResizeObserver: GlResizeObserverDirective,
}, },
mixins: [timeagoMixin, Tracking.mixin()], mixins: [timeagoMixin, Tracking.mixin()],
loader: {
repeat: 10,
width: 1000,
height: 40,
},
data() { data() {
return { return {
selectedItems: [], selectedItems: [],
...@@ -61,15 +66,16 @@ export default { ...@@ -61,15 +66,16 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['tags', 'tagsPagination', 'isLoading', 'config']), ...mapGetters(['tags']),
...mapState(['tagsPagination', 'isLoading', 'config']),
imageName() { imageName() {
const { name } = decodeAndParse(this.$route.params.id); const { name } = decodeAndParse(this.$route.params.id);
return name; return name;
}, },
fields() { fields() {
return [ return [
{ key: LIST_KEY_CHECKBOX, label: '' }, { key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' },
{ key: LIST_KEY_TAG, label: LIST_LABEL_TAG }, { key: LIST_KEY_TAG, label: LIST_LABEL_TAG, class: 'w-25' },
{ key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID }, { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID },
{ key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE }, { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE },
{ key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED }, { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED },
...@@ -209,9 +215,8 @@ export default { ...@@ -209,9 +215,8 @@ export default {
</gl-sprintf> </gl-sprintf>
</h4> </h4>
</div> </div>
<gl-loading-icon v-if="isLoading" />
<template v-else-if="tags.length > 0"> <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty>
<gl-table :items="tags" :fields="fields" :stacked="!isDesktop">
<template v-if="isDesktop" #head(checkbox)> <template v-if="isDesktop" #head(checkbox)>
<gl-form-checkbox <gl-form-checkbox
ref="mainCheckbox" ref="mainCheckbox"
...@@ -280,13 +285,47 @@ export default { ...@@ -280,13 +285,47 @@ export default {
:aria-label="s__('ContainerRegistry|Remove tag')" :aria-label="s__('ContainerRegistry|Remove tag')"
:disabled="!item.destroy_path" :disabled="!item.destroy_path"
variant="danger" 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)" @click="deleteSingleItem(index)"
> >
<gl-icon name="remove" /> <gl-icon name="remove" />
</gl-button> </gl-button>
</template> </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-table>
<gl-pagination <gl-pagination
ref="pagination" ref="pagination"
v-model="currentPage" v-model="currentPage"
...@@ -295,6 +334,7 @@ export default { ...@@ -295,6 +334,7 @@ export default {
align="center" align="center"
class="w-100" class="w-100"
/> />
<gl-modal <gl-modal
ref="deleteModal" ref="deleteModal"
modal-id="delete-tag-modal" modal-id="delete-tag-modal"
...@@ -312,19 +352,5 @@ export default { ...@@ -312,19 +352,5 @@ export default {
</gl-sprintf> </gl-sprintf>
</p> </p>
</gl-modal> </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> </div>
</template> </template>
<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,10 +109,7 @@ export default { ...@@ -104,10 +109,7 @@ 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>
<template v-else>
<div v-if="images.length" ref="imagesList">
<h4>{{ s__('ContainerRegistry|Container Registry') }}</h4> <h4>{{ s__('ContainerRegistry|Container Registry') }}</h4>
<p> <p>
<gl-sprintf <gl-sprintf
...@@ -124,16 +126,29 @@ export default { ...@@ -124,16 +126,29 @@ export default {
</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 <div
v-for="(listItem, index) in images" v-for="(listItem, index) in images"
:key="index" :key="index"
ref="rowItem" ref="rowItem"
:class="[ :class="{ 'border-top': index === 0 }"
'd-flex justify-content-between align-items-center py-2 border-bottom', class="d-flex justify-content-between align-items-center py-2 border-bottom"
{ 'border-top': index === 0 },
]"
> >
<div> <div>
<router-link <router-link
...@@ -154,9 +169,7 @@ export default { ...@@ -154,9 +169,7 @@ export default {
v-gl-tooltip="{ disabled: listItem.destroy_path }" v-gl-tooltip="{ disabled: listItem.destroy_path }"
class="d-none d-sm-block" class="d-none d-sm-block"
:title=" :title="
s__( s__('ContainerRegistry|Missing or insufficient permission, delete button disabled')
'ContainerRegistry|Missing or insufficient permission, delete button disabled',
)
" "
> >
<gl-button <gl-button
...@@ -173,7 +186,6 @@ export default { ...@@ -173,7 +186,6 @@ export default {
</gl-button> </gl-button>
</div> </div>
</div> </div>
</div>
<gl-pagination <gl-pagination
v-model="currentPage" v-model="currentPage"
:per-page="pagination.perPage" :per-page="pagination.perPage"
...@@ -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