diff options
Diffstat (limited to 'app/assets/javascripts/registry')
23 files changed, 385 insertions, 295 deletions
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue b/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue new file mode 100644 index 00000000000..d13d815a59e --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/partial_cleanup_alert.vue @@ -0,0 +1,38 @@ +<script> +import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui'; + +import { DELETE_ALERT_TITLE, DELETE_ALERT_LINK_TEXT } from '../../constants/index'; + +export default { + components: { + GlSprintf, + GlAlert, + GlLink, + }, + props: { + runCleanupPoliciesHelpPagePath: { type: String, required: false, default: '' }, + cleanupPoliciesHelpPagePath: { type: String, required: false, default: '' }, + }, + i18n: { + DELETE_ALERT_TITLE, + DELETE_ALERT_LINK_TEXT, + }, +}; +</script> + +<template> + <gl-alert variant="warning" :title="$options.i18n.DELETE_ALERT_TITLE" @dismiss="$emit('dismiss')"> + <gl-sprintf :message="$options.i18n.DELETE_ALERT_LINK_TEXT"> + <template #adminLink="{content}"> + <gl-link data-testid="run-link" :href="runCleanupPoliciesHelpPagePath" target="_blank">{{ + content + }}</gl-link> + </template> + <template #docLink="{content}"> + <gl-link data-testid="help-link" :href="cleanupPoliciesHelpPagePath" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue index 661213733ac..0f6297ca406 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue @@ -123,7 +123,7 @@ export default { v-if="tag.location" :title="tag.location" :text="tag.location" - css-class="btn-default btn-transparent btn-clipboard" + category="tertiary" /> <gl-icon @@ -171,7 +171,7 @@ export default { /> </template> - <template v-if="!invalidTag" #details_published> + <template v-if="!invalidTag" #details-published> <details-row icon="clock" data-testid="published-date-detail"> <gl-sprintf :message="$options.i18n.PUBLISHED_DETAILS_ROW_TEXT"> <template #repositoryPath> @@ -186,7 +186,7 @@ export default { </gl-sprintf> </details-row> </template> - <template v-if="!invalidTag" #details_manifest_digest> + <template v-if="!invalidTag" #details-manifest-digest> <details-row icon="log" data-testid="manifest-detail"> <gl-sprintf :message="$options.i18n.MANIFEST_DETAILS_ROW_TEST"> <template #digest> @@ -197,11 +197,12 @@ export default { v-if="tag.digest" :title="tag.digest" :text="tag.digest" - css-class="btn-default btn-transparent btn-clipboard gl-p-0" + category="tertiary" + size="small" /> </details-row> </template> - <template v-if="!invalidTag" #details_configuration_digest> + <template v-if="!invalidTag" #details-configuration-digest> <details-row icon="cloud-gear" data-testid="configuration-detail"> <gl-sprintf :message="$options.i18n.CONFIGURATION_DETAILS_ROW_TEST"> <template #digest> @@ -212,7 +213,8 @@ export default { v-if="formattedRevision" :title="formattedRevision" :text="formattedRevision" - css-class="btn-default btn-transparent btn-clipboard gl-p-0" + category="tertiary" + size="small" /> </details-row> </template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue index 32bf27f1143..cfd787b3f52 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue @@ -10,6 +10,7 @@ import { LIST_DELETE_BUTTON_DISABLED, REMOVE_REPOSITORY_LABEL, ROW_SCHEDULED_FOR_DELETION, + CLEANUP_TIMED_OUT_ERROR_MESSAGE, } from '../../constants/index'; export default { @@ -34,7 +35,6 @@ export default { LIST_DELETE_BUTTON_DISABLED, REMOVE_REPOSITORY_LABEL, ROW_SCHEDULED_FOR_DELETION, - ASYNC_DELETE_IMAGE_ERROR_MESSAGE, }, computed: { encodedItem() { @@ -42,6 +42,7 @@ export default { name: this.item.path, tags_path: this.item.tags_path, id: this.item.id, + cleanup_policy_started_at: this.item.cleanup_policy_started_at, }); return window.btoa(params); }, @@ -55,6 +56,14 @@ export default { this.item.tags_count, ); }, + warningIconText() { + if (this.item.failedDelete) { + return ASYNC_DELETE_IMAGE_ERROR_MESSAGE; + } else if (this.item.cleanup_policy_started_at) { + return CLEANUP_TIMED_OUT_ERROR_MESSAGE; + } + return null; + }, }, }; </script> @@ -82,11 +91,12 @@ export default { :disabled="item.deleting" :text="item.location" :title="item.location" - css-class="btn-default btn-transparent btn-clipboard gl-text-gray-300" + category="tertiary" /> <gl-icon - v-if="item.failedDelete" - v-gl-tooltip="{ title: $options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE }" + v-if="warningIconText" + v-gl-tooltip="{ title: warningIconText }" + data-testid="warning-icon" name="warning" class="gl-text-orange-500" /> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue index 7be68e77def..c2bd01701df 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue @@ -1,5 +1,4 @@ <script> -import { GlSprintf, GlLink } from '@gitlab/ui'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import { n__, sprintf } from '~/locale'; @@ -15,8 +14,6 @@ import { export default { components: { - GlSprintf, - GlLink, TitleArea, MetadataItem, }, @@ -54,8 +51,6 @@ export default { }, i18n: { CONTAINER_REGISTRY_TITLE, - LIST_INTRO_TEXT, - EXPIRATION_POLICY_DISABLED_MESSAGE, }, computed: { imagesCountText() { @@ -83,52 +78,40 @@ export default { !this.expirationPolicyEnabled && this.imagesCount > 0 && !this.hideExpirationPolicyData ); }, + infoMessages() { + const base = [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }]; + return this.showExpirationPolicyTip + ? [ + ...base, + { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: this.expirationPolicyHelpPagePath }, + ] + : base; + }, }, }; </script> <template> - <div> - <title-area :title="$options.i18n.CONTAINER_REGISTRY_TITLE"> - <template #right-actions> - <slot name="commands"></slot> - </template> - <template #metadata_count> - <metadata-item - v-if="imagesCount" - data-testid="images-count" - icon="container-image" - :text="imagesCountText" - /> - </template> - <template #metadata_exp_policies> - <metadata-item - v-if="!hideExpirationPolicyData" - data-testid="expiration-policy" - icon="expire" - :text="expirationPolicyText" - size="xl" - /> - </template> - </title-area> - - <div data-testid="info-area"> - <p> - <span data-testid="default-intro"> - <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT"> - <template #docLink="{content}"> - <gl-link :href="helpPagePath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </span> - <span v-if="showExpirationPolicyTip" data-testid="expiration-disabled-message"> - <gl-sprintf :message="$options.i18n.EXPIRATION_POLICY_DISABLED_MESSAGE"> - <template #docLink="{content}"> - <gl-link :href="expirationPolicyHelpPagePath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </span> - </p> - </div> - </div> + <title-area :title="$options.i18n.CONTAINER_REGISTRY_TITLE" :info-messages="infoMessages"> + <template #right-actions> + <slot name="commands"></slot> + </template> + <template #metadata-count> + <metadata-item + v-if="imagesCount" + data-testid="images-count" + icon="container-image" + :text="imagesCountText" + /> + </template> + <template #metadata-exp-policies> + <metadata-item + v-if="!hideExpirationPolicyData" + data-testid="expiration-policy" + icon="expire" + :text="expirationPolicyText" + size="xl" + /> + </template> + </title-area> </template> diff --git a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js index 8af25ca6ecc..48a6a015461 100644 --- a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js +++ b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js @@ -9,3 +9,10 @@ export const EXPIRATION_POLICY_DISABLED_TEXT = s__( export const EXPIRATION_POLICY_DISABLED_MESSAGE = s__( 'ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}', ); +export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted'); +export const DELETE_ALERT_LINK_TEXT = s__( + '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}', +); +export const CLEANUP_TIMED_OUT_ERROR_MESSAGE = s__( + 'ContainerRegistry|Cleanup timed out before it could delete all tags', +); diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index b697bca6259..d2fb695dbfa 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -4,6 +4,7 @@ import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import Tracking from '~/tracking'; import DeleteAlert from '../components/details_page/delete_alert.vue'; +import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue'; import DeleteModal from '../components/details_page/delete_modal.vue'; import DetailsHeader from '../components/details_page/details_header.vue'; import TagsList from '../components/details_page/tags_list.vue'; @@ -21,6 +22,7 @@ import { export default { components: { DeleteAlert, + PartialCleanupAlert, DetailsHeader, GlPagination, DeleteModal, @@ -37,13 +39,16 @@ export default { itemsToBeDeleted: [], isDesktop: true, deleteAlertType: null, + dismissPartialCleanupWarning: false, }; }, computed: { ...mapState(['tagsPagination', 'isLoading', 'config', 'tags']), - imageName() { - const { name } = decodeAndParse(this.$route.params.id); - return name; + queryParameters() { + return decodeAndParse(this.$route.params.id); + }, + showPartialCleanupWarning() { + return this.queryParameters.cleanup_policy_started_at && !this.dismissPartialCleanupWarning; }, tracking() { return { @@ -120,7 +125,14 @@ export default { class="gl-my-2" /> - <details-header :image-name="imageName" /> + <partial-cleanup-alert + v-if="showPartialCleanupWarning" + :run-cleanup-policies-help-page-path="config.runCleanupPoliciesHelpPagePath" + :cleanup-policies-help-page-path="config.cleanupPoliciesHelpPagePath" + @dismiss="dismissPartialCleanupWarning = true" + /> + + <details-header :image-name="queryParameters.name" /> <tags-loader v-if="isLoading" /> <template v-else> diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue index 2ee7bbef4c6..264d39a406a 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -1,7 +1,7 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; - +import { isEqual, get } from 'lodash'; +import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.graphql'; import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants'; import SettingsForm from './settings_form.vue'; @@ -19,21 +19,39 @@ export default { 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: { - ...mapState(['isAdmin', 'adminSettingsPath']), - ...mapGetters({ isDisabled: 'getIsDisabled' }), - showSettingForm() { - return !this.isDisabled && !this.fetchSettingsError; + isDisabled() { + return !(this.containerExpirationPolicy || this.enableHistoricEntries); }, showDisabledFormMessage() { return this.isDisabled && !this.fetchSettingsError; @@ -41,21 +59,27 @@ export default { unavailableFeatureMessage() { return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT; }, - }, - mounted() { - this.fetchSettings().catch(() => { - this.fetchSettingsError = true; - }); + isEdited() { + return !isEqual(this.containerExpirationPolicy, this.workingCopy); + }, }, methods: { - ...mapActions(['fetchSettings']), + restoreOriginal() { + this.workingCopy = { ...this.containerExpirationPolicy }; + }, }, }; </script> <template> <div> - <settings-form v-if="showSettingForm" /> + <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" diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index 7a26fb5cbee..a9b35d4e29f 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -1,28 +1,45 @@ <script> -import { get } from 'lodash'; -import { mapActions, mapState, mapGetters } from 'vuex'; -import { GlCard, GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlCard, GlButton } from '@gitlab/ui'; import Tracking from '~/tracking'; -import { mapComputed } from '~/vuex_shared/bindings'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, } from '../../shared/constants'; import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue'; import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants'; +import { formOptionsGenerator } from '~/registry/shared/utils'; +import updateContainerExpirationPolicyMutation from '../graphql/mutations/update_container_expiration_policy.graphql'; +import { updateContainerExpirationPolicy } from '../graphql/utils/cache_update'; export default { components: { GlCard, GlButton, - GlLoadingIcon, ExpirationPolicyFields, }, 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, + }, + }, labelsConfig: { cols: 3, align: 'right', }, + formOptions: formOptionsGenerator(), i18n: { CLEANUP_POLICY_CARD_HEADER, SET_CLEANUP_POLICY_BUTTON, @@ -34,49 +51,85 @@ export default { }, fieldsAreValid: true, apiErrors: null, + mutationLoading: false, }; }, computed: { - ...mapState(['formOptions', 'isLoading']), - ...mapGetters({ isEdited: 'getIsEdited' }), - ...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'), + prefilledForm() { + return { + ...this.value, + cadence: this.findDefaultOption('cadence'), + keepN: this.findDefaultOption('keepN'), + olderThan: this.findDefaultOption('olderThan'), + }; + }, + showLoadingIcon() { + return this.isLoading || this.mutationLoading; + }, isSubmitButtonDisabled() { - return !this.fieldsAreValid || this.isLoading; + return !this.fieldsAreValid || this.showLoadingIcon; }, isCancelButtonDisabled() { - return !this.isEdited || this.isLoading; + return !this.isEdited || this.isLoading || this.mutationLoading; + }, + mutationVariables() { + return { + projectPath: this.projectPath, + enabled: this.value.enabled, + cadence: this.value.cadence, + olderThan: this.value.olderThan, + keepN: this.value.keepN, + nameRegex: this.value.nameRegex, + nameRegexKeep: this.value.nameRegexKeep, + }; }, }, methods: { - ...mapActions(['resetSettings', 'saveSettings']), + findDefaultOption(option) { + return this.value[option] || this.$options.formOptions[option].find(f => f.default)?.key; + }, reset() { this.track('reset_form'); this.apiErrors = null; - this.resetSettings(); + this.$emit('reset'); }, setApiErrors(response) { - const messages = get(response, 'data.message', []); - - this.apiErrors = Object.keys(messages).reduce((acc, curr) => { - if (curr.startsWith('container_expiration_policy.')) { - const key = curr.replace('container_expiration_policy.', ''); - acc[key] = get(messages, [curr, 0], ''); - } + this.apiErrors = response.graphQLErrors.reduce((acc, curr) => { + curr.extensions.problems.forEach(item => { + acc[item.path[0]] = item.message; + }); return acc; }, {}); }, submit() { this.track('submit_form'); this.apiErrors = null; - this.saveSettings() - .then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' })) - .catch(({ response }) => { - this.setApiErrors(response); + 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' }); + } + 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(changePayload) { - this.settings = changePayload.newValue; + this.$emit('input', changePayload.newValue); if (this.apiErrors) { this.apiErrors[changePayload.modified] = undefined; } @@ -93,8 +146,8 @@ export default { </template> <template #default> <expiration-policy-fields - :value="settings" - :form-options="formOptions" + :value="prefilledForm" + :form-options="$options.formOptions" :is-loading="isLoading" :api-errors="apiErrors" @validated="fieldsAreValid = true" @@ -103,27 +156,25 @@ export default { /> </template> <template #footer> - <div class="gl-display-flex gl-justify-content-end"> - <gl-button - ref="cancel-button" - type="reset" - class="gl-mr-3 gl-display-block" - :disabled="isCancelButtonDisabled" - > - {{ __('Cancel') }} - </gl-button> - <gl-button - ref="save-button" - type="submit" - :disabled="isSubmitButtonDisabled" - variant="success" - category="primary" - class="gl-display-flex gl-justify-content-center gl-align-items-center js-no-auto-disable" - > - {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} - <gl-loading-icon v-if="isLoading" class="gl-ml-3" /> - </gl-button> - </div> + <gl-button + ref="cancel-button" + type="reset" + class="gl-mr-3 gl-display-block float-right" + :disabled="isCancelButtonDisabled" + > + {{ __('Cancel') }} + </gl-button> + <gl-button + ref="save-button" + type="submit" + :disabled="isSubmitButtonDisabled" + :loading="showLoadingIcon" + variant="success" + category="primary" + class="js-no-auto-disable" + > + {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} + </gl-button> </template> </gl-card> </form> diff --git a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql new file mode 100644 index 00000000000..224e0ed9472 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql @@ -0,0 +1,8 @@ +fragment ContainerExpirationPolicyFields on ContainerExpirationPolicy { + cadence + enabled + keepN + nameRegex + nameRegexKeep + olderThan +} diff --git a/app/assets/javascripts/registry/settings/graphql/index.js b/app/assets/javascripts/registry/settings/graphql/index.js new file mode 100644 index 00000000000..16152eb81f6 --- /dev/null +++ b/app/assets/javascripts/registry/settings/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/registry/settings/graphql/mutations/update_container_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql new file mode 100644 index 00000000000..c40cd115ab0 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.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/registry/settings/graphql/queries/get_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql new file mode 100644 index 00000000000..c171be0ad07 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.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/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js new file mode 100644 index 00000000000..88067d52b51 --- /dev/null +++ b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js @@ -0,0 +1,22 @@ +import { produce } from 'immer'; +import expirationPolicyQuery from '../queries/get_expiration_policy.graphql'; + +export const updateContainerExpirationPolicy = projectPath => (client, { data: updatedData }) => { + const queryAndParams = { + query: expirationPolicyQuery, + variables: { projectPath }, + }; + const sourceData = client.readQuery(queryAndParams); + + const data = produce(sourceData, draftState => { + // eslint-disable-next-line no-param-reassign + draftState.project.containerExpirationPolicy = { + ...updatedData.updateContainerExpirationPolicy.containerExpirationPolicy, + }; + }); + + client.writeQuery({ + ...queryAndParams, + data, + }); +}; diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js index a318aa2a694..f7b1c5abd3a 100644 --- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js +++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js @@ -1,8 +1,9 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; import Translate from '~/vue_shared/translate'; -import store from './store'; +import { parseBoolean } from '~/lib/utils/common_utils'; import RegistrySettingsApp from './components/registry_settings_app.vue'; +import { apolloProvider } from './graphql/index'; Vue.use(GlToast); Vue.use(Translate); @@ -12,13 +13,19 @@ export default () => { if (!el) { return null; } - store.dispatch('setInitialState', el.dataset); + const { projectPath, isAdmin, adminSettingsPath, enableHistoricEntries } = el.dataset; return new Vue({ el, - store, + apolloProvider, components: { RegistrySettingsApp, }, + provide: { + projectPath, + isAdmin: parseBoolean(isAdmin), + adminSettingsPath, + enableHistoricEntries: parseBoolean(enableHistoricEntries), + }, render(createElement) { return createElement('registry-settings-app', {}); }, diff --git a/app/assets/javascripts/registry/settings/store/actions.js b/app/assets/javascripts/registry/settings/store/actions.js deleted file mode 100644 index 0530a870ecc..00000000000 --- a/app/assets/javascripts/registry/settings/store/actions.js +++ /dev/null @@ -1,30 +0,0 @@ -import Api from '~/api'; -import * as types from './mutation_types'; - -export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); -export const updateSettings = ({ commit }, data) => commit(types.UPDATE_SETTINGS, data); -export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); -export const receiveSettingsSuccess = ({ commit }, data) => { - commit(types.SET_SETTINGS, data); -}; -export const resetSettings = ({ commit }) => commit(types.RESET_SETTINGS); - -export const fetchSettings = ({ dispatch, state }) => { - dispatch('toggleLoading'); - return Api.project(state.projectId) - .then(({ data: { container_expiration_policy } }) => - dispatch('receiveSettingsSuccess', container_expiration_policy), - ) - .finally(() => dispatch('toggleLoading')); -}; - -export const saveSettings = ({ dispatch, state }) => { - dispatch('toggleLoading'); - return Api.updateProject(state.projectId, { - container_expiration_policy_attributes: state.settings, - }) - .then(({ data: { container_expiration_policy } }) => - dispatch('receiveSettingsSuccess', container_expiration_policy), - ) - .finally(() => dispatch('toggleLoading')); -}; diff --git a/app/assets/javascripts/registry/settings/store/getters.js b/app/assets/javascripts/registry/settings/store/getters.js deleted file mode 100644 index ac1a931d8e0..00000000000 --- a/app/assets/javascripts/registry/settings/store/getters.js +++ /dev/null @@ -1,26 +0,0 @@ -import { isEqual } from 'lodash'; -import { findDefaultOption } from '../../shared/utils'; - -export const getCadence = state => - state.settings.cadence || findDefaultOption(state.formOptions.cadence); - -export const getKeepN = state => - state.settings.keep_n || findDefaultOption(state.formOptions.keepN); - -export const getOlderThan = state => - state.settings.older_than || findDefaultOption(state.formOptions.olderThan); - -export const getSettings = (state, getters) => ({ - enabled: state.settings.enabled, - cadence: getters.getCadence, - older_than: getters.getOlderThan, - keep_n: getters.getKeepN, - name_regex: state.settings.name_regex, - name_regex_keep: state.settings.name_regex_keep, -}); - -export const getIsEdited = state => !isEqual(state.original, state.settings); - -export const getIsDisabled = state => { - return !(state.original || state.enableHistoricEntries); -}; diff --git a/app/assets/javascripts/registry/settings/store/index.js b/app/assets/javascripts/registry/settings/store/index.js deleted file mode 100644 index c2500454d8e..00000000000 --- a/app/assets/javascripts/registry/settings/store/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import mutations from './mutations'; -import * as getters from './getters'; -import state from './state'; - -Vue.use(Vuex); - -export const createStore = () => - new Vuex.Store({ - state, - actions, - mutations, - getters, - }); - -export default createStore(); diff --git a/app/assets/javascripts/registry/settings/store/mutation_types.js b/app/assets/javascripts/registry/settings/store/mutation_types.js deleted file mode 100644 index db499ffa761..00000000000 --- a/app/assets/javascripts/registry/settings/store/mutation_types.js +++ /dev/null @@ -1,5 +0,0 @@ -export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; -export const UPDATE_SETTINGS = 'UPDATE_SETTINGS'; -export const TOGGLE_LOADING = 'TOGGLE_LOADING'; -export const SET_SETTINGS = 'SET_SETTINGS'; -export const RESET_SETTINGS = 'RESET_SETTINGS'; diff --git a/app/assets/javascripts/registry/settings/store/mutations.js b/app/assets/javascripts/registry/settings/store/mutations.js deleted file mode 100644 index 3ba13419b98..00000000000 --- a/app/assets/javascripts/registry/settings/store/mutations.js +++ /dev/null @@ -1,29 +0,0 @@ -import { parseBoolean } from '~/lib/utils/common_utils'; -import * as types from './mutation_types'; - -export default { - [types.SET_INITIAL_STATE](state, initialState) { - state.projectId = initialState.projectId; - state.formOptions = { - cadence: JSON.parse(initialState.cadenceOptions), - keepN: JSON.parse(initialState.keepNOptions), - olderThan: JSON.parse(initialState.olderThanOptions), - }; - state.enableHistoricEntries = parseBoolean(initialState.enableHistoricEntries); - state.isAdmin = parseBoolean(initialState.isAdmin); - state.adminSettingsPath = initialState.adminSettingsPath; - }, - [types.UPDATE_SETTINGS](state, data) { - state.settings = { ...state.settings, ...data.settings }; - }, - [types.SET_SETTINGS](state, settings) { - state.settings = settings ?? state.settings; - state.original = Object.freeze(settings); - }, - [types.RESET_SETTINGS](state) { - state.settings = { ...state.original }; - }, - [types.TOGGLE_LOADING](state) { - state.isLoading = !state.isLoading; - }, -}; diff --git a/app/assets/javascripts/registry/settings/store/state.js b/app/assets/javascripts/registry/settings/store/state.js deleted file mode 100644 index fccc0991c1c..00000000000 --- a/app/assets/javascripts/registry/settings/store/state.js +++ /dev/null @@ -1,42 +0,0 @@ -export default () => ({ - /* - * Project Id used to build the API call - */ - projectId: '', - /* - * Boolean to determine if the UI is loading data from the API - */ - isLoading: false, - /* - * Boolean to determine if the user is an admin - */ - isAdmin: false, - /* - * String containing the full path to the admin config page for CI/CD - */ - adminSettingsPath: '', - /* - * Boolean to determine if project created before 12.8 can use this feature - */ - enableHistoricEntries: false, - /* - * This contains the data shown and manipulated in the UI - * Has the following structure: - * { - * enabled: Boolean - * cadence: String, - * older_than: String, - * keep_n: String, - * name_regex: String - * } - */ - settings: {}, - /* - * Same structure as settings, above but Frozen object and used only in case the user clicks 'cancel', initialized to null - */ - original: null, - /* - * Contains the options used to populate the form selects - */ - formOptions: {}, -}); diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue index 1ff2f6f99e5..2b8e9f6ff64 100644 --- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue +++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue @@ -68,34 +68,31 @@ export default { { name: 'expiration-policy-interval', label: EXPIRATION_INTERVAL_LABEL, - model: 'older_than', - optionKey: 'olderThan', + model: 'olderThan', }, { name: 'expiration-policy-schedule', label: EXPIRATION_SCHEDULE_LABEL, model: 'cadence', - optionKey: 'cadence', }, { name: 'expiration-policy-latest', label: KEEP_N_LABEL, - model: 'keep_n', - optionKey: 'keepN', + model: 'keepN', }, ], textAreaList: [ { name: 'expiration-policy-name-matching', label: NAME_REGEX_LABEL, - model: 'name_regex', + model: 'nameRegex', placeholder: NAME_REGEX_PLACEHOLDER, description: NAME_REGEX_DESCRIPTION, }, { name: 'expiration-policy-keep-name', label: NAME_REGEX_KEEP_LABEL, - model: 'name_regex_keep', + model: 'nameRegexKeep', placeholder: NAME_REGEX_KEEP_PLACEHOLDER, description: NAME_REGEX_KEEP_DESCRIPTION, }, @@ -107,17 +104,16 @@ export default { }, computed: { ...mapComputedToEvent( - ['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex', 'name_regex_keep'], + ['enabled', 'cadence', 'olderThan', 'keepN', 'nameRegex', 'nameRegexKeep'], 'value', ), policyEnabledText() { return this.enabled ? ENABLED_TEXT : DISABLED_TEXT; }, textAreaValidation() { - const nameRegexErrors = - this.apiErrors?.name_regex || this.validateRegexLength(this.name_regex); + const nameRegexErrors = this.apiErrors?.nameRegex || this.validateRegexLength(this.nameRegex); const nameKeepRegexErrors = - this.apiErrors?.name_regex_keep || this.validateRegexLength(this.name_regex_keep); + this.apiErrors?.nameRegexKeep || this.validateRegexLength(this.nameRegexKeep); return { /* @@ -127,11 +123,11 @@ export default { * false: red border, error message * So in this function we keep null if the are no message otherwise we 'invert' the error message */ - name_regex: { + nameRegex: { state: nameRegexErrors === null ? null : !nameRegexErrors, message: nameRegexErrors, }, - name_regex_keep: { + nameRegexKeep: { state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors, message: nameKeepRegexErrors, }, @@ -139,8 +135,8 @@ export default { }, fieldsValidity() { return ( - this.textAreaValidation.name_regex.state !== false && - this.textAreaValidation.name_regex_keep.state !== false + this.textAreaValidation.nameRegex.state !== false && + this.textAreaValidation.nameRegexKeep.state !== false ); }, isFormElementDisabled() { @@ -216,11 +212,7 @@ export default { :disabled="isFormElementDisabled" @input="updateModel($event, select.model)" > - <option - v-for="option in formOptions[select.optionKey]" - :key="option.key" - :value="option.key" - > + <option v-for="option in formOptions[select.model]" :key="option.key" :value="option.key"> {{ option.label }} </option> </gl-form-select> diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js index 36d55c7610e..735d72972e6 100644 --- a/app/assets/javascripts/registry/shared/constants.js +++ b/app/assets/javascripts/registry/shared/constants.js @@ -43,3 +43,27 @@ export const NAME_REGEX_KEEP_PLACEHOLDER = ''; export const NAME_REGEX_KEEP_DESCRIPTION = s__( 'ContainerRegistry|Wildcards such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported', ); + +export const KEEP_N_OPTIONS = [ + { variable: 1, key: 'ONE_TAG', default: false }, + { variable: 5, key: 'FIVE_TAGS', default: false }, + { variable: 10, key: 'TEN_TAGS', default: true }, + { variable: 25, key: 'TWENTY_FIVE_TAGS', default: false }, + { variable: 50, key: 'FIFTY_TAGS', default: false }, + { variable: 100, key: 'ONE_HUNDRED_TAGS', 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 }, +]; diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js index a7377773842..bdf1ab9507d 100644 --- a/app/assets/javascripts/registry/shared/utils.js +++ b/app/assets/javascripts/registry/shared/utils.js @@ -1,3 +1,6 @@ +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; @@ -17,3 +20,27 @@ export const mapComputedToEvent = (list, root) => { }); return result; }; + +export const olderThanTranslationGenerator = variable => + n__( + '%d day until tags are automatically removed', + '%d days until tags are automatically removed', + 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), + }; +}; |