Commit 06aa3a5b authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch...

Merge branch '276900-use-the-expiration_policy_cleanup_status-to-report-the-cleanup-policy-current-status' into 'master'

Add tags count and cleanup status to registry details

See merge request gitlab-org/gitlab!50756
parents 0bb09eee 9f30c3e7
<script>
import { GlSprintf } from '@gitlab/ui';
import { sprintf } from '~/locale';
import { sprintf, n__ } from '~/locale';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { DETAILS_PAGE_TITLE, UPDATED_AT } from '../../constants/index';
import {
DETAILS_PAGE_TITLE,
UPDATED_AT,
CLEANUP_UNSCHEDULED_TEXT,
CLEANUP_SCHEDULED_TEXT,
CLEANUP_ONGOING_TEXT,
CLEANUP_UNFINISHED_TEXT,
CLEANUP_DISABLED_TEXT,
CLEANUP_SCHEDULED_TOOLTIP,
CLEANUP_ONGOING_TOOLTIP,
CLEANUP_UNFINISHED_TOOLTIP,
CLEANUP_DISABLED_TOOLTIP,
UNFINISHED_STATUS,
UNSCHEDULED_STATUS,
SCHEDULED_STATUS,
ONGOING_STATUS,
} from '../../constants/index';
export default {
name: 'DetailsHeader',
......@@ -15,6 +31,11 @@ export default {
type: Object,
required: true,
},
metadataLoading: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
visibilityIcon() {
......@@ -26,6 +47,24 @@ export default {
updatedText() {
return sprintf(UPDATED_AT, { time: this.timeAgo });
},
tagCountText() {
return n__('%d tag', '%d tags', this.image.tagsCount);
},
cleanupTextAndTooltip() {
if (!this.image.project.containerExpirationPolicy?.enabled) {
return { text: CLEANUP_DISABLED_TEXT, tooltip: CLEANUP_DISABLED_TOOLTIP };
}
return {
[UNSCHEDULED_STATUS]: {
text: sprintf(CLEANUP_UNSCHEDULED_TEXT, {
time: this.timeFormatted(this.image.project.containerExpirationPolicy.nextRunAt),
}),
},
[SCHEDULED_STATUS]: { text: CLEANUP_SCHEDULED_TEXT, tooltip: CLEANUP_SCHEDULED_TOOLTIP },
[ONGOING_STATUS]: { text: CLEANUP_ONGOING_TEXT, tooltip: CLEANUP_ONGOING_TOOLTIP },
[UNFINISHED_STATUS]: { text: CLEANUP_UNFINISHED_TEXT, tooltip: CLEANUP_UNFINISHED_TOOLTIP },
}[this.image?.expirationPolicyCleanupStatus];
},
},
i18n: {
DETAILS_PAGE_TITLE,
......@@ -34,7 +73,7 @@ export default {
</script>
<template>
<title-area>
<title-area :metadata-loading="metadataLoading">
<template #title>
<gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE">
<template #imageName>
......@@ -42,6 +81,20 @@ export default {
</template>
</gl-sprintf>
</template>
<template #metadata-tags-count>
<metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" />
</template>
<template #metadata-cleanup>
<metadata-item
icon="expire"
:text="cleanupTextAndTooltip.text"
:text-tooltip="cleanupTextAndTooltip.tooltip"
size="xl"
data-testid="cleanup"
/>
</template>
<template #metadata-updated>
<metadata-item
:icon="visibilityIcon"
......
......@@ -60,6 +60,22 @@ export const UPDATED_AT = s__('ContainerRegistry|Last updated %{time}');
export const NOT_AVAILABLE_TEXT = __('N/A');
export const NOT_AVAILABLE_SIZE = __('0 bytes');
export const CLEANUP_UNSCHEDULED_TEXT = s__('ContainerRegistry|Cleanup will run %{time}');
export const CLEANUP_SCHEDULED_TEXT = s__('ContainerRegistry|Cleanup pending');
export const CLEANUP_ONGOING_TEXT = s__('ContainerRegistry|Cleanup in progress');
export const CLEANUP_UNFINISHED_TEXT = s__('ContainerRegistry|Cleanup incomplete');
export const CLEANUP_DISABLED_TEXT = s__('ContainerRegistry|Cleanup disabled');
export const CLEANUP_SCHEDULED_TOOLTIP = s__('ContainerRegistry|Cleanup will run soon');
export const CLEANUP_ONGOING_TOOLTIP = s__('ContainerRegistry|Cleanup is currently removing tags');
export const CLEANUP_UNFINISHED_TOOLTIP = s__(
'ContainerRegistry|Cleanup ran but some tags were not removed',
);
export const CLEANUP_DISABLED_TOOLTIP = s__(
'ContainerRegistry|Cleanup is disabled for this project',
);
// Parameters
export const DEFAULT_PAGE = 1;
......@@ -76,3 +92,8 @@ export const ALERT_MESSAGES = {
[ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE,
[ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE,
};
export const UNFINISHED_STATUS = 'UNFINISHED';
export const UNSCHEDULED_STATUS = 'UNSCHEDULED';
export const SCHEDULED_STATUS = 'SCHEDULED';
export const ONGOING_STATUS = 'ONGOING';
......@@ -18,6 +18,7 @@ query getContainerRepositoryDetails(
updatedAt
tagsCount
expirationPolicyStartedAt
expirationPolicyCleanupStatus
tags(after: $after, before: $before, first: $first, last: $last) {
nodes {
digest
......@@ -36,6 +37,10 @@ query getContainerRepositoryDetails(
}
project {
visibility
containerExpirationPolicy {
enabled
nextRunAt
}
}
}
}
......@@ -22,6 +22,7 @@ import {
ALERT_DANGER_TAGS,
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS,
} from '../constants/index';
export default {
......@@ -84,7 +85,10 @@ export default {
return this.image?.tags?.nodes || [];
},
showPartialCleanupWarning() {
return this.image?.expirationPolicyStartedAt && !this.dismissPartialCleanupWarning;
return (
this.image?.expirationPolicyCleanupStatus === UNFINISHED_STATUS &&
!this.dismissPartialCleanupWarning
);
},
tracking() {
return {
......@@ -184,7 +188,7 @@ export default {
@dismiss="dismissPartialCleanupWarning = true"
/>
<details-header :image="image" />
<details-header :image="image" :metadata-loading="isLoading" />
<tags-loader v-if="isLoading" />
<template v-else>
......
<script>
import { GlIcon, GlLink } from '@gitlab/ui';
import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
export default {
......@@ -9,6 +9,9 @@ export default {
GlLink,
TooltipOnTruncate,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
icon: {
type: String,
......@@ -32,6 +35,11 @@ export default {
return !value || ['xs', 's', 'm', 'l', 'xl'].includes(value);
},
},
textTooltip: {
type: String,
required: false,
default: '',
},
},
computed: {
sizeClass() {
......@@ -55,9 +63,12 @@ export default {
class="gl-font-weight-bold gl-display-inline-flex"
:class="sizeClass"
>
<tooltip-on-truncate :title="text" class="gl-text-truncate">
<tooltip-on-truncate v-if="!textTooltip" :title="text" class="gl-text-truncate">
{{ text }}
</tooltip-on-truncate>
<span v-else v-gl-tooltip="{ title: textTooltip }" data-testid="text-tooltip-container">
{{ text }}</span
>
</div>
</div>
</template>
......@@ -50,7 +50,7 @@ export default {
<template>
<div class="gl-display-flex gl-flex-direction-column">
<div class="gl-display-flex gl-justify-content-space-between gl-py-3">
<div class="gl-flex-direction-column">
<div class="gl-flex-direction-column gl-flex-grow-1">
<div class="gl-display-flex">
<gl-avatar
v-if="avatar"
......@@ -85,7 +85,7 @@ export default {
</template>
<template v-else>
<div class="gl-w-full">
<gl-skeleton-loader :width="200" :height="16" preserve-aspect-ratio="xMinYMax meet">
<gl-skeleton-loader :width="960" :height="16" preserve-aspect-ratio="xMinYMax meet">
<circle cx="6" cy="8" r="6" />
<rect x="16" y="4" width="200" height="8" rx="4" />
</gl-skeleton-loader>
......
---
title: Add tags count and cleanup status to registry details
merge_request: 50756
author:
type: changed
......@@ -7471,15 +7471,42 @@ msgstr ""
msgid "ContainerRegistry|CLI Commands"
msgstr ""
msgid "ContainerRegistry|Cleanup disabled"
msgstr ""
msgid "ContainerRegistry|Cleanup in progress"
msgstr ""
msgid "ContainerRegistry|Cleanup incomplete"
msgstr ""
msgid "ContainerRegistry|Cleanup is currently removing tags"
msgstr ""
msgid "ContainerRegistry|Cleanup is disabled for this project"
msgstr ""
msgid "ContainerRegistry|Cleanup pending"
msgstr ""
msgid "ContainerRegistry|Cleanup policy for tags is disabled"
msgstr ""
msgid "ContainerRegistry|Cleanup policy successfully saved."
msgstr ""
msgid "ContainerRegistry|Cleanup ran but some tags were not removed"
msgstr ""
msgid "ContainerRegistry|Cleanup timed out before it could delete all tags"
msgstr ""
msgid "ContainerRegistry|Cleanup will run %{time}"
msgstr ""
msgid "ContainerRegistry|Cleanup will run soon"
msgstr ""
msgid "ContainerRegistry|Configuration digest: %{digest}"
msgstr ""
......
......@@ -9,7 +9,7 @@ exports[`PackageTitle renders with tags 1`] = `
class="gl-display-flex gl-justify-content-space-between gl-py-3"
>
<div
class="gl-flex-direction-column"
class="gl-flex-direction-column gl-flex-grow-1"
>
<div
class="gl-display-flex"
......@@ -54,6 +54,7 @@ exports[`PackageTitle renders with tags 1`] = `
link=""
size="s"
text="maven"
texttooltip=""
/>
</div>
<div
......@@ -65,6 +66,7 @@ exports[`PackageTitle renders with tags 1`] = `
link=""
size="s"
text="300 bytes"
texttooltip=""
/>
</div>
<div
......@@ -95,7 +97,7 @@ exports[`PackageTitle renders without tags 1`] = `
class="gl-display-flex gl-justify-content-space-between gl-py-3"
>
<div
class="gl-flex-direction-column"
class="gl-flex-direction-column gl-flex-grow-1"
>
<div
class="gl-display-flex"
......@@ -140,6 +142,7 @@ exports[`PackageTitle renders without tags 1`] = `
link=""
size="s"
text="maven"
texttooltip=""
/>
</div>
<div
......@@ -151,6 +154,7 @@ exports[`PackageTitle renders without tags 1`] = `
link=""
size="s"
text="300 bytes"
texttooltip=""
/>
</div>
</div>
......
......@@ -3,7 +3,18 @@ import { GlSprintf } from '@gitlab/ui';
import { useFakeDate } from 'helpers/fake_date';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import component from '~/registry/explorer/components/details_page/details_header.vue';
import { DETAILS_PAGE_TITLE } from '~/registry/explorer/constants';
import {
DETAILS_PAGE_TITLE,
UNSCHEDULED_STATUS,
SCHEDULED_STATUS,
ONGOING_STATUS,
UNFINISHED_STATUS,
CLEANUP_DISABLED_TEXT,
CLEANUP_DISABLED_TOOLTIP,
CLEANUP_SCHEDULED_TOOLTIP,
CLEANUP_ONGOING_TOOLTIP,
CLEANUP_UNFINISHED_TOOLTIP,
} from '~/registry/explorer/constants';
describe('Details Header', () => {
let wrapper;
......@@ -11,15 +22,22 @@ describe('Details Header', () => {
const defaultImage = {
name: 'foo',
updatedAt: '2020-11-03T13:29:21Z',
tagsCount: 10,
project: {
visibility: 'public',
containerExpirationPolicy: {
enabled: false,
},
},
};
// set the date to Dec 4, 2020
useFakeDate(2020, 11, 4);
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const findLastUpdatedAndVisibility = () => wrapper.find('[data-testid="updated-and-visibility"]');
const findLastUpdatedAndVisibility = () => findByTestId('updated-and-visibility');
const findTagsCount = () => findByTestId('tags-count');
const findCleanup = () => findByTestId('cleanup');
const waitForMetadataItems = async () => {
// Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available
......@@ -54,25 +72,96 @@ describe('Details Header', () => {
expect(wrapper.text()).toContain('foo');
});
it('has a metadata item with last updated text', async () => {
mountComponent();
await waitForMetadataItems();
describe('metadata items', () => {
describe('tags count', () => {
it('when there is more than one tag has the correct text', async () => {
mountComponent();
await waitForMetadataItems();
expect(findLastUpdatedAndVisibility().props('text')).toBe('Last updated 1 month ago');
});
expect(findTagsCount().props('text')).toBe('10 tags');
});
it('when there is one tag has the correct text', async () => {
mountComponent({ ...defaultImage, tagsCount: 1 });
await waitForMetadataItems();
expect(findTagsCount().props('text')).toBe('1 tag');
});
it('has the correct icon', async () => {
mountComponent();
await waitForMetadataItems();
expect(findTagsCount().props('icon')).toBe('tag');
});
});
describe('visibility icon', () => {
it('shows an eye when the project is public', async () => {
mountComponent();
await waitForMetadataItems();
describe('cleanup metadata item', () => {
it('has the correct icon', async () => {
mountComponent();
await waitForMetadataItems();
expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye');
expect(findCleanup().props('icon')).toBe('expire');
});
it('when the expiration policy is disabled', async () => {
mountComponent();
await waitForMetadataItems();
expect(findCleanup().props()).toMatchObject({
text: CLEANUP_DISABLED_TEXT,
textTooltip: CLEANUP_DISABLED_TOOLTIP,
});
});
it.each`
status | text | tooltip
${UNSCHEDULED_STATUS} | ${'Cleanup will run in 1 month'} | ${''}
${SCHEDULED_STATUS} | ${'Cleanup pending'} | ${CLEANUP_SCHEDULED_TOOLTIP}
${ONGOING_STATUS} | ${'Cleanup in progress'} | ${CLEANUP_ONGOING_TOOLTIP}
${UNFINISHED_STATUS} | ${'Cleanup incomplete'} | ${CLEANUP_UNFINISHED_TOOLTIP}
`(
'when the status is $status the text is $text and the tooltip is $tooltip',
async ({ status, text, tooltip }) => {
mountComponent({
...defaultImage,
expirationPolicyCleanupStatus: status,
project: {
containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' },
},
});
await waitForMetadataItems();
expect(findCleanup().props()).toMatchObject({
text,
textTooltip: tooltip,
});
},
);
});
it('shows an eye slashed when the project is not public', async () => {
mountComponent({ ...defaultImage, project: { visibility: 'private' } });
await waitForMetadataItems();
expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash');
describe('visibility and updated at ', () => {
it('has last updated text', async () => {
mountComponent();
await waitForMetadataItems();
expect(findLastUpdatedAndVisibility().props('text')).toBe('Last updated 1 month ago');
});
describe('visibility icon', () => {
it('shows an eye when the project is public', async () => {
mountComponent();
await waitForMetadataItems();
expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye');
});
it('shows an eye slashed when the project is not public', async () => {
mountComponent({ ...defaultImage, project: { visibility: 'private' } });
await waitForMetadataItems();
expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash');
});
});
});
});
});
......@@ -115,8 +115,13 @@ export const containerRepositoryMock = {
updatedAt: '2020-11-03T13:29:21Z',
tagsCount: 13,
expirationPolicyStartedAt: null,
expirationPolicyCleanupStatus: 'UNSCHEDULED',
project: {
visibility: 'public',
containerExpirationPolicy: {
enabled: false,
nextRunAt: '2020-11-27T08:59:27Z',
},
__typename: 'Project',
},
};
......
......@@ -15,6 +15,8 @@ import EmptyTagsState from '~/registry/explorer/components/details_page/empty_ta
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';
import { UNFINISHED_STATUS } from '~/registry/explorer/constants/index';
import {
graphQLImageDetailsMock,
graphQLImageDetailsEmptyTagsMock,
......@@ -353,10 +355,13 @@ describe('Details Page', () => {
mountComponent();
await waitForApolloRequestRender();
expect(findDetailsHeader().props('image')).toMatchObject({
name: containerRepositoryMock.name,
project: {
visibility: containerRepositoryMock.project.visibility,
expect(findDetailsHeader().props()).toMatchObject({
metadataLoading: false,
image: {
name: containerRepositoryMock.name,
project: {
visibility: containerRepositoryMock.project.visibility,
},
},
});
});
......@@ -398,13 +403,13 @@ describe('Details Page', () => {
cleanupPoliciesHelpPagePath: 'bar',
};
describe('when expiration_policy_started is not null', () => {
describe(`when expirationPolicyCleanupStatus is ${UNFINISHED_STATUS}`, () => {
let resolver;
beforeEach(() => {
resolver = jest.fn().mockResolvedValue(
graphQLImageDetailsMock({
expirationPolicyStartedAt: Date.now().toString(),
expirationPolicyCleanupStatus: UNFINISHED_STATUS,
}),
);
});
......@@ -439,7 +444,7 @@ describe('Details Page', () => {
});
});
describe('when expiration_policy_started is null', () => {
describe(`when expirationPolicyCleanupStatus is not ${UNFINISHED_STATUS}`, () => {
it('the component is hidden', async () => {
mountComponent();
await waitForApolloRequestRender();
......
import { shallowMount } from '@vue/test-utils';
import { GlIcon, GlLink } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import component from '~/vue_shared/components/registry/metadata_item.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
......@@ -12,6 +13,9 @@ describe('Metadata Item', () => {
const mountComponent = (propsData = defaultProps) => {
wrapper = shallowMount(component, {
propsData,
directives: {
GlTooltip: createMockDirective(),
},
});
};
......@@ -24,6 +28,7 @@ describe('Metadata Item', () => {
const findLink = (w = wrapper) => w.find(GlLink);
const findText = () => wrapper.find('[data-testid="metadata-item-text"]');
const findTooltipOnTruncate = (w = wrapper) => w.find(TooltipOnTruncate);
const findTextTooltip = () => wrapper.find('[data-testid="text-tooltip-container"]');
describe.each(['xs', 's', 'm', 'l', 'xl'])('size class', (size) => {
const className = `mw-${size}`;
......@@ -55,6 +60,22 @@ describe('Metadata Item', () => {
expect(tooltip.exists()).toBe(true);
expect(tooltip.attributes('title')).toBe(defaultProps.text);
});
describe('with tooltip prop set to something', () => {
const textTooltip = 'foo';
it('hides tooltip_on_truncate', () => {
mountComponent({ ...defaultProps, textTooltip });
expect(findTooltipOnTruncate(findText()).exists()).toBe(false);
});
it('set the tooltip on the text', () => {
mountComponent({ ...defaultProps, textTooltip });
const tooltip = getBinding(findTextTooltip().element, 'gl-tooltip');
expect(tooltip.value.title).toBe(textTooltip);
});
});
});
describe('link', () => {
......
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