Commit af8249ab authored by David O'Regan's avatar David O'Regan

Merge branch '296962-container-registry-details-split-details-from-tags-call' into 'master'

Defer tag count loading in container registry details

See merge request gitlab-org/gitlab!60595
parents 2a8ba9d6 26041143
<script> <script>
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, n__ } from '~/locale'; import { sprintf, n__, s__ } 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';
...@@ -23,6 +23,8 @@ import { ...@@ -23,6 +23,8 @@ import {
ROOT_IMAGE_TOOLTIP, ROOT_IMAGE_TOOLTIP,
} from '../../constants/index'; } from '../../constants/index';
import getContainerRepositoryTagsCountQuery from '../../graphql/queries/get_container_repository_tags_count.query.graphql';
export default { export default {
name: 'DetailsHeader', name: 'DetailsHeader',
components: { GlButton, GlIcon, TitleArea, MetadataItem }, components: { GlButton, GlIcon, TitleArea, MetadataItem },
...@@ -35,60 +37,77 @@ export default { ...@@ -35,60 +37,77 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
metadataLoading: {
type: Boolean,
required: false,
default: false,
},
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false, default: false,
required: false, required: false,
}, },
}, },
data() {
return {
containerRepository: {},
fetchTagsCount: false,
};
},
apollo: {
containerRepository: {
query: getContainerRepositoryTagsCountQuery,
variables() {
return {
id: this.image.id,
};
},
},
},
computed: { computed: {
imageDetails() {
return { ...this.image, ...this.containerRepository };
},
visibilityIcon() { visibilityIcon() {
return this.image?.project?.visibility === 'public' ? 'eye' : 'eye-slash'; return this.imageDetails?.project?.visibility === 'public' ? 'eye' : 'eye-slash';
}, },
timeAgo() { timeAgo() {
return this.timeFormatted(this.image.updatedAt); return this.timeFormatted(this.imageDetails.updatedAt);
}, },
updatedText() { updatedText() {
return sprintf(UPDATED_AT, { time: this.timeAgo }); return sprintf(UPDATED_AT, { time: this.timeAgo });
}, },
tagCountText() { tagCountText() {
return n__('%d tag', '%d tags', this.image.tagsCount); if (this.$apollo.queries.containerRepository.loading) {
return s__('ContainerRegistry|-- tags');
}
return n__('%d tag', '%d tags', this.imageDetails.tagsCount);
}, },
cleanupTextAndTooltip() { cleanupTextAndTooltip() {
if (!this.image.project.containerExpirationPolicy?.enabled) { if (!this.imageDetails.project.containerExpirationPolicy?.enabled) {
return { text: CLEANUP_DISABLED_TEXT, tooltip: CLEANUP_DISABLED_TOOLTIP }; return { text: CLEANUP_DISABLED_TEXT, tooltip: CLEANUP_DISABLED_TOOLTIP };
} }
return { return {
[UNSCHEDULED_STATUS]: { [UNSCHEDULED_STATUS]: {
text: sprintf(CLEANUP_UNSCHEDULED_TEXT, { text: sprintf(CLEANUP_UNSCHEDULED_TEXT, {
time: this.timeFormatted(this.image.project.containerExpirationPolicy.nextRunAt), time: this.timeFormatted(this.imageDetails.project.containerExpirationPolicy.nextRunAt),
}), }),
}, },
[SCHEDULED_STATUS]: { text: CLEANUP_SCHEDULED_TEXT, tooltip: CLEANUP_SCHEDULED_TOOLTIP }, [SCHEDULED_STATUS]: { text: CLEANUP_SCHEDULED_TEXT, tooltip: CLEANUP_SCHEDULED_TOOLTIP },
[ONGOING_STATUS]: { text: CLEANUP_ONGOING_TEXT, tooltip: CLEANUP_ONGOING_TOOLTIP }, [ONGOING_STATUS]: { text: CLEANUP_ONGOING_TEXT, tooltip: CLEANUP_ONGOING_TOOLTIP },
[UNFINISHED_STATUS]: { text: CLEANUP_UNFINISHED_TEXT, tooltip: CLEANUP_UNFINISHED_TOOLTIP }, [UNFINISHED_STATUS]: { text: CLEANUP_UNFINISHED_TEXT, tooltip: CLEANUP_UNFINISHED_TOOLTIP },
}[this.image?.expirationPolicyCleanupStatus]; }[this.imageDetails?.expirationPolicyCleanupStatus];
}, },
deleteButtonDisabled() { deleteButtonDisabled() {
return this.disabled || !this.image.canDelete; return this.disabled || !this.imageDetails.canDelete;
}, },
rootImageTooltip() { rootImageTooltip() {
return !this.image.name ? ROOT_IMAGE_TOOLTIP : ''; return !this.imageDetails.name ? ROOT_IMAGE_TOOLTIP : '';
}, },
imageName() { imageName() {
return this.image.name || ROOT_IMAGE_TEXT; return this.imageDetails.name || ROOT_IMAGE_TEXT;
}, },
}, },
}; };
</script> </script>
<template> <template>
<title-area :metadata-loading="metadataLoading"> <title-area>
<template #title> <template #title>
<span data-testid="title"> <span data-testid="title">
{{ imageName }} {{ imageName }}
...@@ -124,12 +143,7 @@ export default { ...@@ -124,12 +143,7 @@ export default {
/> />
</template> </template>
<template #right-actions> <template #right-actions>
<gl-button <gl-button variant="danger" :disabled="deleteButtonDisabled" @click="$emit('delete')">
v-if="!metadataLoading"
variant="danger"
:disabled="deleteButtonDisabled"
@click="$emit('delete')"
>
{{ __('Delete image repository') }} {{ __('Delete image repository') }}
</gl-button> </gl-button>
</template> </template>
......
...@@ -8,7 +8,6 @@ query getContainerRepositoryDetails($id: ID!) { ...@@ -8,7 +8,6 @@ query getContainerRepositoryDetails($id: ID!) {
canDelete canDelete
createdAt createdAt
updatedAt updatedAt
tagsCount
expirationPolicyStartedAt expirationPolicyStartedAt
expirationPolicyCleanupStatus expirationPolicyCleanupStatus
project { project {
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" #import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getContainerRepositoryDetails( query getContainerRepositoryTags(
$id: ID! $id: ID!
$first: Int $first: Int
$last: Int $last: Int
......
query getContainerRepositoryTagsCount($id: ID!) {
containerRepository(id: $id) {
id
tagsCount
}
}
...@@ -48,14 +48,11 @@ export default { ...@@ -48,14 +48,11 @@ export default {
mixins: [Tracking.mixin()], mixins: [Tracking.mixin()],
inject: ['breadCrumbState', 'config'], inject: ['breadCrumbState', 'config'],
apollo: { apollo: {
image: { containerRepository: {
query: getContainerRepositoryDetailsQuery, query: getContainerRepositoryDetailsQuery,
variables() { variables() {
return this.queryVariables; return this.queryVariables;
}, },
update(data) {
return data.containerRepository;
},
result() { result() {
this.updateBreadcrumb(); this.updateBreadcrumb();
}, },
...@@ -66,7 +63,7 @@ export default { ...@@ -66,7 +63,7 @@ export default {
}, },
data() { data() {
return { return {
image: {}, containerRepository: {},
itemsToBeDeleted: [], itemsToBeDeleted: [],
isMobile: false, isMobile: false,
mutationLoading: false, mutationLoading: false,
...@@ -82,12 +79,12 @@ export default { ...@@ -82,12 +79,12 @@ export default {
}; };
}, },
isLoading() { isLoading() {
return this.$apollo.queries.image.loading || this.mutationLoading; return this.$apollo.queries.containerRepository.loading || this.mutationLoading;
}, },
showPartialCleanupWarning() { showPartialCleanupWarning() {
return ( return (
this.config.showUnfinishedTagCleanupCallout && this.config.showUnfinishedTagCleanupCallout &&
this.image?.expirationPolicyCleanupStatus === UNFINISHED_STATUS && this.containerRepository?.expirationPolicyCleanupStatus === UNFINISHED_STATUS &&
!this.hidePartialCleanupWarning !this.hidePartialCleanupWarning
); );
}, },
...@@ -98,13 +95,13 @@ export default { ...@@ -98,13 +95,13 @@ export default {
}; };
}, },
pageActionsAreDisabled() { pageActionsAreDisabled() {
return Boolean(this.image?.status); return Boolean(this.containerRepository?.status);
}, },
}, },
methods: { methods: {
updateBreadcrumb() { updateBreadcrumb() {
const name = this.image?.id const name = this.containerRepository?.id
? this.image?.name || ROOT_IMAGE_TEXT ? this.containerRepository?.name || ROOT_IMAGE_TEXT
: MISSING_OR_DELETED_IMAGE_BREADCRUMB; : MISSING_OR_DELETED_IMAGE_BREADCRUMB;
this.breadCrumbState.updateName(name); this.breadCrumbState.updateName(name);
}, },
...@@ -164,7 +161,7 @@ export default { ...@@ -164,7 +161,7 @@ export default {
}, },
deleteImage() { deleteImage() {
this.deleteImageAlert = true; this.deleteImageAlert = true;
this.itemsToBeDeleted = [{ path: this.image.path }]; this.itemsToBeDeleted = [{ path: this.containerRepository.path }];
this.$refs.deleteModal.show(); this.$refs.deleteModal.show();
}, },
deleteImageError() { deleteImageError() {
...@@ -180,7 +177,7 @@ export default { ...@@ -180,7 +177,7 @@ export default {
<template> <template>
<div v-gl-resize-observer="handleResize" class="gl-my-3"> <div v-gl-resize-observer="handleResize" class="gl-my-3">
<template v-if="image"> <template v-if="containerRepository">
<delete-alert <delete-alert
v-model="deleteAlertType" v-model="deleteAlertType"
:garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath"
...@@ -195,11 +192,11 @@ export default { ...@@ -195,11 +192,11 @@ export default {
@dismiss="dismissPartialCleanupWarning" @dismiss="dismissPartialCleanupWarning"
/> />
<status-alert v-if="image.status" :status="image.status" /> <status-alert v-if="containerRepository.status" :status="containerRepository.status" />
<details-header <details-header
:image="image" v-if="!isLoading"
:metadata-loading="isLoading" :image="containerRepository"
:disabled="pageActionsAreDisabled" :disabled="pageActionsAreDisabled"
@delete="deleteImage" @delete="deleteImage"
/> />
...@@ -215,7 +212,7 @@ export default { ...@@ -215,7 +212,7 @@ export default {
/> />
<delete-image <delete-image
:id="image.id" :id="containerRepository.id"
ref="deleteImage" ref="deleteImage"
use-update-fn use-update-fn
@start="deleteImageIniit" @start="deleteImageIniit"
......
...@@ -8564,6 +8564,9 @@ msgstr "" ...@@ -8564,6 +8564,9 @@ msgstr ""
msgid "ContainerRegistry|%{title} was successfully scheduled for deletion" msgid "ContainerRegistry|%{title} was successfully scheduled for deletion"
msgstr "" msgstr ""
msgid "ContainerRegistry|-- tags"
msgstr ""
msgid "ContainerRegistry|Build an image" msgid "ContainerRegistry|Build an image"
msgstr "" msgstr ""
......
import { GlButton, GlIcon } from '@gitlab/ui'; import { GlButton, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { useFakeDate } from 'helpers/fake_date'; import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import waitForPromises from 'helpers/wait_for_promises';
import component from '~/registry/explorer/components/details_page/details_header.vue'; import component from '~/registry/explorer/components/details_page/details_header.vue';
import { import {
UNSCHEDULED_STATUS, UNSCHEDULED_STATUS,
...@@ -16,15 +19,18 @@ import { ...@@ -16,15 +19,18 @@ import {
ROOT_IMAGE_TEXT, ROOT_IMAGE_TEXT,
ROOT_IMAGE_TOOLTIP, ROOT_IMAGE_TOOLTIP,
} from '~/registry/explorer/constants'; } from '~/registry/explorer/constants';
import getContainerRepositoryTagCountQuery from '~/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql';
import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import { imageTagsCountMock } from '../../mock_data';
describe('Details Header', () => { describe('Details Header', () => {
let wrapper; let wrapper;
let apolloProvider;
let localVue;
const defaultImage = { const defaultImage = {
name: 'foo', name: 'foo',
updatedAt: '2020-11-03T13:29:21Z', updatedAt: '2020-11-03T13:29:21Z',
tagsCount: 10,
canDelete: true, canDelete: true,
project: { project: {
visibility: 'public', visibility: 'public',
...@@ -51,12 +57,31 @@ describe('Details Header', () => { ...@@ -51,12 +57,31 @@ describe('Details Header', () => {
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
}; };
const mountComponent = (propsData = { image: defaultImage }) => { const mountComponent = ({
propsData = { image: defaultImage },
resolver = jest.fn().mockResolvedValue(imageTagsCountMock()),
$apollo = undefined,
} = {}) => {
const mocks = {};
if ($apollo) {
mocks.$apollo = $apollo;
} else {
localVue = createLocalVue();
localVue.use(VueApollo);
const requestHandlers = [[getContainerRepositoryTagCountQuery, resolver]];
apolloProvider = createMockApollo(requestHandlers);
}
wrapper = shallowMount(component, { wrapper = shallowMount(component, {
localVue,
apolloProvider,
propsData, propsData,
directives: { directives: {
GlTooltip: createMockDirective(), GlTooltip: createMockDirective(),
}, },
mocks,
stubs: { stubs: {
TitleArea, TitleArea,
}, },
...@@ -64,41 +89,48 @@ describe('Details Header', () => { ...@@ -64,41 +89,48 @@ describe('Details Header', () => {
}; };
afterEach(() => { afterEach(() => {
// if we want to mix createMockApollo and manual mocks we need to reset everything
wrapper.destroy(); wrapper.destroy();
apolloProvider = undefined;
localVue = undefined;
wrapper = null; wrapper = null;
}); });
describe('image name', () => { describe('image name', () => {
describe('missing image name', () => { describe('missing image name', () => {
it('root image ', () => { beforeEach(() => {
mountComponent({ image: { ...defaultImage, name: '' } }); mountComponent({ propsData: { image: { ...defaultImage, name: '' } } });
return waitForPromises();
});
it('root image ', () => {
expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT); expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT);
}); });
it('has an icon', () => { it('has an icon', () => {
mountComponent({ image: { ...defaultImage, name: '' } });
expect(findInfoIcon().exists()).toBe(true); expect(findInfoIcon().exists()).toBe(true);
expect(findInfoIcon().props('name')).toBe('information-o'); expect(findInfoIcon().props('name')).toBe('information-o');
}); });
it('has a tooltip', () => { it('has a tooltip', () => {
mountComponent({ image: { ...defaultImage, name: '' } });
const tooltip = getBinding(findInfoIcon().element, 'gl-tooltip'); const tooltip = getBinding(findInfoIcon().element, 'gl-tooltip');
expect(tooltip.value).toBe(ROOT_IMAGE_TOOLTIP); expect(tooltip.value).toBe(ROOT_IMAGE_TOOLTIP);
}); });
}); });
describe('with image name present', () => { describe('with image name present', () => {
it('shows image.name ', () => { beforeEach(() => {
mountComponent(); mountComponent();
return waitForPromises();
});
it('shows image.name ', () => {
expect(findTitle().text()).toContain('foo'); expect(findTitle().text()).toContain('foo');
}); });
it('has no icon', () => { it('has no icon', () => {
mountComponent();
expect(findInfoIcon().exists()).toBe(false); expect(findInfoIcon().exists()).toBe(false);
}); });
}); });
...@@ -111,12 +143,6 @@ describe('Details Header', () => { ...@@ -111,12 +143,6 @@ describe('Details Header', () => {
expect(findDeleteButton().exists()).toBe(true); expect(findDeleteButton().exists()).toBe(true);
}); });
it('is hidden while loading', () => {
mountComponent({ image: defaultImage, metadataLoading: true });
expect(findDeleteButton().exists()).toBe(false);
});
it('has the correct text', () => { it('has the correct text', () => {
mountComponent(); mountComponent();
...@@ -149,7 +175,7 @@ describe('Details Header', () => { ...@@ -149,7 +175,7 @@ describe('Details Header', () => {
`( `(
'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled', 'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled',
({ canDelete, disabled, isDisabled }) => { ({ canDelete, disabled, isDisabled }) => {
mountComponent({ image: { ...defaultImage, canDelete }, disabled }); mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } });
expect(findDeleteButton().props('disabled')).toBe(isDisabled); expect(findDeleteButton().props('disabled')).toBe(isDisabled);
}, },
...@@ -158,15 +184,32 @@ describe('Details Header', () => { ...@@ -158,15 +184,32 @@ describe('Details Header', () => {
describe('metadata items', () => { describe('metadata items', () => {
describe('tags count', () => { describe('tags count', () => {
it('displays "-- tags" while loading', async () => {
// here we are forced to mock apollo because `waitForMetadataItems` waits
// for two ticks, de facto allowing the promise to resolve, so there is
// no way to catch the component as both rendered and in loading state
mountComponent({ $apollo: { queries: { containerRepository: { loading: true } } } });
await waitForMetadataItems();
expect(findTagsCount().props('text')).toBe('-- tags');
});
it('when there is more than one tag has the correct text', async () => { it('when there is more than one tag has the correct text', async () => {
mountComponent(); mountComponent();
await waitForPromises();
await waitForMetadataItems(); await waitForMetadataItems();
expect(findTagsCount().props('text')).toBe('10 tags'); expect(findTagsCount().props('text')).toBe('13 tags');
}); });
it('when there is one tag has the correct text', async () => { it('when there is one tag has the correct text', async () => {
mountComponent({ image: { ...defaultImage, tagsCount: 1 } }); mountComponent({
resolver: jest.fn().mockResolvedValue(imageTagsCountMock({ tagsCount: 1 })),
});
await waitForPromises();
await waitForMetadataItems(); await waitForMetadataItems();
expect(findTagsCount().props('text')).toBe('1 tag'); expect(findTagsCount().props('text')).toBe('1 tag');
...@@ -208,11 +251,13 @@ describe('Details Header', () => { ...@@ -208,11 +251,13 @@ describe('Details Header', () => {
'when the status is $status the text is $text and the tooltip is $tooltip', 'when the status is $status the text is $text and the tooltip is $tooltip',
async ({ status, text, tooltip }) => { async ({ status, text, tooltip }) => {
mountComponent({ mountComponent({
image: { propsData: {
...defaultImage, image: {
expirationPolicyCleanupStatus: status, ...defaultImage,
project: { expirationPolicyCleanupStatus: status,
containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' }, project: {
containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' },
},
}, },
}, },
}); });
...@@ -242,7 +287,9 @@ describe('Details Header', () => { ...@@ -242,7 +287,9 @@ describe('Details Header', () => {
expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye'); expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye');
}); });
it('shows an eye slashed when the project is not public', async () => { it('shows an eye slashed when the project is not public', async () => {
mountComponent({ image: { ...defaultImage, project: { visibility: 'private' } } }); mountComponent({
propsData: { image: { ...defaultImage, project: { visibility: 'private' } } },
});
await waitForMetadataItems(); await waitForMetadataItems();
expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash'); expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash');
......
...@@ -113,7 +113,6 @@ export const containerRepositoryMock = { ...@@ -113,7 +113,6 @@ export const containerRepositoryMock = {
canDelete: true, canDelete: true,
createdAt: '2020-11-03T13:29:21Z', createdAt: '2020-11-03T13:29:21Z',
updatedAt: '2020-11-03T13:29:21Z', updatedAt: '2020-11-03T13:29:21Z',
tagsCount: 13,
expirationPolicyStartedAt: null, expirationPolicyStartedAt: null,
expirationPolicyCleanupStatus: 'UNSCHEDULED', expirationPolicyCleanupStatus: 'UNSCHEDULED',
project: { project: {
...@@ -175,6 +174,16 @@ export const imageTagsMock = (nodes = tagsMock) => ({ ...@@ -175,6 +174,16 @@ export const imageTagsMock = (nodes = tagsMock) => ({
}, },
}); });
export const imageTagsCountMock = (override) => ({
data: {
containerRepository: {
id: containerRepositoryMock.id,
tagsCount: 13,
...override,
},
},
});
export const graphQLImageDetailsMock = (override) => ({ export const graphQLImageDetailsMock = (override) => ({
data: { data: {
containerRepository: { containerRepository: {
......
...@@ -292,7 +292,6 @@ describe('Details Page', () => { ...@@ -292,7 +292,6 @@ describe('Details Page', () => {
await waitForApolloRequestRender(); await waitForApolloRequestRender();
expect(findDetailsHeader().props()).toMatchObject({ expect(findDetailsHeader().props()).toMatchObject({
metadataLoading: false,
image: { image: {
name: containerRepositoryMock.name, name: containerRepositoryMock.name,
project: { project: {
......
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