diff options
Diffstat (limited to 'app/assets/javascripts/registry')
27 files changed, 992 insertions, 718 deletions
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue b/app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue new file mode 100644 index 00000000000..8bdf043a106 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/delete_alert.vue @@ -0,0 +1,70 @@ +<script> +import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui'; + +import { ALERT_MESSAGES, ADMIN_GARBAGE_COLLECTION_TIP } from '../../constants/index'; + +export default { + components: { + GlSprintf, + GlAlert, + GlLink, + }, + model: { + prop: 'deleteAlertType', + event: 'change', + }, + props: { + deleteAlertType: { + type: String, + default: null, + required: false, + validator(value) { + return !value || ALERT_MESSAGES[value] !== undefined; + }, + }, + garbageCollectionHelpPagePath: { type: String, required: false, default: '' }, + isAdmin: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + deleteAlertConfig() { + const config = { + title: '', + message: '', + type: 'success', + }; + if (this.deleteAlertType) { + [config.type] = this.deleteAlertType.split('_'); + + config.message = ALERT_MESSAGES[this.deleteAlertType]; + + if (this.isAdmin && config.type === 'success') { + config.title = config.message; + config.message = ADMIN_GARBAGE_COLLECTION_TIP; + } + } + return config; + }, + }, +}; +</script> + +<template> + <gl-alert + v-if="deleteAlertType" + :variant="deleteAlertConfig.type" + :title="deleteAlertConfig.title" + @dismiss="$emit('change', null)" + > + <gl-sprintf :message="deleteAlertConfig.message"> + <template #docLink="{content}"> + <gl-link :href="garbageCollectionHelpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue new file mode 100644 index 00000000000..96f221bf71d --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue @@ -0,0 +1,67 @@ +<script> +import { GlModal, GlSprintf } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import { REMOVE_TAG_CONFIRMATION_TEXT, REMOVE_TAGS_CONFIRMATION_TEXT } from '../../constants/index'; + +export default { + components: { + GlModal, + GlSprintf, + }, + props: { + itemsToBeDeleted: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + modalAction() { + return n__( + 'ContainerRegistry|Remove tag', + 'ContainerRegistry|Remove tags', + this.itemsToBeDeleted.length, + ); + }, + modalDescription() { + if (this.itemsToBeDeleted.length > 1) { + return { + message: REMOVE_TAGS_CONFIRMATION_TEXT, + item: this.itemsToBeDeleted.length, + }; + } + const [first] = this.itemsToBeDeleted; + + return { + message: REMOVE_TAG_CONFIRMATION_TEXT, + item: first?.path, + }; + }, + }, + methods: { + show() { + this.$refs.deleteModal.show(); + }, + }, +}; +</script> + +<template> + <gl-modal + ref="deleteModal" + modal-id="delete-tag-modal" + ok-variant="danger" + @ok="$emit('confirmDelete')" + @cancel="$emit('cancelDelete')" + > + <template #modal-title>{{ modalAction }}</template> + <template #modal-ok>{{ modalAction }}</template> + <p v-if="modalDescription" data-testid="description"> + <gl-sprintf :message="modalDescription.message"> + <template #item + ><b>{{ modalDescription.item }}</b></template + > + </gl-sprintf> + </p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue new file mode 100644 index 00000000000..c254dd05aa4 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue @@ -0,0 +1,30 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { DETAILS_PAGE_TITLE } from '../../constants/index'; + +export default { + components: { GlSprintf }, + props: { + imageName: { + type: String, + required: false, + default: '', + }, + }, + i18n: { + DETAILS_PAGE_TITLE, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-my-2 gl-align-items-center"> + <h4> + <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE"> + <template #imageName> + {{ imageName }} + </template> + </gl-sprintf> + </h4> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue b/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue new file mode 100644 index 00000000000..0c684d124d5 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/empty_tags_state.vue @@ -0,0 +1,33 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { + EMPTY_IMAGE_REPOSITORY_TITLE, + EMPTY_IMAGE_REPOSITORY_MESSAGE, +} from '../../constants/index'; + +export default { + components: { + GlEmptyState, + }, + props: { + noContainersImage: { + type: String, + required: false, + default: '', + }, + }, + i18n: { + EMPTY_IMAGE_REPOSITORY_TITLE, + EMPTY_IMAGE_REPOSITORY_MESSAGE, + }, +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE" + :svg-path="noContainersImage" + :description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE" + class="gl-mx-auto gl-my-0" + /> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue new file mode 100644 index 00000000000..b7afa5fba33 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_loader.vue @@ -0,0 +1,34 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, + loader: { + repeat: 10, + width: 1000, + height: 40, + }, +}; +</script> + +<template> + <div> + <gl-skeleton-loader + v-for="index in $options.loader.repeat" + :key="index" + :width="$options.loader.width" + :height="$options.loader.height" + preserve-aspect-ratio="xMinYMax meet" + > + <rect width="15" x="0" y="12.5" height="15" rx="4" /> + <rect width="250" x="25" y="10" height="20" rx="4" /> + <circle cx="290" cy="20" r="10" /> + <rect width="100" x="315" y="10" height="20" rx="4" /> + <rect width="100" x="500" y="10" height="20" rx="4" /> + <rect width="100" x="630" y="10" height="20" rx="4" /> + <rect x="960" y="0" width="40" height="40" rx="4" /> + </gl-skeleton-loader> + </div> +</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 new file mode 100644 index 00000000000..81be778e1e5 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_table.vue @@ -0,0 +1,210 @@ +<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/image_list.vue b/app/assets/javascripts/registry/explorer/components/image_list.vue deleted file mode 100644 index bc209b12738..00000000000 --- a/app/assets/javascripts/registry/explorer/components/image_list.vue +++ /dev/null @@ -1,124 +0,0 @@ -<script> -import { GlPagination, GlTooltipDirective, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; - -import { - ASYNC_DELETE_IMAGE_ERROR_MESSAGE, - LIST_DELETE_BUTTON_DISABLED, - REMOVE_REPOSITORY_LABEL, - ROW_SCHEDULED_FOR_DELETION, -} from '../constants'; - -export default { - name: 'ImageList', - components: { - GlPagination, - ClipboardButton, - GlDeprecatedButton, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - images: { - type: Array, - required: true, - }, - pagination: { - type: Object, - required: true, - }, - }, - i18n: { - LIST_DELETE_BUTTON_DISABLED, - REMOVE_REPOSITORY_LABEL, - ROW_SCHEDULED_FOR_DELETION, - ASYNC_DELETE_IMAGE_ERROR_MESSAGE, - }, - computed: { - currentPage: { - get() { - return this.pagination.page; - }, - set(page) { - this.$emit('pageChange', page); - }, - }, - }, - methods: { - encodeListItem(item) { - const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id }); - return window.btoa(params); - }, - }, -}; -</script> - -<template> - <div class="gl-display-flex gl-flex-direction-column"> - <div - v-for="(listItem, index) in images" - :key="index" - v-gl-tooltip="{ - placement: 'left', - disabled: !listItem.deleting, - title: $options.i18n.ROW_SCHEDULED_FOR_DELETION, - }" - data-testid="rowItem" - > - <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 border-bottom" - :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }" - > - <div class="gl-display-flex gl-align-items-center"> - <router-link - data-testid="detailsLink" - :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" - > - {{ listItem.path }} - </router-link> - <clipboard-button - v-if="listItem.location" - :disabled="listItem.deleting" - :text="listItem.location" - :title="listItem.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - <gl-icon - v-if="listItem.failedDelete" - v-gl-tooltip - :title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE" - name="warning" - class="text-warning align-middle" - /> - </div> - <div - v-gl-tooltip="{ disabled: listItem.destroy_path }" - class="d-none d-sm-block" - :title="$options.i18n.LIST_DELETE_BUTTON_DISABLED" - > - <gl-deprecated-button - v-gl-tooltip - data-testid="deleteImageButton" - :disabled="!listItem.destroy_path || listItem.deleting" - :title="$options.i18n.REMOVE_REPOSITORY_LABEL" - :aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL" - class="btn-inverted" - variant="danger" - @click="$emit('delete', listItem)" - > - <gl-icon name="remove" /> - </gl-deprecated-button> - </div> - </div> - </div> - <gl-pagination - v-model="currentPage" - :per-page="pagination.perPage" - :total-items="pagination.total" - align="center" - class="w-100 gl-mt-2" - /> - </div> -</template> diff --git a/app/assets/javascripts/registry/explorer/components/quickstart_dropdown.vue b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue index 96455496239..8b06797c0ae 100644 --- a/app/assets/javascripts/registry/explorer/components/quickstart_dropdown.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue @@ -11,7 +11,7 @@ import { COPY_BUILD_TITLE, PUSH_COMMAND_LABEL, COPY_PUSH_TITLE, -} from '../constants'; +} from '../../constants/index'; export default { components: { diff --git a/app/assets/javascripts/registry/explorer/components/group_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue index a29a9bd23c3..a29a9bd23c3 100644 --- a/app/assets/javascripts/registry/explorer/components/group_empty_state.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue 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 new file mode 100644 index 00000000000..9d48769cbad --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue @@ -0,0 +1,52 @@ +<script> +import { GlPagination } from '@gitlab/ui'; +import ImageListRow from './image_list_row.vue'; + +export default { + name: 'ImageList', + components: { + GlPagination, + ImageListRow, + }, + props: { + images: { + type: Array, + required: true, + }, + pagination: { + type: Object, + required: true, + }, + }, + computed: { + currentPage: { + get() { + return this.pagination.page; + }, + set(page) { + this.$emit('pageChange', page); + }, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column"> + <image-list-row + v-for="(listItem, index) in images" + :key="index" + :item="listItem" + :show-top-border="index === 0" + @delete="$emit('delete', $event)" + /> + + <gl-pagination + v-model="currentPage" + :per-page="pagination.perPage" + :total-items="pagination.total" + align="center" + class="w-100 gl-mt-3" + /> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue new file mode 100644 index 00000000000..cd878c38081 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue @@ -0,0 +1,136 @@ +<script> +import { GlTooltipDirective, GlButton, GlIcon, GlSprintf } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +import { + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + ROW_SCHEDULED_FOR_DELETION, +} from '../../constants/index'; + +export default { + name: 'ImageListrow', + components: { + ClipboardButton, + GlButton, + GlSprintf, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + item: { + type: Object, + required: true, + }, + showTopBorder: { + type: Boolean, + default: false, + required: false, + }, + }, + i18n: { + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + ROW_SCHEDULED_FOR_DELETION, + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + }, + computed: { + encodedItem() { + const params = JSON.stringify({ + name: this.item.path, + tags_path: this.item.tags_path, + id: this.item.id, + }); + return window.btoa(params); + }, + disabledDelete() { + return !this.item.destroy_path || this.item.deleting; + }, + tagsCountText() { + return n__( + 'ContainerRegistry|%{count} Tag', + 'ContainerRegistry|%{count} Tags', + this.item.tags_count, + ); + }, + }, +}; +</script> + +<template> + <div + v-gl-tooltip="{ + placement: 'left', + disabled: !item.deleting, + title: $options.i18n.ROW_SCHEDULED_FOR_DELETION, + }" + > + <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" + > + <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> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/project_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue index 0ce38c4a9ec..c27d53f4351 100644 --- a/app/assets/javascripts/registry/explorer/components/project_empty_state.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue @@ -3,7 +3,12 @@ import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import { COPY_LOGIN_TITLE, COPY_BUILD_TITLE, COPY_PUSH_TITLE, QUICK_START } from '../constants'; +import { + COPY_LOGIN_TITLE, + COPY_BUILD_TITLE, + COPY_PUSH_TITLE, + QUICK_START, +} from '../../constants/index'; export default { name: 'ProjectEmptyState', diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue new file mode 100644 index 00000000000..d4ff84447bb --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue @@ -0,0 +1,138 @@ +<script> +import { GlSprintf, GlLink, GlIcon } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility'; + +import { + CONTAINER_REGISTRY_TITLE, + LIST_INTRO_TEXT, + EXPIRATION_POLICY_WILL_RUN_IN, + EXPIRATION_POLICY_DISABLED_TEXT, + EXPIRATION_POLICY_DISABLED_MESSAGE, +} from '../../constants/index'; + +export default { + components: { + GlIcon, + GlSprintf, + GlLink, + }, + props: { + expirationPolicy: { + type: Object, + default: () => ({}), + required: false, + }, + imagesCount: { + type: Number, + default: 0, + required: false, + }, + helpPagePath: { + type: String, + default: '', + required: false, + }, + expirationPolicyHelpPagePath: { + type: String, + default: '', + required: false, + }, + hideExpirationPolicyData: { + type: Boolean, + required: false, + default: false, + }, + }, + loader: { + repeat: 10, + width: 1000, + height: 40, + }, + i18n: { + CONTAINER_REGISTRY_TITLE, + LIST_INTRO_TEXT, + EXPIRATION_POLICY_DISABLED_MESSAGE, + }, + computed: { + imagesCountText() { + return n__( + 'ContainerRegistry|%{count} Image repository', + 'ContainerRegistry|%{count} Image repositories', + this.imagesCount, + ); + }, + timeTillRun() { + const difference = calculateRemainingMilliseconds(this.expirationPolicy?.next_run_at); + return approximateDuration(difference / 1000); + }, + expirationPolicyEnabled() { + return this.expirationPolicy?.enabled; + }, + expirationPolicyText() { + return this.expirationPolicyEnabled + ? EXPIRATION_POLICY_WILL_RUN_IN + : EXPIRATION_POLICY_DISABLED_TEXT; + }, + showExpirationPolicyTip() { + return ( + !this.expirationPolicyEnabled && this.imagesCount > 0 && !this.hideExpirationPolicyData + ); + }, + }, +}; +</script> + +<template> + <div> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center" + data-testid="header" + > + <h4 data-testid="title">{{ $options.i18n.CONTAINER_REGISTRY_TITLE }}</h4> + <div class="gl-display-none d-sm-block" data-testid="commands-slot"> + <slot name="commands"></slot> + </div> + </div> + <div + v-if="imagesCount" + class="gl-display-flex gl-align-items-center gl-mt-1 gl-mb-3 gl-text-gray-700" + data-testid="subheader" + > + <span class="gl-mr-3" data-testid="images-count"> + <gl-icon class="gl-mr-1" name="container-image" /> + <gl-sprintf :message="imagesCountText"> + <template #count> + {{ imagesCount }} + </template> + </gl-sprintf> + </span> + <span v-if="!hideExpirationPolicyData" data-testid="expiration-policy"> + <gl-icon class="gl-mr-1" name="expire" /> + <gl-sprintf :message="expirationPolicyText"> + <template #time> + {{ timeTillRun }} + </template> + </gl-sprintf> + </span> + </div> + <div data-testid="info-area"> + <p> + <span data-testid="default-intro"> + <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT"> + <template #docLink="{content}"> + <gl-link :href="helpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + <span v-if="showExpirationPolicyTip" data-testid="expiration-disabled-message"> + <gl-sprintf :message="$options.i18n.EXPIRATION_POLICY_DISABLED_MESSAGE"> + <template #docLink="{content}"> + <gl-link :href="expirationPolicyHelpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue deleted file mode 100644 index 88a0710574f..00000000000 --- a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue +++ /dev/null @@ -1,68 +0,0 @@ -<script> -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { mapState } from 'vuex'; -import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility'; -import { - EXPIRATION_POLICY_ALERT_TITLE, - EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON, - EXPIRATION_POLICY_ALERT_FULL_MESSAGE, - EXPIRATION_POLICY_ALERT_SHORT_MESSAGE, -} from '../constants'; - -export default { - components: { - GlAlert, - GlSprintf, - GlLink, - }, - - computed: { - ...mapState(['config', 'images', 'isLoading']), - isEmpty() { - return !this.images || this.images.length === 0; - }, - showAlert() { - return this.config.expirationPolicy?.enabled; - }, - timeTillRun() { - const difference = calculateRemainingMilliseconds(this.config.expirationPolicy?.next_run_at); - return approximateDuration(difference / 1000); - }, - alertConfiguration() { - if (this.isEmpty || this.isLoading) { - return { - title: null, - primaryButton: null, - message: EXPIRATION_POLICY_ALERT_SHORT_MESSAGE, - }; - } - return { - title: EXPIRATION_POLICY_ALERT_TITLE, - primaryButton: EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON, - message: EXPIRATION_POLICY_ALERT_FULL_MESSAGE, - }; - }, - }, -}; -</script> - -<template> - <gl-alert - v-if="showAlert" - :dismissible="false" - :primary-button-text="alertConfiguration.primaryButton" - :primary-button-link="config.settingsPath" - :title="alertConfiguration.title" - > - <gl-sprintf :message="alertConfiguration.message"> - <template #days> - <strong>{{ timeTillRun }}</strong> - </template> - <template #link="{content}"> - <gl-link :href="config.expirationPolicyHelpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </gl-alert> -</template> diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js deleted file mode 100644 index 7cbe657bfc0..00000000000 --- a/app/assets/javascripts/registry/explorer/constants.js +++ /dev/null @@ -1,130 +0,0 @@ -import { s__ } from '~/locale'; - -// List page - -export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry'); -export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error'); -export const CONNECTION_ERROR_MESSAGE = s__( - `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`, -); -export const LIST_INTRO_TEXT = s__( - `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`, -); - -export const LIST_DELETE_BUTTON_DISABLED = s__( - 'ContainerRegistry|Missing or insufficient permission, delete button disabled', -); -export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository'); -export const REMOVE_REPOSITORY_MODAL_TEXT = s__( - 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', -); -export const ROW_SCHEDULED_FOR_DELETION = s__( - `ContainerRegistry|This image repository is scheduled for deletion`, -); -export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while fetching the repository list.', -); -export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while fetching the tags list.', -); -export const DELETE_IMAGE_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.', -); -export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__( - `ContainerRegistry|There was an error during the deletion of this image repository, please try again.`, -); -export const DELETE_IMAGE_SUCCESS_MESSAGE = s__( - 'ContainerRegistry|%{title} was successfully scheduled for deletion', -); - -export const IMAGE_REPOSITORY_LIST_LABEL = s__('ContainerRegistry|Image Repositories'); - -export const SEARCH_PLACEHOLDER_TEXT = s__('ContainerRegistry|Filter by name'); - -export const EMPTY_RESULT_TITLE = s__('ContainerRegistry|Sorry, your filter produced no results.'); -export const EMPTY_RESULT_MESSAGE = s__( - 'ContainerRegistry|To widen your search, change or remove the filters above.', -); - -// Image details page - -export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags'); - -export const DELETE_TAG_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while marking the tag for deletion.', -); -export const DELETE_TAG_SUCCESS_MESSAGE = s__( - 'ContainerRegistry|Tag successfully marked for deletion.', -); -export const DELETE_TAGS_ERROR_MESSAGE = s__( - 'ContainerRegistry|Something went wrong while marking the tags for deletion.', -); -export const DELETE_TAGS_SUCCESS_MESSAGE = s__( - 'ContainerRegistry|Tags successfully marked for deletion.', -); - -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 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 REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag'); -export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Remove selected tags'); - -export const REMOVE_TAG_CONFIRMATION_TEXT = s__( - `ContainerRegistry|You are about to remove %{item}. Are you sure?`, -); -export const REMOVE_TAGS_CONFIRMATION_TEXT = s__( - `ContainerRegistry|You are about to remove %{item} tags. Are you sure?`, -); - -export const EMPTY_IMAGE_REPOSITORY_TITLE = s__('ContainerRegistry|This image has no active tags'); -export const EMPTY_IMAGE_REPOSITORY_MESSAGE = s__( - `ContainerRegistry|The last tag related to this image was recently removed. -This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. -If you have any questions, contact your administrator.`, -); - -export const ADMIN_GARBAGE_COLLECTION_TIP = s__( - 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.', -); - -// Expiration policies - -export const EXPIRATION_POLICY_ALERT_TITLE = s__( - 'ContainerRegistry|Retention policy has been Enabled', -); -export const EXPIRATION_POLICY_ALERT_PRIMARY_BUTTON = s__('ContainerRegistry|Edit Settings'); -export const EXPIRATION_POLICY_ALERT_FULL_MESSAGE = s__( - 'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled and will run in %{days}. For more information visit the %{linkStart}documentation%{linkEnd}', -); -export const EXPIRATION_POLICY_ALERT_SHORT_MESSAGE = s__( - 'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}', -); - -// Quick Start - -export const QUICK_START = s__('ContainerRegistry|Quick Start'); -export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login'); -export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command'); -export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image'); -export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command'); -export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image'); -export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command'); - -// Image state - -export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled'; -export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed'; diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js new file mode 100644 index 00000000000..a1fa995c17f --- /dev/null +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -0,0 +1,60 @@ +import { s__ } from '~/locale'; + +// Translations strings +export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags'); +export const DELETE_TAG_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while marking the tag for deletion.', +); +export const DELETE_TAG_SUCCESS_MESSAGE = s__( + 'ContainerRegistry|Tag successfully marked for deletion.', +); +export const DELETE_TAGS_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while marking the tags for deletion.', +); +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 REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag'); +export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Remove selected tags'); +export const REMOVE_TAG_CONFIRMATION_TEXT = s__( + `ContainerRegistry|You are about to remove %{item}. Are you sure?`, +); +export const REMOVE_TAGS_CONFIRMATION_TEXT = s__( + `ContainerRegistry|You are about to remove %{item} tags. Are you sure?`, +); +export const EMPTY_IMAGE_REPOSITORY_TITLE = s__('ContainerRegistry|This image has no active tags'); +export const EMPTY_IMAGE_REPOSITORY_MESSAGE = s__( + `ContainerRegistry|The last tag related to this image was recently removed. +This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. +If you have any questions, contact your administrator.`, +); +export const ADMIN_GARBAGE_COLLECTION_TIP = s__( + 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.', +); + +// 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'; +export const ALERT_DANGER_TAGS = 'danger_tags'; + +export const ALERT_MESSAGES = { + [ALERT_SUCCESS_TAG]: DELETE_TAG_SUCCESS_MESSAGE, + [ALERT_DANGER_TAG]: DELETE_TAG_ERROR_MESSAGE, + [ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE, + [ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE, +}; diff --git a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js new file mode 100644 index 00000000000..8af25ca6ecc --- /dev/null +++ b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js @@ -0,0 +1,11 @@ +import { s__ } from '~/locale'; + +export const EXPIRATION_POLICY_WILL_RUN_IN = s__( + 'ContainerRegistry|Expiration policy will run in %{time}', +); +export const EXPIRATION_POLICY_DISABLED_TEXT = s__( + 'ContainerRegistry|Expiration policy is disabled', +); +export const EXPIRATION_POLICY_DISABLED_MESSAGE = s__( + 'ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}', +); diff --git a/app/assets/javascripts/registry/explorer/constants/index.js b/app/assets/javascripts/registry/explorer/constants/index.js new file mode 100644 index 00000000000..10816e12ead --- /dev/null +++ b/app/assets/javascripts/registry/explorer/constants/index.js @@ -0,0 +1,4 @@ +export * from './expiration_policies'; +export * from './quick_start'; +export * from './list'; +export * from './details'; diff --git a/app/assets/javascripts/registry/explorer/constants/list.js b/app/assets/javascripts/registry/explorer/constants/list.js new file mode 100644 index 00000000000..39f63d2a153 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/constants/list.js @@ -0,0 +1,48 @@ +import { s__ } from '~/locale'; + +// Translations strings + +export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry'); +export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error'); +export const CONNECTION_ERROR_MESSAGE = s__( + `ContainerRegistry|We are having trouble connecting to the Registry, which could be due to an issue with your project name or path. %{docLinkStart}More information%{docLinkEnd}`, +); +export const LIST_INTRO_TEXT = s__( + `ContainerRegistry|With the GitLab Container Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`, +); +export const LIST_DELETE_BUTTON_DISABLED = s__( + 'ContainerRegistry|Missing or insufficient permission, delete button disabled', +); +export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository'); +export const REMOVE_REPOSITORY_MODAL_TEXT = s__( + 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', +); +export const ROW_SCHEDULED_FOR_DELETION = s__( + `ContainerRegistry|This image repository is scheduled for deletion`, +); +export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while fetching the repository list.', +); +export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while fetching the tags list.', +); +export const DELETE_IMAGE_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.', +); +export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__( + `ContainerRegistry|There was an error during the deletion of this image repository, please try again.`, +); +export const DELETE_IMAGE_SUCCESS_MESSAGE = s__( + 'ContainerRegistry|%{title} was successfully scheduled for deletion', +); +export const IMAGE_REPOSITORY_LIST_LABEL = s__('ContainerRegistry|Image Repositories'); +export const SEARCH_PLACEHOLDER_TEXT = s__('ContainerRegistry|Filter by name'); +export const EMPTY_RESULT_TITLE = s__('ContainerRegistry|Sorry, your filter produced no results.'); +export const EMPTY_RESULT_MESSAGE = s__( + 'ContainerRegistry|To widen your search, change or remove the filters above.', +); + +// Parameters + +export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled'; +export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed'; diff --git a/app/assets/javascripts/registry/explorer/constants/quick_start.js b/app/assets/javascripts/registry/explorer/constants/quick_start.js new file mode 100644 index 00000000000..6a39c07eba2 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/constants/quick_start.js @@ -0,0 +1,9 @@ +import { s__ } from '~/locale'; + +export const QUICK_START = s__('ContainerRegistry|CLI Commands'); +export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login'); +export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command'); +export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image'); +export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command'); +export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image'); +export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command'); diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index cc2dc531dc8..598e643ce1a 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -1,139 +1,56 @@ <script> -import { mapState, mapActions, mapGetters } from 'vuex'; -import { - GlTable, - GlFormCheckbox, - GlDeprecatedButton, - GlIcon, - GlTooltipDirective, - GlPagination, - GlModal, - GlSprintf, - GlAlert, - GlLink, - GlEmptyState, - GlResizeObserverDirective, - GlSkeletonLoader, -} from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui'; import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; -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 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 TagsLoader from '../components/details_page/tags_loader.vue'; +import EmptyTagsState from '../components/details_page/empty_tags_state.vue'; + import { decodeAndParse } from '../utils'; 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, - DELETE_TAG_SUCCESS_MESSAGE, - DELETE_TAG_ERROR_MESSAGE, - DELETE_TAGS_SUCCESS_MESSAGE, - DELETE_TAGS_ERROR_MESSAGE, - REMOVE_TAG_CONFIRMATION_TEXT, - REMOVE_TAGS_CONFIRMATION_TEXT, - DETAILS_PAGE_TITLE, - REMOVE_TAGS_BUTTON_TITLE, - REMOVE_TAG_BUTTON_TITLE, - EMPTY_IMAGE_REPOSITORY_TITLE, - EMPTY_IMAGE_REPOSITORY_MESSAGE, - ADMIN_GARBAGE_COLLECTION_TIP, -} from '../constants'; + ALERT_SUCCESS_TAG, + ALERT_DANGER_TAG, + ALERT_SUCCESS_TAGS, + ALERT_DANGER_TAGS, +} from '../constants/index'; export default { components: { - GlTable, - GlFormCheckbox, - GlDeprecatedButton, - GlIcon, - ClipboardButton, + DeleteAlert, + DetailsHeader, GlPagination, - GlModal, - GlSkeletonLoader, - GlSprintf, - GlEmptyState, - GlAlert, - GlLink, + DeleteModal, + TagsTable, + TagsLoader, + EmptyTagsState, }, directives: { - GlTooltip: GlTooltipDirective, GlResizeObserver: GlResizeObserverDirective, }, - mixins: [timeagoMixin, Tracking.mixin()], - loader: { - repeat: 10, - width: 1000, - height: 40, - }, - i18n: { - DETAILS_PAGE_TITLE, - REMOVE_TAGS_BUTTON_TITLE, - REMOVE_TAG_BUTTON_TITLE, - EMPTY_IMAGE_REPOSITORY_TITLE, - EMPTY_IMAGE_REPOSITORY_MESSAGE, - }, - alertMessages: { - success_tag: DELETE_TAG_SUCCESS_MESSAGE, - danger_tag: DELETE_TAG_ERROR_MESSAGE, - success_tags: DELETE_TAGS_SUCCESS_MESSAGE, - danger_tags: DELETE_TAGS_ERROR_MESSAGE, - }, + mixins: [Tracking.mixin()], data() { return { - selectedItems: [], itemsToBeDeleted: [], - selectAllChecked: false, - modalDescription: null, isDesktop: true, - deleteAlertType: false, + deleteAlertType: null, }; }, computed: { - ...mapGetters(['tags']), - ...mapState(['tagsPagination', 'isLoading', 'config']), + ...mapState(['tagsPagination', 'isLoading', 'config', 'tags']), imageName() { const { name } = decodeAndParse(this.$route.params.id); return name; }, - 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); - }, - isMultiDelete() { - return this.itemsToBeDeleted.length > 1; - }, tracking() { return { - label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete', + label: + this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete', }; }, - modalAction() { - return n__( - 'ContainerRegistry|Remove tag', - 'ContainerRegistry|Remove tags', - this.isMultiDelete ? this.itemsToBeDeleted.length : 1, - ); - }, currentPage: { get() { return this.tagsPagination.page; @@ -142,132 +59,51 @@ export default { this.requestTagsList({ pagination: { page }, params: this.$route.params.id }); }, }, - deleteAlertConfig() { - const config = { - title: '', - message: '', - type: 'success', - }; - if (this.deleteAlertType) { - [config.type] = this.deleteAlertType.split('_'); - - const defaultMessage = this.$options.alertMessages[this.deleteAlertType]; - - if (this.config.isAdmin && config.type === 'success') { - config.title = defaultMessage; - config.message = ADMIN_GARBAGE_COLLECTION_TIP; - } else { - config.message = defaultMessage; - } - } - return config; - }, }, mounted() { this.requestTagsList({ params: this.$route.params.id }); }, methods: { ...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']), - setModalDescription(itemIndex = -1) { - if (itemIndex === -1) { - this.modalDescription = { - message: REMOVE_TAGS_CONFIRMATION_TEXT, - item: this.itemsToBeDeleted.length, - }; - } else { - const { path } = this.tags[itemIndex]; - - this.modalDescription = { - message: REMOVE_TAG_CONFIRMATION_TEXT, - item: path, - }; - } - }, - formatSize(size) { - return numberToHumanSize(size); - }, - layers(layers) { - return layers ? n__('%d layer', '%d layers', layers) : ''; - }, - onSelectAllChange() { - if (this.selectAllChecked) { - this.deselectAll(); - } else { - this.selectAll(); - } - }, - selectAll() { - this.selectedItems = this.tags.map((x, index) => index); - this.selectAllChecked = true; - }, - deselectAll() { - this.selectedItems = []; - this.selectAllChecked = false; - }, - updateSelectedItems(index) { - const delIndex = this.selectedItems.findIndex(x => x === index); - - if (delIndex > -1) { - this.selectedItems.splice(delIndex, 1); - this.selectAllChecked = false; - } else { - this.selectedItems.push(index); - - if (this.selectedItems.length === this.tags.length) { - this.selectAllChecked = true; - } - } - }, - deleteSingleItem(index) { - this.setModalDescription(index); - this.itemsToBeDeleted = [index]; + deleteTags(toBeDeletedList) { + this.itemsToBeDeleted = toBeDeletedList.map(name => ({ + ...this.tags.find(t => t.name === name), + })); this.track('click_button'); this.$refs.deleteModal.show(); }, - deleteMultipleItems() { - this.itemsToBeDeleted = [...this.selectedItems]; - if (this.selectedItems.length === 1) { - this.setModalDescription(this.itemsToBeDeleted[0]); - } else if (this.selectedItems.length > 1) { - this.setModalDescription(); - } - this.track('click_button'); - this.$refs.deleteModal.show(); - }, - handleSingleDelete(index) { - const itemToDelete = this.tags[index]; + handleSingleDelete() { + const [itemToDelete] = this.itemsToBeDeleted; this.itemsToBeDeleted = []; - this.selectedItems = this.selectedItems.filter(i => i !== index); return this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id }) .then(() => { - this.deleteAlertType = 'success_tag'; + this.deleteAlertType = ALERT_SUCCESS_TAG; }) .catch(() => { - this.deleteAlertType = 'danger_tag'; + this.deleteAlertType = ALERT_DANGER_TAG; }); }, handleMultipleDelete() { const { itemsToBeDeleted } = this; this.itemsToBeDeleted = []; - this.selectedItems = []; return this.requestDeleteTags({ - ids: itemsToBeDeleted.map(x => this.tags[x].name), + ids: itemsToBeDeleted.map(x => x.name), params: this.$route.params.id, }) .then(() => { - this.deleteAlertType = 'success_tags'; + this.deleteAlertType = ALERT_SUCCESS_TAGS; }) .catch(() => { - this.deleteAlertType = 'danger_tags'; + this.deleteAlertType = ALERT_DANGER_TAGS; }); }, onDeletionConfirmed() { this.track('confirm_delete'); - if (this.isMultiDelete) { + if (this.itemsToBeDeleted.length > 1) { this.handleMultipleDelete(); } else { - this.handleSingleDelete(this.itemsToBeDeleted[0]); + this.handleSingleDelete(); } }, handleResize() { @@ -279,141 +115,23 @@ export default { <template> <div v-gl-resize-observer="handleResize" class="my-3 w-100 slide-enter-to-element"> - <gl-alert - v-if="deleteAlertType" - :variant="deleteAlertConfig.type" - :title="deleteAlertConfig.title" + <delete-alert + v-model="deleteAlertType" + :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" + :is-admin="config.isAdmin" class="my-2" - @dismiss="deleteAlertType = null" - > - <gl-sprintf :message="deleteAlertConfig.message"> - <template #docLink="{content}"> - <gl-link :href="config.garbageCollectionHelpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </gl-alert> - <div class="d-flex my-3 align-items-center"> - <h4> - <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE"> - <template #imageName> - {{ imageName }} - </template> - </gl-sprintf> - </h4> - </div> - - <gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty> - <template v-if="isDesktop" #head(checkbox)> - <gl-form-checkbox - ref="mainCheckbox" - :checked="selectAllChecked" - @change="onSelectAllChange" - /> - </template> - <template #head(actions)> - <gl-deprecated-button - ref="bulkDeleteButton" - v-gl-tooltip - :disabled="!selectedItems || selectedItems.length === 0" - class="float-right" - variant="danger" - :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" - :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE" - @click="deleteMultipleItems()" - > - <gl-icon name="remove" /> - </gl-deprecated-button> - </template> + /> - <template #cell(checkbox)="{index}"> - <gl-form-checkbox - ref="rowCheckbox" - class="js-row-checkbox" - :checked="selectedItems.includes(index)" - @change="updateSelectedItems(index)" - /> - </template> - <template #cell(name)="{item, field}"> - <div ref="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" - ref="rowClipboardButton" - :title="item.location" - :text="item.location" - css-class="btn-default btn-transparent btn-clipboard" - /> - </div> - </template> - <template #cell(short_revision)="{value}"> - <span ref="rowShortRevision"> - {{ value }} - </span> - </template> - <template #cell(total_size)="{item}"> - <span ref="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 ref="rowTime" v-gl-tooltip :title="tooltipTitle(value)"> - {{ timeFormatted(value) }} - </span> - </template> - <template #cell(actions)="{index, item}"> - <gl-deprecated-button - ref="singleDeleteButton" - :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" - :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE" - :disabled="!item.destroy_path" - variant="danger" - class="js-delete-registry float-right btn-inverted btn-border-color btn-icon" - @click="deleteSingleItem(index)" - > - <gl-icon name="remove" /> - </gl-deprecated-button> - </template> + <details-header :image-name="imageName" /> + <tags-table :tags="tags" :is-loading="isLoading" :is-desktop="isDesktop" @delete="deleteTags"> <template #empty> - <template v-if="isLoading"> - <gl-skeleton-loader - v-for="index in $options.loader.repeat" - :key="index" - :width="$options.loader.width" - :height="$options.loader.height" - preserve-aspect-ratio="xMinYMax meet" - > - <rect width="15" x="0" y="12.5" height="15" rx="4" /> - <rect width="250" x="25" y="10" height="20" rx="4" /> - <circle cx="290" cy="20" r="10" /> - <rect width="100" x="315" y="10" height="20" rx="4" /> - <rect width="100" x="500" y="10" height="20" rx="4" /> - <rect width="100" x="630" y="10" height="20" rx="4" /> - <rect x="960" y="0" width="40" height="40" rx="4" /> - </gl-skeleton-loader> - </template> - <gl-empty-state - v-else - :title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE" - :svg-path="config.noContainersImage" - :description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE" - class="mx-auto my-0" - /> + <empty-tags-state :no-containers-image="config.noContainersImage" /> </template> - </gl-table> + <template #loader> + <tags-loader v-once /> + </template> + </tags-table> <gl-pagination v-if="!isLoading" @@ -425,22 +143,11 @@ export default { class="w-100" /> - <gl-modal + <delete-modal ref="deleteModal" - modal-id="delete-tag-modal" - ok-variant="danger" - @ok="onDeletionConfirmed" + :items-to-be-deleted="itemsToBeDeleted" + @confirmDelete="onDeletionConfirmed" @cancel="track('cancel_delete')" - > - <template #modal-title>{{ modalAction }}</template> - <template #modal-ok>{{ modalAction }}</template> - <p v-if="modalDescription"> - <gl-sprintf :message="modalDescription.message"> - <template #item> - <b>{{ modalDescription.item }}</b> - </template> - </gl-sprintf> - </p> - </gl-modal> + /> </div> </template> diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 4efa6f08d84..e8a26dc58f2 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -12,26 +12,24 @@ import { } from '@gitlab/ui'; import Tracking from '~/tracking'; -import ProjectEmptyState from '../components/project_empty_state.vue'; -import GroupEmptyState from '../components/group_empty_state.vue'; -import ProjectPolicyAlert from '../components/project_policy_alert.vue'; -import QuickstartDropdown from '../components/quickstart_dropdown.vue'; -import ImageList from '../components/image_list.vue'; +import ProjectEmptyState from '../components/list_page/project_empty_state.vue'; +import GroupEmptyState from '../components/list_page/group_empty_state.vue'; +import RegistryHeader from '../components/list_page/registry_header.vue'; +import ImageList from '../components/list_page/image_list.vue'; +import CliCommands from '../components/list_page/cli_commands.vue'; import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE, - CONTAINER_REGISTRY_TITLE, CONNECTION_ERROR_TITLE, CONNECTION_ERROR_MESSAGE, - LIST_INTRO_TEXT, REMOVE_REPOSITORY_MODAL_TEXT, REMOVE_REPOSITORY_LABEL, SEARCH_PLACEHOLDER_TEXT, IMAGE_REPOSITORY_LIST_LABEL, EMPTY_RESULT_TITLE, EMPTY_RESULT_MESSAGE, -} from '../constants'; +} from '../constants/index'; export default { name: 'RegistryListApp', @@ -39,8 +37,6 @@ export default { GlEmptyState, ProjectEmptyState, GroupEmptyState, - ProjectPolicyAlert, - QuickstartDropdown, ImageList, GlModal, GlSprintf, @@ -48,6 +44,8 @@ export default { GlAlert, GlSkeletonLoader, GlSearchBoxByClick, + RegistryHeader, + CliCommands, }, directives: { GlTooltip: GlTooltipDirective, @@ -59,10 +57,8 @@ export default { height: 40, }, i18n: { - CONTAINER_REGISTRY_TITLE, CONNECTION_ERROR_TITLE, CONNECTION_ERROR_MESSAGE, - LIST_INTRO_TEXT, REMOVE_REPOSITORY_MODAL_TEXT, REMOVE_REPOSITORY_LABEL, SEARCH_PLACEHOLDER_TEXT, @@ -85,7 +81,7 @@ export default { label: 'registry_repository_delete', }; }, - showQuickStartDropdown() { + showCommands() { return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); }, showDeleteAlert() { @@ -149,8 +145,6 @@ export default { </gl-sprintf> </gl-alert> - <project-policy-alert v-if="!config.isGroupPage" class="mt-2" /> - <gl-empty-state v-if="config.characterError" :title="$options.i18n.CONNECTION_ERROR_TITLE" @@ -170,21 +164,17 @@ export default { </gl-empty-state> <template v-else> - <div> - <div class="d-flex justify-content-between align-items-center"> - <h4>{{ $options.i18n.CONTAINER_REGISTRY_TITLE }}</h4> - <quickstart-dropdown v-if="showQuickStartDropdown" class="d-none d-sm-block" /> - </div> - <p> - <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT"> - <template #docLink="{content}"> - <gl-link :href="config.helpPagePath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </p> - </div> + <registry-header + :images-count="pagination.total" + :expiration-policy="config.expirationPolicy" + :help-page-path="config.helpPagePath" + :expiration-policy-help-page-path="config.expirationPolicyHelpPagePath" + :hide-expiration-policy-data="config.isGroupPage" + > + <template #commands> + <cli-commands v-if="showCommands" /> + </template> + </registry-header> <div v-if="isLoading" class="mt-2"> <gl-skeleton-loader @@ -201,7 +191,7 @@ export default { </div> <template v-else> <template v-if="!isEmpty"> - <div class="gl-display-flex gl-p-1" data-testid="listHeader"> + <div class="gl-display-flex gl-p-1 gl-mt-3" data-testid="listHeader"> <div class="gl-flex-fill-1"> <h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5> </div> diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/registry/explorer/router.js index 478eaca1a68..f570987023b 100644 --- a/app/assets/javascripts/registry/explorer/router.js +++ b/app/assets/javascripts/registry/explorer/router.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; -import { s__ } from '~/locale'; import List from './pages/list.vue'; import Details from './pages/details.vue'; import { decodeAndParse } from './utils'; +import { CONTAINER_REGISTRY_TITLE } from './constants/index'; Vue.use(VueRouter); @@ -17,7 +17,7 @@ export default function createRouter(base) { path: '/', component: List, meta: { - nameGenerator: () => s__('ContainerRegistry|Container Registry'), + nameGenerator: () => CONTAINER_REGISTRY_TITLE, root: true, }, }, diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js index 7f80bc21d6e..3d73ffbd23f 100644 --- a/app/assets/javascripts/registry/explorer/stores/actions.js +++ b/app/assets/javascripts/registry/explorer/stores/actions.js @@ -6,7 +6,7 @@ import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, FETCH_TAGS_LIST_ERROR_MESSAGE, -} from '../constants'; +} from '../constants/index'; import { decodeAndParse } from '../utils'; export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); diff --git a/app/assets/javascripts/registry/explorer/stores/getters.js b/app/assets/javascripts/registry/explorer/stores/getters.js index a371d0e6356..7b5d1bd6da3 100644 --- a/app/assets/javascripts/registry/explorer/stores/getters.js +++ b/app/assets/javascripts/registry/explorer/stores/getters.js @@ -1,9 +1,3 @@ -export const tags = state => { - // to show the loader inside the table we need to pass an empty array to gl-table whenever the table is loading - // this is to take in account isLoading = true and state.tags =[1,2,3] during pagination and delete - return state.isLoading ? [] : state.tags; -}; - export const dockerBuildCommand = state => { /* eslint-disable @gitlab/require-i18n-strings */ return `docker build -t ${state.config.repositoryUrl} .`; diff --git a/app/assets/javascripts/registry/explorer/stores/index.js b/app/assets/javascripts/registry/explorer/stores/index.js index 153032e37d3..54a8e0e1c1c 100644 --- a/app/assets/javascripts/registry/explorer/stores/index.js +++ b/app/assets/javascripts/registry/explorer/stores/index.js @@ -7,6 +7,7 @@ import state from './state'; Vue.use(Vuex); +// eslint-disable-next-line import/prefer-default-export export const createStore = () => new Vuex.Store({ state, @@ -14,6 +15,3 @@ export const createStore = () => actions, mutations, }); - -// Deprecated and to be removed -export default createStore(); diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js index b25a0221dc1..706f6489287 100644 --- a/app/assets/javascripts/registry/explorer/stores/mutations.js +++ b/app/assets/javascripts/registry/explorer/stores/mutations.js @@ -1,14 +1,14 @@ import * as types from './mutation_types'; -import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; -import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants'; +import { parseIntPagination, normalizeHeaders, parseBoolean } from '~/lib/utils/common_utils'; +import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants/index'; export default { [types.SET_INITIAL_STATE](state, config) { state.config = { ...config, expirationPolicy: config.expirationPolicy ? JSON.parse(config.expirationPolicy) : undefined, - isGroupPage: config.isGroupPage !== undefined, - isAdmin: config.isAdmin !== undefined, + isGroupPage: parseBoolean(config.isGroupPage), + isAdmin: parseBoolean(config.isAdmin), }; }, |