diff options
Diffstat (limited to 'app/assets/javascripts/registry')
19 files changed, 755 insertions, 418 deletions
diff --git a/app/assets/javascripts/registry/explorer/components/delete_button.vue b/app/assets/javascripts/registry/explorer/components/delete_button.vue new file mode 100644 index 00000000000..dab6a26ea16 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/delete_button.vue @@ -0,0 +1,56 @@ +<script> +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; + +export default { + name: 'DeleteButton', + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + title: { + type: String, + required: true, + }, + tooltipTitle: { + type: String, + required: true, + }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + tooltipDisabled: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + tooltipConfiguration() { + return { + disabled: this.tooltipDisabled, + title: this.tooltipTitle, + }; + }, + }, +}; +</script> + +<template> + <div v-gl-tooltip="tooltipConfiguration"> + <gl-button + v-gl-tooltip + :disabled="disabled" + :title="title" + :aria-label="title" + category="secondary" + variant="danger" + icon="remove" + @click="$emit('delete')" + /> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue new file mode 100644 index 00000000000..c4358b83e23 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue @@ -0,0 +1,26 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + props: { + icon: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-align-items-center gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all" + > + <gl-icon :name="icon" class="gl-mr-4" /> + <span> + <slot></slot> + </span> + </div> +</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 new file mode 100644 index 00000000000..8494967ab57 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue @@ -0,0 +1,77 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import TagsListRow from './tags_list_row.vue'; +import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE } from '../../constants/index'; + +export default { + components: { + GlButton, + TagsListRow, + }, + props: { + tags: { + type: Array, + required: false, + default: () => [], + }, + isDesktop: { + type: Boolean, + default: false, + required: false, + }, + }, + i18n: { + REMOVE_TAGS_BUTTON_TITLE, + TAGS_LIST_TITLE, + }, + data() { + return { + selectedItems: {}, + }; + }, + computed: { + hasSelectedItems() { + return this.tags.some(tag => this.selectedItems[tag.name]); + }, + showMultiDeleteButton() { + return this.tags.some(tag => tag.destroy_path) && this.isDesktop; + }, + }, + methods: { + updateSelectedItems(name) { + this.$set(this.selectedItems, name, !this.selectedItems[name]); + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-display-flex gl-justify-content-space-between gl-mb-3"> + <h5 data-testid="list-title"> + {{ $options.i18n.TAGS_LIST_TITLE }} + </h5> + + <gl-button + v-if="showMultiDeleteButton" + :disabled="!hasSelectedItems" + category="secondary" + variant="danger" + @click="$emit('delete', selectedItems)" + > + {{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }} + </gl-button> + </div> + <tags-list-row + v-for="(tag, index) in tags" + :key="tag.path" + :tag="tag" + :first="index === 0" + :last="index === tags.length - 1" + :selected="selectedItems[tag.name]" + :is-desktop="isDesktop" + @select="updateSelectedItems(tag.name)" + @delete="$emit('delete', { [tag.name]: true })" + /> + </div> +</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 new file mode 100644 index 00000000000..51ba2337db6 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue @@ -0,0 +1,220 @@ +<script> +import { GlFormCheckbox, GlTooltipDirective, GlSprintf, GlIcon } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import DeleteButton from '../delete_button.vue'; +import ListItem from '../list_item.vue'; +import DetailsRow from './details_row.vue'; +import { + REMOVE_TAG_BUTTON_TITLE, + DIGEST_LABEL, + CREATED_AT_LABEL, + REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, + PUBLISHED_DETAILS_ROW_TEXT, + MANIFEST_DETAILS_ROW_TEST, + CONFIGURATION_DETAILS_ROW_TEST, + MISSING_MANIFEST_WARNING_TOOLTIP, + NOT_AVAILABLE_TEXT, + NOT_AVAILABLE_SIZE, +} from '../../constants/index'; + +export default { + components: { + GlSprintf, + GlFormCheckbox, + GlIcon, + DeleteButton, + ListItem, + ClipboardButton, + TimeAgoTooltip, + DetailsRow, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + tag: { + type: Object, + required: true, + }, + isDesktop: { + type: Boolean, + default: false, + required: false, + }, + selected: { + type: Boolean, + default: false, + required: false, + }, + }, + i18n: { + REMOVE_TAG_BUTTON_TITLE, + DIGEST_LABEL, + CREATED_AT_LABEL, + REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, + PUBLISHED_DETAILS_ROW_TEXT, + MANIFEST_DETAILS_ROW_TEST, + CONFIGURATION_DETAILS_ROW_TEST, + MISSING_MANIFEST_WARNING_TOOLTIP, + }, + computed: { + formattedSize() { + return this.tag.total_size ? numberToHumanSize(this.tag.total_size) : NOT_AVAILABLE_SIZE; + }, + layers() { + return this.tag.layers ? n__('%d layer', '%d layers', this.tag.layers) : ''; + }, + mobileClasses() { + return this.isDesktop ? '' : 'mw-s'; + }, + shortDigest() { + // remove sha256: from the string, and show only the first 7 char + return this.tag.digest?.substring(7, 14) ?? NOT_AVAILABLE_TEXT; + }, + publishedDate() { + return formatDate(this.tag.created_at, 'isoDate'); + }, + publishedTime() { + return formatDate(this.tag.created_at, 'hh:MM Z'); + }, + formattedRevision() { + // to be removed when API response is adjusted + // see https://gitlab.com/gitlab-org/gitlab/-/issues/225324 + // eslint-disable-next-line @gitlab/require-i18n-strings + return `sha256:${this.tag.revision}`; + }, + tagLocation() { + return this.tag.path?.replace(`:${this.tag.name}`, ''); + }, + invalidTag() { + return !this.tag.digest; + }, + }, +}; +</script> + +<template> + <list-item v-bind="$attrs" :selected="selected"> + <template #left-action> + <gl-form-checkbox + v-if="Boolean(tag.destroy_path)" + :disabled="invalidTag" + class="gl-m-0" + :checked="selected" + @change="$emit('select')" + /> + </template> + <template #left-primary> + <div class="gl-display-flex gl-align-items-center"> + <div + v-gl-tooltip="{ title: tag.name }" + data-testid="name" + class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap" + :class="mobileClasses" + > + {{ tag.name }} + </div> + + <clipboard-button + v-if="tag.location" + :title="tag.location" + :text="tag.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + + <gl-icon + v-if="invalidTag" + v-gl-tooltip="{ title: $options.i18n.MISSING_MANIFEST_WARNING_TOOLTIP }" + name="warning" + class="gl-text-orange-500 gl-mb-2 gl-ml-2" + /> + </div> + </template> + + <template #left-secondary> + <span data-testid="size"> + {{ formattedSize }} + <template v-if="formattedSize && layers" + >·</template + > + {{ layers }} + </span> + </template> + <template #right-primary> + <span data-testid="time"> + <gl-sprintf :message="$options.i18n.CREATED_AT_LABEL"> + <template #timeInfo> + <time-ago-tooltip :time="tag.created_at" /> + </template> + </gl-sprintf> + </span> + </template> + <template #right-secondary> + <span data-testid="digest"> + <gl-sprintf :message="$options.i18n.DIGEST_LABEL"> + <template #imageId>{{ shortDigest }}</template> + </gl-sprintf> + </span> + </template> + <template #right-action> + <delete-button + :disabled="!tag.destroy_path || invalidTag" + :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" + :tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP" + :tooltip-disabled="Boolean(tag.destroy_path)" + data-testid="single-delete-button" + @delete="$emit('delete')" + /> + </template> + + <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> + <i>{{ tagLocation }}</i> + </template> + <template #time> + {{ publishedTime }} + </template> + <template #date> + {{ publishedDate }} + </template> + </gl-sprintf> + </details-row> + </template> + <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> + {{ tag.digest }} + </template> + </gl-sprintf> + <clipboard-button + v-if="tag.digest" + :title="tag.digest" + :text="tag.digest" + css-class="btn-default btn-transparent btn-clipboard gl-p-0" + /> + </details-row> + </template> + <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> + {{ formattedRevision }} + </template> + </gl-sprintf> + <clipboard-button + v-if="formattedRevision" + :title="formattedRevision" + :text="formattedRevision" + css-class="btn-default btn-transparent btn-clipboard gl-p-0" + /> + </details-row> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue deleted file mode 100644 index 81be778e1e5..00000000000 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue +++ /dev/null @@ -1,210 +0,0 @@ -<script> -import { GlTable, GlFormCheckbox, GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { n__ } from '~/locale'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import { numberToHumanSize } from '~/lib/utils/number_utils'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; -import { - LIST_KEY_TAG, - LIST_KEY_IMAGE_ID, - LIST_KEY_SIZE, - LIST_KEY_LAST_UPDATED, - LIST_KEY_ACTIONS, - LIST_KEY_CHECKBOX, - LIST_LABEL_TAG, - LIST_LABEL_IMAGE_ID, - LIST_LABEL_SIZE, - LIST_LABEL_LAST_UPDATED, - REMOVE_TAGS_BUTTON_TITLE, - REMOVE_TAG_BUTTON_TITLE, -} from '../../constants/index'; - -export default { - components: { - GlTable, - GlFormCheckbox, - GlButton, - ClipboardButton, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [timeagoMixin], - props: { - tags: { - type: Array, - required: false, - default: () => [], - }, - isLoading: { - type: Boolean, - required: false, - default: false, - }, - isDesktop: { - type: Boolean, - required: false, - default: false, - }, - }, - i18n: { - REMOVE_TAGS_BUTTON_TITLE, - REMOVE_TAG_BUTTON_TITLE, - }, - data() { - return { - selectedItems: [], - }; - }, - computed: { - fields() { - const tagClass = this.isDesktop ? 'w-25' : ''; - const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end'; - return [ - { key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' }, - { - key: LIST_KEY_TAG, - label: LIST_LABEL_TAG, - class: `${tagClass} js-tag-column`, - innerClass: tagInnerClass, - }, - { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID }, - { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE }, - { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED }, - { key: LIST_KEY_ACTIONS, label: '' }, - ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop); - }, - tagsNames() { - return this.tags.map(t => t.name); - }, - selectAllChecked() { - return this.selectedItems.length === this.tags.length && this.tags.length > 0; - }, - }, - watch: { - tagsNames: { - immediate: false, - handler(tagsNames) { - this.selectedItems = this.selectedItems.filter(t => tagsNames.includes(t)); - }, - }, - }, - methods: { - formatSize(size) { - return numberToHumanSize(size); - }, - layers(layers) { - return layers ? n__('%d layer', '%d layers', layers) : ''; - }, - onSelectAllChange() { - if (this.selectAllChecked) { - this.selectedItems = []; - } else { - this.selectedItems = this.tags.map(x => x.name); - } - }, - updateSelectedItems(name) { - const delIndex = this.selectedItems.findIndex(x => x === name); - - if (delIndex > -1) { - this.selectedItems.splice(delIndex, 1); - } else { - this.selectedItems.push(name); - } - }, - }, -}; -</script> - -<template> - <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty :busy="isLoading"> - <template v-if="isDesktop" #head(checkbox)> - <gl-form-checkbox - data-testid="mainCheckbox" - :checked="selectAllChecked" - @change="onSelectAllChange" - /> - </template> - <template #head(actions)> - <span class="gl-display-flex gl-justify-content-end"> - <gl-button - v-gl-tooltip - data-testid="bulkDeleteButton" - :disabled="!selectedItems || selectedItems.length === 0" - icon="remove" - variant="danger" - :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" - :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" - @click="$emit('delete', selectedItems)" - /> - </span> - </template> - - <template #cell(checkbox)="{item}"> - <gl-form-checkbox - data-testid="rowCheckbox" - :checked="selectedItems.includes(item.name)" - @change="updateSelectedItems(item.name)" - /> - </template> - <template #cell(name)="{item, field}"> - <div data-testid="rowName" :class="[field.innerClass, 'gl-display-flex']"> - <span - v-gl-tooltip - data-testid="rowNameText" - :title="item.name" - class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap" - > - {{ item.name }} - </span> - <clipboard-button - v-if="item.location" - data-testid="rowClipboardButton" - :title="item.location" - :text="item.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - </div> - </template> - <template #cell(short_revision)="{value}"> - <span data-testid="rowShortRevision"> - {{ value }} - </span> - </template> - <template #cell(total_size)="{item}"> - <span data-testid="rowSize"> - {{ formatSize(item.total_size) }} - <template v-if="item.total_size && item.layers"> - · - </template> - {{ layers(item.layers) }} - </span> - </template> - <template #cell(created_at)="{value}"> - <span v-gl-tooltip data-testid="rowTime" :title="tooltipTitle(value)"> - {{ timeFormatted(value) }} - </span> - </template> - <template #cell(actions)="{item}"> - <span class="gl-display-flex gl-justify-content-end"> - <gl-button - data-testid="singleDeleteButton" - :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" - :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE" - :disabled="!item.destroy_path" - variant="danger" - icon="remove" - category="secondary" - @click="$emit('delete', [item.name])" - /> - </span> - </template> - - <template #empty> - <slot name="empty"></slot> - </template> - <template #table-busy> - <slot name="loader"></slot> - </template> - </gl-table> -</template> diff --git a/app/assets/javascripts/registry/explorer/components/list_item.vue b/app/assets/javascripts/registry/explorer/components/list_item.vue new file mode 100644 index 00000000000..7b5afe8fd9d --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/list_item.vue @@ -0,0 +1,128 @@ +<script> +import { GlButton } from '@gitlab/ui'; + +export default { + name: 'ListItem', + components: { GlButton }, + props: { + first: { + type: Boolean, + default: false, + required: false, + }, + last: { + type: Boolean, + default: false, + required: false, + }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + selected: { + type: Boolean, + default: false, + required: false, + }, + }, + data() { + return { + isDetailsShown: false, + detailsSlots: [], + }; + }, + computed: { + optionalClasses() { + return { + 'gl-border-t-1': !this.first, + 'gl-border-t-2': this.first, + 'gl-border-b-1': !this.last, + 'gl-border-b-2': this.last, + 'disabled-content': this.disabled, + 'gl-border-gray-100': !this.selected, + 'gl-bg-blue-50 gl-border-blue-200': this.selected, + }; + }, + }, + mounted() { + this.detailsSlots = Object.keys(this.$slots).filter(k => k.startsWith('details_')); + }, + methods: { + toggleDetails() { + this.isDetailsShown = !this.isDetailsShown; + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid" + :class="optionalClasses" + > + <div class="gl-display-flex gl-align-items-center gl-py-4 gl-px-2"> + <div + v-if="$slots['left-action']" + class="gl-w-7 gl-display-none gl-display-sm-flex gl-justify-content-start gl-pl-2" + > + <slot name="left-action"></slot> + </div> + <div class="gl-display-flex gl-flex-direction-column gl-flex-fill-1"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-text-black-normal gl-font-weight-bold" + > + <div class="gl-display-flex gl-align-items-center"> + <slot name="left-primary"></slot> + <gl-button + v-if="detailsSlots.length > 0" + :selected="isDetailsShown" + icon="ellipsis_h" + size="small" + class="gl-ml-2 gl-display-none gl-display-sm-block" + @click="toggleDetails" + /> + </div> + <div> + <slot name="right-primary"></slot> + </div> + </div> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-font-sm gl-text-gray-500" + > + <div> + <slot name="left-secondary"></slot> + </div> + <div> + <slot name="right-secondary"></slot> + </div> + </div> + </div> + <div + v-if="$slots['right-action']" + class="gl-w-9 gl-display-none gl-display-sm-flex gl-justify-content-end gl-pr-2" + > + <slot name="right-action"></slot> + </div> + </div> + <div class="gl-display-flex"> + <div class="gl-w-7"></div> + <div + v-if="isDetailsShown" + class="gl-display-flex gl-flex-direction-column gl-flex-fill-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100 gl-mb-3" + > + <div + v-for="(row, detailIndex) in detailsSlots" + :key="detailIndex" + class="gl-px-5 gl-py-2" + :class="{ + 'gl-border-gray-100 gl-border-t-solid gl-border-t-1': detailIndex !== 0, + }" + > + <slot :name="row"></slot> + </div> + </div> + <div class="gl-w-9"></div> + </div> + </div> +</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 a29a9bd23c3..80cc392f86a 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 @@ -18,10 +18,9 @@ export default { <gl-empty-state :title="s__('ContainerRegistry|There are no container images available in this group')" :svg-path="config.noContainersImage" - class="container-message" > <template #description> - <p class="js-no-container-images-text"> + <p> <gl-sprintf :message=" s__( 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 9d48769cbad..65cf51fd1d1 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 @@ -37,7 +37,8 @@ export default { v-for="(listItem, index) in images" :key="index" :item="listItem" - :show-top-border="index === 0" + :first="index === 0" + :last="index === images.length - 1" @delete="$emit('delete', $event)" /> 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 cd878c38081..2874d89d913 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,7 +1,9 @@ <script> -import { GlTooltipDirective, GlButton, GlIcon, GlSprintf } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlSprintf } from '@gitlab/ui'; import { n__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ListItem from '../list_item.vue'; +import DeleteButton from '../delete_button.vue'; import { ASYNC_DELETE_IMAGE_ERROR_MESSAGE, @@ -14,9 +16,10 @@ export default { name: 'ImageListrow', components: { ClipboardButton, - GlButton, + DeleteButton, GlSprintf, GlIcon, + ListItem, }, directives: { GlTooltip: GlTooltipDirective, @@ -26,11 +29,6 @@ export default { type: Object, required: true, }, - showTopBorder: { - type: Boolean, - default: false, - required: false, - }, }, i18n: { LIST_DELETE_BUTTON_DISABLED, @@ -62,75 +60,55 @@ export default { </script> <template> - <div + <list-item v-gl-tooltip="{ placement: 'left', disabled: !item.deleting, title: $options.i18n.ROW_SCHEDULED_FOR_DELETION, }" + v-bind="$attrs" + :disabled="item.deleting" > - <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-py-4 " - :class="{ - 'gl-border-t-solid gl-border-t-1': showTopBorder, - 'disabled-content': item.deleting, - }" - > - <div class="gl-display-flex gl-flex-direction-column"> - <div class="gl-display-flex gl-align-items-center"> - <router-link - class="gl-text-black-normal gl-font-weight-bold" - data-testid="detailsLink" - :to="{ name: 'details', params: { id: encodedItem } }" - > - {{ item.path }} - </router-link> - <clipboard-button - v-if="item.location" - :disabled="item.deleting" - :text="item.location" - :title="item.location" - css-class="btn-default btn-transparent btn-clipboard gl-text-gray-500" - /> - <gl-icon - v-if="item.failedDelete" - v-gl-tooltip - :title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE" - name="warning" - class="text-warning" - /> - </div> - <div class="gl-font-sm gl-text-gray-500"> - <span class="gl-display-flex gl-align-items-center" data-testid="tagsCount"> - <gl-icon name="tag" class="gl-mr-2" /> - <gl-sprintf :message="tagsCountText"> - <template #count> - {{ item.tags_count }} - </template> - </gl-sprintf> - </span> - </div> - </div> - <div - v-gl-tooltip="{ - disabled: item.destroy_path, - title: $options.i18n.LIST_DELETE_BUTTON_DISABLED, - }" - class="d-none d-sm-block" - data-testid="deleteButtonWrapper" + <template #left-primary> + <router-link + class="gl-text-black-normal gl-font-weight-bold" + data-testid="detailsLink" + :to="{ name: 'details', params: { id: encodedItem } }" > - <gl-button - v-gl-tooltip - data-testid="deleteImageButton" - :disabled="disabledDelete" - :title="$options.i18n.REMOVE_REPOSITORY_LABEL" - :aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL" - category="secondary" - variant="danger" - icon="remove" - @click="$emit('delete', item)" - /> - </div> - </div> - </div> + {{ item.path }} + </router-link> + <clipboard-button + v-if="item.location" + :disabled="item.deleting" + :text="item.location" + :title="item.location" + css-class="btn-default btn-transparent btn-clipboard gl-text-gray-500" + /> + <gl-icon + v-if="item.failedDelete" + v-gl-tooltip="{ title: $options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE }" + name="warning" + class="gl-text-orange-500" + /> + </template> + <template #left-secondary> + <span class="gl-display-flex gl-align-items-center" data-testid="tagsCount"> + <gl-icon name="tag" class="gl-mr-2" /> + <gl-sprintf :message="tagsCountText"> + <template #count> + {{ item.tags_count }} + </template> + </gl-sprintf> + </span> + </template> + <template #right-action> + <delete-button + :title="$options.i18n.REMOVE_REPOSITORY_LABEL" + :disabled="disabledDelete" + :tooltip-disabled="Boolean(item.destroy_path)" + :tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED" + @delete="$emit('delete', item)" + /> + </template> + </list-item> </template> 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 c27d53f4351..35eb0b11e40 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,5 +1,5 @@ <script> -import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +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'; @@ -17,6 +17,8 @@ export default { GlEmptyState, GlSprintf, GlLink, + GlFormInputGroup, + GlFormInput, }, i18n: { quickStart: QUICK_START, @@ -43,10 +45,9 @@ export default { <gl-empty-state :title="s__('ContainerRegistry|There are no container images stored for this project')" :svg-path="config.noContainersImage" - class="container-message" > <template #description> - <p class="js-no-container-images-text"> + <p> <gl-sprintf :message="$options.i18n.introText"> <template #docLink="{content}"> <gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link> @@ -54,7 +55,7 @@ export default { </gl-sprintf> </p> <h5>{{ $options.i18n.quickStart }}</h5> - <p class="js-not-logged-in-to-registry-text"> + <p> <gl-sprintf :message="$options.i18n.notLoggedInMessage"> <template #twofaDocLink="{content}"> <gl-link :href="config.twoFactorAuthHelpLink" target="_blank">{{ content }}</gl-link> @@ -66,42 +67,49 @@ export default { </template> </gl-sprintf> </p> - <div class="input-group append-bottom-10"> - <input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> + <gl-form-input-group class="gl-mb-4"> + <gl-form-input + :value="dockerLoginCommand" + readonly + type="text" + class="gl-font-monospace!" + /> + <template #append> <clipboard-button :text="dockerLoginCommand" :title="$options.i18n.copyLoginTitle" - class="input-group-text" + class="gl-m-0!" /> - </span> - </div> - <p></p> - <p> + </template> + </gl-form-input-group> + <p class="gl-mb-4"> {{ $options.i18n.addImageText }} </p> - - <div class="input-group append-bottom-10"> - <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> + <gl-form-input-group class="gl-mb-4 "> + <gl-form-input + :value="dockerBuildCommand" + readonly + type="text" + class="gl-font-monospace!" + /> + <template #append> <clipboard-button :text="dockerBuildCommand" :title="$options.i18n.copyBuildTitle" - class="input-group-text" + class="gl-m-0!" /> - </span> - </div> - - <div class="input-group"> - <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> + </template> + </gl-form-input-group> + <gl-form-input-group> + <gl-form-input :value="dockerPushCommand" readonly type="text" class="gl-font-monospace!" /> + <template #append> <clipboard-button :text="dockerPushCommand" :title="$options.i18n.copyPushTitle" - class="input-group-text" + class="gl-m-0!" /> - </span> - </div> + </template> + </gl-form-input-group> </template> </gl-empty-state> </template> diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js index a1fa995c17f..1dc5882d415 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; // Translations strings export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags'); @@ -14,12 +14,20 @@ export const DELETE_TAGS_ERROR_MESSAGE = s__( export const DELETE_TAGS_SUCCESS_MESSAGE = s__( 'ContainerRegistry|Tags successfully marked for deletion.', ); -export const LIST_LABEL_TAG = s__('ContainerRegistry|Tag'); -export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID'); -export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size'); -export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated'); + +export const TAGS_LIST_TITLE = s__('ContainerRegistry|Image tags'); +export const DIGEST_LABEL = s__('ContainerRegistry|Digest: %{imageId}'); +export const CREATED_AT_LABEL = s__('ContainerRegistry|Published %{timeInfo}'); +export const PUBLISHED_DETAILS_ROW_TEXT = s__( + 'ContainerRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}', +); +export const MANIFEST_DETAILS_ROW_TEST = s__('ContainerRegistry|Manifest digest: %{digest}'); +export const CONFIGURATION_DETAILS_ROW_TEST = s__( + 'ContainerRegistry|Configuration digest: %{digest}', +); + export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag'); -export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Remove selected tags'); +export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected'); export const REMOVE_TAG_CONFIRMATION_TEXT = s__( `ContainerRegistry|You are about to remove %{item}. Are you sure?`, ); @@ -36,17 +44,21 @@ export const ADMIN_GARBAGE_COLLECTION_TIP = s__( 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.', ); +export const REMOVE_TAG_BUTTON_DISABLE_TOOLTIP = s__( + 'ContainerRegistry|Deletion disabled due to missing or insufficient permissions.', +); + +export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( + 'ContainerRegistry|Invalid tag: missing manifest digest', +); + +export const NOT_AVAILABLE_TEXT = __('N/A'); +export const NOT_AVAILABLE_SIZE = __('0 bytes'); // Parameters export const DEFAULT_PAGE = 1; export const DEFAULT_PAGE_SIZE = 10; export const GROUP_PAGE_TYPE = 'groups'; -export const LIST_KEY_TAG = 'name'; -export const LIST_KEY_IMAGE_ID = 'short_revision'; -export const LIST_KEY_SIZE = 'total_size'; -export const LIST_KEY_LAST_UPDATED = 'created_at'; -export const LIST_KEY_ACTIONS = 'actions'; -export const LIST_KEY_CHECKBOX = 'checkbox'; export const ALERT_SUCCESS_TAG = 'success_tag'; export const ALERT_DANGER_TAG = 'danger_tag'; export const ALERT_SUCCESS_TAGS = 'success_tags'; diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index 598e643ce1a..cf811156704 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -6,7 +6,7 @@ import Tracking from '~/tracking'; import DeleteAlert from '../components/details_page/delete_alert.vue'; import DeleteModal from '../components/details_page/delete_modal.vue'; import DetailsHeader from '../components/details_page/details_header.vue'; -import TagsTable from '../components/details_page/tags_table.vue'; +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'; @@ -24,7 +24,7 @@ export default { DetailsHeader, GlPagination, DeleteModal, - TagsTable, + TagsList, TagsLoader, EmptyTagsState, }, @@ -65,10 +65,8 @@ export default { }, methods: { ...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']), - deleteTags(toBeDeletedList) { - this.itemsToBeDeleted = toBeDeletedList.map(name => ({ - ...this.tags.find(t => t.name === name), - })); + deleteTags(toBeDeleted) { + this.itemsToBeDeleted = this.tags.filter(tag => toBeDeleted[tag.name]); this.track('click_button'); this.$refs.deleteModal.show(); }, @@ -114,24 +112,21 @@ export default { </script> <template> - <div v-gl-resize-observer="handleResize" class="my-3 w-100 slide-enter-to-element"> + <div v-gl-resize-observer="handleResize" class="gl-my-3 gl-w-full slide-enter-to-element"> <delete-alert v-model="deleteAlertType" :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" :is-admin="config.isAdmin" - class="my-2" + class="gl-my-2" /> <details-header :image-name="imageName" /> - <tags-table :tags="tags" :is-loading="isLoading" :is-desktop="isDesktop" @delete="deleteTags"> - <template #empty> - <empty-tags-state :no-containers-image="config.noContainersImage" /> - </template> - <template #loader> - <tags-loader v-once /> - </template> - </tags-table> + <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-desktop="isDesktop" @delete="deleteTags" /> + </template> <gl-pagination v-if="!isLoading" @@ -140,7 +135,7 @@ export default { :per-page="tagsPagination.perPage" :total-items="tagsPagination.total" align="center" - class="w-100" + class="gl-w-full gl-mt-3" /> <delete-modal diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index e8a26dc58f2..1d353651c38 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -217,7 +217,6 @@ export default { :svg-path="config.noContainersImage" data-testid="emptySearch" :title="$options.i18n.EMPTY_RESULT_TITLE" - class="container-message" > <template #description> {{ $options.i18n.EMPTY_RESULT_MESSAGE }} 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 b4a59fd0178..2ee7bbef4c6 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -1,11 +1,16 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { s__ } from '~/locale'; import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants'; import SettingsForm from './settings_form.vue'; +import { + UNAVAILABLE_FEATURE_TITLE, + UNAVAILABLE_FEATURE_INTRO_TEXT, + UNAVAILABLE_USER_FEATURE_TEXT, + UNAVAILABLE_ADMIN_FEATURE_TEXT, +} from '../constants'; export default { components: { @@ -15,17 +20,9 @@ export default { GlLink, }, i18n: { - unavailableFeatureTitle: s__( - `ContainerRegistry|Container Registry tag expiration and retention policy is disabled`, - ), - unavailableFeatureIntroText: s__( - `ContainerRegistry|The Container Registry tag expiration and retention policies for this project have not been enabled.`, - ), - unavailableUserFeatureText: s__(`ContainerRegistry|Please contact your administrator.`), - unavailableAdminFeatureText: s__( - `ContainerRegistry| Please visit the %{linkStart}administration settings%{linkEnd} to enable this feature.`, - ), - fetchSettingsErrorText: FETCH_SETTINGS_ERROR_MESSAGE, + UNAVAILABLE_FEATURE_TITLE, + UNAVAILABLE_FEATURE_INTRO_TEXT, + FETCH_SETTINGS_ERROR_MESSAGE, }, data() { return { @@ -42,9 +39,7 @@ export default { return this.isDisabled && !this.fetchSettingsError; }, unavailableFeatureMessage() { - return this.isAdmin - ? this.$options.i18n.unavailableAdminFeatureText - : this.$options.i18n.unavailableUserFeatureText; + return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT; }, }, mounted() { @@ -60,39 +55,24 @@ export default { <template> <div> - <p> - {{ s__('ContainerRegistry|Tag expiration policy is designed to:') }} - </p> - <ul> - <li>{{ s__('ContainerRegistry|Keep and protect the images that matter most.') }}</li> - <li> - {{ - s__( - "ContainerRegistry|Automatically remove extra images that aren't designed to be kept.", - ) - }} - </li> - </ul> <settings-form v-if="showSettingForm" /> <template v-else> <gl-alert v-if="showDisabledFormMessage" :dismissible="false" - :title="$options.i18n.unavailableFeatureTitle" + :title="$options.i18n.UNAVAILABLE_FEATURE_TITLE" variant="tip" > - {{ $options.i18n.unavailableFeatureIntroText }} + {{ $options.i18n.UNAVAILABLE_FEATURE_INTRO_TEXT }} <gl-sprintf :message="unavailableFeatureMessage"> <template #link="{ content }"> - <gl-link :href="adminSettingsPath" target="_blank"> - {{ content }} - </gl-link> + <gl-link :href="adminSettingsPath" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </gl-alert> <gl-alert v-else-if="fetchSettingsError" variant="warning" :dismissible="false"> - <gl-sprintf :message="$options.i18n.fetchSettingsErrorText" /> + <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" /> </gl-alert> </template> </div> diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index afd502109bf..f129922c1d2 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -1,13 +1,15 @@ <script> +import { get } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import { GlCard, GlDeprecatedButton, GlLoadingIcon } 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 { mapComputed } from '~/vuex_shared/bindings'; import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue'; +import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants'; export default { components: { @@ -21,12 +23,17 @@ export default { cols: 3, align: 'right', }, + i18n: { + CLEANUP_POLICY_CARD_HEADER, + SET_CLEANUP_POLICY_BUTTON, + }, data() { return { tracking: { label: 'docker_container_retention_and_expiration_policies', }, - formIsValid: true, + fieldsAreValid: true, + apiErrors: null, }; }, computed: { @@ -34,7 +41,7 @@ export default { ...mapGetters({ isEdited: 'getIsEdited' }), ...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'), isSubmitButtonDisabled() { - return !this.formIsValid || this.isLoading; + return !this.fieldsAreValid || this.isLoading; }, isCancelButtonDisabled() { return !this.isEdited || this.isLoading; @@ -44,13 +51,35 @@ export default { ...mapActions(['resetSettings', 'saveSettings']), reset() { this.track('reset_form'); + this.apiErrors = null; this.resetSettings(); }, + 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], ''); + } + return acc; + }, {}); + }, submit() { this.track('submit_form'); + this.apiErrors = null; this.saveSettings() .then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' })) - .catch(() => this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' })); + .catch(({ response }) => { + this.setApiErrors(response); + this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' }); + }); + }, + onModelChange(changePayload) { + this.settings = changePayload.newValue; + if (this.apiErrors) { + this.apiErrors[changePayload.modified] = undefined; + } }, }, }; @@ -60,23 +89,25 @@ export default { <form ref="form-element" @submit.prevent="submit" @reset.prevent="reset"> <gl-card> <template #header> - {{ s__('ContainerRegistry|Tag expiration policy') }} + {{ $options.i18n.CLEANUP_POLICY_CARD_HEADER }} </template> <template #default> <expiration-policy-fields - v-model="settings" + :value="settings" :form-options="formOptions" :is-loading="isLoading" - @validated="formIsValid = true" - @invalidated="formIsValid = false" + :api-errors="apiErrors" + @validated="fieldsAreValid = true" + @invalidated="fieldsAreValid = false" + @input="onModelChange" /> </template> <template #footer> - <div class="d-flex justify-content-end"> + <div class="gl-display-flex gl-justify-content-end"> <gl-deprecated-button ref="cancel-button" type="reset" - class="mr-2 d-block" + class="gl-mr-3 gl-display-block" :disabled="isCancelButtonDisabled" > {{ __('Cancel') }} @@ -86,10 +117,10 @@ export default { type="submit" :disabled="isSubmitButtonDisabled" variant="success" - class="d-flex justify-content-center align-items-center js-no-auto-disable" + class="gl-display-flex gl-justify-content-center gl-align-items-center js-no-auto-disable" > - {{ __('Save expiration policy') }} - <gl-loading-icon v-if="isLoading" class="ml-2" /> + {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} + <gl-loading-icon v-if="isLoading" class="gl-ml-3" /> </gl-deprecated-button> </div> </template> diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/registry/settings/constants.js new file mode 100644 index 00000000000..e790658f491 --- /dev/null +++ b/app/assets/javascripts/registry/settings/constants.js @@ -0,0 +1,14 @@ +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 UNAVAILABLE_FEATURE_TITLE = s__( + `ContainerRegistry|Cleanup policy for tags is disabled`, +); +export const UNAVAILABLE_FEATURE_INTRO_TEXT = s__( + `ContainerRegistry|This project's cleanup policy for tags is not enabled.`, +); +export const UNAVAILABLE_USER_FEATURE_TEXT = __(`Please contact your administrator.`); +export const UNAVAILABLE_ADMIN_FEATURE_TEXT = s__( + `ContainerRegistry| Please visit the %{linkStart}administration settings%{linkEnd} to enable this feature.`, +); 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 04a547db07e..1ff2f6f99e5 100644 --- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue +++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue @@ -34,6 +34,11 @@ export default { required: false, default: () => ({}), }, + apiErrors: { + type: Object, + required: false, + default: null, + }, isLoading: { type: Boolean, required: false, @@ -56,9 +61,8 @@ export default { }, }, i18n: { - textAreaInvalidFeedback: TEXT_AREA_INVALID_FEEDBACK, - enableToggleLabel: ENABLE_TOGGLE_LABEL, - enableToggleDescription: ENABLE_TOGGLE_DESCRIPTION, + ENABLE_TOGGLE_LABEL, + ENABLE_TOGGLE_DESCRIPTION, }, selectList: [ { @@ -86,7 +90,6 @@ export default { label: NAME_REGEX_LABEL, model: 'name_regex', placeholder: NAME_REGEX_PLACEHOLDER, - stateVariable: 'nameRegexState', description: NAME_REGEX_DESCRIPTION, }, { @@ -94,7 +97,6 @@ export default { label: NAME_REGEX_KEEP_LABEL, model: 'name_regex_keep', placeholder: NAME_REGEX_KEEP_PLACEHOLDER, - stateVariable: 'nameKeepRegexState', description: NAME_REGEX_KEEP_DESCRIPTION, }, ], @@ -111,16 +113,34 @@ export default { policyEnabledText() { return this.enabled ? ENABLED_TEXT : DISABLED_TEXT; }, - textAreaState() { + textAreaValidation() { + const nameRegexErrors = + this.apiErrors?.name_regex || this.validateRegexLength(this.name_regex); + const nameKeepRegexErrors = + this.apiErrors?.name_regex_keep || this.validateRegexLength(this.name_regex_keep); + return { - nameRegexState: this.validateNameRegex(this.name_regex), - nameKeepRegexState: this.validateNameRegex(this.name_regex_keep), + /* + * 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 + */ + name_regex: { + state: nameRegexErrors === null ? null : !nameRegexErrors, + message: nameRegexErrors, + }, + name_regex_keep: { + state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors, + message: nameKeepRegexErrors, + }, }; }, fieldsValidity() { return ( - this.textAreaState.nameRegexState !== false && - this.textAreaState.nameKeepRegexState !== false + this.textAreaValidation.name_regex.state !== false && + this.textAreaValidation.name_regex_keep.state !== false ); }, isFormElementDisabled() { @@ -140,8 +160,11 @@ export default { }, }, methods: { - validateNameRegex(value) { - return value ? value.length <= NAME_REGEX_LENGTH : null; + validateRegexLength(value) { + if (!value) { + return null; + } + return value.length <= NAME_REGEX_LENGTH ? '' : TEXT_AREA_INVALID_FEEDBACK; }, idGenerator(id) { return `${id}_${this.uniqueId}`; @@ -154,22 +177,22 @@ export default { </script> <template> - <div ref="form-elements" class="lh-2"> + <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.enableToggleLabel" + :label="$options.i18n.ENABLE_TOGGLE_LABEL" > - <div class="d-flex align-items-start"> + <div class="gl-display-flex"> <gl-toggle :id="idGenerator('expiration-policy-toggle')" v-model="enabled" :disabled="isLoading" /> - <span class="mb-2 ml-1 lh-2"> - <gl-sprintf :message="$options.i18n.enableToggleDescription"> + <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> @@ -210,8 +233,8 @@ export default { :label-cols="labelCols" :label-align="labelAlign" :label-for="idGenerator(textarea.name)" - :state="textAreaState[textarea.stateVariable]" - :invalid-feedback="$options.i18n.textAreaInvalidFeedback" + :state="textAreaValidation[textarea.model].state" + :invalid-feedback="textAreaValidation[textarea.model].message" > <template #label> <gl-sprintf :message="textarea.label"> @@ -224,7 +247,7 @@ export default { :id="idGenerator(textarea.name)" :value="value[textarea.model]" :placeholder="textarea.placeholder" - :state="textAreaState[textarea.stateVariable]" + :state="textAreaValidation[textarea.model].state" :disabled="isFormElementDisabled" trim @input="updateModel($event, textarea.model)" diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js index 4689d01b1c8..36d55c7610e 100644 --- a/app/assets/javascripts/registry/shared/constants.js +++ b/app/assets/javascripts/registry/shared/constants.js @@ -1,29 +1,29 @@ import { s__, __ } from '~/locale'; export const FETCH_SETTINGS_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while fetching the expiration policy.', + 'ContainerRegistry|Something went wrong while fetching the cleanup policy.', ); export const UPDATE_SETTINGS_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while updating the expiration policy.', + 'ContainerRegistry|Something went wrong while updating the cleanup policy.', ); export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__( - 'ContainerRegistry|Expiration policy successfully saved.', + 'ContainerRegistry|Cleanup policy successfully saved.', ); export const NAME_REGEX_LENGTH = 255; -export const ENABLED_TEXT = __('enabled'); -export const DISABLED_TEXT = __('disabled'); +export const ENABLED_TEXT = __('Enabled'); +export const DISABLED_TEXT = __('Disabled'); -export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Expiration policy:'); +export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Cleanup policy:'); export const ENABLE_TOGGLE_DESCRIPTION = s__( - 'ContainerRegistry|Docker tag expiration policy is %{toggleStatus}', + '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 255 characters', + 'ContainerRegistry|The value of this input should be less than 256 characters', ); export const EXPIRATION_INTERVAL_LABEL = s__('ContainerRegistry|Expiration interval:'); @@ -34,12 +34,12 @@ export const NAME_REGEX_LABEL = s__( ); export const NAME_REGEX_PLACEHOLDER = '.*'; export const NAME_REGEX_DESCRIPTION = s__( - 'ContainerRegistry|Regular expressions such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', + '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|Regular expressions such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported', + 'ContainerRegistry|Wildcards such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported', ); diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js index d85a3ad28c2..a7377773842 100644 --- a/app/assets/javascripts/registry/shared/utils.js +++ b/app/assets/javascripts/registry/shared/utils.js @@ -11,7 +11,7 @@ export const mapComputedToEvent = (list, root) => { return this[root][e]; }, set(value) { - this.$emit('input', { ...this[root], [e]: value }); + this.$emit('input', { newValue: { ...this[root], [e]: value }, modified: e }); }, }; }); |