diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-17 14:59:07 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-17 14:59:07 +0300 |
commit | 8b573c94895dc0ac0e1d9d59cf3e8745e8b539ca (patch) | |
tree | 544930fb309b30317ae9797a9683768705d664c4 /app/assets/javascripts/registry | |
parent | 4b1de649d0168371549608993deac953eb692019 (diff) |
Add latest changes from gitlab-org/gitlab@13-7-stable-eev13.7.0-rc42
Diffstat (limited to 'app/assets/javascripts/registry')
45 files changed, 1018 insertions, 832 deletions
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue index ff613daf7fa..3eeb7b29386 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue @@ -1,15 +1,29 @@ <script> import { GlSprintf } from '@gitlab/ui'; +import { sprintf } from '~/locale'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; -import { DETAILS_PAGE_TITLE } from '../../constants/index'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { DETAILS_PAGE_TITLE, UPDATED_AT } from '../../constants/index'; export default { - components: { GlSprintf, TitleArea }, + components: { GlSprintf, TitleArea, MetadataItem }, + mixins: [timeagoMixin], props: { - imageName: { - type: String, - required: false, - default: '', + image: { + type: Object, + required: true, + }, + }, + computed: { + visibilityIcon() { + return this.image?.project?.visibility === 'public' ? 'eye' : 'eye-slash'; + }, + timeAgo() { + return this.timeFormatted(this.image.updatedAt); + }, + updatedText() { + return sprintf(UPDATED_AT, { time: this.timeAgo }); }, }, i18n: { @@ -23,9 +37,17 @@ export default { <template #title> <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE"> <template #imageName> - {{ imageName }} + {{ image.name }} </template> </gl-sprintf> </template> + <template #metadata-updated> + <metadata-item + :icon="visibilityIcon" + :text="updatedText" + size="xl" + data-testid="updated-and-visibility" + /> + </template> </title-area> </template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue index 2844b4ffde3..ad39a898e7b 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue @@ -34,7 +34,7 @@ export default { return this.tags.some(tag => this.selectedItems[tag.name]); }, showMultiDeleteButton() { - return this.tags.some(tag => tag.destroy_path) && !this.isMobile; + return this.tags.some(tag => tag.canDelete) && !this.isMobile; }, }, methods: { 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 2edeac1144f..5aeafd318aa 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 @@ -63,7 +63,7 @@ export default { }, computed: { formattedSize() { - return this.tag.total_size ? numberToHumanSize(this.tag.total_size) : NOT_AVAILABLE_SIZE; + return this.tag.totalSize ? numberToHumanSize(this.tag.totalSize) : NOT_AVAILABLE_SIZE; }, layers() { return this.tag.layers ? n__('%d layer', '%d layers', this.tag.layers) : ''; @@ -76,10 +76,10 @@ export default { return this.tag.digest?.substring(7, 14) ?? NOT_AVAILABLE_TEXT; }, publishedDate() { - return formatDate(this.tag.created_at, 'isoDate'); + return formatDate(this.tag.createdAt, 'isoDate'); }, publishedTime() { - return formatDate(this.tag.created_at, 'hh:MM Z'); + return formatDate(this.tag.createdAt, 'hh:MM Z'); }, formattedRevision() { // to be removed when API response is adjusted @@ -101,7 +101,7 @@ export default { <list-item v-bind="$attrs" :selected="selected"> <template #left-action> <gl-form-checkbox - v-if="Boolean(tag.destroy_path)" + v-if="tag.canDelete" :disabled="invalidTag" class="gl-m-0" :checked="selected" @@ -148,7 +148,7 @@ export default { <span data-testid="time"> <gl-sprintf :message="$options.i18n.CREATED_AT_LABEL"> <template #timeInfo> - <time-ago-tooltip :time="tag.created_at" /> + <time-ago-tooltip :time="tag.createdAt" /> </template> </gl-sprintf> </span> @@ -162,10 +162,10 @@ export default { </template> <template #right-action> <delete-button - :disabled="!tag.destroy_path || invalidTag" + :disabled="!tag.canDelete || invalidTag" :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" :tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP" - :tooltip-disabled="Boolean(tag.destroy_path)" + :tooltip-disabled="tag.canDelete" data-testid="single-delete-button" @delete="$emit('delete')" /> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue index ba55822f0ca..319666210d6 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue @@ -1,6 +1,5 @@ <script> import { GlDropdown } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; import Tracking from '~/tracking'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; import { @@ -20,6 +19,7 @@ export default { GlDropdown, CodeInstruction, }, + inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'], mixins: [Tracking.mixin({ label: trackingLabel })], trackingLabel, i18n: { @@ -31,9 +31,6 @@ export default { PUSH_COMMAND_LABEL, COPY_PUSH_TITLE, }, - computed: { - ...mapGetters(['dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand']), - }, }; </script> <template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue index 80cc392f86a..26e9fee63af 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue @@ -1,17 +1,14 @@ <script> import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; -import { mapState } from 'vuex'; export default { name: 'GroupEmptyState', + inject: ['config'], components: { GlEmptyState, GlSprintf, GlLink, }, - computed: { - ...mapState(['config']), - }, }; </script> <template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue index d1b9894da0e..f8b3233438f 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue @@ -1,11 +1,11 @@ <script> -import { GlPagination } from '@gitlab/ui'; +import { GlKeysetPagination } from '@gitlab/ui'; import ImageListRow from './image_list_row.vue'; export default { name: 'ImageList', components: { - GlPagination, + GlKeysetPagination, ImageListRow, }, props: { @@ -13,19 +13,14 @@ export default { type: Array, required: true, }, - pagination: { + pageInfo: { type: Object, required: true, }, }, computed: { - currentPage: { - get() { - return this.pagination.page; - }, - set(page) { - this.$emit('pageChange', page); - }, + showPagination() { + return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage; }, }, }; @@ -40,13 +35,15 @@ export default { :first="index === 0" @delete="$emit('delete', $event)" /> - - <gl-pagination - v-model="currentPage" - :per-page="pagination.perPage" - :total-items="pagination.total" - align="center" - class="w-100 gl-mt-3" - /> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-if="showPagination" + :has-next-page="pageInfo.hasNextPage" + :has-previous-page="pageInfo.hasPreviousPage" + class="gl-mt-3" + @prev="$emit('prev-page')" + @next="$emit('next-page')" + /> + </div> </div> </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 b0a7c4824bd..3fe61dc231a 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 @@ -1,6 +1,8 @@ <script> import { GlTooltipDirective, GlIcon, GlSprintf } from '@gitlab/ui'; import { n__ } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import DeleteButton from '../delete_button.vue'; @@ -11,6 +13,8 @@ import { REMOVE_REPOSITORY_LABEL, ROW_SCHEDULED_FOR_DELETION, CLEANUP_TIMED_OUT_ERROR_MESSAGE, + IMAGE_DELETE_SCHEDULED_STATUS, + IMAGE_FAILED_DELETED_STATUS, } from '../../constants/index'; export default { @@ -38,19 +42,29 @@ export default { }, computed: { disabledDelete() { - return !this.item.destroy_path || this.item.deleting; + return !this.item.canDelete || this.deleting; + }, + id() { + return getIdFromGraphQLId(this.item.id); + }, + deleting() { + return this.item.status === IMAGE_DELETE_SCHEDULED_STATUS; + }, + failedDelete() { + return this.item.status === IMAGE_FAILED_DELETED_STATUS; }, tagsCountText() { return n__( 'ContainerRegistry|%{count} Tag', 'ContainerRegistry|%{count} Tags', - this.item.tags_count, + this.item.tagsCount, ); }, warningIconText() { - if (this.item.failedDelete) { + if (this.failedDelete) { return ASYNC_DELETE_IMAGE_ERROR_MESSAGE; - } else if (this.item.cleanup_policy_started_at) { + } + if (this.item.expirationPolicyStartedAt) { return CLEANUP_TIMED_OUT_ERROR_MESSAGE; } return null; @@ -63,23 +77,23 @@ export default { <list-item v-gl-tooltip="{ placement: 'left', - disabled: !item.deleting, + disabled: !deleting, title: $options.i18n.ROW_SCHEDULED_FOR_DELETION, }" v-bind="$attrs" - :disabled="item.deleting" + :disabled="deleting" > <template #left-primary> <router-link class="gl-text-body gl-font-weight-bold" data-testid="details-link" - :to="{ name: 'details', params: { id: item.id } }" + :to="{ name: 'details', params: { id } }" > {{ item.path }} </router-link> <clipboard-button v-if="item.location" - :disabled="item.deleting" + :disabled="deleting" :text="item.location" :title="item.location" category="tertiary" @@ -97,7 +111,7 @@ export default { <gl-icon name="tag" class="gl-mr-2" /> <gl-sprintf :message="tagsCountText"> <template #count> - {{ item.tags_count }} + {{ item.tagsCount }} </template> </gl-sprintf> </span> @@ -106,7 +120,7 @@ export default { <delete-button :title="$options.i18n.REMOVE_REPOSITORY_LABEL" :disabled="disabledDelete" - :tooltip-disabled="Boolean(item.destroy_path)" + :tooltip-disabled="item.canDelete" :tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED" @delete="$emit('delete', item)" /> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue index 35eb0b11e40..5308b025cc0 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue @@ -1,6 +1,5 @@ <script> import { GlEmptyState, GlSprintf, GlLink, GlFormInputGroup, GlFormInput } from '@gitlab/ui'; -import { mapState, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { @@ -20,6 +19,7 @@ export default { GlFormInputGroup, GlFormInput, }, + inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'], i18n: { quickStart: QUICK_START, copyLoginTitle: COPY_LOGIN_TITLE, @@ -35,10 +35,6 @@ export default { 'ContainerRegistry|You can add an image to this registry with the following commands:', ), }, - computed: { - ...mapState(['config']), - ...mapGetters(['dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand']), - }, }; </script> <template> diff --git a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue index 666d8b042da..1cedcc41b2b 100644 --- a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue +++ b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue @@ -1,9 +1,11 @@ <script> +/* eslint-disable vue/no-v-html */ +// We are forced to use `v-html` untill this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079 +// then we can re-write this to use gl-breadcrumb import { initial, first, last } from 'lodash'; -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { sanitize } from '~/lib/dompurify'; export default { - directives: { SafeHtml }, props: { crumbs: { type: Array, @@ -11,6 +13,9 @@ export default { }, }, computed: { + parsedCrumbs() { + return this.crumbs.map(c => ({ ...c, innerHTML: sanitize(c.innerHTML) })); + }, rootRoute() { return this.$router.options.routes.find(r => r.meta.root); }, @@ -18,11 +23,11 @@ export default { return this.$route.name === this.rootRoute.name; }, rootCrumbs() { - return initial(this.crumbs); + return initial(this.parsedCrumbs); }, divider() { const { classList, tagName, innerHTML } = first(this.crumbs).querySelector('svg'); - return { classList: [...classList], tagName, innerHTML }; + return { classList: [...classList], tagName, innerHTML: sanitize(innerHTML) }; }, lastCrumb() { const { children } = last(this.crumbs); @@ -30,7 +35,7 @@ export default { return { tagName, className, - text: this.$route.meta.nameGenerator(this.$store.state), + text: this.$route.meta.nameGenerator(), path: { to: this.$route.name }, }; }, @@ -43,14 +48,14 @@ export default { <li v-for="(crumb, index) in rootCrumbs" :key="index" - v-safe-html="crumb.innerHTML" :class="crumb.className" + v-html="crumb.innerHTML" ></li> <li v-if="!isRootRoute"> <router-link ref="rootRouteLink" :to="rootRoute.path"> - {{ rootRoute.meta.nameGenerator($store.state) }} + {{ rootRoute.meta.nameGenerator() }} </router-link> - <component :is="divider.tagName" v-safe-html="divider.innerHTML" :class="divider.classList" /> + <component :is="divider.tagName" :class="divider.classList" v-html="divider.innerHTML" /> </li> <li> <component :is="lastCrumb.tagName" ref="lastCrumb" :class="lastCrumb.className"> diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js index 306e6903a4f..1babaaa93da 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -56,6 +56,8 @@ export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( 'ContainerRegistry|Invalid tag: missing manifest digest', ); +export const UPDATED_AT = s__('ContainerRegistry|Last updated %{time}'); + export const NOT_AVAILABLE_TEXT = __('N/A'); export const NOT_AVAILABLE_SIZE = __('0 bytes'); // Parameters diff --git a/app/assets/javascripts/registry/explorer/constants/list.js b/app/assets/javascripts/registry/explorer/constants/list.js index 39f63d2a153..37ced72861e 100644 --- a/app/assets/javascripts/registry/explorer/constants/list.js +++ b/app/assets/javascripts/registry/explorer/constants/list.js @@ -44,5 +44,6 @@ export const EMPTY_RESULT_MESSAGE = s__( // Parameters -export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled'; -export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed'; +export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED'; +export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED'; +export const GRAPHQL_PAGE_SIZE = 10; diff --git a/app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql b/app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql new file mode 100644 index 00000000000..9a3579ee8e0 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql @@ -0,0 +1,11 @@ +fragment ContainerRepositoryFields on ContainerRepository { + id + name + path + status + location + canDelete + createdAt + tagsCount + expirationPolicyStartedAt +} diff --git a/app/assets/javascripts/registry/explorer/graphql/index.js b/app/assets/javascripts/registry/explorer/graphql/index.js new file mode 100644 index 00000000000..16152eb81f6 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/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/explorer/graphql/mutations/delete_container_repository.mutation.graphql b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql new file mode 100644 index 00000000000..4c88b726ee5 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql @@ -0,0 +1,9 @@ +mutation destroyContainerRepository($id: ContainerRepositoryID!) { + destroyContainerRepository(input: { id: $id }) { + containerRepository { + id + status + } + errors + } +} diff --git a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql new file mode 100644 index 00000000000..a31f2829e13 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql @@ -0,0 +1,5 @@ +mutation destroyContainerRepositoryTags($id: ContainerRepositoryID!, $tagNames: [String!]!) { + destroyContainerRepositoryTags(input: { id: $id, tagNames: $tagNames }) { + errors + } +} diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql new file mode 100644 index 00000000000..b40200e020b --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql @@ -0,0 +1,41 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getContainerRepositoryDetails( + $id: ID! + $first: Int + $last: Int + $after: String + $before: String +) { + containerRepository(id: $id) { + id + name + path + status + location + canDelete + createdAt + updatedAt + tagsCount + expirationPolicyStartedAt + tags(after: $after, before: $before, first: $first, last: $last) { + nodes { + digest + location + path + name + revision + shortRevision + createdAt + totalSize + canDelete + } + pageInfo { + ...PageInfo + } + } + project { + visibility + } + } +} diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.query.graphql new file mode 100644 index 00000000000..348eda97ea7 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.query.graphql @@ -0,0 +1,23 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/container_repository.fragment.graphql" + +query getGroupContainerRepositories( + $fullPath: ID! + $name: String + $first: Int + $last: Int + $after: String + $before: String +) { + group(fullPath: $fullPath) { + containerRepositoriesCount + containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) { + nodes { + ...ContainerRepositoryFields + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.query.graphql new file mode 100644 index 00000000000..338e27745f7 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.query.graphql @@ -0,0 +1,23 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/container_repository.fragment.graphql" + +query getProjectContainerRepositories( + $fullPath: ID! + $name: String + $first: Int + $last: Int + $after: String + $before: String +) { + project(fullPath: $fullPath) { + containerRepositoriesCount + containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) { + nodes { + ...ContainerRepositoryFields + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js index 2bba3ee4ff9..d887b6a1b15 100644 --- a/app/assets/javascripts/registry/explorer/index.js +++ b/app/assets/javascripts/registry/explorer/index.js @@ -1,10 +1,11 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; import Translate from '~/vue_shared/translate'; +import { parseBoolean } from '~/lib/utils/common_utils'; import RegistryExplorer from './pages/index.vue'; import RegistryBreadcrumb from './components/registry_breadcrumb.vue'; -import { createStore } from './stores'; import createRouter from './router'; +import { apolloProvider } from './graphql/index'; Vue.use(Translate); Vue.use(GlToast); @@ -16,20 +17,42 @@ export default () => { return null; } - const { endpoint } = el.dataset; + const { endpoint, expirationPolicy, isGroupPage, isAdmin, ...config } = el.dataset; - const store = createStore(); - const router = createRouter(endpoint); - store.dispatch('setInitialState', el.dataset); + // This is a mini state to help the breadcrumb have the correct name in the details page + const breadCrumbState = Vue.observable({ + name: '', + updateName(value) { + this.name = value; + }, + }); + + const router = createRouter(endpoint, breadCrumbState); const attachMainComponent = () => new Vue({ el, - store, router, + apolloProvider, components: { RegistryExplorer, }, + provide() { + return { + breadCrumbState, + config: { + ...config, + expirationPolicy: expirationPolicy ? JSON.parse(expirationPolicy) : undefined, + isGroupPage: parseBoolean(isGroupPage), + isAdmin: parseBoolean(isAdmin), + }, + /* eslint-disable @gitlab/require-i18n-strings */ + dockerBuildCommand: `docker build -t ${config.repositoryUrl} .`, + dockerPushCommand: `docker push ${config.repositoryUrl}`, + dockerLoginCommand: `docker login ${config.registryHostUrlWithPort}`, + /* eslint-enable @gitlab/require-i18n-strings */ + }; + }, render(createElement) { return createElement('registry-explorer'); }, @@ -40,8 +63,8 @@ export default () => { const crumbs = [...document.querySelectorAll('.js-breadcrumbs-list li')]; return new Vue({ el: breadCrumbEl, - store, router, + apolloProvider, components: { RegistryBreadcrumb, }, diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index a60ef5c4982..540f02d58d4 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -1,8 +1,9 @@ <script> -import { mapState, mapActions } from 'vuex'; -import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui'; +import { GlKeysetPagination, GlResizeObserverDirective } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; +import createFlash from '~/flash'; import Tracking from '~/tracking'; +import { joinPaths } from '~/lib/utils/url_utility'; 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'; @@ -11,11 +12,16 @@ import TagsList from '../components/details_page/tags_list.vue'; import TagsLoader from '../components/details_page/tags_loader.vue'; import EmptyTagsState from '../components/details_page/empty_tags_state.vue'; +import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; +import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql'; + import { ALERT_SUCCESS_TAG, ALERT_DANGER_TAG, ALERT_SUCCESS_TAGS, ALERT_DANGER_TAGS, + GRAPHQL_PAGE_SIZE, + FETCH_IMAGES_LIST_ERROR_MESSAGE, } from '../constants/index'; export default { @@ -23,28 +29,61 @@ export default { DeleteAlert, PartialCleanupAlert, DetailsHeader, - GlPagination, + GlKeysetPagination, DeleteModal, TagsList, TagsLoader, EmptyTagsState, }, + inject: ['breadCrumbState', 'config'], directives: { GlResizeObserver: GlResizeObserverDirective, }, mixins: [Tracking.mixin()], + apollo: { + image: { + query: getContainerRepositoryDetailsQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data.containerRepository; + }, + result({ data }) { + this.tagsPageInfo = data.containerRepository?.tags?.pageInfo; + this.breadCrumbState.updateName(data.containerRepository?.name); + }, + error() { + createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); + }, + }, + }, data() { return { + image: {}, + tagsPageInfo: {}, itemsToBeDeleted: [], isMobile: false, + mutationLoading: false, deleteAlertType: null, dismissPartialCleanupWarning: false, }; }, computed: { - ...mapState(['tagsPagination', 'isLoading', 'config', 'tags', 'imageDetails']), + queryVariables() { + return { + id: joinPaths(this.config.gidPrefix, `${this.$route.params.id}`), + first: GRAPHQL_PAGE_SIZE, + }; + }, + isLoading() { + return this.$apollo.queries.image.loading || this.mutationLoading; + }, + tags() { + return this.image?.tags?.nodes || []; + }, showPartialCleanupWarning() { - return this.imageDetails?.cleanup_policy_started_at && !this.dismissPartialCleanupWarning; + return this.image?.expirationPolicyStartedAt && !this.dismissPartialCleanupWarning; }, tracking() { return { @@ -52,66 +91,78 @@ export default { this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete', }; }, - currentPage: { - get() { - return this.tagsPagination.page; - }, - set(page) { - this.requestTagsList({ page }); - }, + showPagination() { + return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage; }, }, - mounted() { - this.requestImageDetailsAndTagsList(this.$route.params.id); - }, methods: { - ...mapActions([ - 'requestTagsList', - 'requestDeleteTag', - 'requestDeleteTags', - 'requestImageDetailsAndTagsList', - ]), deleteTags(toBeDeleted) { this.itemsToBeDeleted = this.tags.filter(tag => toBeDeleted[tag.name]); this.track('click_button'); this.$refs.deleteModal.show(); }, - handleSingleDelete() { - const [itemToDelete] = this.itemsToBeDeleted; - this.itemsToBeDeleted = []; - return this.requestDeleteTag({ tag: itemToDelete }) - .then(() => { - this.deleteAlertType = ALERT_SUCCESS_TAG; - }) - .catch(() => { - this.deleteAlertType = ALERT_DANGER_TAG; - }); - }, - handleMultipleDelete() { + async handleDelete() { + this.track('confirm_delete'); const { itemsToBeDeleted } = this; this.itemsToBeDeleted = []; - - return this.requestDeleteTags({ - ids: itemsToBeDeleted.map(x => x.name), - }) - .then(() => { - this.deleteAlertType = ALERT_SUCCESS_TAGS; - }) - .catch(() => { - this.deleteAlertType = ALERT_DANGER_TAGS; + this.mutationLoading = true; + try { + const { data } = await this.$apollo.mutate({ + mutation: deleteContainerRepositoryTagsMutation, + variables: { + id: this.queryVariables.id, + tagNames: itemsToBeDeleted.map(i => i.name), + }, + awaitRefetchQueries: true, + refetchQueries: [ + { + query: getContainerRepositoryDetailsQuery, + variables: this.queryVariables, + }, + ], }); - }, - onDeletionConfirmed() { - this.track('confirm_delete'); - if (this.itemsToBeDeleted.length > 1) { - this.handleMultipleDelete(); - } else { - this.handleSingleDelete(); + + if (data?.destroyContainerRepositoryTags?.errors[0]) { + throw new Error(); + } + this.deleteAlertType = + itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS; + } catch (e) { + this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS; } + + this.mutationLoading = false; }, handleResize() { this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs'; }, + fetchNextPage() { + if (this.tagsPageInfo?.hasNextPage) { + this.$apollo.queries.image.fetchMore({ + variables: { + after: this.tagsPageInfo?.endCursor, + first: GRAPHQL_PAGE_SIZE, + }, + updateQuery(previousResult, { fetchMoreResult }) { + return fetchMoreResult; + }, + }); + } + }, + fetchPreviousPage() { + if (this.tagsPageInfo?.hasPreviousPage) { + this.$apollo.queries.image.fetchMore({ + variables: { + first: null, + before: this.tagsPageInfo?.startCursor, + last: GRAPHQL_PAGE_SIZE, + }, + updateQuery(previousResult, { fetchMoreResult }) { + return fetchMoreResult; + }, + }); + } + }, }, }; </script> @@ -132,28 +183,30 @@ export default { @dismiss="dismissPartialCleanupWarning = true" /> - <details-header :image-name="imageDetails.name" /> + <details-header :image="image" /> <tags-loader v-if="isLoading" /> <template v-else> <empty-tags-state v-if="tags.length === 0" :no-containers-image="config.noContainersImage" /> - <tags-list v-else :tags="tags" :is-mobile="isMobile" @delete="deleteTags" /> + <template v-else> + <tags-list :tags="tags" :is-mobile="isMobile" @delete="deleteTags" /> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-if="showPagination" + :has-next-page="tagsPageInfo.hasNextPage" + :has-previous-page="tagsPageInfo.hasPreviousPage" + class="gl-mt-3" + @prev="fetchPreviousPage" + @next="fetchNextPage" + /> + </div> + </template> </template> - <gl-pagination - v-if="!isLoading" - ref="pagination" - v-model="currentPage" - :per-page="tagsPagination.perPage" - :total-items="tagsPagination.total" - align="center" - class="gl-w-full gl-mt-3" - /> - <delete-modal ref="deleteModal" :items-to-be-deleted="itemsToBeDeleted" - @confirmDelete="onDeletionConfirmed" + @confirmDelete="handleDelete" @cancel="track('cancel_delete')" /> </div> diff --git a/app/assets/javascripts/registry/explorer/pages/index.vue b/app/assets/javascripts/registry/explorer/pages/index.vue index 4ac0bca84c1..dca63e1a569 100644 --- a/app/assets/javascripts/registry/explorer/pages/index.vue +++ b/app/assets/javascripts/registry/explorer/pages/index.vue @@ -1,7 +1,3 @@ -<script> -export default {}; -</script> - <template> <div> <router-view ref="router-view" /> diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 81e47073fe9..3192ba82db8 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -1,5 +1,4 @@ <script> -import { mapState, mapActions } from 'vuex'; import { GlEmptyState, GlTooltipDirective, @@ -11,6 +10,7 @@ import { GlSearchBoxByClick, } from '@gitlab/ui'; import Tracking from '~/tracking'; +import createFlash from '~/flash'; import ProjectEmptyState from '../components/list_page/project_empty_state.vue'; import GroupEmptyState from '../components/list_page/group_empty_state.vue'; @@ -18,6 +18,10 @@ import RegistryHeader from '../components/list_page/registry_header.vue'; import ImageList from '../components/list_page/image_list.vue'; import CliCommands from '../components/list_page/cli_commands.vue'; +import getProjectContainerRepositoriesQuery from '../graphql/queries/get_project_container_repositories.query.graphql'; +import getGroupContainerRepositoriesQuery from '../graphql/queries/get_group_container_repositories.query.graphql'; +import deleteContainerRepositoryMutation from '../graphql/mutations/delete_container_repository.mutation.graphql'; + import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE, @@ -29,6 +33,8 @@ import { IMAGE_REPOSITORY_LIST_LABEL, EMPTY_RESULT_TITLE, EMPTY_RESULT_MESSAGE, + GRAPHQL_PAGE_SIZE, + FETCH_IMAGES_LIST_ERROR_MESSAGE, } from '../constants/index'; export default { @@ -47,6 +53,7 @@ export default { RegistryHeader, CliCommands, }, + inject: ['config'], directives: { GlTooltip: GlTooltipDirective, }, @@ -66,21 +73,62 @@ export default { EMPTY_RESULT_TITLE, EMPTY_RESULT_MESSAGE, }, + apollo: { + images: { + query() { + return this.graphQlQuery; + }, + variables() { + return this.queryVariables; + }, + update(data) { + return data[this.graphqlResource]?.containerRepositories.nodes; + }, + result({ data }) { + this.pageInfo = data[this.graphqlResource]?.containerRepositories?.pageInfo; + this.containerRepositoriesCount = data[this.graphqlResource]?.containerRepositoriesCount; + }, + error() { + createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); + }, + }, + }, data() { return { + images: [], + pageInfo: {}, + containerRepositoriesCount: 0, itemToDelete: {}, deleteAlertType: null, - search: null, - isEmpty: false, + searchValue: null, + name: null, + mutationLoading: false, }; }, computed: { - ...mapState(['config', 'isLoading', 'images', 'pagination']), + graphqlResource() { + return this.config.isGroupPage ? 'group' : 'project'; + }, + graphQlQuery() { + return this.config.isGroupPage + ? getGroupContainerRepositoriesQuery + : getProjectContainerRepositoriesQuery; + }, + queryVariables() { + return { + name: this.name, + fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath, + first: GRAPHQL_PAGE_SIZE, + }; + }, tracking() { return { label: 'registry_repository_delete', }; }, + isLoading() { + return this.$apollo.queries.images.loading || this.mutationLoading; + }, showCommands() { return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); }, @@ -93,19 +141,7 @@ export default { : DELETE_IMAGE_ERROR_MESSAGE; }, }, - mounted() { - this.loadImageList(this.$route.name); - }, methods: { - ...mapActions(['requestImagesList', 'requestDeleteImage']), - loadImageList(fromName) { - if (!fromName || !this.images?.length) { - return this.requestImagesList().then(() => { - this.isEmpty = this.images.length === 0; - }); - } - return Promise.resolve(); - }, deleteImage(item) { this.track('click_button'); this.itemToDelete = item; @@ -113,18 +149,59 @@ export default { }, handleDeleteImage() { this.track('confirm_delete'); - return this.requestDeleteImage(this.itemToDelete) - .then(() => { - this.deleteAlertType = 'success'; + this.mutationLoading = true; + return this.$apollo + .mutate({ + mutation: deleteContainerRepositoryMutation, + variables: { + id: this.itemToDelete.id, + }, + }) + .then(({ data }) => { + if (data?.destroyContainerRepository?.errors[0]) { + this.deleteAlertType = 'danger'; + } else { + this.deleteAlertType = 'success'; + } }) .catch(() => { this.deleteAlertType = 'danger'; + }) + .finally(() => { + this.mutationLoading = false; }); }, dismissDeleteAlert() { this.deleteAlertType = null; this.itemToDelete = {}; }, + fetchNextPage() { + if (this.pageInfo?.hasNextPage) { + this.$apollo.queries.images.fetchMore({ + variables: { + after: this.pageInfo?.endCursor, + first: GRAPHQL_PAGE_SIZE, + }, + updateQuery(previousResult, { fetchMoreResult }) { + return fetchMoreResult; + }, + }); + } + }, + fetchPreviousPage() { + if (this.pageInfo?.hasPreviousPage) { + this.$apollo.queries.images.fetchMore({ + variables: { + first: null, + before: this.pageInfo?.startCursor, + last: GRAPHQL_PAGE_SIZE, + }, + updateQuery(previousResult, { fetchMoreResult }) { + return fetchMoreResult; + }, + }); + } + }, }, }; </script> @@ -134,7 +211,7 @@ export default { <gl-alert v-if="showDeleteAlert" :variant="deleteAlertType" - class="mt-2" + class="gl-mt-5" dismissible @dismiss="dismissDeleteAlert" > @@ -165,7 +242,7 @@ export default { <template v-else> <registry-header - :images-count="pagination.total" + :images-count="containerRepositoriesCount" :expiration-policy="config.expirationPolicy" :help-page-path="config.helpPagePath" :expiration-policy-help-page-path="config.expirationPolicyHelpPagePath" @@ -176,7 +253,7 @@ export default { </template> </registry-header> - <div v-if="isLoading" class="mt-2"> + <div v-if="isLoading" class="gl-mt-5"> <gl-skeleton-loader v-for="index in $options.loader.repeat" :key="index" @@ -190,16 +267,17 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <template v-if="!isEmpty"> + <template v-if="images.length > 0 || name"> <div class="gl-display-flex gl-p-1 gl-mt-3" data-testid="listHeader"> <div class="gl-flex-fill-1"> <h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5> </div> <div> <gl-search-box-by-click - v-model="search" + v-model="searchValue" :placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT" - @submit="requestImagesList({ name: $event })" + @clear="name = null" + @submit="name = $event" /> </div> </div> @@ -207,9 +285,10 @@ export default { <image-list v-if="images.length" :images="images" - :pagination="pagination" - @pageChange="requestImagesList({ pagination: { page: $event }, name: search })" + :page-info="pageInfo" @delete="deleteImage" + @prev-page="fetchPreviousPage" + @next-page="fetchNextPage" /> <gl-empty-state diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/registry/explorer/router.js index dcf1c77329d..d8903cf0931 100644 --- a/app/assets/javascripts/registry/explorer/router.js +++ b/app/assets/javascripts/registry/explorer/router.js @@ -6,7 +6,7 @@ import { CONTAINER_REGISTRY_TITLE } from './constants/index'; Vue.use(VueRouter); -export default function createRouter(base) { +export default function createRouter(base, breadCrumbState) { const router = new VueRouter({ base, mode: 'history', @@ -25,7 +25,7 @@ export default function createRouter(base) { path: '/:id', component: Details, meta: { - nameGenerator: ({ imageDetails }) => imageDetails?.name, + nameGenerator: () => breadCrumbState.name, }, }, ], diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js deleted file mode 100644 index c1883095097..00000000000 --- a/app/assets/javascripts/registry/explorer/stores/actions.js +++ /dev/null @@ -1,119 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; -import Api from '~/api'; -import * as types from './mutation_types'; -import { - FETCH_IMAGES_LIST_ERROR_MESSAGE, - DEFAULT_PAGE, - DEFAULT_PAGE_SIZE, - FETCH_TAGS_LIST_ERROR_MESSAGE, - FETCH_IMAGE_DETAILS_ERROR_MESSAGE, -} from '../constants/index'; -import { pathGenerator } from '../utils'; - -export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); -export const setShowGarbageCollectionTip = ({ commit }, data) => - commit(types.SET_SHOW_GARBAGE_COLLECTION_TIP, data); - -export const receiveImagesListSuccess = ({ commit }, { data, headers }) => { - commit(types.SET_IMAGES_LIST_SUCCESS, data); - commit(types.SET_PAGINATION, headers); -}; - -export const receiveTagsListSuccess = ({ commit }, { data, headers }) => { - commit(types.SET_TAGS_LIST_SUCCESS, data); - commit(types.SET_TAGS_PAGINATION, headers); -}; - -export const requestImagesList = ( - { commit, dispatch, state }, - { pagination = {}, name = null } = {}, -) => { - commit(types.SET_MAIN_LOADING, true); - const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination; - - return axios - .get(state.config.endpoint, { params: { page, per_page: perPage, name } }) - .then(({ data, headers }) => { - dispatch('receiveImagesListSuccess', { data, headers }); - }) - .catch(() => { - createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); - }) - .finally(() => { - commit(types.SET_MAIN_LOADING, false); - }); -}; - -export const requestTagsList = ({ commit, dispatch, state: { imageDetails } }, pagination = {}) => { - commit(types.SET_MAIN_LOADING, true); - const tagsPath = pathGenerator(imageDetails); - - const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination; - return axios - .get(tagsPath, { params: { page, per_page: perPage } }) - .then(({ data, headers }) => { - dispatch('receiveTagsListSuccess', { data, headers }); - }) - .catch(() => { - createFlash({ message: FETCH_TAGS_LIST_ERROR_MESSAGE }); - }) - .finally(() => { - commit(types.SET_MAIN_LOADING, false); - }); -}; - -export const requestImageDetailsAndTagsList = ({ dispatch, commit }, id) => { - commit(types.SET_MAIN_LOADING, true); - return Api.containerRegistryDetails(id) - .then(({ data }) => { - commit(types.SET_IMAGE_DETAILS, data); - dispatch('requestTagsList'); - }) - .catch(() => { - createFlash({ message: FETCH_IMAGE_DETAILS_ERROR_MESSAGE }); - commit(types.SET_MAIN_LOADING, false); - }); -}; - -export const requestDeleteTag = ({ commit, dispatch, state }, { tag }) => { - commit(types.SET_MAIN_LOADING, true); - return axios - .delete(tag.destroy_path) - .then(() => { - dispatch('setShowGarbageCollectionTip', true); - - return dispatch('requestTagsList', state.tagsPagination); - }) - .finally(() => { - commit(types.SET_MAIN_LOADING, false); - }); -}; - -export const requestDeleteTags = ({ commit, dispatch, state }, { ids }) => { - commit(types.SET_MAIN_LOADING, true); - - const tagsPath = pathGenerator(state.imageDetails, '/bulk_destroy'); - - return axios - .delete(tagsPath, { params: { ids } }) - .then(() => { - dispatch('setShowGarbageCollectionTip', true); - return dispatch('requestTagsList', state.tagsPagination); - }) - .finally(() => { - commit(types.SET_MAIN_LOADING, false); - }); -}; - -export const requestDeleteImage = ({ commit }, image) => { - commit(types.SET_MAIN_LOADING, true); - return axios - .delete(image.destroy_path) - .then(() => { - commit(types.UPDATE_IMAGE, { ...image, deleting: true }); - }) - .finally(() => { - commit(types.SET_MAIN_LOADING, false); - }); -}; diff --git a/app/assets/javascripts/registry/explorer/stores/getters.js b/app/assets/javascripts/registry/explorer/stores/getters.js deleted file mode 100644 index 7b5d1bd6da3..00000000000 --- a/app/assets/javascripts/registry/explorer/stores/getters.js +++ /dev/null @@ -1,18 +0,0 @@ -export const dockerBuildCommand = state => { - /* eslint-disable @gitlab/require-i18n-strings */ - return `docker build -t ${state.config.repositoryUrl} .`; -}; - -export const dockerPushCommand = state => { - /* eslint-disable @gitlab/require-i18n-strings */ - return `docker push ${state.config.repositoryUrl}`; -}; - -export const dockerLoginCommand = state => { - /* eslint-disable @gitlab/require-i18n-strings */ - return `docker login ${state.config.registryHostUrlWithPort}`; -}; - -export const showGarbageCollection = state => { - return state.showGarbageCollectionTip && state.config.isAdmin; -}; diff --git a/app/assets/javascripts/registry/explorer/stores/index.js b/app/assets/javascripts/registry/explorer/stores/index.js deleted file mode 100644 index 18e3351ed13..00000000000 --- a/app/assets/javascripts/registry/explorer/stores/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import state from './state'; - -Vue.use(Vuex); - -export const createStore = () => - new Vuex.Store({ - state, - getters, - actions, - mutations, - }); diff --git a/app/assets/javascripts/registry/explorer/stores/mutation_types.js b/app/assets/javascripts/registry/explorer/stores/mutation_types.js deleted file mode 100644 index 5dd0cec52eb..00000000000 --- a/app/assets/javascripts/registry/explorer/stores/mutation_types.js +++ /dev/null @@ -1,10 +0,0 @@ -export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; - -export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS'; -export const UPDATE_IMAGE = 'UPDATE_IMAGE'; -export const SET_PAGINATION = 'SET_PAGINATION'; -export const SET_MAIN_LOADING = 'SET_MAIN_LOADING'; -export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION'; -export const SET_TAGS_LIST_SUCCESS = 'SET_TAGS_LIST_SUCCESS'; -export const SET_SHOW_GARBAGE_COLLECTION_TIP = 'SET_SHOW_GARBAGE_COLLECTION_TIP'; -export const SET_IMAGE_DETAILS = 'SET_IMAGE_DETAILS'; diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js deleted file mode 100644 index 5bdb431ad2e..00000000000 --- a/app/assets/javascripts/registry/explorer/stores/mutations.js +++ /dev/null @@ -1,54 +0,0 @@ -import * as types from './mutation_types'; -import { parseIntPagination, normalizeHeaders, parseBoolean } from '~/lib/utils/common_utils'; -import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants/index'; - -export default { - [types.SET_INITIAL_STATE](state, config) { - state.config = { - ...config, - expirationPolicy: config.expirationPolicy ? JSON.parse(config.expirationPolicy) : undefined, - isGroupPage: parseBoolean(config.isGroupPage), - isAdmin: parseBoolean(config.isAdmin), - }; - }, - - [types.SET_IMAGES_LIST_SUCCESS](state, images) { - state.images = images.map(i => ({ - ...i, - status: undefined, - deleting: i.status === IMAGE_DELETE_SCHEDULED_STATUS, - failedDelete: i.status === IMAGE_FAILED_DELETED_STATUS, - })); - }, - - [types.UPDATE_IMAGE](state, image) { - const index = state.images.findIndex(i => i.id === image.id); - state.images.splice(index, 1, { ...image }); - }, - - [types.SET_TAGS_LIST_SUCCESS](state, tags) { - state.tags = tags; - }, - - [types.SET_MAIN_LOADING](state, isLoading) { - state.isLoading = isLoading; - }, - - [types.SET_SHOW_GARBAGE_COLLECTION_TIP](state, showGarbageCollectionTip) { - state.showGarbageCollectionTip = showGarbageCollectionTip; - }, - - [types.SET_PAGINATION](state, headers) { - const normalizedHeaders = normalizeHeaders(headers); - state.pagination = parseIntPagination(normalizedHeaders); - }, - - [types.SET_TAGS_PAGINATION](state, headers) { - const normalizedHeaders = normalizeHeaders(headers); - state.tagsPagination = parseIntPagination(normalizedHeaders); - }, - - [types.SET_IMAGE_DETAILS](state, details) { - state.imageDetails = details; - }, -}; diff --git a/app/assets/javascripts/registry/explorer/stores/state.js b/app/assets/javascripts/registry/explorer/stores/state.js deleted file mode 100644 index 66ee56eb47b..00000000000 --- a/app/assets/javascripts/registry/explorer/stores/state.js +++ /dev/null @@ -1,10 +0,0 @@ -export default () => ({ - isLoading: false, - showGarbageCollectionTip: false, - config: {}, - images: [], - imageDetails: {}, - tags: [], - pagination: {}, - tagsPagination: {}, -}); diff --git a/app/assets/javascripts/registry/explorer/utils.js b/app/assets/javascripts/registry/explorer/utils.js deleted file mode 100644 index a48da51caae..00000000000 --- a/app/assets/javascripts/registry/explorer/utils.js +++ /dev/null @@ -1,25 +0,0 @@ -import { joinPaths } from '~/lib/utils/url_utility'; - -export const pathGenerator = (imageDetails, ending = '?format=json') => { - // this method is a temporary workaround, to be removed with graphql implementation - // https://gitlab.com/gitlab-org/gitlab/-/issues/276432 - - const splitPath = imageDetails.path.split('/').reverse(); - const splitName = imageDetails.name ? imageDetails.name.split('/').reverse() : []; - const basePath = splitPath - .reduce((acc, curr, index) => { - if (splitPath[index] !== splitName[index]) { - acc.unshift(curr); - } - return acc; - }, []) - .join('/'); - - return joinPaths( - window.gon.relative_url_root, - `/${basePath}`, - '/registry/repository/', - `${imageDetails.id}`, - `tags${ending}`, - ); -}; diff --git a/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue b/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue new file mode 100644 index 00000000000..d75fb31fd98 --- /dev/null +++ b/app/assets/javascripts/registry/settings/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/registry/settings/components/expiration_input.vue b/app/assets/javascripts/registry/settings/components/expiration_input.vue new file mode 100644 index 00000000000..2dbd9d26f60 --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/expiration_input.vue @@ -0,0 +1,110 @@ +<script> +import { GlFormGroup, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui'; +import { NAME_REGEX_LENGTH, TEXT_AREA_INVALID_FEEDBACK } from '../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/registry/settings/components/expiration_run_text.vue b/app/assets/javascripts/registry/settings/components/expiration_run_text.vue new file mode 100644 index 00000000000..fd9ca6a54c5 --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/expiration_run_text.vue @@ -0,0 +1,46 @@ +<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, + }, + 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/registry/settings/components/expiration_toggle.vue b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue new file mode 100644 index 00000000000..7f045244926 --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue @@ -0,0 +1,52 @@ +<script> +import { GlFormGroup, GlToggle, GlSprintf } from '@gitlab/ui'; +import { ENABLED_TOGGLE_DESCRIPTION, DISABLED_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, + }, + }, + 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" :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/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue index 264d39a406a..35c7a8be4ea 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -1,17 +1,17 @@ <script> 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'; +import { isEqual, get, isEmpty } from 'lodash'; +import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.query.graphql'; import { + FETCH_SETTINGS_ERROR_MESSAGE, UNAVAILABLE_FEATURE_TITLE, UNAVAILABLE_FEATURE_INTRO_TEXT, UNAVAILABLE_USER_FEATURE_TEXT, UNAVAILABLE_ADMIN_FEATURE_TEXT, } from '../constants'; +import SettingsForm from './settings_form.vue'; + export default { components: { SettingsForm, @@ -60,6 +60,9 @@ export default { 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); }, }, diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index fe4aee6806e..1f374c7b60e 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -1,21 +1,41 @@ <script> -import { GlCard, GlButton } from '@gitlab/ui'; +import { GlCard, GlButton, GlSprintf } from '@gitlab/ui'; import Tracking from '~/tracking'; 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'; + 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 '~/registry/settings/constants'; +import { formOptionsGenerator } from '~/registry/settings/utils'; +import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql'; +import { updateContainerExpirationPolicy } from '~/registry/settings/graphql/utils/cache_update'; +import ExpirationDropdown from './expiration_dropdown.vue'; +import ExpirationInput from './expiration_input.vue'; +import ExpirationToggle from './expiration_toggle.vue'; +import ExpirationRunText from './expiration_run_text.vue'; export default { components: { GlCard, GlButton, - ExpirationPolicyFields, + GlSprintf, + ExpirationDropdown, + ExpirationInput, + ExpirationToggle, + ExpirationRunText, }, mixins: [Tracking.mixin()], inject: ['projectPath'], @@ -35,22 +55,31 @@ export default { default: false, }, }, - labelsConfig: { - cols: 3, - align: 'right', - }, + formOptions: formOptionsGenerator(), i18n: { - CLEANUP_POLICY_CARD_HEADER, + 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', }, - fieldsAreValid: true, - apiErrors: null, + apiErrors: {}, + localErrors: {}, mutationLoading: false, }; }, @@ -66,12 +95,18 @@ export default { 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, @@ -90,7 +125,8 @@ export default { }, reset() { this.track('reset_form'); - this.apiErrors = null; + this.apiErrors = {}; + this.localErrors = {}; this.$emit('reset'); }, setApiErrors(response) { @@ -101,9 +137,15 @@ export default { return acc; }, {}); }, + setLocalErrors(state, model) { + this.localErrors = { + ...this.localErrors, + [model]: state, + }; + }, submit() { this.track('submit_form'); - this.apiErrors = null; + this.apiErrors = {}; this.mutationLoading = true; return this.$apollo .mutate({ @@ -129,11 +171,9 @@ export default { this.mutationLoading = false; }); }, - onModelChange(changePayload) { - this.$emit('input', changePayload.newValue); - if (this.apiErrors) { - this.apiErrors[changePayload.modified] = undefined; - } + onModelChange(newValue, model) { + this.$emit('input', { ...this.value, [model]: newValue }); + this.apiErrors[model] = undefined; }, }, }; @@ -141,42 +181,133 @@ export default { <template> <form ref="form-element" @submit.prevent="submit" @reset.prevent="reset"> - <gl-card> + <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.CLEANUP_POLICY_CARD_HEADER }} + {{ $options.i18n.KEEP_HEADER_TEXT }} </template> <template #default> - <expiration-policy-fields - :value="prefilledForm" - :form-options="$options.formOptions" - :is-loading="isLoading" - :api-errors="apiErrors" - @validated="fieldsAreValid = true" - @invalidated="fieldsAreValid = false" - @input="onModelChange" - /> + <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> - <template #footer> - <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> + </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" + variant="success" + category="primary" + 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/registry/settings/constants.js b/app/assets/javascripts/registry/settings/constants.js index e790658f491..21c54299632 100644 --- a/app/assets/javascripts/registry/settings/constants.js +++ b/app/assets/javascripts/registry/settings/constants.js @@ -1,7 +1,6 @@ import { s__, __ } from '~/locale'; -export const SET_CLEANUP_POLICY_BUTTON = s__('ContainerRegistry|Set cleanup policy'); -export const CLEANUP_POLICY_CARD_HEADER = s__('ContainerRegistry|Tag expiration policy'); +export const SET_CLEANUP_POLICY_BUTTON = __('Save'); export const UNAVAILABLE_FEATURE_TITLE = s__( `ContainerRegistry|Cleanup policy for tags is disabled`, ); @@ -12,3 +11,81 @@ 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 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}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 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}More information%{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/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql index 224e0ed9472..1d6c89133af 100644 --- 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 @@ -5,4 +5,5 @@ fragment ContainerExpirationPolicyFields on ContainerExpirationPolicy { nameRegex nameRegexKeep olderThan + nextRunAt } 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.mutation.graphql index c40cd115ab0..c40cd115ab0 100644 --- 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.mutation.graphql 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.query.graphql index c171be0ad07..c171be0ad07 100644 --- a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql +++ b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.query.graphql diff --git a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js index 88067d52b51..05b4125a2fc 100644 --- a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js +++ b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js @@ -1,5 +1,5 @@ import { produce } from 'immer'; -import expirationPolicyQuery from '../queries/get_expiration_policy.graphql'; +import expirationPolicyQuery from '../queries/get_expiration_policy.query.graphql'; export const updateContainerExpirationPolicy = projectPath => (client, { data: updatedData }) => { const queryAndParams = { diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js index f7b1c5abd3a..6a4584b1b28 100644 --- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js +++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js @@ -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', {}); diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/settings/utils.js index bdf1ab9507d..51b4fb6bdb8 100644 --- a/app/assets/javascripts/registry/shared/utils.js +++ b/app/assets/javascripts/registry/settings/utils.js @@ -6,27 +6,7 @@ export const findDefaultOption = options => { return item ? item.key : null; }; -export const mapComputedToEvent = (list, root) => { - const result = {}; - list.forEach(e => { - result[e] = { - get() { - return this[root][e]; - }, - set(value) { - this.$emit('input', { newValue: { ...this[root], [e]: value }, modified: e }); - }, - }; - }); - return result; -}; - -export const olderThanTranslationGenerator = variable => - n__( - '%d day until tags are automatically removed', - '%d days until tags are automatically removed', - variable, - ); +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); diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue deleted file mode 100644 index 2b8e9f6ff64..00000000000 --- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue +++ /dev/null @@ -1,258 +0,0 @@ -<script> -import { uniqueId } from 'lodash'; -import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlSprintf } from '@gitlab/ui'; -import { - NAME_REGEX_LENGTH, - ENABLED_TEXT, - DISABLED_TEXT, - TEXT_AREA_INVALID_FEEDBACK, - EXPIRATION_INTERVAL_LABEL, - EXPIRATION_SCHEDULE_LABEL, - KEEP_N_LABEL, - NAME_REGEX_LABEL, - NAME_REGEX_PLACEHOLDER, - NAME_REGEX_DESCRIPTION, - NAME_REGEX_KEEP_LABEL, - NAME_REGEX_KEEP_PLACEHOLDER, - NAME_REGEX_KEEP_DESCRIPTION, - ENABLE_TOGGLE_LABEL, - ENABLE_TOGGLE_DESCRIPTION, -} from '../constants'; -import { mapComputedToEvent } from '../utils'; - -export default { - components: { - GlFormGroup, - GlToggle, - GlFormSelect, - GlFormTextarea, - GlSprintf, - }, - props: { - formOptions: { - type: Object, - required: false, - default: () => ({}), - }, - apiErrors: { - type: Object, - required: false, - default: null, - }, - isLoading: { - type: Boolean, - required: false, - default: false, - }, - value: { - type: Object, - required: false, - default: () => ({}), - }, - labelCols: { - type: [Number, String], - required: false, - default: 3, - }, - labelAlign: { - type: String, - required: false, - default: 'right', - }, - }, - i18n: { - ENABLE_TOGGLE_LABEL, - ENABLE_TOGGLE_DESCRIPTION, - }, - selectList: [ - { - name: 'expiration-policy-interval', - label: EXPIRATION_INTERVAL_LABEL, - model: 'olderThan', - }, - { - name: 'expiration-policy-schedule', - label: EXPIRATION_SCHEDULE_LABEL, - model: 'cadence', - }, - { - name: 'expiration-policy-latest', - label: KEEP_N_LABEL, - model: 'keepN', - }, - ], - textAreaList: [ - { - name: 'expiration-policy-name-matching', - label: NAME_REGEX_LABEL, - model: 'nameRegex', - placeholder: NAME_REGEX_PLACEHOLDER, - description: NAME_REGEX_DESCRIPTION, - }, - { - name: 'expiration-policy-keep-name', - label: NAME_REGEX_KEEP_LABEL, - model: 'nameRegexKeep', - placeholder: NAME_REGEX_KEEP_PLACEHOLDER, - description: NAME_REGEX_KEEP_DESCRIPTION, - }, - ], - data() { - return { - uniqueId: uniqueId(), - }; - }, - computed: { - ...mapComputedToEvent( - ['enabled', 'cadence', 'olderThan', 'keepN', 'nameRegex', 'nameRegexKeep'], - 'value', - ), - policyEnabledText() { - return this.enabled ? ENABLED_TEXT : DISABLED_TEXT; - }, - textAreaValidation() { - const nameRegexErrors = this.apiErrors?.nameRegex || this.validateRegexLength(this.nameRegex); - const nameKeepRegexErrors = - this.apiErrors?.nameRegexKeep || this.validateRegexLength(this.nameRegexKeep); - - return { - /* - * The state has this form: - * null: gray border, no message - * true: green border, no message ( because none is configured) - * false: red border, error message - * So in this function we keep null if the are no message otherwise we 'invert' the error message - */ - nameRegex: { - state: nameRegexErrors === null ? null : !nameRegexErrors, - message: nameRegexErrors, - }, - nameRegexKeep: { - state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors, - message: nameKeepRegexErrors, - }, - }; - }, - fieldsValidity() { - return ( - this.textAreaValidation.nameRegex.state !== false && - this.textAreaValidation.nameRegexKeep.state !== false - ); - }, - isFormElementDisabled() { - return !this.enabled || this.isLoading; - }, - }, - watch: { - fieldsValidity: { - immediate: true, - handler(valid) { - if (valid) { - this.$emit('validated'); - } else { - this.$emit('invalidated'); - } - }, - }, - }, - methods: { - validateRegexLength(value) { - if (!value) { - return null; - } - return value.length <= NAME_REGEX_LENGTH ? '' : TEXT_AREA_INVALID_FEEDBACK; - }, - idGenerator(id) { - return `${id}_${this.uniqueId}`; - }, - updateModel(value, key) { - this[key] = value; - }, - }, -}; -</script> - -<template> - <div ref="form-elements" class="gl-line-height-20"> - <gl-form-group - :id="idGenerator('expiration-policy-toggle-group')" - :label-cols="labelCols" - :label-align="labelAlign" - :label-for="idGenerator('expiration-policy-toggle')" - :label="$options.i18n.ENABLE_TOGGLE_LABEL" - > - <div class="gl-display-flex"> - <gl-toggle - :id="idGenerator('expiration-policy-toggle')" - v-model="enabled" - :disabled="isLoading" - /> - <span class="gl-mb-3 gl-ml-3 gl-line-height-20"> - <gl-sprintf :message="$options.i18n.ENABLE_TOGGLE_DESCRIPTION"> - <template #toggleStatus> - <strong>{{ policyEnabledText }}</strong> - </template> - </gl-sprintf> - </span> - </div> - </gl-form-group> - - <gl-form-group - v-for="select in $options.selectList" - :id="idGenerator(`${select.name}-group`)" - :key="select.name" - :label-cols="labelCols" - :label-align="labelAlign" - :label-for="idGenerator(select.name)" - :label="select.label" - > - <gl-form-select - :id="idGenerator(select.name)" - :value="value[select.model]" - :disabled="isFormElementDisabled" - @input="updateModel($event, select.model)" - > - <option v-for="option in formOptions[select.model]" :key="option.key" :value="option.key"> - {{ option.label }} - </option> - </gl-form-select> - </gl-form-group> - - <gl-form-group - v-for="textarea in $options.textAreaList" - :id="idGenerator(`${textarea.name}-group`)" - :key="textarea.name" - :label-cols="labelCols" - :label-align="labelAlign" - :label-for="idGenerator(textarea.name)" - :state="textAreaValidation[textarea.model].state" - :invalid-feedback="textAreaValidation[textarea.model].message" - > - <template #label> - <gl-sprintf :message="textarea.label"> - <template #italic="{content}"> - <i>{{ content }}</i> - </template> - </gl-sprintf> - </template> - <gl-form-textarea - :id="idGenerator(textarea.name)" - :value="value[textarea.model]" - :placeholder="textarea.placeholder" - :state="textAreaValidation[textarea.model].state" - :disabled="isFormElementDisabled" - trim - @input="updateModel($event, textarea.model)" - /> - <template #description> - <span ref="regex-description"> - <gl-sprintf :message="textarea.description"> - <template #code="{content}"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </span> - </template> - </gl-form-group> - </div> -</template> diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js deleted file mode 100644 index d1e3d93938b..00000000000 --- a/app/assets/javascripts/registry/shared/constants.js +++ /dev/null @@ -1,69 +0,0 @@ -import { s__, __ } from '~/locale'; - -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; - -export const ENABLED_TEXT = __('Enabled'); -export const DISABLED_TEXT = __('Disabled'); - -export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Cleanup policy:'); -export const ENABLE_TOGGLE_DESCRIPTION = s__( - 'ContainerRegistry|%{toggleStatus} - Tags matching the patterns defined below will be scheduled for deletion', -); - -export const TEXT_AREA_INVALID_FEEDBACK = s__( - 'ContainerRegistry|The value of this input should be less than 256 characters', -); - -export const EXPIRATION_INTERVAL_LABEL = s__('ContainerRegistry|Expiration interval:'); -export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Expiration schedule:'); -export const KEEP_N_LABEL = s__('ContainerRegistry|Number of tags to retain:'); -export const NAME_REGEX_LABEL = s__( - 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}expire:%{italicEnd}', -); -export const NAME_REGEX_PLACEHOLDER = ''; -export const NAME_REGEX_DESCRIPTION = s__( - 'ContainerRegistry|Wildcards such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', -); -export const NAME_REGEX_KEEP_LABEL = s__( - 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}be preserved:%{italicEnd}', -); -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 }, -]; |