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> <script>
import { GlEmptyState } from '@gitlab/ui';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { n__ } from '~/locale'; import { n__ } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility'; import { joinPaths } from '~/lib/utils/url_utility';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; 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 { import {
REMOVE_TAGS_BUTTON_TITLE, REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE, TAGS_LIST_TITLE,
GRAPHQL_PAGE_SIZE, GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE, 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'; } from '../../constants/index';
import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql'; 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 TagsListRow from './tags_list_row.vue';
import TagsLoader from './tags_loader.vue'; import TagsLoader from './tags_loader.vue';
export default { export default {
name: 'TagsList', name: 'TagsList',
components: { components: {
GlEmptyState,
TagsListRow, TagsListRow,
EmptyState,
TagsLoader, TagsLoader,
RegistryList, RegistryList,
PersistedSearch,
}, },
inject: ['config'], inject: ['config'],
props: { props: {
id: { id: {
type: [Number, String], type: [Number, String],
...@@ -44,6 +54,7 @@ export default { ...@@ -44,6 +54,7 @@ export default {
required: false, required: false,
}, },
}, },
searchConfig: { NAME_SORT_FIELD },
i18n: { i18n: {
REMOVE_TAGS_BUTTON_TITLE, REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE, TAGS_LIST_TITLE,
...@@ -51,6 +62,9 @@ export default { ...@@ -51,6 +62,9 @@ export default {
apollo: { apollo: {
containerRepository: { containerRepository: {
query: getContainerRepositoryTagsQuery, query: getContainerRepositoryTagsQuery,
skip() {
return !this.sort;
},
variables() { variables() {
return this.queryVariables; return this.queryVariables;
}, },
...@@ -62,6 +76,8 @@ export default { ...@@ -62,6 +76,8 @@ export default {
data() { data() {
return { return {
containerRepository: {}, containerRepository: {},
filters: {},
sort: null,
}; };
}, },
computed: { computed: {
...@@ -78,6 +94,8 @@ export default { ...@@ -78,6 +94,8 @@ export default {
return { return {
id: joinPaths(this.config.gidPrefix, `${this.id}`), id: joinPaths(this.config.gidPrefix, `${this.id}`),
first: GRAPHQL_PAGE_SIZE, first: GRAPHQL_PAGE_SIZE,
name: this.filters?.name,
sort: this.sort,
}; };
}, },
showMultiDeleteButton() { showMultiDeleteButton() {
...@@ -87,7 +105,16 @@ export default { ...@@ -87,7 +105,16 @@ export default {
return this.tags.length === 0; return this.tags.length === 0;
}, },
isLoading() { 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: { methods: {
...@@ -114,15 +141,47 @@ export default { ...@@ -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> </script>
<template> <template>
<div> <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" /> <tags-loader v-if="isLoading" />
<template v-else> <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> <template v-else>
<registry-list <registry-list
:title="listTitle" :title="listTitle"
......
...@@ -2,3 +2,5 @@ import { s__, __ } from '~/locale'; ...@@ -2,3 +2,5 @@ import { s__, __ } from '~/locale';
export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image'); export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image');
export const MORE_ACTIONS_TEXT = __('More actions'); 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__( ...@@ -116,6 +116,13 @@ export const ROOT_IMAGE_TOOLTIP = s__(
'ContainerRegistry|Image repository with no name located at the project URL.', '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 // Parameters
export const DEFAULT_PAGE = 1; export const DEFAULT_PAGE = 1;
......
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { NAME_SORT_FIELD } from './common';
// Translations strings // Translations strings
...@@ -49,5 +50,5 @@ export const GRAPHQL_PAGE_SIZE = 10; ...@@ -49,5 +50,5 @@ export const GRAPHQL_PAGE_SIZE = 10;
export const SORT_FIELDS = [ export const SORT_FIELDS = [
{ orderBy: 'UPDATED', label: __('Updated') }, { orderBy: 'UPDATED', label: __('Updated') },
{ orderBy: 'CREATED', label: __('Created') }, { orderBy: 'CREATED', label: __('Created') },
{ orderBy: 'NAME', label: __('Name') }, NAME_SORT_FIELD,
]; ];
...@@ -6,11 +6,13 @@ query getContainerRepositoryTags( ...@@ -6,11 +6,13 @@ query getContainerRepositoryTags(
$last: Int $last: Int
$after: String $after: String
$before: String $before: String
$name: String
$sort: ContainerRepositoryTagSort
) { ) {
containerRepository(id: $id) { containerRepository(id: $id) {
id id
tagsCount tagsCount
tags(after: $after, before: $before, first: $first, last: $last) { tags(after: $after, before: $before, first: $first, last: $last, name: $name, sort: $sort) {
nodes { nodes {
digest digest
location location
......
<script> <script>
import { GlResizeObserverDirective } from '@gitlab/ui'; import { GlResizeObserverDirective, GlEmptyState } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import createFlash from '~/flash'; import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
...@@ -9,7 +9,6 @@ import DeleteImage from '../components/delete_image.vue'; ...@@ -9,7 +9,6 @@ import DeleteImage from '../components/delete_image.vue';
import DeleteAlert from '../components/details_page/delete_alert.vue'; import DeleteAlert from '../components/details_page/delete_alert.vue';
import DeleteModal from '../components/details_page/delete_modal.vue'; import DeleteModal from '../components/details_page/delete_modal.vue';
import DetailsHeader from '../components/details_page/details_header.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 PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue';
import StatusAlert from '../components/details_page/status_alert.vue'; import StatusAlert from '../components/details_page/status_alert.vue';
import TagsList from '../components/details_page/tags_list.vue'; import TagsList from '../components/details_page/tags_list.vue';
...@@ -26,6 +25,8 @@ import { ...@@ -26,6 +25,8 @@ import {
MISSING_OR_DELETED_IMAGE_BREADCRUMB, MISSING_OR_DELETED_IMAGE_BREADCRUMB,
ROOT_IMAGE_TEXT, ROOT_IMAGE_TEXT,
GRAPHQL_PAGE_SIZE, GRAPHQL_PAGE_SIZE,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
} from '../constants/index'; } from '../constants/index';
import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql'; import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql';
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
...@@ -34,13 +35,13 @@ import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_re ...@@ -34,13 +35,13 @@ import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_re
export default { export default {
name: 'RegistryDetailsPage', name: 'RegistryDetailsPage',
components: { components: {
GlEmptyState,
DeleteAlert, DeleteAlert,
PartialCleanupAlert, PartialCleanupAlert,
DetailsHeader, DetailsHeader,
DeleteModal, DeleteModal,
TagsList, TagsList,
TagsLoader, TagsLoader,
EmptyState,
StatusAlert, StatusAlert,
DeleteImage, DeleteImage,
}, },
...@@ -49,6 +50,10 @@ export default { ...@@ -49,6 +50,10 @@ export default {
}, },
mixins: [Tracking.mixin()], mixins: [Tracking.mixin()],
inject: ['breadCrumbState', 'config'], inject: ['breadCrumbState', 'config'],
i18n: {
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
},
apollo: { apollo: {
containerRepository: { containerRepository: {
query: getContainerRepositoryDetailsQuery, query: getContainerRepositoryDetailsQuery,
...@@ -230,6 +235,12 @@ export default { ...@@ -230,6 +235,12 @@ export default {
@cancel="track('cancel_delete')" @cancel="track('cancel_delete')"
/> />
</template> </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> </div>
</template> </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 "" ...@@ -9383,6 +9383,9 @@ msgstr ""
msgid "ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time" msgid "ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time"
msgstr "" msgstr ""
msgid "ContainerRegistry|Please try different search criteria"
msgstr ""
msgid "ContainerRegistry|Published %{timeInfo}" msgid "ContainerRegistry|Published %{timeInfo}"
msgstr "" msgstr ""
...@@ -9472,6 +9475,9 @@ 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}" 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 "" msgstr ""
msgid "ContainerRegistry|The filter returned no results"
msgstr ""
msgid "ContainerRegistry|The image repository could not be found." msgid "ContainerRegistry|The image repository could not be found."
msgstr "" 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 { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { GlEmptyState } from '@gitlab/ui';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { stripTypenames } from 'helpers/graphql_helpers'; 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 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 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 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 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 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'; import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -21,11 +30,20 @@ describe('Tags List', () => { ...@@ -21,11 +30,20 @@ describe('Tags List', () => {
let resolver; let resolver;
const tags = [...tagsMock]; const tags = [...tagsMock];
const defaultConfig = {
noContainersImage: 'noContainersImage',
};
const findPersistedSearch = () => wrapper.findComponent(PersistedSearch);
const findTagsListRow = () => wrapper.findAllComponents(TagsListRow); const findTagsListRow = () => wrapper.findAllComponents(TagsListRow);
const findRegistryList = () => wrapper.findComponent(RegistryList); const findRegistryList = () => wrapper.findComponent(RegistryList);
const findEmptyState = () => wrapper.findComponent(EmptyTagsState); const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findTagsLoader = () => wrapper.findComponent(TagsLoader); const findTagsLoader = () => wrapper.findComponent(TagsLoader);
const fireFirstSortUpdate = () => {
findPersistedSearch().vm.$emit('update', { sort: 'NAME_ASC', filters: [] });
};
const waitForApolloRequestRender = async () => { const waitForApolloRequestRender = async () => {
await waitForPromises(); await waitForPromises();
await nextTick(); await nextTick();
...@@ -44,7 +62,7 @@ describe('Tags List', () => { ...@@ -44,7 +62,7 @@ describe('Tags List', () => {
stubs: { RegistryList }, stubs: { RegistryList },
provide() { provide() {
return { return {
config: {}, config: defaultConfig,
}; };
}, },
}); });
...@@ -61,10 +79,23 @@ describe('Tags List', () => { ...@@ -61,10 +79,23 @@ describe('Tags List', () => {
describe('registry list', () => { describe('registry list', () => {
beforeEach(() => { beforeEach(() => {
mountComponent(); mountComponent();
fireFirstSortUpdate();
return waitForApolloRequestRender(); 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', () => { it('binds the correct props', () => {
expect(findRegistryList().props()).toMatchObject({ expect(findRegistryList().props()).toMatchObject({
title: '2 tags', title: '2 tags',
...@@ -75,11 +106,13 @@ describe('Tags List', () => { ...@@ -75,11 +106,13 @@ describe('Tags List', () => {
}); });
describe('events', () => { describe('events', () => {
it('prev-page fetch the previous page', () => { it('prev-page fetch the previous page', async () => {
findRegistryList().vm.$emit('prev-page'); findRegistryList().vm.$emit('prev-page');
expect(resolver).toHaveBeenCalledWith({ expect(resolver).toHaveBeenCalledWith({
first: null, first: null,
name: '',
sort: 'NAME_ASC',
before: tagsPageInfo.startCursor, before: tagsPageInfo.startCursor,
last: GRAPHQL_PAGE_SIZE, last: GRAPHQL_PAGE_SIZE,
id: '1', id: '1',
...@@ -92,6 +125,8 @@ describe('Tags List', () => { ...@@ -92,6 +125,8 @@ describe('Tags List', () => {
expect(resolver).toHaveBeenCalledWith({ expect(resolver).toHaveBeenCalledWith({
after: tagsPageInfo.endCursor, after: tagsPageInfo.endCursor,
first: GRAPHQL_PAGE_SIZE, first: GRAPHQL_PAGE_SIZE,
name: '',
sort: 'NAME_ASC',
id: '1', id: '1',
}); });
}); });
...@@ -108,6 +143,7 @@ describe('Tags List', () => { ...@@ -108,6 +143,7 @@ describe('Tags List', () => {
describe('list rows', () => { describe('list rows', () => {
it('one row exist for each tag', async () => { it('one row exist for each tag', async () => {
mountComponent(); mountComponent();
fireFirstSortUpdate();
await waitForApolloRequestRender(); await waitForApolloRequestRender();
...@@ -116,6 +152,7 @@ describe('Tags List', () => { ...@@ -116,6 +152,7 @@ describe('Tags List', () => {
it('the correct props are bound to it', async () => { it('the correct props are bound to it', async () => {
mountComponent({ propsData: { disabled: true, id: 1 } }); mountComponent({ propsData: { disabled: true, id: 1 } });
fireFirstSortUpdate();
await waitForApolloRequestRender(); await waitForApolloRequestRender();
...@@ -130,7 +167,7 @@ describe('Tags List', () => { ...@@ -130,7 +167,7 @@ describe('Tags List', () => {
describe('events', () => { describe('events', () => {
it('select event update the selected items', async () => { it('select event update the selected items', async () => {
mountComponent(); mountComponent();
fireFirstSortUpdate();
await waitForApolloRequestRender(); await waitForApolloRequestRender();
findTagsListRow().at(0).vm.$emit('select'); findTagsListRow().at(0).vm.$emit('select');
...@@ -142,7 +179,7 @@ describe('Tags List', () => { ...@@ -142,7 +179,7 @@ describe('Tags List', () => {
it('delete event emit a delete event', async () => { it('delete event emit a delete event', async () => {
mountComponent(); mountComponent();
fireFirstSortUpdate();
await waitForApolloRequestRender(); await waitForApolloRequestRender();
findTagsListRow().at(0).vm.$emit('delete'); findTagsListRow().at(0).vm.$emit('delete');
...@@ -154,32 +191,45 @@ describe('Tags List', () => { ...@@ -154,32 +191,45 @@ describe('Tags List', () => {
describe('when the list of tags is empty', () => { describe('when the list of tags is empty', () => {
beforeEach(() => { beforeEach(() => {
resolver = jest.fn().mockResolvedValue(imageTagsMock([])); resolver = jest.fn().mockResolvedValue(imageTagsMock([]));
});
it('has the empty state', async () => {
mountComponent(); mountComponent();
fireFirstSortUpdate();
await waitForApolloRequestRender(); return waitForApolloRequestRender();
expect(findEmptyState().exists()).toBe(true);
}); });
it('does not show the loader', async () => { it('does not show the loader', () => {
mountComponent();
await waitForApolloRequestRender();
expect(findTagsLoader().exists()).toBe(false); expect(findTagsLoader().exists()).toBe(false);
}); });
it('does not show the list', async () => { it('does not show the list', () => {
mountComponent(); 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', () => { describe('loading state', () => {
it.each` it.each`
isImageLoading | queryExecuting | loadingVisible isImageLoading | queryExecuting | loadingVisible
...@@ -191,7 +241,7 @@ describe('Tags List', () => { ...@@ -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', '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 }) => { async ({ isImageLoading, queryExecuting, loadingVisible }) => {
mountComponent({ propsData: { isImageLoading, isMobile: false, id: 1 } }); mountComponent({ propsData: { isImageLoading, isMobile: false, id: 1 } });
fireFirstSortUpdate();
if (!queryExecuting) { if (!queryExecuting) {
await waitForApolloRequestRender(); await waitForApolloRequestRender();
} }
......
import { GlKeysetPagination } from '@gitlab/ui'; import { GlKeysetPagination, GlEmptyState } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
...@@ -8,7 +8,6 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -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 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 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 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 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 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'; import TagsList from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue';
...@@ -20,6 +19,8 @@ import { ...@@ -20,6 +19,8 @@ import {
ALERT_DANGER_IMAGE, ALERT_DANGER_IMAGE,
MISSING_OR_DELETED_IMAGE_BREADCRUMB, MISSING_OR_DELETED_IMAGE_BREADCRUMB,
ROOT_IMAGE_TEXT, ROOT_IMAGE_TEXT,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
} from '~/packages_and_registries/container_registry/explorer/constants'; } 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 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'; import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql';
...@@ -50,7 +51,7 @@ describe('Details Page', () => { ...@@ -50,7 +51,7 @@ describe('Details Page', () => {
const findTagsList = () => wrapper.find(TagsList); const findTagsList = () => wrapper.find(TagsList);
const findDeleteAlert = () => wrapper.find(DeleteAlert); const findDeleteAlert = () => wrapper.find(DeleteAlert);
const findDetailsHeader = () => wrapper.find(DetailsHeader); const findDetailsHeader = () => wrapper.find(DetailsHeader);
const findEmptyState = () => wrapper.find(EmptyTagsState); const findEmptyState = () => wrapper.find(GlEmptyState);
const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert); const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert);
const findStatusAlert = () => wrapper.find(StatusAlert); const findStatusAlert = () => wrapper.find(StatusAlert);
const findDeleteImage = () => wrapper.find(DeleteImage); const findDeleteImage = () => wrapper.find(DeleteImage);
...@@ -61,6 +62,10 @@ describe('Details Page', () => { ...@@ -61,6 +62,10 @@ describe('Details Page', () => {
updateName: jest.fn(), updateName: jest.fn(),
}; };
const defaultConfig = {
noContainersImage: 'noContainersImage',
};
const cleanTags = tagsMock.map((t) => { const cleanTags = tagsMock.map((t) => {
const result = { ...t }; const result = { ...t };
// eslint-disable-next-line no-underscore-dangle // eslint-disable-next-line no-underscore-dangle
...@@ -78,7 +83,7 @@ describe('Details Page', () => { ...@@ -78,7 +83,7 @@ describe('Details Page', () => {
mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock), mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock),
tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock)), tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock)),
options, options,
config = {}, config = defaultConfig,
} = {}) => { } = {}) => {
localVue.use(VueApollo); localVue.use(VueApollo);
...@@ -154,7 +159,11 @@ describe('Details Page', () => { ...@@ -154,7 +159,11 @@ describe('Details Page', () => {
await waitForApolloRequestRender(); 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