summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/packages_and_registries/settings
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-05-19 15:44:42 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-05-19 15:44:42 +0000
commit4555e1b21c365ed8303ffb7a3325d773c9b8bf31 (patch)
tree5423a1c7516cffe36384133ade12572cf709398d /app/assets/javascripts/packages_and_registries/settings
parente570267f2f6b326480d284e0164a6464ba4081bc (diff)
downloadgitlab-ce-4555e1b21c365ed8303ffb7a3325d773c9b8bf31.tar.gz
Add latest changes from gitlab-org/gitlab@13-12-stable-eev13.12.0-rc42
Diffstat (limited to 'app/assets/javascripts/packages_and_registries/settings')
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue118
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue26
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue38
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue114
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue25
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js12
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue50
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue113
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_run_text.vue49
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue65
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue106
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue313
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js91
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/container_expiration_policy.fragment.graphql9
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js14
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql10
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql9
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js21
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js40
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/utils.js26
22 files changed, 1135 insertions, 118 deletions
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
new file mode 100644
index 00000000000..d66a30e7e81
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
@@ -0,0 +1,118 @@
+<script>
+import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { isEqual } from 'lodash';
+
+import {
+ DUPLICATES_TOGGLE_LABEL,
+ DUPLICATES_ALLOWED_DISABLED,
+ DUPLICATES_ALLOWED_ENABLED,
+ DUPLICATES_SETTING_EXCEPTION_TITLE,
+ DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
+} from '~/packages_and_registries/settings/group/constants';
+
+export default {
+ name: 'DuplicatesSettings',
+ i18n: {
+ DUPLICATES_TOGGLE_LABEL,
+ DUPLICATES_SETTING_EXCEPTION_TITLE,
+ DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
+ },
+ components: {
+ GlSprintf,
+ GlToggle,
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ duplicatesAllowed: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ duplicateExceptionRegex: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ duplicateExceptionRegexError: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ modelNames: {
+ type: Object,
+ required: true,
+ validator(value) {
+ return isEqual(Object.keys(value), ['allowed', 'exception']);
+ },
+ },
+ toggleQaSelector: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ labelQaSelector: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ enabledButtonLabel() {
+ return this.duplicatesAllowed ? DUPLICATES_ALLOWED_ENABLED : DUPLICATES_ALLOWED_DISABLED;
+ },
+ isExceptionRegexValid() {
+ return !this.duplicateExceptionRegexError;
+ },
+ },
+ methods: {
+ update(type, value) {
+ this.$emit('update', { [type]: value });
+ },
+ },
+};
+</script>
+
+<template>
+ <form>
+ <div class="gl-display-flex">
+ <gl-toggle
+ :data-qa-selector="toggleQaSelector"
+ :label="$options.i18n.DUPLICATES_TOGGLE_LABEL"
+ label-position="hidden"
+ :value="duplicatesAllowed"
+ @change="update(modelNames.allowed, $event)"
+ />
+ <div class="gl-ml-5">
+ <div data-testid="toggle-label" :data-qa-selector="labelQaSelector">
+ <gl-sprintf :message="enabledButtonLabel">
+ <template #bold="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ <gl-form-group
+ v-if="!duplicatesAllowed"
+ class="gl-mt-4"
+ :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE"
+ label-size="sm"
+ :state="isExceptionRegexValid"
+ :invalid-feedback="duplicateExceptionRegexError"
+ :description="$options.i18n.DUPLICATES_SETTINGS_EXCEPTION_LEGEND"
+ label-for="maven-duplicated-settings-regex-input"
+ >
+ <gl-form-input
+ id="maven-duplicated-settings-regex-input"
+ :value="duplicateExceptionRegex"
+ @change="update(modelNames.exception, $event)"
+ />
+ </gl-form-group>
+ </div>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue
new file mode 100644
index 00000000000..e5f63fe8d0d
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue
@@ -0,0 +1,26 @@
+<script>
+import { s__ } from '~/locale';
+import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue';
+
+export default {
+ name: 'GenericSettings',
+ components: {
+ SettingsTitles,
+ },
+ i18n: {
+ title: s__('PackageRegistry|Generic'),
+ subTitle: s__('PackageRegistry|Settings for Generic packages'),
+ },
+ modelNames: {
+ allowed: 'genericDuplicatesAllowed',
+ exception: 'genericDuplicateExceptionRegex',
+ },
+};
+</script>
+
+<template>
+ <div>
+ <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" />
+ <slot :model-names="$options.modelNames"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
index 4f5c53ed4a3..01d4861f5c2 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
@@ -1,7 +1,8 @@
<script>
import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
+import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
+import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
-
import {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
@@ -30,6 +31,8 @@ export default {
GlLink,
SettingsBlock,
MavenSettings,
+ GenericSettings,
+ DuplicatesSettings,
},
inject: ['defaultExpanded', 'groupPath'],
apollo: {
@@ -128,13 +131,32 @@ export default {
</span>
</template>
<template #default>
- <maven-settings
- :maven-duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
- :maven-duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
- :maven-duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
- :loading="isLoading"
- @update="updateSettings"
- />
+ <maven-settings data-testid="maven-settings">
+ <template #default="{ modelNames }">
+ <duplicates-settings
+ :duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
+ :duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
+ :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
+ :model-names="modelNames"
+ :loading="isLoading"
+ toggle-qa-selector="allow_duplicates_toggle"
+ label-qa-selector="allow_duplicates_label"
+ @update="updateSettings"
+ />
+ </template>
+ </maven-settings>
+ <generic-settings class="gl-mt-6" data-testid="generic-settings">
+ <template #default="{ modelNames }">
+ <duplicates-settings
+ :duplicates-allowed="packageSettings.genericDuplicatesAllowed"
+ :duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex"
+ :duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex"
+ :model-names="modelNames"
+ :loading="isLoading"
+ @update="updateSettings"
+ />
+ </template>
+ </generic-settings>
</template>
</settings-block>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
index faacabb44ce..a1cbd695f34 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
@@ -1,118 +1,26 @@
<script>
-import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui';
-
-import {
- MAVEN_TOGGLE_LABEL,
- MAVEN_TITLE,
- MAVEN_SETTINGS_SUBTITLE,
- MAVEN_DUPLICATES_ALLOWED_DISABLED,
- MAVEN_DUPLICATES_ALLOWED_ENABLED,
- MAVEN_SETTING_EXCEPTION_TITLE,
- MAVEN_SETTINGS_EXCEPTION_LEGEND,
- MAVEN_DUPLICATES_ALLOWED,
- MAVEN_DUPLICATE_EXCEPTION_REGEX,
-} from '~/packages_and_registries/settings/group/constants';
+import { s__ } from '~/locale';
+import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue';
export default {
name: 'MavenSettings',
- i18n: {
- MAVEN_TOGGLE_LABEL,
- MAVEN_TITLE,
- MAVEN_SETTINGS_SUBTITLE,
- MAVEN_SETTING_EXCEPTION_TITLE,
- MAVEN_SETTINGS_EXCEPTION_LEGEND,
- },
- modelNames: {
- MAVEN_DUPLICATES_ALLOWED,
- MAVEN_DUPLICATE_EXCEPTION_REGEX,
- },
components: {
- GlSprintf,
- GlToggle,
- GlFormGroup,
- GlFormInput,
- },
- props: {
- loading: {
- type: Boolean,
- required: false,
- default: false,
- },
- mavenDuplicatesAllowed: {
- type: Boolean,
- default: false,
- required: true,
- },
- mavenDuplicateExceptionRegex: {
- type: String,
- default: '',
- required: true,
- },
- mavenDuplicateExceptionRegexError: {
- type: String,
- default: '',
- required: false,
- },
+ SettingsTitles,
},
- computed: {
- enabledButtonLabel() {
- return this.mavenDuplicatesAllowed
- ? MAVEN_DUPLICATES_ALLOWED_ENABLED
- : MAVEN_DUPLICATES_ALLOWED_DISABLED;
- },
- isMavenDuplicateExceptionRegexValid() {
- return !this.mavenDuplicateExceptionRegexError;
- },
+ i18n: {
+ title: s__('PackageRegistry|Maven'),
+ subTitle: s__('PackageRegistry|Settings for Maven packages'),
},
- methods: {
- update(type, value) {
- this.$emit('update', { [type]: value });
- },
+ modelNames: {
+ allowed: 'mavenDuplicatesAllowed',
+ exception: 'mavenDuplicateExceptionRegex',
},
};
</script>
<template>
<div>
- <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200">
- {{ $options.i18n.MAVEN_TITLE }}
- </h5>
- <p>{{ $options.i18n.MAVEN_SETTINGS_SUBTITLE }}</p>
- <form>
- <div class="gl-display-flex">
- <gl-toggle
- data-qa-selector="allow_duplicates_toggle"
- :label="$options.i18n.MAVEN_TOGGLE_LABEL"
- label-position="hidden"
- :value="mavenDuplicatesAllowed"
- @change="update($options.modelNames.MAVEN_DUPLICATES_ALLOWED, $event)"
- />
- <div class="gl-ml-5">
- <div data-testid="toggle-label" data-qa-selector="allow_duplicates_label">
- <gl-sprintf :message="enabledButtonLabel">
- <template #bold="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </div>
- <gl-form-group
- v-if="!mavenDuplicatesAllowed"
- class="gl-mt-4"
- :label="$options.i18n.MAVEN_SETTING_EXCEPTION_TITLE"
- label-size="sm"
- :state="isMavenDuplicateExceptionRegexValid"
- :invalid-feedback="mavenDuplicateExceptionRegexError"
- :description="$options.i18n.MAVEN_SETTINGS_EXCEPTION_LEGEND"
- label-for="maven-duplicated-settings-regex-input"
- >
- <gl-form-input
- id="maven-duplicated-settings-regex-input"
- :value="mavenDuplicateExceptionRegex"
- @change="update($options.modelNames.MAVEN_DUPLICATE_EXCEPTION_REGEX, $event)"
- />
- </gl-form-group>
- </div>
- </div>
- </form>
+ <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" />
+ <slot :model-names="$options.modelNames"></slot>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue
new file mode 100644
index 00000000000..3f0ab7686e5
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue
@@ -0,0 +1,25 @@
+<script>
+export default {
+ name: 'SettingsTitle',
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ subTitle: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200">
+ {{ title }}
+ </h5>
+ <p>{{ subTitle }}</p>
+ <slot></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index d52a6a626f9..a2256c5c371 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -6,17 +6,15 @@ export const PACKAGE_SETTINGS_DESCRIPTION = s__(
'PackageRegistry|GitLab Packages allows organizations to utilize GitLab as a private repository for a variety of common package formats. %{linkStart}More Information%{linkEnd}',
);
-export const MAVEN_TITLE = s__('PackageRegistry|Maven');
-export const MAVEN_SETTINGS_SUBTITLE = s__('PackageRegistry|Settings for Maven packages');
-export const MAVEN_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates');
-export const MAVEN_DUPLICATES_ALLOWED_DISABLED = s__(
+export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates');
+export const DUPLICATES_ALLOWED_DISABLED = s__(
'PackageRegistry|%{boldStart}Do not allow duplicates%{boldEnd} - Packages with the same name and version are rejected.',
);
-export const MAVEN_DUPLICATES_ALLOWED_ENABLED = s__(
+export const DUPLICATES_ALLOWED_ENABLED = s__(
'PackageRegistry|%{boldStart}Allow duplicates%{boldEnd} - Packages with the same name and version are accepted.',
);
-export const MAVEN_SETTING_EXCEPTION_TITLE = __('Exceptions');
-export const MAVEN_SETTINGS_EXCEPTION_LEGEND = s__(
+export const DUPLICATES_SETTING_EXCEPTION_TITLE = __('Exceptions');
+export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
'PackageRegistry|Packages can be published if their name or version matches this regex',
);
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql
index 1fc59bd3496..5c245ff9453 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql
@@ -3,6 +3,8 @@ mutation updateNamespacePackageSettings($input: UpdateNamespacePackageSettingsIn
packageSettings {
mavenDuplicatesAllowed
mavenDuplicateExceptionRegex
+ genericDuplicatesAllowed
+ genericDuplicateExceptionRegex
}
errors
}
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
index 2011659887d..a1c01300893 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
@@ -3,6 +3,8 @@ query getGroupPackagesSettings($fullPath: ID!) {
packageSettings {
mavenDuplicatesAllowed
mavenDuplicateExceptionRegex
+ genericDuplicatesAllowed
+ genericDuplicateExceptionRegex
}
}
}
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
new file mode 100644
index 00000000000..d75fb31fd98
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
@@ -0,0 +1,50 @@
+<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>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue
new file mode 100644
index 00000000000..d6d85189792
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue
@@ -0,0 +1,113 @@
+<script>
+import { GlFormGroup, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
+import {
+ NAME_REGEX_LENGTH,
+ TEXT_AREA_INVALID_FEEDBACK,
+} from '~/packages_and_registries/settings/project/constants';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ 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: false,
+ default: '',
+ },
+ description: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ textAreaLengthErrorMessage() {
+ return this.isInputValid(this.value) ? '' : TEXT_AREA_INVALID_FEEDBACK;
+ },
+ inputValidation() {
+ 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="inputValidation.state"
+ :invalid-feedback="inputValidation.message"
+ >
+ <template #label>
+ <span data-testid="label">
+ <gl-sprintf :message="label">
+ <template #italic="{ content }">
+ <i>{{ content }}</i>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <gl-form-input
+ :id="name"
+ v-model="internalValue"
+ :placeholder="placeholder"
+ :state="inputValidation.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>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_run_text.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_run_text.vue
new file mode 100644
index 00000000000..0c595fa79b4
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_run_text.vue
@@ -0,0 +1,49 @@
+<script>
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import {
+ NEXT_CLEANUP_LABEL,
+ NOT_SCHEDULED_POLICY_TEXT,
+} from '~/packages_and_registries/settings/project/constants';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: NOT_SCHEDULED_POLICY_TEXT,
+ },
+ enabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ parsedValue() {
+ return this.enabled ? this.value : 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="parsedValue"
+ />
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue
new file mode 100644
index 00000000000..7a9ea7c0bf7
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlFormGroup, GlToggle, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import {
+ ENABLED_TOGGLE_DESCRIPTION,
+ DISABLED_TOGGLE_DESCRIPTION,
+} from '~/packages_and_registries/settings/project/constants';
+
+export default {
+ i18n: {
+ toggleLabel: s__('ContainerRegistry|Enable expiration policy'),
+ },
+ components: {
+ GlFormGroup,
+ GlToggle,
+ GlSprintf,
+ },
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ value: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ enabled: {
+ get() {
+ return this.value;
+ },
+ set(value) {
+ this.$emit('input', value);
+ },
+ },
+ toggleText() {
+ return this.enabled ? ENABLED_TOGGLE_DESCRIPTION : DISABLED_TOGGLE_DESCRIPTION;
+ },
+ },
+};
+</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"
+ :label="$options.i18n.toggleLabel"
+ label-position="hidden"
+ :disabled="disabled"
+ />
+ <span class="gl-ml-5 gl-line-height-24" data-testid="description">
+ <gl-sprintf :message="toggleText">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </span>
+ </div>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
new file mode 100644
index 00000000000..edbe9441e57
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -0,0 +1,106 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { isEqual, get, isEmpty } from 'lodash';
+import {
+ FETCH_SETTINGS_ERROR_MESSAGE,
+ UNAVAILABLE_FEATURE_TITLE,
+ UNAVAILABLE_FEATURE_INTRO_TEXT,
+ UNAVAILABLE_USER_FEATURE_TEXT,
+ UNAVAILABLE_ADMIN_FEATURE_TEXT,
+} from '~/packages_and_registries/settings/project/constants';
+import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
+
+import SettingsForm from './settings_form.vue';
+
+export default {
+ components: {
+ SettingsForm,
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ },
+ inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries'],
+ i18n: {
+ UNAVAILABLE_FEATURE_TITLE,
+ UNAVAILABLE_FEATURE_INTRO_TEXT,
+ FETCH_SETTINGS_ERROR_MESSAGE,
+ },
+ apollo: {
+ containerExpirationPolicy: {
+ query: expirationPolicyQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update: (data) => data.project?.containerExpirationPolicy,
+ result({ data }) {
+ this.workingCopy = { ...get(data, 'project.containerExpirationPolicy', {}) };
+ },
+ error(e) {
+ this.fetchSettingsError = e;
+ },
+ },
+ },
+ data() {
+ return {
+ fetchSettingsError: false,
+ containerExpirationPolicy: null,
+ workingCopy: {},
+ };
+ },
+ computed: {
+ isDisabled() {
+ return !(this.containerExpirationPolicy || this.enableHistoricEntries);
+ },
+ showDisabledFormMessage() {
+ return this.isDisabled && !this.fetchSettingsError;
+ },
+ unavailableFeatureMessage() {
+ return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT;
+ },
+ isEdited() {
+ if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) {
+ return false;
+ }
+ return !isEqual(this.containerExpirationPolicy, this.workingCopy);
+ },
+ },
+ methods: {
+ restoreOriginal() {
+ this.workingCopy = { ...this.containerExpirationPolicy };
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <settings-form
+ v-if="!isDisabled"
+ v-model="workingCopy"
+ :is-loading="$apollo.queries.containerExpirationPolicy.loading"
+ :is-edited="isEdited"
+ @reset="restoreOriginal"
+ />
+ <template v-else>
+ <gl-alert
+ v-if="showDisabledFormMessage"
+ :dismissible="false"
+ :title="$options.i18n.UNAVAILABLE_FEATURE_TITLE"
+ variant="tip"
+ >
+ {{ $options.i18n.UNAVAILABLE_FEATURE_INTRO_TEXT }}
+
+ <gl-sprintf :message="unavailableFeatureMessage">
+ <template #link="{ content }">
+ <gl-link :href="adminSettingsPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <gl-alert v-else-if="fetchSettingsError" variant="warning" :dismissible="false">
+ <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" />
+ </gl-alert>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
new file mode 100644
index 00000000000..41be70a3ad5
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
@@ -0,0 +1,313 @@
+<script>
+import { GlCard, GlButton, GlSprintf } from '@gitlab/ui';
+import {
+ UPDATE_SETTINGS_ERROR_MESSAGE,
+ UPDATE_SETTINGS_SUCCESS_MESSAGE,
+ SET_CLEANUP_POLICY_BUTTON,
+ KEEP_HEADER_TEXT,
+ KEEP_INFO_TEXT,
+ KEEP_N_LABEL,
+ NAME_REGEX_KEEP_LABEL,
+ NAME_REGEX_KEEP_DESCRIPTION,
+ REMOVE_HEADER_TEXT,
+ REMOVE_INFO_TEXT,
+ EXPIRATION_SCHEDULE_LABEL,
+ NAME_REGEX_LABEL,
+ NAME_REGEX_PLACEHOLDER,
+ NAME_REGEX_DESCRIPTION,
+ CADENCE_LABEL,
+ EXPIRATION_POLICY_FOOTER_NOTE,
+} from '~/packages_and_registries/settings/project/constants';
+import updateContainerExpirationPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql';
+import { updateContainerExpirationPolicy } from '~/packages_and_registries/settings/project/graphql/utils/cache_update';
+import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils';
+import Tracking from '~/tracking';
+import ExpirationDropdown from './expiration_dropdown.vue';
+import ExpirationInput from './expiration_input.vue';
+import ExpirationRunText from './expiration_run_text.vue';
+import ExpirationToggle from './expiration_toggle.vue';
+
+export default {
+ components: {
+ GlCard,
+ GlButton,
+ GlSprintf,
+ ExpirationDropdown,
+ ExpirationInput,
+ ExpirationToggle,
+ ExpirationRunText,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['projectPath'],
+ props: {
+ value: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isEdited: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ formOptions: formOptionsGenerator(),
+ i18n: {
+ KEEP_HEADER_TEXT,
+ KEEP_INFO_TEXT,
+ KEEP_N_LABEL,
+ NAME_REGEX_KEEP_LABEL,
+ SET_CLEANUP_POLICY_BUTTON,
+ NAME_REGEX_KEEP_DESCRIPTION,
+ REMOVE_HEADER_TEXT,
+ REMOVE_INFO_TEXT,
+ EXPIRATION_SCHEDULE_LABEL,
+ NAME_REGEX_LABEL,
+ NAME_REGEX_PLACEHOLDER,
+ NAME_REGEX_DESCRIPTION,
+ CADENCE_LABEL,
+ EXPIRATION_POLICY_FOOTER_NOTE,
+ },
+ data() {
+ return {
+ tracking: {
+ label: 'docker_container_retention_and_expiration_policies',
+ },
+ apiErrors: {},
+ localErrors: {},
+ mutationLoading: false,
+ };
+ },
+ computed: {
+ prefilledForm() {
+ return {
+ ...this.value,
+ cadence: this.findDefaultOption('cadence'),
+ keepN: this.findDefaultOption('keepN'),
+ olderThan: this.findDefaultOption('olderThan'),
+ };
+ },
+ showLoadingIcon() {
+ return this.isLoading || this.mutationLoading;
+ },
+ fieldsAreValid() {
+ return Object.values(this.localErrors).every((error) => error);
+ },
+ isSubmitButtonDisabled() {
+ return !this.fieldsAreValid || this.showLoadingIcon;
+ },
+ isCancelButtonDisabled() {
+ return !this.isEdited || this.isLoading || this.mutationLoading;
+ },
+ isFieldDisabled() {
+ return this.showLoadingIcon || !this.value.enabled;
+ },
+ mutationVariables() {
+ return {
+ projectPath: this.projectPath,
+ enabled: this.prefilledForm.enabled,
+ cadence: this.prefilledForm.cadence,
+ olderThan: this.prefilledForm.olderThan,
+ keepN: this.prefilledForm.keepN,
+ nameRegex: this.prefilledForm.nameRegex,
+ nameRegexKeep: this.prefilledForm.nameRegexKeep,
+ };
+ },
+ },
+ methods: {
+ findDefaultOption(option) {
+ return this.value[option] || this.$options.formOptions[option].find((f) => f.default)?.key;
+ },
+ reset() {
+ this.track('reset_form');
+ this.apiErrors = {};
+ this.localErrors = {};
+ this.$emit('reset');
+ },
+ setApiErrors(response) {
+ this.apiErrors = response.graphQLErrors.reduce((acc, curr) => {
+ curr.extensions.problems.forEach((item) => {
+ acc[item.path[0]] = item.message;
+ });
+ return acc;
+ }, {});
+ },
+ setLocalErrors(state, model) {
+ this.localErrors = {
+ ...this.localErrors,
+ [model]: state,
+ };
+ },
+ submit() {
+ this.track('submit_form');
+ this.apiErrors = {};
+ this.mutationLoading = true;
+ return this.$apollo
+ .mutate({
+ mutation: updateContainerExpirationPolicyMutation,
+ variables: {
+ input: this.mutationVariables,
+ },
+ update: updateContainerExpirationPolicy(this.projectPath),
+ })
+ .then(({ data }) => {
+ const errorMessage = data?.updateContainerExpirationPolicy?.errors[0];
+ if (errorMessage) {
+ this.$toast.show(errorMessage, { type: 'error' });
+ } else {
+ this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' });
+ }
+ })
+ .catch((error) => {
+ this.setApiErrors(error);
+ this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' });
+ })
+ .finally(() => {
+ this.mutationLoading = false;
+ });
+ },
+ onModelChange(newValue, model) {
+ this.$emit('input', { ...this.value, [model]: newValue });
+ this.apiErrors[model] = undefined;
+ },
+ },
+};
+</script>
+
+<template>
+ <form ref="form-element" @submit.prevent="submit" @reset.prevent="reset">
+ <expiration-toggle
+ :value="prefilledForm.enabled"
+ :disabled="showLoadingIcon"
+ class="gl-mb-0!"
+ data-testid="enable-toggle"
+ @input="onModelChange($event, 'enabled')"
+ />
+
+ <div class="gl-display-flex gl-mt-7">
+ <expiration-dropdown
+ v-model="prefilledForm.cadence"
+ :disabled="isFieldDisabled"
+ :form-options="$options.formOptions.cadence"
+ :label="$options.i18n.CADENCE_LABEL"
+ name="cadence"
+ class="gl-mr-7 gl-mb-0!"
+ data-testid="cadence-dropdown"
+ @input="onModelChange($event, 'cadence')"
+ />
+ <expiration-run-text
+ :value="prefilledForm.nextRunAt"
+ :enabled="prefilledForm.enabled"
+ class="gl-mb-0!"
+ />
+ </div>
+ <gl-card class="gl-mt-7">
+ <template #header>
+ {{ $options.i18n.KEEP_HEADER_TEXT }}
+ </template>
+ <template #default>
+ <div>
+ <p>
+ <gl-sprintf :message="$options.i18n.KEEP_INFO_TEXT">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #secondStrong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <expiration-dropdown
+ v-model="prefilledForm.keepN"
+ :disabled="isFieldDisabled"
+ :form-options="$options.formOptions.keepN"
+ :label="$options.i18n.KEEP_N_LABEL"
+ name="keep-n"
+ data-testid="keep-n-dropdown"
+ @input="onModelChange($event, 'keepN')"
+ />
+ <expiration-input
+ v-model="prefilledForm.nameRegexKeep"
+ :error="apiErrors.nameRegexKeep"
+ :disabled="isFieldDisabled"
+ :label="$options.i18n.NAME_REGEX_KEEP_LABEL"
+ :description="$options.i18n.NAME_REGEX_KEEP_DESCRIPTION"
+ name="keep-regex"
+ data-testid="keep-regex-input"
+ @input="onModelChange($event, 'nameRegexKeep')"
+ @validation="setLocalErrors($event, 'nameRegexKeep')"
+ />
+ </div>
+ </template>
+ </gl-card>
+ <gl-card class="gl-mt-7">
+ <template #header>
+ {{ $options.i18n.REMOVE_HEADER_TEXT }}
+ </template>
+ <template #default>
+ <div>
+ <p>
+ <gl-sprintf :message="$options.i18n.REMOVE_INFO_TEXT">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #secondStrong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <expiration-dropdown
+ v-model="prefilledForm.olderThan"
+ :disabled="isFieldDisabled"
+ :form-options="$options.formOptions.olderThan"
+ :label="$options.i18n.EXPIRATION_SCHEDULE_LABEL"
+ name="older-than"
+ data-testid="older-than-dropdown"
+ @input="onModelChange($event, 'olderThan')"
+ />
+ <expiration-input
+ v-model="prefilledForm.nameRegex"
+ :error="apiErrors.nameRegex"
+ :disabled="isFieldDisabled"
+ :label="$options.i18n.NAME_REGEX_LABEL"
+ :placeholder="$options.i18n.NAME_REGEX_PLACEHOLDER"
+ :description="$options.i18n.NAME_REGEX_DESCRIPTION"
+ name="remove-regex"
+ data-testid="remove-regex-input"
+ @input="onModelChange($event, 'nameRegex')"
+ @validation="setLocalErrors($event, 'nameRegex')"
+ />
+ </div>
+ </template>
+ </gl-card>
+ <div class="gl-mt-7 gl-display-flex gl-align-items-center">
+ <gl-button
+ data-testid="save-button"
+ type="submit"
+ :disabled="isSubmitButtonDisabled"
+ :loading="showLoadingIcon"
+ category="primary"
+ variant="confirm"
+ class="js-no-auto-disable gl-mr-4"
+ >
+ {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
+ </gl-button>
+ <gl-button
+ data-testid="cancel-button"
+ type="reset"
+ :disabled="isCancelButtonDisabled"
+ class="gl-mr-4"
+ >
+ {{ __('Cancel') }}
+ </gl-button>
+ <span class="gl-font-style-italic gl-text-gray-400">{{
+ $options.i18n.EXPIRATION_POLICY_FOOTER_NOTE
+ }}</span>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
new file mode 100644
index 00000000000..165c4aae3cb
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -0,0 +1,91 @@
+import { s__, __ } from '~/locale';
+
+export const SET_CLEANUP_POLICY_BUTTON = __('Save');
+export const UNAVAILABLE_FEATURE_TITLE = s__(
+ `ContainerRegistry|Cleanup policy for tags is disabled`,
+);
+export const UNAVAILABLE_FEATURE_INTRO_TEXT = s__(
+ `ContainerRegistry|This project's cleanup policy for tags is not enabled.`,
+);
+export const UNAVAILABLE_USER_FEATURE_TEXT = __(`Please contact your administrator.`);
+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 are %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{secondStrongStart}latest%{secondStrongEnd} tag is always 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_DESCRIPTION = s__(
+ 'ContainerRegistry|Tags with names that match this regex pattern are kept. %{linkStart}View regex examples.%{linkEnd}',
+);
+
+export const REMOVE_HEADER_TEXT = s__('ContainerRegistry|Remove these tags');
+export const REMOVE_INFO_TEXT = s__(
+ 'ContainerRegistry|Tags that match these rules are %{strongStart}removed%{strongEnd}, unless a rule above says to keep them.',
+);
+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 that match this regex pattern are removed. %{linkStart}View regex examples.%{linkEnd}',
+);
+
+export const ENABLED_TOGGLE_DESCRIPTION = s__(
+ 'ContainerRegistry|%{strongStart}Enabled%{strongEnd} - Tags that match the rules on this page are automatically scheduled for deletion.',
+);
+export const DISABLED_TOGGLE_DESCRIPTION = s__(
+ 'ContainerRegistry|%{strongStart}Disabled%{strongEnd} - Tags will not be automatically deleted.',
+);
+
+export const CADENCE_LABEL = s__('ContainerRegistry|Run cleanup:');
+
+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 KEEP_N_OPTIONS = [
+ { key: 'ONE_TAG', variable: 1, default: false },
+ { key: 'FIVE_TAGS', variable: 5, default: false },
+ { key: 'TEN_TAGS', variable: 10, default: true },
+ { key: 'TWENTY_FIVE_TAGS', variable: 25, default: false },
+ { key: 'FIFTY_TAGS', variable: 50, default: false },
+ { key: 'ONE_HUNDRED_TAGS', variable: 100, default: false },
+];
+
+export const CADENCE_OPTIONS = [
+ { key: 'EVERY_DAY', label: __('Every day'), default: true },
+ { key: 'EVERY_WEEK', label: __('Every week'), default: false },
+ { key: 'EVERY_TWO_WEEKS', label: __('Every two weeks'), default: false },
+ { key: 'EVERY_MONTH', label: __('Every month'), default: false },
+ { key: 'EVERY_THREE_MONTHS', label: __('Every three months'), default: false },
+];
+
+export const OLDER_THAN_OPTIONS = [
+ { key: 'SEVEN_DAYS', variable: 7, default: false },
+ { key: 'FOURTEEN_DAYS', variable: 14, default: false },
+ { key: 'THIRTY_DAYS', variable: 30, default: false },
+ { key: 'NINETY_DAYS', variable: 90, default: true },
+];
+
+export const FETCH_SETTINGS_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while fetching the cleanup policy.',
+);
+
+export const UPDATE_SETTINGS_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while updating the cleanup policy.',
+);
+
+export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__(
+ 'ContainerRegistry|Cleanup policy successfully saved.',
+);
+
+export const NAME_REGEX_LENGTH = 255;
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/container_expiration_policy.fragment.graphql
new file mode 100644
index 00000000000..1d6c89133af
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/container_expiration_policy.fragment.graphql
@@ -0,0 +1,9 @@
+fragment ContainerExpirationPolicyFields on ContainerExpirationPolicy {
+ cadence
+ enabled
+ keepN
+ nameRegex
+ nameRegexKeep
+ olderThan
+ nextRunAt
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js b/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js
new file mode 100644
index 00000000000..16152eb81f6
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+export const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
+});
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql
new file mode 100644
index 00000000000..c40cd115ab0
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/container_expiration_policy.fragment.graphql"
+
+mutation updateContainerExpirationPolicy($input: UpdateContainerExpirationPolicyInput!) {
+ updateContainerExpirationPolicy(input: $input) {
+ containerExpirationPolicy {
+ ...ContainerExpirationPolicyFields
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql
new file mode 100644
index 00000000000..c171be0ad07
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql
@@ -0,0 +1,9 @@
+#import "../fragments/container_expiration_policy.fragment.graphql"
+
+query getProjectExpirationPolicy($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ containerExpirationPolicy {
+ ...ContainerExpirationPolicyFields
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js
new file mode 100644
index 00000000000..c4b2af13862
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js
@@ -0,0 +1,21 @@
+import { produce } from 'immer';
+import expirationPolicyQuery from '../queries/get_expiration_policy.query.graphql';
+
+export const updateContainerExpirationPolicy = (projectPath) => (client, { data: updatedData }) => {
+ const queryAndParams = {
+ query: expirationPolicyQuery,
+ variables: { projectPath },
+ };
+ const sourceData = client.readQuery(queryAndParams);
+
+ const data = produce(sourceData, (draftState) => {
+ draftState.project.containerExpirationPolicy = {
+ ...updatedData.updateContainerExpirationPolicy.containerExpirationPolicy,
+ };
+ });
+
+ client.writeQuery({
+ ...queryAndParams,
+ data,
+ });
+};
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
new file mode 100644
index 00000000000..65af6f846aa
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
@@ -0,0 +1,40 @@
+import { GlToast } from '@gitlab/ui';
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import Translate from '~/vue_shared/translate';
+import RegistrySettingsApp from './components/registry_settings_app.vue';
+import { apolloProvider } from './graphql/index';
+
+Vue.use(GlToast);
+Vue.use(Translate);
+
+export default () => {
+ const el = document.getElementById('js-registry-settings');
+ if (!el) {
+ return null;
+ }
+ const {
+ isAdmin,
+ enableHistoricEntries,
+ projectPath,
+ adminSettingsPath,
+ tagsRegexHelpPagePath,
+ } = el.dataset;
+ return new Vue({
+ el,
+ apolloProvider,
+ components: {
+ RegistrySettingsApp,
+ },
+ provide: {
+ isAdmin: parseBoolean(isAdmin),
+ enableHistoricEntries: parseBoolean(enableHistoricEntries),
+ projectPath,
+ adminSettingsPath,
+ tagsRegexHelpPagePath,
+ },
+ render(createElement) {
+ return createElement('registry-settings-app', {});
+ },
+ });
+};
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/utils.js b/app/assets/javascripts/packages_and_registries/settings/project/utils.js
new file mode 100644
index 00000000000..4a2d7c7d466
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/utils.js
@@ -0,0 +1,26 @@
+import { n__ } from '~/locale';
+import { KEEP_N_OPTIONS, CADENCE_OPTIONS, OLDER_THAN_OPTIONS } from './constants';
+
+export const findDefaultOption = (options) => {
+ const item = options.find((o) => o.default);
+ return item ? item.key : null;
+};
+
+export const olderThanTranslationGenerator = (variable) => n__('%d day', '%d days', variable);
+
+export const keepNTranslationGenerator = (variable) =>
+ n__('%d tag per image name', '%d tags per image name', variable);
+
+export const optionLabelGenerator = (collection, translationFn) =>
+ collection.map((option) => ({
+ ...option,
+ label: translationFn(option.variable),
+ }));
+
+export const formOptionsGenerator = () => {
+ return {
+ olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator),
+ cadence: CADENCE_OPTIONS,
+ keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator),
+ };
+};