diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue index a4b4c08bc3482fad568785ff223a320927b056a1..f46068acd681b841c288568a2ec9606624fc2911 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue @@ -1,11 +1,10 @@ <script> -import { GlSprintf, GlButton } from '@gitlab/ui'; +import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { sprintf, n__ } from '~/locale'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { - DETAILS_PAGE_TITLE, UPDATED_AT, CLEANUP_UNSCHEDULED_TEXT, CLEANUP_SCHEDULED_TEXT, @@ -20,11 +19,16 @@ import { UNSCHEDULED_STATUS, SCHEDULED_STATUS, ONGOING_STATUS, + ROOT_IMAGE_TEXT, + ROOT_IMAGE_TOOLTIP, } from '../../constants/index'; export default { name: 'DetailsHeader', - components: { GlSprintf, GlButton, TitleArea, MetadataItem }, + components: { GlButton, GlIcon, TitleArea, MetadataItem }, + directives: { + GlTooltip: GlTooltipDirective, + }, mixins: [timeagoMixin], props: { image: { @@ -73,9 +77,12 @@ export default { deleteButtonDisabled() { return this.disabled || !this.image.canDelete; }, - }, - i18n: { - DETAILS_PAGE_TITLE, + rootImageTooltip() { + return !this.image.name ? ROOT_IMAGE_TOOLTIP : ''; + }, + imageName() { + return this.image.name || ROOT_IMAGE_TEXT; + }, }, }; </script> @@ -84,12 +91,15 @@ export default { <title-area :metadata-loading="metadataLoading"> <template #title> <span data-testid="title"> - <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE"> - <template #imageName> - {{ image.name }} - </template> - </gl-sprintf> + {{ imageName }} </span> + <gl-icon + v-if="rootImageTooltip" + v-gl-tooltip="rootImageTooltip" + class="gl-text-blue-600" + name="information-o" + :aria-label="rootImageTooltip" + /> </template> <template #metadata-tags-count> <metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" /> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue index a09844b59bc111d08d576e79c217b27dcf1cbfca..0373a84b271bfc6067b5643736b3c1b7e6cee23a 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue @@ -13,6 +13,7 @@ import { CLEANUP_TIMED_OUT_ERROR_MESSAGE, IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS, + ROOT_IMAGE_TEXT, } from '../../constants/index'; import DeleteButton from '../delete_button.vue'; @@ -74,6 +75,9 @@ export default { } return null; }, + imageName() { + return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`; + }, }, }; </script> @@ -95,7 +99,7 @@ export default { data-qa-selector="registry_image_content" :to="{ name: 'details', params: { id } }" > - {{ item.path }} + {{ imageName }} </router-link> <clipboard-button v-if="item.location" diff --git a/app/assets/javascripts/registry/explorer/constants/common.js b/app/assets/javascripts/registry/explorer/constants/common.js new file mode 100644 index 0000000000000000000000000000000000000000..dc71ef8450b294934f8b8d54d955ffd312580803 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/constants/common.js @@ -0,0 +1,3 @@ +import { s__ } from '~/locale'; + +export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image'); diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js index 3f04538a18bb24339287d9ce174dba430121bc03..7220f9646db82d0b975a5961797d0e689beb03af 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -2,7 +2,6 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { s__, __ } from '~/locale'; // Translations strings -export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags'); export const DELETE_TAG_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while marking the tag for deletion.', ); @@ -53,7 +52,8 @@ export const MISSING_OR_DELETED_IMAGE_TITLE = s__( 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__( + +export const MISSING_OR_DELETED_IMAGE_BREADCRUMB = s__( 'ContainerRegistry|Image repository not found', ); @@ -112,6 +112,10 @@ export const FAILED_DELETION_STATUS_MESSAGE = s__( 'ContainerRegistry|This image repository has failed to be deleted', ); +export const ROOT_IMAGE_TOOLTIP = s__( + 'ContainerRegistry|Image repository with no name located at the project URL.', +); + // Parameters export const DEFAULT_PAGE = 1; diff --git a/app/assets/javascripts/registry/explorer/constants/index.js b/app/assets/javascripts/registry/explorer/constants/index.js index 10816e12ead333ae2b194dd61e9a415e719ecc94..6886356d8e294cf2243d6fc22b99fe1468b0d8d8 100644 --- a/app/assets/javascripts/registry/explorer/constants/index.js +++ b/app/assets/javascripts/registry/explorer/constants/index.js @@ -1,3 +1,4 @@ +export * from './common'; export * from './expiration_policies'; export * from './quick_start'; export * from './list'; diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index 0403467468ad9c38962dc23a7255b9ea801800d4..2f515356fa7f793616bb9425b7973211edd87fab 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -24,7 +24,8 @@ import { GRAPHQL_PAGE_SIZE, FETCH_IMAGES_LIST_ERROR_MESSAGE, UNFINISHED_STATUS, - MISSING_OR_DELETE_IMAGE_BREADCRUMB, + MISSING_OR_DELETED_IMAGE_BREADCRUMB, + ROOT_IMAGE_TEXT, } 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'; @@ -116,7 +117,9 @@ export default { }, methods: { updateBreadcrumb() { - const name = this.image?.name || MISSING_OR_DELETE_IMAGE_BREADCRUMB; + const name = this.image?.id + ? this.image?.name || ROOT_IMAGE_TEXT + : MISSING_OR_DELETED_IMAGE_BREADCRUMB; this.breadCrumbState.updateName(name); }, deleteTags(toBeDeleted) { diff --git a/changelogs/unreleased/320901-container-page-says-image-repository-not-found.yml b/changelogs/unreleased/320901-container-page-says-image-repository-not-found.yml new file mode 100644 index 0000000000000000000000000000000000000000..a9494b43b2e84b35ba6768e15b8531bf26e2f4fc --- /dev/null +++ b/changelogs/unreleased/320901-container-page-says-image-repository-not-found.yml @@ -0,0 +1,5 @@ +--- +title: Use Root Image for images with missing name +merge_request: 54693 +author: +type: changed diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 18eae551b0c6d71777f14e0769995d818bd0c192..e089cb1191e1bfb4ef36a3126ecb25a3521b9735 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -7958,9 +7958,6 @@ msgid_plural "ContainerRegistry|%{count} Tags" msgstr[0] "" msgstr[1] "" -msgid "ContainerRegistry|%{imageName} tags" -msgstr "" - msgid "ContainerRegistry|%{strongStart}Disabled%{strongEnd} - Tags will not be automatically deleted." msgstr "" @@ -8063,6 +8060,9 @@ msgstr "" msgid "ContainerRegistry|Image repository will be deleted" msgstr "" +msgid "ContainerRegistry|Image repository with no name located at the project URL." +msgstr "" + msgid "ContainerRegistry|Image tags" msgstr "" @@ -8128,6 +8128,9 @@ msgstr "" msgid "ContainerRegistry|Remove these tags" msgstr "" +msgid "ContainerRegistry|Root image" +msgstr "" + msgid "ContainerRegistry|Run cleanup:" msgstr "" diff --git a/spec/features/groups/container_registry_spec.rb b/spec/features/groups/container_registry_spec.rb index cacabdda22d79473b3c7675a3baee7a81cfcdaea..65374263f450ad8532ec2cdbf91493233c740b31 100644 --- a/spec/features/groups/container_registry_spec.rb +++ b/spec/features/groups/container_registry_spec.rb @@ -67,7 +67,13 @@ end it 'shows the image title' do - expect(page).to have_content 'my/image tags' + expect(page).to have_content 'my/image' + end + + it 'shows the image tags' do + expect(page).to have_content 'Image tags' + first_tag = first('[data-testid="name"]') + expect(first_tag).to have_content 'latest' end it 'user removes a specific tag from container repository' do diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb index d0ad6668c07623099d927782413055c52dcd5f1b..40d0260eafdd27a7a90754c4b9454b0605a189e9 100644 --- a/spec/features/projects/container_registry_spec.rb +++ b/spec/features/projects/container_registry_spec.rb @@ -82,7 +82,13 @@ end it 'shows the image title' do - expect(page).to have_content 'my/image tags' + expect(page).to have_content 'my/image' + end + + it 'shows the image tags' do + expect(page).to have_content 'Image tags' + first_tag = first('[data-testid="name"]') + expect(first_tag).to have_content '1' end it 'user removes a specific tag from container repository' do diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js index 3fa3a2ae1de7c2c3d6f50c4514e828c8750c0f25..b50ed87a563364c08552e40c5de3681e57bc907a 100644 --- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js @@ -1,9 +1,9 @@ -import { GlSprintf, GlButton } from '@gitlab/ui'; +import { GlButton, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { useFakeDate } from 'helpers/fake_date'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import component from '~/registry/explorer/components/details_page/details_header.vue'; import { - DETAILS_PAGE_TITLE, UNSCHEDULED_STATUS, SCHEDULED_STATUS, ONGOING_STATUS, @@ -13,6 +13,8 @@ import { CLEANUP_SCHEDULED_TOOLTIP, CLEANUP_ONGOING_TOOLTIP, CLEANUP_UNFINISHED_TOOLTIP, + ROOT_IMAGE_TEXT, + ROOT_IMAGE_TOOLTIP, } from '~/registry/explorer/constants'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; @@ -41,6 +43,7 @@ describe('Details Header', () => { const findTagsCount = () => findByTestId('tags-count'); const findCleanup = () => findByTestId('cleanup'); const findDeleteButton = () => wrapper.find(GlButton); + const findInfoIcon = () => wrapper.find(GlIcon); const waitForMetadataItems = async () => { // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available @@ -51,8 +54,10 @@ describe('Details Header', () => { const mountComponent = (propsData = { image: defaultImage }) => { wrapper = shallowMount(component, { propsData, + directives: { + GlTooltip: createMockDirective(), + }, stubs: { - GlSprintf, TitleArea, }, }); @@ -62,15 +67,41 @@ describe('Details Header', () => { wrapper.destroy(); wrapper = null; }); + describe('image name', () => { + describe('missing image name', () => { + it('root image ', () => { + mountComponent({ image: { ...defaultImage, name: '' } }); - it('has the correct title ', () => { - mountComponent({ image: { ...defaultImage, name: '' } }); - expect(findTitle().text()).toMatchInterpolatedText(DETAILS_PAGE_TITLE); - }); + expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT); + }); - it('shows imageName in the title', () => { - mountComponent(); - expect(findTitle().text()).toContain('foo'); + it('has an icon', () => { + mountComponent({ image: { ...defaultImage, name: '' } }); + + expect(findInfoIcon().exists()).toBe(true); + expect(findInfoIcon().props('name')).toBe('information-o'); + }); + + it('has a tooltip', () => { + mountComponent({ image: { ...defaultImage, name: '' } }); + + const tooltip = getBinding(findInfoIcon().element, 'gl-tooltip'); + expect(tooltip.value).toBe(ROOT_IMAGE_TOOLTIP); + }); + }); + + describe('with image name present', () => { + it('shows image.name ', () => { + mountComponent(); + expect(findTitle().text()).toContain('foo'); + }); + + it('has no icon', () => { + mountComponent(); + + expect(findInfoIcon().exists()).toBe(false); + }); + }); }); describe('delete button', () => { diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js index d6ee871341b6c7f33608961d81105e224d5cb736..6c897b983f7fe20a726973d2572f19e9261263b9 100644 --- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js @@ -12,6 +12,7 @@ import { CLEANUP_TIMED_OUT_ERROR_MESSAGE, IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS, + ROOT_IMAGE_TEXT, } from '~/registry/explorer/constants'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; @@ -73,8 +74,8 @@ describe('Image List Row', () => { mountComponent(); const link = findDetailsLink(); - expect(link.html()).toContain(item.path); - expect(link.props('to')).toMatchObject({ + expect(link.text()).toBe(item.path); + expect(findDetailsLink().props('to')).toMatchObject({ name: 'details', params: { id: getIdFromGraphQLId(item.id), @@ -82,6 +83,12 @@ describe('Image List Row', () => { }); }); + it(`when the image has no name appends ${ROOT_IMAGE_TEXT} to the path`, () => { + mountComponent({ item: { ...item, name: '' } }); + + expect(findDetailsLink().text()).toBe(`${item.path}/ ${ROOT_IMAGE_TEXT}`); + }); + it('contains a clipboard button', () => { mountComponent(); const button = findClipboardButton(); diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index 65c58bf98747360ac9ced8a3af5988b5181d7ff8..76baf4f72c96af91a414cc6d29553d5c1c1748a2 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -17,6 +17,8 @@ import { UNFINISHED_STATUS, DELETE_SCHEDULED, ALERT_DANGER_IMAGE, + MISSING_OR_DELETED_IMAGE_BREADCRUMB, + ROOT_IMAGE_TEXT, } from '~/registry/explorer/constants'; import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; @@ -515,6 +517,26 @@ describe('Details Page', () => { expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name); }); + + it(`when the image is missing set the breadcrumb to ${MISSING_OR_DELETED_IMAGE_BREADCRUMB}`, async () => { + mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) }); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(MISSING_OR_DELETED_IMAGE_BREADCRUMB); + }); + + it(`when the image has no name set the breadcrumb to ${ROOT_IMAGE_TEXT}`, async () => { + mountComponent({ + resolver: jest + .fn() + .mockResolvedValue(graphQLImageDetailsMock({ ...containerRepositoryMock, name: null })), + }); + + await waitForApolloRequestRender(); + + expect(breadCrumbState.updateName).toHaveBeenCalledWith(ROOT_IMAGE_TEXT); + }); }); describe('when the image has a status different from null', () => {