Commit 6f3b0814 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera Committed by Mike Greiling

Set breadcrumb to Root image when name missing

- source
- tests
parent 5492f611
<script> <script>
import { GlSprintf, GlButton } from '@gitlab/ui'; import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, n__ } from '~/locale'; import { sprintf, n__ } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import { import {
DETAILS_PAGE_TITLE,
UPDATED_AT, UPDATED_AT,
CLEANUP_UNSCHEDULED_TEXT, CLEANUP_UNSCHEDULED_TEXT,
CLEANUP_SCHEDULED_TEXT, CLEANUP_SCHEDULED_TEXT,
...@@ -20,11 +19,16 @@ import { ...@@ -20,11 +19,16 @@ import {
UNSCHEDULED_STATUS, UNSCHEDULED_STATUS,
SCHEDULED_STATUS, SCHEDULED_STATUS,
ONGOING_STATUS, ONGOING_STATUS,
ROOT_IMAGE_TEXT,
ROOT_IMAGE_TOOLTIP,
} from '../../constants/index'; } from '../../constants/index';
export default { export default {
name: 'DetailsHeader', name: 'DetailsHeader',
components: { GlSprintf, GlButton, TitleArea, MetadataItem }, components: { GlButton, GlIcon, TitleArea, MetadataItem },
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin], mixins: [timeagoMixin],
props: { props: {
image: { image: {
...@@ -73,9 +77,12 @@ export default { ...@@ -73,9 +77,12 @@ export default {
deleteButtonDisabled() { deleteButtonDisabled() {
return this.disabled || !this.image.canDelete; return this.disabled || !this.image.canDelete;
}, },
}, rootImageTooltip() {
i18n: { return !this.image.name ? ROOT_IMAGE_TOOLTIP : '';
DETAILS_PAGE_TITLE, },
imageName() {
return this.image.name || ROOT_IMAGE_TEXT;
},
}, },
}; };
</script> </script>
...@@ -84,12 +91,15 @@ export default { ...@@ -84,12 +91,15 @@ export default {
<title-area :metadata-loading="metadataLoading"> <title-area :metadata-loading="metadataLoading">
<template #title> <template #title>
<span data-testid="title"> <span data-testid="title">
<gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE"> {{ imageName }}
<template #imageName>
{{ image.name }}
</template>
</gl-sprintf>
</span> </span>
<gl-icon
v-if="rootImageTooltip"
v-gl-tooltip="rootImageTooltip"
class="gl-text-blue-600"
name="information-o"
:aria-label="rootImageTooltip"
/>
</template> </template>
<template #metadata-tags-count> <template #metadata-tags-count>
<metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" /> <metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" />
......
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
CLEANUP_TIMED_OUT_ERROR_MESSAGE, CLEANUP_TIMED_OUT_ERROR_MESSAGE,
IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS, IMAGE_FAILED_DELETED_STATUS,
ROOT_IMAGE_TEXT,
} from '../../constants/index'; } from '../../constants/index';
import DeleteButton from '../delete_button.vue'; import DeleteButton from '../delete_button.vue';
...@@ -74,6 +75,9 @@ export default { ...@@ -74,6 +75,9 @@ export default {
} }
return null; return null;
}, },
imageName() {
return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`;
},
}, },
}; };
</script> </script>
...@@ -95,7 +99,7 @@ export default { ...@@ -95,7 +99,7 @@ export default {
data-qa-selector="registry_image_content" data-qa-selector="registry_image_content"
:to="{ name: 'details', params: { id } }" :to="{ name: 'details', params: { id } }"
> >
{{ item.path }} {{ imageName }}
</router-link> </router-link>
<clipboard-button <clipboard-button
v-if="item.location" v-if="item.location"
......
import { s__ } from '~/locale';
export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image');
...@@ -2,7 +2,6 @@ import { helpPagePath } from '~/helpers/help_page_helper'; ...@@ -2,7 +2,6 @@ import { helpPagePath } from '~/helpers/help_page_helper';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
// Translations strings // Translations strings
export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags');
export const DELETE_TAG_ERROR_MESSAGE = s__( export const DELETE_TAG_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while marking the tag for deletion.', 'ContainerRegistry|Something went wrong while marking the tag for deletion.',
); );
...@@ -53,7 +52,8 @@ export const MISSING_OR_DELETED_IMAGE_TITLE = s__( ...@@ -53,7 +52,8 @@ export const MISSING_OR_DELETED_IMAGE_TITLE = s__(
export const MISSING_OR_DELETED_IMAGE_MESSAGE = 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.', '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', 'ContainerRegistry|Image repository not found',
); );
...@@ -112,6 +112,10 @@ export const FAILED_DELETION_STATUS_MESSAGE = s__( ...@@ -112,6 +112,10 @@ export const FAILED_DELETION_STATUS_MESSAGE = s__(
'ContainerRegistry|This image repository has failed to be deleted', '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 // Parameters
export const DEFAULT_PAGE = 1; export const DEFAULT_PAGE = 1;
......
export * from './common';
export * from './expiration_policies'; export * from './expiration_policies';
export * from './quick_start'; export * from './quick_start';
export * from './list'; export * from './list';
......
...@@ -24,7 +24,8 @@ import { ...@@ -24,7 +24,8 @@ import {
GRAPHQL_PAGE_SIZE, GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE, FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS, UNFINISHED_STATUS,
MISSING_OR_DELETE_IMAGE_BREADCRUMB, MISSING_OR_DELETED_IMAGE_BREADCRUMB,
ROOT_IMAGE_TEXT,
} 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';
...@@ -116,7 +117,9 @@ export default { ...@@ -116,7 +117,9 @@ export default {
}, },
methods: { methods: {
updateBreadcrumb() { 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); this.breadCrumbState.updateName(name);
}, },
deleteTags(toBeDeleted) { deleteTags(toBeDeleted) {
......
---
title: Use Root Image for images with missing name
merge_request: 54693
author:
type: changed
...@@ -7958,9 +7958,6 @@ msgid_plural "ContainerRegistry|%{count} Tags" ...@@ -7958,9 +7958,6 @@ msgid_plural "ContainerRegistry|%{count} Tags"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "ContainerRegistry|%{imageName} tags"
msgstr ""
msgid "ContainerRegistry|%{strongStart}Disabled%{strongEnd} - Tags will not be automatically deleted." msgid "ContainerRegistry|%{strongStart}Disabled%{strongEnd} - Tags will not be automatically deleted."
msgstr "" msgstr ""
...@@ -8063,6 +8060,9 @@ msgstr "" ...@@ -8063,6 +8060,9 @@ msgstr ""
msgid "ContainerRegistry|Image repository will be deleted" msgid "ContainerRegistry|Image repository will be deleted"
msgstr "" msgstr ""
msgid "ContainerRegistry|Image repository with no name located at the project URL."
msgstr ""
msgid "ContainerRegistry|Image tags" msgid "ContainerRegistry|Image tags"
msgstr "" msgstr ""
...@@ -8128,6 +8128,9 @@ msgstr "" ...@@ -8128,6 +8128,9 @@ msgstr ""
msgid "ContainerRegistry|Remove these tags" msgid "ContainerRegistry|Remove these tags"
msgstr "" msgstr ""
msgid "ContainerRegistry|Root image"
msgstr ""
msgid "ContainerRegistry|Run cleanup:" msgid "ContainerRegistry|Run cleanup:"
msgstr "" msgstr ""
......
...@@ -67,7 +67,13 @@ RSpec.describe 'Container Registry', :js do ...@@ -67,7 +67,13 @@ RSpec.describe 'Container Registry', :js do
end end
it 'shows the image title' do 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 end
it 'user removes a specific tag from container repository' do it 'user removes a specific tag from container repository' do
......
...@@ -82,7 +82,13 @@ RSpec.describe 'Container Registry', :js do ...@@ -82,7 +82,13 @@ RSpec.describe 'Container Registry', :js do
end end
it 'shows the image title' do 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 end
it 'user removes a specific tag from container repository' do it 'user removes a specific tag from container repository' do
......
import { GlSprintf, GlButton } from '@gitlab/ui'; import { GlButton, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { useFakeDate } from 'helpers/fake_date'; 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 component from '~/registry/explorer/components/details_page/details_header.vue';
import { import {
DETAILS_PAGE_TITLE,
UNSCHEDULED_STATUS, UNSCHEDULED_STATUS,
SCHEDULED_STATUS, SCHEDULED_STATUS,
ONGOING_STATUS, ONGOING_STATUS,
...@@ -13,6 +13,8 @@ import { ...@@ -13,6 +13,8 @@ import {
CLEANUP_SCHEDULED_TOOLTIP, CLEANUP_SCHEDULED_TOOLTIP,
CLEANUP_ONGOING_TOOLTIP, CLEANUP_ONGOING_TOOLTIP,
CLEANUP_UNFINISHED_TOOLTIP, CLEANUP_UNFINISHED_TOOLTIP,
ROOT_IMAGE_TEXT,
ROOT_IMAGE_TOOLTIP,
} from '~/registry/explorer/constants'; } from '~/registry/explorer/constants';
import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue';
...@@ -41,6 +43,7 @@ describe('Details Header', () => { ...@@ -41,6 +43,7 @@ describe('Details Header', () => {
const findTagsCount = () => findByTestId('tags-count'); const findTagsCount = () => findByTestId('tags-count');
const findCleanup = () => findByTestId('cleanup'); const findCleanup = () => findByTestId('cleanup');
const findDeleteButton = () => wrapper.find(GlButton); const findDeleteButton = () => wrapper.find(GlButton);
const findInfoIcon = () => wrapper.find(GlIcon);
const waitForMetadataItems = async () => { const waitForMetadataItems = async () => {
// Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available // 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', () => { ...@@ -51,8 +54,10 @@ describe('Details Header', () => {
const mountComponent = (propsData = { image: defaultImage }) => { const mountComponent = (propsData = { image: defaultImage }) => {
wrapper = shallowMount(component, { wrapper = shallowMount(component, {
propsData, propsData,
directives: {
GlTooltip: createMockDirective(),
},
stubs: { stubs: {
GlSprintf,
TitleArea, TitleArea,
}, },
}); });
...@@ -62,15 +67,41 @@ describe('Details Header', () => { ...@@ -62,15 +67,41 @@ describe('Details Header', () => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
}); });
describe('image name', () => {
describe('missing image name', () => {
it('root image ', () => {
mountComponent({ image: { ...defaultImage, name: '' } });
it('has the correct title ', () => { expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT);
mountComponent({ image: { ...defaultImage, name: '' } }); });
expect(findTitle().text()).toMatchInterpolatedText(DETAILS_PAGE_TITLE);
});
it('shows imageName in the title', () => { it('has an icon', () => {
mountComponent(); mountComponent({ image: { ...defaultImage, name: '' } });
expect(findTitle().text()).toContain('foo');
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', () => { describe('delete button', () => {
......
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
CLEANUP_TIMED_OUT_ERROR_MESSAGE, CLEANUP_TIMED_OUT_ERROR_MESSAGE,
IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_DELETE_SCHEDULED_STATUS,
IMAGE_FAILED_DELETED_STATUS, IMAGE_FAILED_DELETED_STATUS,
ROOT_IMAGE_TEXT,
} from '~/registry/explorer/constants'; } from '~/registry/explorer/constants';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue';
...@@ -73,8 +74,8 @@ describe('Image List Row', () => { ...@@ -73,8 +74,8 @@ describe('Image List Row', () => {
mountComponent(); mountComponent();
const link = findDetailsLink(); const link = findDetailsLink();
expect(link.html()).toContain(item.path); expect(link.text()).toBe(item.path);
expect(link.props('to')).toMatchObject({ expect(findDetailsLink().props('to')).toMatchObject({
name: 'details', name: 'details',
params: { params: {
id: getIdFromGraphQLId(item.id), id: getIdFromGraphQLId(item.id),
...@@ -82,6 +83,12 @@ describe('Image List Row', () => { ...@@ -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', () => { it('contains a clipboard button', () => {
mountComponent(); mountComponent();
const button = findClipboardButton(); const button = findClipboardButton();
......
...@@ -17,6 +17,8 @@ import { ...@@ -17,6 +17,8 @@ import {
UNFINISHED_STATUS, UNFINISHED_STATUS,
DELETE_SCHEDULED, DELETE_SCHEDULED,
ALERT_DANGER_IMAGE, ALERT_DANGER_IMAGE,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
ROOT_IMAGE_TEXT,
} from '~/registry/explorer/constants'; } from '~/registry/explorer/constants';
import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; 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'; import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql';
...@@ -515,6 +517,26 @@ describe('Details Page', () => { ...@@ -515,6 +517,26 @@ describe('Details Page', () => {
expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name); 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', () => { describe('when the image has a status different from null', () => {
......
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