Commit f5209507 authored by Simon Knox's avatar Simon Knox

Merge branch '15489-sort-docker-tags-in-the-container-registry-browser' into 'master'

Enable Search and Sorting in container images tags

See merge request gitlab-org/gitlab!76759
parents 232dc9e3 ee4c64d2
<script>
import { GlEmptyState } from '@gitlab/ui';
import {
NO_TAGS_TITLE,
NO_TAGS_MESSAGE,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
} from '../../constants/index';
export default {
components: {
GlEmptyState,
},
props: {
noContainersImage: {
type: String,
required: false,
default: '',
},
isEmptyImage: {
type: Boolean,
default: false,
required: false,
},
},
computed: {
title() {
return this.isEmptyImage ? MISSING_OR_DELETED_IMAGE_TITLE : NO_TAGS_TITLE;
},
description() {
return this.isEmptyImage ? MISSING_OR_DELETED_IMAGE_MESSAGE : NO_TAGS_MESSAGE;
},
},
};
</script>
<template>
<gl-empty-state
:title="title"
:svg-path="noContainersImage"
:description="description"
class="gl-mx-auto gl-my-0"
/>
</template>
<script>
import { GlEmptyState } from '@gitlab/ui';
import createFlash from '~/flash';
import { n__ } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import {
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
NAME_SORT_FIELD,
NO_TAGS_TITLE,
NO_TAGS_MESSAGE,
NO_TAGS_MATCHING_FILTERS_TITLE,
NO_TAGS_MATCHING_FILTERS_DESCRIPTION,
} from '../../constants/index';
import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql';
import EmptyState from './empty_state.vue';
import TagsListRow from './tags_list_row.vue';
import TagsLoader from './tags_loader.vue';
export default {
name: 'TagsList',
components: {
GlEmptyState,
TagsListRow,
EmptyState,
TagsLoader,
RegistryList,
PersistedSearch,
},
inject: ['config'],
props: {
id: {
type: [Number, String],
......@@ -44,6 +54,7 @@ export default {
required: false,
},
},
searchConfig: { NAME_SORT_FIELD },
i18n: {
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
......@@ -51,6 +62,9 @@ export default {
apollo: {
containerRepository: {
query: getContainerRepositoryTagsQuery,
skip() {
return !this.sort;
},
variables() {
return this.queryVariables;
},
......@@ -62,6 +76,8 @@ export default {
data() {
return {
containerRepository: {},
filters: {},
sort: null,
};
},
computed: {
......@@ -78,6 +94,8 @@ export default {
return {
id: joinPaths(this.config.gidPrefix, `${this.id}`),
first: GRAPHQL_PAGE_SIZE,
name: this.filters?.name,
sort: this.sort,
};
},
showMultiDeleteButton() {
......@@ -87,7 +105,16 @@ export default {
return this.tags.length === 0;
},
isLoading() {
return this.isImageLoading || this.$apollo.queries.containerRepository.loading;
return this.isImageLoading || this.$apollo.queries.containerRepository.loading || !this.sort;
},
hasFilters() {
return this.filters?.name;
},
emptyStateTitle() {
return this.hasFilters ? NO_TAGS_MATCHING_FILTERS_TITLE : NO_TAGS_TITLE;
},
emptyStateDescription() {
return this.hasFilters ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : NO_TAGS_MESSAGE;
},
},
methods: {
......@@ -114,15 +141,47 @@ export default {
},
});
},
handleSearchUpdate({ sort, filters }) {
this.sort = sort;
const parsed = {
name: '',
};
// This takes in account the fact that we will be adding more filters types
// this is why is an object and not an array or a simple string
this.filters = filters.reduce((acc, filter) => {
if (filter.type === FILTERED_SEARCH_TERM) {
return {
...acc,
name: `${acc.name} ${filter.value.data}`.trim(),
};
}
return acc;
}, parsed);
},
},
};
</script>
<template>
<div>
<persisted-search
class="gl-mb-5"
:sortable-fields="[$options.searchConfig.NAME_SORT_FIELD]"
:default-order="$options.searchConfig.NAME_SORT_FIELD.orderBy"
default-sort="asc"
@update="handleSearchUpdate"
/>
<tags-loader v-if="isLoading" />
<template v-else>
<empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" />
<gl-empty-state
v-if="hasNoTags"
:title="emptyStateTitle"
:svg-path="config.noContainersImage"
:description="emptyStateDescription"
class="gl-mx-auto gl-my-0"
/>
<template v-else>
<registry-list
:title="listTitle"
......
......@@ -2,3 +2,5 @@ import { s__, __ } from '~/locale';
export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image');
export const MORE_ACTIONS_TEXT = __('More actions');
export const NAME_SORT_FIELD = { orderBy: 'NAME', label: __('Name') };
......@@ -116,6 +116,13 @@ export const ROOT_IMAGE_TOOLTIP = s__(
'ContainerRegistry|Image repository with no name located at the project URL.',
);
export const NO_TAGS_MATCHING_FILTERS_TITLE = s__(
'ContainerRegistry|The filter returned no results',
);
export const NO_TAGS_MATCHING_FILTERS_DESCRIPTION = s__(
'ContainerRegistry|Please try different search criteria',
);
// Parameters
export const DEFAULT_PAGE = 1;
......
import { s__, __ } from '~/locale';
import { NAME_SORT_FIELD } from './common';
// Translations strings
......@@ -49,5 +50,5 @@ export const GRAPHQL_PAGE_SIZE = 10;
export const SORT_FIELDS = [
{ orderBy: 'UPDATED', label: __('Updated') },
{ orderBy: 'CREATED', label: __('Created') },
{ orderBy: 'NAME', label: __('Name') },
NAME_SORT_FIELD,
];
......@@ -6,11 +6,13 @@ query getContainerRepositoryTags(
$last: Int
$after: String
$before: String
$name: String
$sort: ContainerRepositoryTagSort
) {
containerRepository(id: $id) {
id
tagsCount
tags(after: $after, before: $before, first: $first, last: $last) {
tags(after: $after, before: $before, first: $first, last: $last, name: $name, sort: $sort) {
nodes {
digest
location
......
<script>
import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlResizeObserverDirective, GlEmptyState } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
......@@ -9,7 +9,6 @@ import DeleteImage from '../components/delete_image.vue';
import DeleteAlert from '../components/details_page/delete_alert.vue';
import DeleteModal from '../components/details_page/delete_modal.vue';
import DetailsHeader from '../components/details_page/details_header.vue';
import EmptyState from '../components/details_page/empty_state.vue';
import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue';
import StatusAlert from '../components/details_page/status_alert.vue';
import TagsList from '../components/details_page/tags_list.vue';
......@@ -26,6 +25,8 @@ import {
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
ROOT_IMAGE_TEXT,
GRAPHQL_PAGE_SIZE,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
} from '../constants/index';
import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql';
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
......@@ -34,13 +35,13 @@ import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_re
export default {
name: 'RegistryDetailsPage',
components: {
GlEmptyState,
DeleteAlert,
PartialCleanupAlert,
DetailsHeader,
DeleteModal,
TagsList,
TagsLoader,
EmptyState,
StatusAlert,
DeleteImage,
},
......@@ -49,6 +50,10 @@ export default {
},
mixins: [Tracking.mixin()],
inject: ['breadCrumbState', 'config'],
i18n: {
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
},
apollo: {
containerRepository: {
query: getContainerRepositoryDetailsQuery,
......@@ -230,6 +235,12 @@ export default {
@cancel="track('cancel_delete')"
/>
</template>
<empty-state v-else is-empty-image :no-containers-image="config.noContainersImage" />
<gl-empty-state
v-else
:title="$options.i18n.MISSING_OR_DELETED_IMAGE_TITLE"
:description="$options.i18n.MISSING_OR_DELETED_IMAGE_MESSAGE"
:svg-path="config.noContainersImage"
class="gl-mx-auto gl-my-0"
/>
</div>
</template>
<script>
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import { extractFilterAndSorting, getQueryParams } from '~/packages_and_registries/shared/utils';
export default {
components: { RegistrySearch, UrlSync },
props: {
sortableFields: {
type: Array,
required: true,
},
defaultOrder: {
type: String,
required: true,
},
defaultSort: {
type: String,
required: true,
},
},
data() {
return {
filters: [],
sorting: {
orderBy: this.defaultOrder,
sort: this.defaultSort,
},
mountRegistrySearch: false,
};
},
computed: {
parsedSorting() {
const cleanOrderBy = this.sorting?.orderBy.replace('_at', '');
return `${cleanOrderBy}_${this.sorting?.sort}`.toUpperCase();
},
},
mounted() {
const queryParams = getQueryParams(window.document.location.search);
const { sorting, filters } = extractFilterAndSorting(queryParams);
this.updateSorting(sorting);
this.updateFilters(filters);
this.mountRegistrySearch = true;
this.emitUpdate();
},
methods: {
updateFilters(newValue) {
this.filters = newValue;
},
updateSorting(newValue) {
this.sorting = { ...this.sorting, ...newValue };
},
updateSortingAndEmitUpdate(newValue) {
this.updateSorting(newValue);
this.emitUpdate();
},
emitUpdate() {
this.$emit('update', { sort: this.parsedSorting, filters: this.filters });
},
},
};
</script>
<template>
<url-sync>
<template #default="{ updateQuery }">
<registry-search
v-if="mountRegistrySearch"
:filter="filters"
:sorting="sorting"
:tokens="$options.tokens"
:sortable-fields="sortableFields"
@sorting:changed="updateSortingAndEmitUpdate"
@filter:changed="updateFilters"
@filter:submit="emitUpdate"
@query:changed="updateQuery"
/>
</template>
</url-sync>
</template>
......@@ -9383,6 +9383,9 @@ msgstr ""
msgid "ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time"
msgstr ""
msgid "ContainerRegistry|Please try different search criteria"
msgstr ""
msgid "ContainerRegistry|Published %{timeInfo}"
msgstr ""
......@@ -9472,6 +9475,9 @@ msgstr ""
msgid "ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}"
msgstr ""
msgid "ContainerRegistry|The filter returned no results"
msgstr ""
msgid "ContainerRegistry|The image repository could not be found."
msgstr ""
......
import { GlEmptyState } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import component from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue';
import {
NO_TAGS_TITLE,
NO_TAGS_MESSAGE,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
} from '~/packages_and_registries/container_registry/explorer/constants';
describe('EmptyTagsState component', () => {
let wrapper;
const findEmptyState = () => wrapper.find(GlEmptyState);
const mountComponent = (propsData) => {
wrapper = shallowMount(component, {
stubs: {
GlEmptyState,
},
propsData,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('contains gl-empty-state', () => {
mountComponent();
expect(findEmptyState().exists()).toBe(true);
});
it.each`
isEmptyImage | title | description
${false} | ${NO_TAGS_TITLE} | ${NO_TAGS_MESSAGE}
${true} | ${MISSING_OR_DELETED_IMAGE_TITLE} | ${MISSING_OR_DELETED_IMAGE_MESSAGE}
`(
'when isEmptyImage is $isEmptyImage has the correct props',
({ isEmptyImage, title, description }) => {
mountComponent({
noContainersImage: 'foo',
isEmptyImage,
});
expect(findEmptyState().props()).toMatchObject({
title,
description,
svgPath: 'foo',
});
},
);
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import { GlEmptyState } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { stripTypenames } from 'helpers/graphql_helpers';
import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue';
import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue';
import TagsListRow from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue';
import TagsLoader from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql';
import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/container_registry/explorer/constants/index';
import {
GRAPHQL_PAGE_SIZE,
NO_TAGS_TITLE,
NO_TAGS_MESSAGE,
NO_TAGS_MATCHING_FILTERS_TITLE,
NO_TAGS_MATCHING_FILTERS_DESCRIPTION,
} from '~/packages_and_registries/container_registry/explorer/constants/index';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data';
const localVue = createLocalVue();
......@@ -21,11 +30,20 @@ describe('Tags List', () => {
let resolver;
const tags = [...tagsMock];
const defaultConfig = {
noContainersImage: 'noContainersImage',
};
const findPersistedSearch = () => wrapper.findComponent(PersistedSearch);
const findTagsListRow = () => wrapper.findAllComponents(TagsListRow);
const findRegistryList = () => wrapper.findComponent(RegistryList);
const findEmptyState = () => wrapper.findComponent(EmptyTagsState);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findTagsLoader = () => wrapper.findComponent(TagsLoader);
const fireFirstSortUpdate = () => {
findPersistedSearch().vm.$emit('update', { sort: 'NAME_ASC', filters: [] });
};
const waitForApolloRequestRender = async () => {
await waitForPromises();
await nextTick();
......@@ -44,7 +62,7 @@ describe('Tags List', () => {
stubs: { RegistryList },
provide() {
return {
config: {},
config: defaultConfig,
};
},
});
......@@ -61,10 +79,23 @@ describe('Tags List', () => {
describe('registry list', () => {
beforeEach(() => {
mountComponent();
fireFirstSortUpdate();
return waitForApolloRequestRender();
});
it('has a persisted search', () => {
expect(findPersistedSearch().props()).toMatchObject({
defaultOrder: 'NAME',
defaultSort: 'asc',
sortableFields: [
{
label: 'Name',
orderBy: 'NAME',
},
],
});
});
it('binds the correct props', () => {
expect(findRegistryList().props()).toMatchObject({
title: '2 tags',
......@@ -75,11 +106,13 @@ describe('Tags List', () => {
});
describe('events', () => {
it('prev-page fetch the previous page', () => {
it('prev-page fetch the previous page', async () => {
findRegistryList().vm.$emit('prev-page');
expect(resolver).toHaveBeenCalledWith({
first: null,
name: '',
sort: 'NAME_ASC',
before: tagsPageInfo.startCursor,
last: GRAPHQL_PAGE_SIZE,
id: '1',
......@@ -92,6 +125,8 @@ describe('Tags List', () => {
expect(resolver).toHaveBeenCalledWith({
after: tagsPageInfo.endCursor,
first: GRAPHQL_PAGE_SIZE,
name: '',
sort: 'NAME_ASC',
id: '1',
});
});
......@@ -108,6 +143,7 @@ describe('Tags List', () => {
describe('list rows', () => {
it('one row exist for each tag', async () => {
mountComponent();
fireFirstSortUpdate();
await waitForApolloRequestRender();
......@@ -116,6 +152,7 @@ describe('Tags List', () => {
it('the correct props are bound to it', async () => {
mountComponent({ propsData: { disabled: true, id: 1 } });
fireFirstSortUpdate();
await waitForApolloRequestRender();
......@@ -130,7 +167,7 @@ describe('Tags List', () => {
describe('events', () => {
it('select event update the selected items', async () => {
mountComponent();
fireFirstSortUpdate();
await waitForApolloRequestRender();
findTagsListRow().at(0).vm.$emit('select');
......@@ -142,7 +179,7 @@ describe('Tags List', () => {
it('delete event emit a delete event', async () => {
mountComponent();
fireFirstSortUpdate();
await waitForApolloRequestRender();
findTagsListRow().at(0).vm.$emit('delete');
......@@ -154,32 +191,45 @@ describe('Tags List', () => {
describe('when the list of tags is empty', () => {
beforeEach(() => {
resolver = jest.fn().mockResolvedValue(imageTagsMock([]));
});
it('has the empty state', async () => {
mountComponent();
await waitForApolloRequestRender();
expect(findEmptyState().exists()).toBe(true);
fireFirstSortUpdate();
return waitForApolloRequestRender();
});
it('does not show the loader', async () => {
mountComponent();
await waitForApolloRequestRender();
it('does not show the loader', () => {
expect(findTagsLoader().exists()).toBe(false);
});
it('does not show the list', async () => {
mountComponent();
it('does not show the list', () => {
expect(findRegistryList().exists()).toBe(false);
});
await waitForApolloRequestRender();
describe('empty state', () => {
it('default empty state', () => {
expect(findEmptyState().props()).toMatchObject({
svgPath: defaultConfig.noContainersImage,
title: NO_TAGS_TITLE,
description: NO_TAGS_MESSAGE,
});
});
expect(findRegistryList().exists()).toBe(false);
it('when filtered shows a filtered message', async () => {
findPersistedSearch().vm.$emit('update', {
sort: 'NAME_ASC',
filters: [{ type: FILTERED_SEARCH_TERM, value: { data: 'foo' } }],
});
await waitForApolloRequestRender();
expect(findEmptyState().props()).toMatchObject({
svgPath: defaultConfig.noContainersImage,
title: NO_TAGS_MATCHING_FILTERS_TITLE,
description: NO_TAGS_MATCHING_FILTERS_DESCRIPTION,
});
});
});
});
describe('loading state', () => {
it.each`
isImageLoading | queryExecuting | loadingVisible
......@@ -191,7 +241,7 @@ describe('Tags List', () => {
'when the isImageLoading is $isImageLoading, and is $queryExecuting that the query is still executing is $loadingVisible that the loader is shown',
async ({ isImageLoading, queryExecuting, loadingVisible }) => {
mountComponent({ propsData: { isImageLoading, isMobile: false, id: 1 } });
fireFirstSortUpdate();
if (!queryExecuting) {
await waitForApolloRequestRender();
}
......
import { GlKeysetPagination } from '@gitlab/ui';
import { GlKeysetPagination, GlEmptyState } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue';
......@@ -8,7 +8,6 @@ import axios from '~/lib/utils/axios_utils';
import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue';
import DeleteAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue';
import DetailsHeader from '~/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue';
import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue';
import PartialCleanupAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue';
import StatusAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue';
import TagsList from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue';
......@@ -20,6 +19,8 @@ import {
ALERT_DANGER_IMAGE,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
ROOT_IMAGE_TEXT,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
} from '~/packages_and_registries/container_registry/explorer/constants';
import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql';
import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql';
......@@ -50,7 +51,7 @@ describe('Details Page', () => {
const findTagsList = () => wrapper.find(TagsList);
const findDeleteAlert = () => wrapper.find(DeleteAlert);
const findDetailsHeader = () => wrapper.find(DetailsHeader);
const findEmptyState = () => wrapper.find(EmptyTagsState);
const findEmptyState = () => wrapper.find(GlEmptyState);
const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert);
const findStatusAlert = () => wrapper.find(StatusAlert);
const findDeleteImage = () => wrapper.find(DeleteImage);
......@@ -61,6 +62,10 @@ describe('Details Page', () => {
updateName: jest.fn(),
};
const defaultConfig = {
noContainersImage: 'noContainersImage',
};
const cleanTags = tagsMock.map((t) => {
const result = { ...t };
// eslint-disable-next-line no-underscore-dangle
......@@ -78,7 +83,7 @@ describe('Details Page', () => {
mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock),
tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock)),
options,
config = {},
config = defaultConfig,
} = {}) => {
localVue.use(VueApollo);
......@@ -154,7 +159,11 @@ describe('Details Page', () => {
await waitForApolloRequestRender();
expect(findEmptyState().exists()).toBe(true);
expect(findEmptyState().props()).toMatchObject({
description: MISSING_OR_DELETED_IMAGE_MESSAGE,
svgPath: defaultConfig.noContainersImage,
title: MISSING_OR_DELETED_IMAGE_TITLE,
});
});
});
......
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import component from '~/packages_and_registries/shared/components/persisted_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
jest.mock('~/packages_and_registries/shared/utils');
useMockLocationHelper();
describe('Persisted Search', () => {
let wrapper;
const defaultQueryParamsMock = {
filters: ['foo'],
sorting: { sort: 'desc', orderBy: 'test' },
};
const defaultProps = {
sortableFields: [
{ orderBy: 'test', label: 'test' },
{ orderBy: 'foo', label: 'foo' },
],
defaultOrder: 'test',
defaultSort: 'asc',
};
const findRegistrySearch = () => wrapper.findComponent(RegistrySearch);
const findUrlSync = () => wrapper.findComponent(UrlSync);
const mountComponent = (propsData = defaultProps) => {
wrapper = shallowMountExtended(component, {
propsData,
stubs: {
UrlSync,
},
});
};
beforeEach(() => {
extractFilterAndSorting.mockReturnValue(defaultQueryParamsMock);
});
afterEach(() => {
wrapper.destroy();
});
it('has a registry search component', async () => {
mountComponent();
await nextTick();
expect(findRegistrySearch().exists()).toBe(true);
});
it('registry search is mounted after mount', async () => {
mountComponent();
expect(findRegistrySearch().exists()).toBe(false);
});
it('has a UrlSync component', () => {
mountComponent();
expect(findUrlSync().exists()).toBe(true);
});
it('on sorting:changed emits update event and update internal sort', async () => {
const payload = { sort: 'desc', orderBy: 'test' };
mountComponent();
await nextTick();
findRegistrySearch().vm.$emit('sorting:changed', payload);
await nextTick();
expect(findRegistrySearch().props('sorting')).toMatchObject(payload);
// there is always a first call on mounted that emits up default values
expect(wrapper.emitted('update')[1]).toEqual([
{
filters: ['foo'],
sort: 'TEST_DESC',
},
]);
});
it('on filter:changed updates the filters', async () => {
const payload = ['foo'];
mountComponent();
await nextTick();
findRegistrySearch().vm.$emit('filter:changed', payload);
await nextTick();
expect(findRegistrySearch().props('filter')).toEqual(['foo']);
});
it('on filter:submit emits update event', async () => {
mountComponent();
await nextTick();
findRegistrySearch().vm.$emit('filter:submit');
expect(wrapper.emitted('update')[1]).toEqual([
{
filters: ['foo'],
sort: 'TEST_DESC',
},
]);
});
it('on query:changed calls updateQuery from UrlSync', async () => {
jest.spyOn(UrlSync.methods, 'updateQuery').mockImplementation(() => {});
mountComponent();
await nextTick();
findRegistrySearch().vm.$emit('query:changed');
expect(UrlSync.methods.updateQuery).toHaveBeenCalled();
});
it('sets the component sorting and filtering based on the querystring', async () => {
mountComponent();
await nextTick();
expect(getQueryParams).toHaveBeenCalled();
expect(findRegistrySearch().props()).toMatchObject({
filter: defaultQueryParamsMock.filters,
sorting: defaultQueryParamsMock.sorting,
});
});
});
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