Commit e34264af authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch '222885-update-design-of-the-container-registry-cleanup-policy-for-tags' into 'master'

Add components for Expiration Policy UI redesign

See merge request gitlab-org/gitlab!47778
parents 5933d4a8 975138a7
<script>
import { GlFormGroup, GlFormSelect } from '@gitlab/ui';
export default {
components: {
GlFormGroup,
GlFormSelect,
},
props: {
formOptions: {
type: Array,
required: false,
default: () => [],
},
disabled: {
type: Boolean,
required: false,
default: false,
},
value: {
type: String,
required: false,
default: '',
},
name: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
},
};
</script>
<template>
<gl-form-group :id="`${name}-form-group`" :label-for="name" :label="label">
<gl-form-select :id="name" :value="value" :disabled="disabled" @input="$emit('input', $event)">
<option
v-for="option in formOptions"
:key="option.key"
:value="option.key"
data-testid="option"
>
{{ option.label }}
</option>
</gl-form-select>
</gl-form-group>
</template>
<script>
import { GlFormGroup, GlFormInput } from '@gitlab/ui';
import { NEXT_CLEANUP_LABEL, NOT_SCHEDULED_POLICY_TEXT } from '~/registry/settings/constants';
export default {
components: {
GlFormGroup,
GlFormInput,
},
props: {
value: {
type: String,
required: false,
default: NOT_SCHEDULED_POLICY_TEXT,
},
},
i18n: {
NEXT_CLEANUP_LABEL,
},
};
</script>
<template>
<gl-form-group
id="expiration-policy-info-text-group"
:label="$options.i18n.NEXT_CLEANUP_LABEL"
label-for="expiration-policy-info-text"
>
<gl-form-input id="expiration-policy-info-text" class="gl-pl-0!" plaintext :value="value" />
</gl-form-group>
</template>
<script>
import { GlFormGroup, GlFormTextarea, GlSprintf, GlLink } from '@gitlab/ui';
import { NAME_REGEX_LENGTH, TEXT_AREA_INVALID_FEEDBACK } from '../constants';
export default {
components: {
GlFormGroup,
GlFormTextarea,
GlSprintf,
GlLink,
},
inject: ['tagsRegexHelpPagePath'],
props: {
error: {
type: String,
required: false,
default: '',
},
disabled: {
type: Boolean,
required: false,
default: false,
},
value: {
type: String,
required: false,
default: '',
},
name: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
placeholder: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
},
computed: {
textAreaLengthErrorMessage() {
return this.isInputValid(this.value) ? '' : TEXT_AREA_INVALID_FEEDBACK;
},
textAreaValidation() {
const nameRegexErrors = this.error || this.textAreaLengthErrorMessage;
return {
state: nameRegexErrors === null ? null : !nameRegexErrors,
message: nameRegexErrors,
};
},
internalValue: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
this.$emit('validation', this.isInputValid(value));
},
},
},
methods: {
isInputValid(value) {
return !value || value.length <= NAME_REGEX_LENGTH;
},
},
};
</script>
<template>
<gl-form-group
:id="`${name}-form-group`"
:label-for="name"
:state="textAreaValidation.state"
:invalid-feedback="textAreaValidation.message"
>
<template #label>
<span data-testid="label">
<gl-sprintf :message="label">
<template #italic="{content}">
<i>{{ content }}</i>
</template>
</gl-sprintf>
</span>
</template>
<gl-form-textarea
:id="name"
v-model="internalValue"
:placeholder="placeholder"
:state="textAreaValidation.state"
:disabled="disabled"
trim
/>
<template #description>
<span data-testid="description" class="gl-text-gray-400">
<gl-sprintf :message="description">
<template #link="{content}">
<gl-link :href="tagsRegexHelpPagePath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</span>
</template>
</gl-form-group>
</template>
<script>
import { GlFormGroup, GlToggle, GlSprintf } from '@gitlab/ui';
import { ENABLED_TEXT, DISABLED_TEXT, ENABLE_TOGGLE_DESCRIPTION } from '../constants';
export default {
components: {
GlFormGroup,
GlToggle,
GlSprintf,
},
props: {
disabled: {
type: Boolean,
required: false,
default: false,
},
value: {
type: Boolean,
required: false,
default: false,
},
},
i18n: {
ENABLE_TOGGLE_DESCRIPTION,
},
computed: {
enabled: {
get() {
return this.value;
},
set(value) {
this.$emit('input', value);
},
},
toggleStatusText() {
return this.enabled ? ENABLED_TEXT : DISABLED_TEXT;
},
},
};
</script>
<template>
<gl-form-group id="expiration-policy-toggle-group" label-for="expiration-policy-toggle">
<div class="gl-display-flex">
<gl-toggle id="expiration-policy-toggle" v-model="enabled" :disabled="disabled" />
<span class="gl-ml-5 gl-line-height-24" data-testid="description">
<gl-sprintf :message="$options.i18n.ENABLE_TOGGLE_DESCRIPTION">
<template #toggleStatus>
<strong>{{ toggleStatusText }}</strong>
</template>
</gl-sprintf>
</span>
</div>
</gl-form-group>
</template>
import { s__, __ } from '~/locale';
export const SET_CLEANUP_POLICY_BUTTON = s__('ContainerRegistry|Set cleanup policy');
export const SET_CLEANUP_POLICY_BUTTON = __('Save');
export const CLEANUP_POLICY_CARD_HEADER = s__('ContainerRegistry|Tag expiration policy');
export const UNAVAILABLE_FEATURE_TITLE = s__(
`ContainerRegistry|Cleanup policy for tags is disabled`,
......@@ -12,3 +12,46 @@ export const UNAVAILABLE_USER_FEATURE_TEXT = __(`Please contact your administrat
export const UNAVAILABLE_ADMIN_FEATURE_TEXT = s__(
`ContainerRegistry| Please visit the %{linkStart}administration settings%{linkEnd} to enable this feature.`,
);
export const TEXT_AREA_INVALID_FEEDBACK = s__(
'ContainerRegistry|The value of this input should be less than 256 characters',
);
export const KEEP_HEADER_TEXT = s__('ContainerRegistry|Keep these tags');
export const KEEP_INFO_TEXT = s__(
'ContainerRegistry|Tags that match these rules will always be %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{secondStrongStart}latest%{secondStrongEnd} tag will always be kept.',
);
export const KEEP_N_LABEL = s__('ContainerRegistry|Keep the most recent:');
export const NAME_REGEX_KEEP_LABEL = s__('ContainerRegistry|Keep tags matching:');
export const NAME_REGEX_KEEP_PLACEHOLDER = 'production-v.*';
export const NAME_REGEX_KEEP_DESCRIPTION = s__(
'ContainerRegistry|Tags with names matching this regex pattern will be kept. %{linkStart}More information%{linkEnd}',
);
export const REMOVE_HEADER_TEXT = s__('ContainerRegistry|Remove these tags');
export const REMOVE_INFO_TEXT = s__(
'ContainerRegistry|Tags that match these rules will be %{strongStart}removed%{strongEnd}, unless kept by a rule above.',
);
export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Remove tags older than:');
export const NAME_REGEX_LABEL = s__('ContainerRegistry|Remove tags matching:');
export const NAME_REGEX_PLACEHOLDER = '.*';
export const NAME_REGEX_DESCRIPTION = s__(
'ContainerRegistry|Tags with names matching this regex pattern will be removed. %{linkStart}More information%{linkEnd}',
);
export const ENABLED_TEXT = __('Enabled');
export const DISABLED_TEXT = __('Disabled');
export const ENABLE_TOGGLE_DESCRIPTION = s__(
'ContainerRegistry|%{toggleStatus} - Tags matching the rules defined below will be automatically scheduled for deletion.',
);
export const CADENCE_LABEL = s__('ContainerRegistry|Run cleanup every:');
export const NEXT_CLEANUP_LABEL = s__('ContainerRegistry|Next cleanup scheduled to run on:');
export const NOT_SCHEDULED_POLICY_TEXT = s__('ContainerRegistry|Not yet scheduled');
export const EXPIRATION_POLICY_FOOTER_NOTE = s__(
'ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time',
);
export const NAME_REGEX_LENGTH = 255;
......@@ -5,4 +5,5 @@ fragment ContainerExpirationPolicyFields on ContainerExpirationPolicy {
nameRegex
nameRegexKeep
olderThan
nextRunAt
}
......@@ -13,7 +13,13 @@ export default () => {
if (!el) {
return null;
}
const { projectPath, isAdmin, adminSettingsPath, enableHistoricEntries } = el.dataset;
const {
isAdmin,
enableHistoricEntries,
projectPath,
adminSettingsPath,
tagsRegexHelpPagePath,
} = el.dataset;
return new Vue({
el,
apolloProvider,
......@@ -21,10 +27,11 @@ export default () => {
RegistrySettingsApp,
},
provide: {
projectPath,
isAdmin: parseBoolean(isAdmin),
adminSettingsPath,
enableHistoricEntries: parseBoolean(enableHistoricEntries),
projectPath,
adminSettingsPath,
tagsRegexHelpPagePath,
},
render(createElement) {
return createElement('registry-settings-app', {});
......
......@@ -5,4 +5,5 @@
older_than_options: older_than_options.to_json,
is_admin: current_user&.admin.to_s,
admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'),
enable_historic_entries: container_expiration_policies_historic_entry_enabled?(@project).to_s} }
enable_historic_entries: container_expiration_policies_historic_entry_enabled?(@project).to_s,
tags_regex_help_page_path: help_page_path('user/packages/container_registry/index', anchor: 'regex-pattern-examples') } }
......@@ -7246,6 +7246,9 @@ msgstr ""
msgid "ContainerRegistry|%{toggleStatus} - Tags matching the patterns defined below will be scheduled for deletion"
msgstr ""
msgid "ContainerRegistry|%{toggleStatus} - Tags matching the rules defined below will be automatically scheduled for deletion."
msgstr ""
msgid "ContainerRegistry|Build an image"
msgstr ""
......@@ -7321,6 +7324,15 @@ msgstr ""
msgid "ContainerRegistry|Invalid tag: missing manifest digest"
msgstr ""
msgid "ContainerRegistry|Keep tags matching:"
msgstr ""
msgid "ContainerRegistry|Keep the most recent:"
msgstr ""
msgid "ContainerRegistry|Keep these tags"
msgstr ""
msgid "ContainerRegistry|Login"
msgstr ""
......@@ -7330,6 +7342,15 @@ msgstr ""
msgid "ContainerRegistry|Missing or insufficient permission, delete button disabled"
msgstr ""
msgid "ContainerRegistry|Next cleanup scheduled to run on:"
msgstr ""
msgid "ContainerRegistry|Not yet scheduled"
msgstr ""
msgid "ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time"
msgstr ""
msgid "ContainerRegistry|Number of tags to retain:"
msgstr ""
......@@ -7353,7 +7374,16 @@ msgid_plural "ContainerRegistry|Remove tags"
msgstr[0] ""
msgstr[1] ""
msgid "ContainerRegistry|Set cleanup policy"
msgid "ContainerRegistry|Remove tags matching:"
msgstr ""
msgid "ContainerRegistry|Remove tags older than:"
msgstr ""
msgid "ContainerRegistry|Remove these tags"
msgstr ""
msgid "ContainerRegistry|Run cleanup every:"
msgstr ""
msgid "ContainerRegistry|Some tags were not deleted"
......@@ -7395,12 +7425,24 @@ msgstr ""
msgid "ContainerRegistry|Tags successfully marked for deletion."
msgstr ""
msgid "ContainerRegistry|Tags that match these rules will always be %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{secondStrongStart}latest%{secondStrongEnd} tag will always be kept."
msgstr ""
msgid "ContainerRegistry|Tags that match these rules will be %{strongStart}removed%{strongEnd}, unless kept by a rule above."
msgstr ""
msgid "ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}be preserved:%{italicEnd}"
msgstr ""
msgid "ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}expire:%{italicEnd}"
msgstr ""
msgid "ContainerRegistry|Tags with names matching this regex pattern will be kept. %{linkStart}More information%{linkEnd}"
msgstr ""
msgid "ContainerRegistry|Tags with names matching this regex pattern will be removed. %{linkStart}More information%{linkEnd}"
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 ""
......
import { shallowMount } from '@vue/test-utils';
import { GlFormGroup, GlFormSelect } from 'jest/registry/shared/stubs';
import component from '~/registry/settings/components/expiration_dropdown.vue';
describe('ExpirationDropdown', () => {
let wrapper;
const defaultProps = {
name: 'foo',
label: 'label-bar',
formOptions: [{ key: 'foo', label: 'bar' }, { key: 'baz', label: 'zab' }],
};
const findFormSelect = () => wrapper.find(GlFormSelect);
const findFormGroup = () => wrapper.find(GlFormGroup);
const findOptions = () => wrapper.findAll('[data-testid="option"]');
const mountComponent = props => {
wrapper = shallowMount(component, {
stubs: {
GlFormGroup,
GlFormSelect,
},
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('structure', () => {
it('has a form-select component', () => {
mountComponent();
expect(findFormSelect().exists()).toBe(true);
});
it('has the correct options', () => {
mountComponent();
expect(findOptions()).toHaveLength(defaultProps.formOptions.length);
});
});
describe('model', () => {
it('assign the right props to the form-select component', () => {
const value = 'foobar';
const disabled = true;
mountComponent({ value, disabled });
expect(findFormSelect().props()).toMatchObject({
value,
disabled,
});
expect(findFormSelect().attributes('id')).toBe(defaultProps.name);
});
it('assign the right props to the form-group component', () => {
mountComponent();
expect(findFormGroup().attributes()).toMatchObject({
id: `${defaultProps.name}-form-group`,
'label-for': defaultProps.name,
label: defaultProps.label,
});
});
it('emits input event when form-select emits input', () => {
const emittedValue = 'barfoo';
mountComponent();
findFormSelect().vm.$emit('input', emittedValue);
expect(wrapper.emitted('input')).toEqual([[emittedValue]]);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlFormInput } from '@gitlab/ui';
import { GlFormGroup } from 'jest/registry/shared/stubs';
import component from '~/registry/settings/components/expiration_run_text.vue';
import { NEXT_CLEANUP_LABEL, NOT_SCHEDULED_POLICY_TEXT } from '~/registry/settings/constants';
describe('ExpirationToggle', () => {
let wrapper;
const value = 'foo';
const findInput = () => wrapper.find(GlFormInput);
const findFormGroup = () => wrapper.find(GlFormGroup);
const mountComponent = propsData => {
wrapper = shallowMount(component, {
stubs: {
GlFormGroup,
},
propsData,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('structure', () => {
it('has an input component', () => {
mountComponent();
expect(findInput().exists()).toBe(true);
});
});
describe('model', () => {
it('assigns the right props to the input component', () => {
mountComponent({ value, disabled: true });
expect(findInput().attributes()).toMatchObject({
value,
});
});
it('assigns the right props to the form-group component', () => {
mountComponent();
expect(findFormGroup().attributes()).toMatchObject({
label: NEXT_CLEANUP_LABEL,
});
});
});
describe('formattedValue', () => {
it('displays the values when it exists', () => {
mountComponent({ value });
expect(findInput().attributes('value')).toBe(value);
});
it('displays a placeholder when no value is present', () => {
mountComponent();
expect(findInput().attributes('value')).toBe(NOT_SCHEDULED_POLICY_TEXT);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlSprintf, GlFormTextarea, GlLink } from '@gitlab/ui';
import { GlFormGroup } from 'jest/registry/shared/stubs';
import component from '~/registry/settings/components/expiration_textarea.vue';
import { NAME_REGEX_LENGTH } from '~/registry/shared/constants';
describe('ExpirationTextarea', () => {
let wrapper;
const defaultProps = {
name: 'foo',
label: 'label-bar',
placeholder: 'placeholder-baz',
description: '%{linkStart}description-foo%{linkEnd}',
};
const tagsRegexHelpPagePath = 'fooPath';
const findTextArea = () => wrapper.find(GlFormTextarea);
const findFormGroup = () => wrapper.find(GlFormGroup);
const findLabel = () => wrapper.find('[data-testid="label"]');
const findDescription = () => wrapper.find('[data-testid="description"]');
const findDescriptionLink = () => wrapper.find(GlLink);
const mountComponent = props => {
wrapper = shallowMount(component, {
stubs: {
GlSprintf,
GlFormGroup,
},
provide: {
tagsRegexHelpPagePath,
},
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('structure', () => {
it('has a label', () => {
mountComponent();
expect(findLabel().text()).toBe(defaultProps.label);
});
it('has a textarea component', () => {
mountComponent();
expect(findTextArea().exists()).toBe(true);
});
it('has a description', () => {
mountComponent();
expect(findDescription().text()).toMatchInterpolatedText(defaultProps.description);
});
it('has a description link', () => {
mountComponent();
const link = findDescriptionLink();
expect(link.exists()).toBe(true);
expect(link.attributes('href')).toBe(tagsRegexHelpPagePath);
});
});
describe('model', () => {
it('assigns the right props to the textarea component', () => {
const value = 'foobar';
const disabled = true;
mountComponent({ value, disabled });
expect(findTextArea().attributes()).toMatchObject({
id: defaultProps.name,
value,
placeholder: defaultProps.placeholder,
disabled: `${disabled}`,
trim: '',
});
});
it('emits input event when textarea emits input', () => {
const emittedValue = 'barfoo';
mountComponent();
findTextArea().vm.$emit('input', emittedValue);
expect(wrapper.emitted('input')).toEqual([[emittedValue]]);
});
});
describe('regex textarea validation', () => {
const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(',');
describe('when error contains an error message', () => {
const errorMessage = 'something went wrong';
it('shows the error message on the relevant field', () => {
mountComponent({ error: errorMessage });
expect(findFormGroup().attributes('invalid-feedback')).toBe(errorMessage);
});
it('gives precedence to API errors compared to local ones', () => {
mountComponent({
error: errorMessage,
value: invalidString,
});
expect(findFormGroup().attributes('invalid-feedback')).toBe(errorMessage);
});
});
describe('when error is empty', () => {
describe('if the user did not type', () => {
it('validation is not emitted', () => {
mountComponent();
expect(wrapper.emitted('validation')).toBeUndefined();
});
it('no error message is shown', () => {
mountComponent();
expect(findFormGroup().props('state')).toBe(true);
expect(findFormGroup().attributes('invalid-feedback')).toBe('');
});
});
describe('when the user typed something', () => {
describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => {
beforeEach(() => {
// since the component has no state we both emit the event and set the prop
mountComponent({ value: invalidString });
findTextArea().vm.$emit('input', invalidString);
});
it('textAreaValidation state is false', () => {
expect(findFormGroup().props('state')).toBe(false);
expect(findTextArea().attributes('state')).toBeUndefined();
});
it('emits the @validation event with false payload', () => {
expect(wrapper.emitted('validation')).toEqual([[false]]);
});
});
it(`when user input is less than ${NAME_REGEX_LENGTH} state is "true"`, () => {
mountComponent();
findTextArea().vm.$emit('input', 'foo');
expect(findFormGroup().props('state')).toBe(true);
expect(findTextArea().attributes('state')).toBe('true');
expect(wrapper.emitted('validation')).toEqual([[true]]);
});
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlToggle, GlSprintf } from '@gitlab/ui';
import { GlFormGroup } from 'jest/registry/shared/stubs';
import component from '~/registry/settings/components/expiration_toggle.vue';
import {
ENABLE_TOGGLE_DESCRIPTION,
ENABLED_TEXT,
DISABLED_TEXT,
} from '~/registry/settings/constants';
describe('ExpirationToggle', () => {
let wrapper;
const findToggle = () => wrapper.find(GlToggle);
const findDescription = () => wrapper.find('[data-testid="description"]');
const mountComponent = propsData => {
wrapper = shallowMount(component, {
stubs: {
GlFormGroup,
GlSprintf,
},
propsData,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('structure', () => {
it('has a toggle component', () => {
mountComponent();
expect(findToggle().exists()).toBe(true);
});
it('has a description', () => {
mountComponent();
expect(findDescription().text()).toContain(
ENABLE_TOGGLE_DESCRIPTION.replace('%{toggleStatus}', ''),
);
});
});
describe('model', () => {
it('assigns the right props to the toggle component', () => {
mountComponent({ value: true, disabled: true });
expect(findToggle().props()).toMatchObject({
value: true,
disabled: true,
});
});
it('emits input event when toggle is updated', () => {
mountComponent();
findToggle().vm.$emit('change', false);
expect(wrapper.emitted('input')).toEqual([[false]]);
});
});
describe('toggle description', () => {
it('says enabled when the toggle is on', () => {
mountComponent({ value: true });
expect(findDescription().text()).toContain(ENABLED_TEXT);
});
it('says disabled when the toggle is off', () => {
mountComponent({ value: false });
expect(findDescription().text()).toContain(DISABLED_TEXT);
});
});
});
export const expirationPolicyPayload = override => ({
data: {
project: {
containerExpirationPolicy: {
export const containerExpirationPolicyData = () => ({
cadence: 'EVERY_DAY',
enabled: true,
keepN: 'TEN_TAGS',
nameRegex: 'asdasdssssdfdf',
nameRegexKeep: 'sss',
olderThan: 'FOURTEEN_DAYS',
nextRunAt: '2020-11-19T07:37:03.941Z',
});
export const expirationPolicyPayload = override => ({
data: {
project: {
containerExpirationPolicy: {
...containerExpirationPolicyData(),
...override,
},
},
......@@ -26,12 +31,7 @@ export const expirationPolicyMutationPayload = ({ override, errors = [] } = {})
data: {
updateContainerExpirationPolicy: {
containerExpirationPolicy: {
cadence: 'EVERY_DAY',
enabled: true,
keepN: 'TEN_TAGS',
nameRegex: 'asdasdssssdfdf',
nameRegexKeep: 'sss',
olderThan: 'FOURTEEN_DAYS',
...containerExpirationPolicyData(),
...override,
},
errors,
......
......@@ -9,3 +9,23 @@ export const GlCard = {
</div>
`,
};
export const GlFormGroup = {
name: 'gl-form-group-stub',
props: ['state'],
template: `
<div>
<slot name="label"></slot>
<slot></slot>
<slot name="description"></slot>
</div>`,
};
export const GlFormSelect = {
name: 'gl-form-select-stub',
props: ['disabled', 'value'],
template: `
<div>
<slot></slot>
</div>`,
};
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