Commit e5eb6714 authored by Enrique Alcántara's avatar Enrique Alcántara

Merge branch '216761-proper-404-page-registry-details' into 'master'

Add 404 state to container registry details page

See merge request gitlab-org/gitlab!52466
parents bb19877a cf506304
<script>
import { GlEmptyState } from '@gitlab/ui';
import {
EMPTY_IMAGE_REPOSITORY_TITLE,
EMPTY_IMAGE_REPOSITORY_MESSAGE,
NO_TAGS_TITLE,
NO_TAGS_MESSAGE,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
} from '../../constants/index';
export default {
......@@ -15,19 +17,28 @@ export default {
required: false,
default: '',
},
isEmptyImage: {
type: Boolean,
default: false,
required: false,
},
},
i18n: {
EMPTY_IMAGE_REPOSITORY_TITLE,
EMPTY_IMAGE_REPOSITORY_MESSAGE,
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="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE"
:title="title"
:svg-path="noContainersImage"
:description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE"
:description="description"
class="gl-mx-auto gl-my-0"
/>
</template>
......@@ -40,12 +40,23 @@ export const REMOVE_TAG_CONFIRMATION_TEXT = s__(
export const REMOVE_TAGS_CONFIRMATION_TEXT = s__(
`ContainerRegistry|You are about to remove %{item} tags. Are you sure?`,
);
export const EMPTY_IMAGE_REPOSITORY_TITLE = s__('ContainerRegistry|This image has no active tags');
export const EMPTY_IMAGE_REPOSITORY_MESSAGE = s__(
export const NO_TAGS_TITLE = s__('ContainerRegistry|This image has no active tags');
export const NO_TAGS_MESSAGE = 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.`,
);
export const MISSING_OR_DELETED_IMAGE_TITLE = s__(
'ContainerRegistry|The image repository could not be found.',
);
export const MISSING_OR_DELETED_IMAGE_MESSAGE = s__(
'ContainerRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page.',
);
export const MISSING_OR_DELETE_IMAGE_BREADCRUMB = s__(
'ContainerRegistry|Image repository not found',
);
export const ADMIN_GARBAGE_COLLECTION_TIP = s__(
'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.',
);
......
......@@ -11,7 +11,7 @@ import DeleteModal from '../components/details_page/delete_modal.vue';
import DetailsHeader from '../components/details_page/details_header.vue';
import TagsList from '../components/details_page/tags_list.vue';
import TagsLoader from '../components/details_page/tags_loader.vue';
import EmptyTagsState from '../components/details_page/empty_tags_state.vue';
import EmptyState from '../components/details_page/empty_state.vue';
import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql';
......@@ -24,6 +24,7 @@ import {
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS,
MISSING_OR_DELETE_IMAGE_BREADCRUMB,
} from '../constants/index';
export default {
......@@ -36,7 +37,7 @@ export default {
DeleteModal,
TagsList,
TagsLoader,
EmptyTagsState,
EmptyState,
},
directives: {
GlResizeObserver: GlResizeObserverDirective,
......@@ -54,7 +55,7 @@ export default {
},
result({ data }) {
this.tagsPageInfo = data.containerRepository?.tags?.pageInfo;
this.breadCrumbState.updateName(data.containerRepository?.name);
this.updateBreadcrumb();
},
error() {
createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
......@@ -101,8 +102,15 @@ export default {
showPagination() {
return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage;
},
hasNoTags() {
return this.tags.length === 0;
},
},
methods: {
updateBreadcrumb() {
const name = this.image?.name || MISSING_OR_DELETE_IMAGE_BREADCRUMB;
this.breadCrumbState.updateName(name);
},
deleteTags(toBeDeleted) {
this.itemsToBeDeleted = this.tags.filter((tag) => toBeDeleted[tag.name]);
this.track('click_button');
......@@ -182,45 +190,48 @@ export default {
<template>
<div v-gl-resize-observer="handleResize" class="gl-my-3">
<delete-alert
v-model="deleteAlertType"
:garbage-collection-help-page-path="config.garbageCollectionHelpPagePath"
:is-admin="config.isAdmin"
class="gl-my-2"
/>
<template v-if="image">
<delete-alert
v-model="deleteAlertType"
:garbage-collection-help-page-path="config.garbageCollectionHelpPagePath"
:is-admin="config.isAdmin"
class="gl-my-2"
/>
<partial-cleanup-alert
v-if="showPartialCleanupWarning"
:run-cleanup-policies-help-page-path="config.runCleanupPoliciesHelpPagePath"
:cleanup-policies-help-page-path="config.cleanupPoliciesHelpPagePath"
@dismiss="dismissPartialCleanupWarning"
/>
<partial-cleanup-alert
v-if="showPartialCleanupWarning"
:run-cleanup-policies-help-page-path="config.runCleanupPoliciesHelpPagePath"
:cleanup-policies-help-page-path="config.cleanupPoliciesHelpPagePath"
@dismiss="dismissPartialCleanupWarning"
/>
<details-header :image="image" :metadata-loading="isLoading" />
<details-header :image="image" :metadata-loading="isLoading" />
<tags-loader v-if="isLoading" />
<template v-else>
<empty-tags-state v-if="tags.length === 0" :no-containers-image="config.noContainersImage" />
<tags-loader v-if="isLoading" />
<template v-else>
<tags-list :tags="tags" :is-mobile="isMobile" @delete="deleteTags" />
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
v-if="showPagination"
:has-next-page="tagsPageInfo.hasNextPage"
:has-previous-page="tagsPageInfo.hasPreviousPage"
class="gl-mt-3"
@prev="fetchPreviousPage"
@next="fetchNextPage"
/>
</div>
<empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" />
<template v-else>
<tags-list :tags="tags" :is-mobile="isMobile" @delete="deleteTags" />
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
v-if="showPagination"
:has-next-page="tagsPageInfo.hasNextPage"
:has-previous-page="tagsPageInfo.hasPreviousPage"
class="gl-mt-3"
@prev="fetchPreviousPage"
@next="fetchNextPage"
/>
</div>
</template>
</template>
</template>
<delete-modal
ref="deleteModal"
:items-to-be-deleted="itemsToBeDeleted"
@confirmDelete="handleDelete"
@cancel="track('cancel_delete')"
/>
<delete-modal
ref="deleteModal"
:items-to-be-deleted="itemsToBeDeleted"
@confirmDelete="handleDelete"
@cancel="track('cancel_delete')"
/>
</template>
<empty-state v-else is-empty-image :no-containers-image="config.noContainersImage" />
</div>
</template>
---
title: Add 404 state to container registry details page
merge_request: 52466
author:
type: changed
......@@ -7680,6 +7680,9 @@ msgstr ""
msgid "ContainerRegistry|Image repository deletion failed"
msgstr ""
msgid "ContainerRegistry|Image repository not found"
msgstr ""
msgid "ContainerRegistry|Image repository will be deleted"
msgstr ""
......@@ -7805,9 +7808,15 @@ 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 image repository could not be found."
msgstr ""
msgid "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."
msgstr ""
msgid "ContainerRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page."
msgstr ""
msgid "ContainerRegistry|The value of this input should be less than 256 characters"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui';
import component from '~/registry/explorer/components/details_page/empty_tags_state.vue';
import component from '~/registry/explorer/components/details_page/empty_state.vue';
import {
EMPTY_IMAGE_REPOSITORY_TITLE,
EMPTY_IMAGE_REPOSITORY_MESSAGE,
NO_TAGS_TITLE,
NO_TAGS_MESSAGE,
MISSING_OR_DELETED_IMAGE_TITLE,
MISSING_OR_DELETED_IMAGE_MESSAGE,
} from '~/registry/explorer/constants';
describe('EmptyTagsState component', () => {
......@@ -11,14 +13,12 @@ describe('EmptyTagsState component', () => {
const findEmptyState = () => wrapper.find(GlEmptyState);
const mountComponent = () => {
const mountComponent = (propsData) => {
wrapper = shallowMount(component, {
stubs: {
GlEmptyState,
},
propsData: {
noContainersImage: 'foo',
},
propsData,
});
};
......@@ -32,12 +32,23 @@ describe('EmptyTagsState component', () => {
expect(findEmptyState().exists()).toBe(true);
});
it('has the correct props', () => {
mountComponent();
expect(findEmptyState().props()).toMatchObject({
title: EMPTY_IMAGE_REPOSITORY_TITLE,
description: EMPTY_IMAGE_REPOSITORY_MESSAGE,
svgPath: 'foo',
});
});
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',
});
},
);
});
......@@ -235,3 +235,9 @@ export const graphQLProjectImageRepositoriesDetailsMock = {
},
},
};
export const graphQLEmptyImageDetailsMock = {
data: {
containerRepository: null,
},
};
......@@ -11,7 +11,7 @@ import PartialCleanupAlert from '~/registry/explorer/components/details_page/par
import DetailsHeader from '~/registry/explorer/components/details_page/details_header.vue';
import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue';
import TagsList from '~/registry/explorer/components/details_page/tags_list.vue';
import EmptyTagsState from '~/registry/explorer/components/details_page/empty_tags_state.vue';
import EmptyTagsState from '~/registry/explorer/components/details_page/empty_state.vue';
import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql';
import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql';
......@@ -23,6 +23,7 @@ import {
graphQLImageDetailsEmptyTagsMock,
graphQLDeleteImageRepositoryTagsMock,
containerRepositoryMock,
graphQLEmptyImageDetailsMock,
tagsMock,
tagsPageInfo,
} from '../mock_data';
......@@ -40,7 +41,7 @@ describe('Details Page', () => {
const findTagsList = () => wrapper.find(TagsList);
const findDeleteAlert = () => wrapper.find(DeleteAlert);
const findDetailsHeader = () => wrapper.find(DetailsHeader);
const findEmptyTagsState = () => wrapper.find(EmptyTagsState);
const findEmptyState = () => wrapper.find(EmptyTagsState);
const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert);
const routeId = 1;
......@@ -134,6 +135,27 @@ describe('Details Page', () => {
});
});
describe('when the image does not exist', () => {
it('does not show the default ui', async () => {
mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) });
await waitForApolloRequestRender();
expect(findTagsLoader().exists()).toBe(false);
expect(findDetailsHeader().exists()).toBe(false);
expect(findTagsList().exists()).toBe(false);
expect(findPagination().exists()).toBe(false);
});
it('shows an empty state message', async () => {
mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) });
await waitForApolloRequestRender();
expect(findEmptyState().exists()).toBe(true);
});
});
describe('when the list of tags is empty', () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageDetailsEmptyTagsMock);
......@@ -142,7 +164,7 @@ describe('Details Page', () => {
await waitForApolloRequestRender();
expect(findEmptyTagsState().exists()).toBe(true);
expect(findEmptyState().exists()).toBe(true);
});
it('does not show the loader', async () => {
......
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