summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/registry
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/registry')
-rw-r--r--app/assets/javascripts/registry/explorer/components/delete_button.vue56
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/details_row.vue26
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue77
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue220
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue210
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_item.vue128
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue3
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list.vue3
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue120
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue60
-rw-r--r--app/assets/javascripts/registry/explorer/constants/details.js36
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue29
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue1
-rw-r--r--app/assets/javascripts/registry/settings/components/registry_settings_app.vue48
-rw-r--r--app/assets/javascripts/registry/settings/components/settings_form.vue57
-rw-r--r--app/assets/javascripts/registry/settings/constants.js14
-rw-r--r--app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue63
-rw-r--r--app/assets/javascripts/registry/shared/constants.js20
-rw-r--r--app/assets/javascripts/registry/shared/utils.js2
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"
+ >&middot;</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">
- &middot;
- </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 });
},
};
});