Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page')
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue70
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue109
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue164
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue44
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue38
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue50
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue179
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue256
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue34
9 files changed, 944 insertions, 0 deletions
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue
new file mode 100644
index 00000000000..56d2ff86fb7
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_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/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue
new file mode 100644
index 00000000000..f857c96c9d1
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue
@@ -0,0 +1,109 @@
+<script>
+import { GlModal, GlSprintf, GlFormInput } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import {
+ REMOVE_TAG_CONFIRMATION_TEXT,
+ REMOVE_TAGS_CONFIRMATION_TEXT,
+ DELETE_IMAGE_CONFIRMATION_TITLE,
+ DELETE_IMAGE_CONFIRMATION_TEXT,
+} from '../../constants';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ GlFormInput,
+ },
+ props: {
+ itemsToBeDeleted: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ deleteImage: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ projectPath: '',
+ };
+ },
+ computed: {
+ imageProjectPath() {
+ return this.itemsToBeDeleted[0]?.project?.path;
+ },
+ modalTitle() {
+ if (this.deleteImage) {
+ return DELETE_IMAGE_CONFIRMATION_TITLE;
+ }
+ return n__(
+ 'ContainerRegistry|Remove tag',
+ 'ContainerRegistry|Remove tags',
+ this.itemsToBeDeleted.length,
+ );
+ },
+ modalDescription() {
+ if (this.deleteImage) {
+ return {
+ message: DELETE_IMAGE_CONFIRMATION_TEXT,
+ item: this.imageProjectPath,
+ };
+ }
+ 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,
+ };
+ },
+ disablePrimaryButton() {
+ return this.deleteImage && this.projectPath !== this.imageProjectPath;
+ },
+ },
+ methods: {
+ show() {
+ this.$refs.deleteModal.show();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="deleteModal"
+ modal-id="delete-tag-modal"
+ ok-variant="danger"
+ :action-primary="{
+ text: __('Delete'),
+ attributes: [{ variant: 'danger' }, { disabled: disablePrimaryButton }],
+ }"
+ :action-cancel="{ text: __('Cancel') }"
+ @primary="$emit('confirmDelete')"
+ @cancel="$emit('cancelDelete')"
+ @change="projectPath = ''"
+ >
+ <template #modal-title>{{ modalTitle }}</template>
+ <p v-if="modalDescription" data-testid="description">
+ <gl-sprintf :message="modalDescription.message">
+ <template #item>
+ <b>{{ modalDescription.item }}</b>
+ </template>
+ <template #code>
+ <code>{{ modalDescription.item }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <div v-if="deleteImage">
+ <gl-form-input v-model="projectPath" />
+ </div>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
new file mode 100644
index 00000000000..e9e36151fe6
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
@@ -0,0 +1,164 @@
+<script>
+import { GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { sprintf, n__, s__ } from '~/locale';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import {
+ UPDATED_AT,
+ CLEANUP_UNSCHEDULED_TEXT,
+ CLEANUP_SCHEDULED_TEXT,
+ CLEANUP_ONGOING_TEXT,
+ CLEANUP_UNFINISHED_TEXT,
+ CLEANUP_DISABLED_TEXT,
+ CLEANUP_SCHEDULED_TOOLTIP,
+ CLEANUP_ONGOING_TOOLTIP,
+ CLEANUP_UNFINISHED_TOOLTIP,
+ CLEANUP_DISABLED_TOOLTIP,
+ UNFINISHED_STATUS,
+ UNSCHEDULED_STATUS,
+ SCHEDULED_STATUS,
+ ONGOING_STATUS,
+ ROOT_IMAGE_TEXT,
+ ROOT_IMAGE_TOOLTIP,
+} from '../../constants/index';
+
+import getContainerRepositoryTagsCountQuery from '../../graphql/queries/get_container_repository_tags_count.query.graphql';
+
+export default {
+ name: 'DetailsHeader',
+ components: { GlIcon, TitleArea, MetadataItem, GlDropdown, GlDropdownItem },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ image: {
+ type: Object,
+ required: true,
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ containerRepository: {},
+ fetchTagsCount: false,
+ };
+ },
+ apollo: {
+ containerRepository: {
+ query: getContainerRepositoryTagsCountQuery,
+ variables() {
+ return {
+ id: this.image.id,
+ };
+ },
+ },
+ },
+ computed: {
+ imageDetails() {
+ return { ...this.image, ...this.containerRepository };
+ },
+ visibilityIcon() {
+ return this.imageDetails?.project?.visibility === 'public' ? 'eye' : 'eye-slash';
+ },
+ timeAgo() {
+ return this.timeFormatted(this.imageDetails.updatedAt);
+ },
+ updatedText() {
+ return sprintf(UPDATED_AT, { time: this.timeAgo });
+ },
+ tagCountText() {
+ if (this.$apollo.queries.containerRepository.loading) {
+ return s__('ContainerRegistry|-- tags');
+ }
+ return n__('%d tag', '%d tags', this.imageDetails.tagsCount);
+ },
+ cleanupTextAndTooltip() {
+ if (!this.imageDetails.project.containerExpirationPolicy?.enabled) {
+ return { text: CLEANUP_DISABLED_TEXT, tooltip: CLEANUP_DISABLED_TOOLTIP };
+ }
+ return {
+ [UNSCHEDULED_STATUS]: {
+ text: sprintf(CLEANUP_UNSCHEDULED_TEXT, {
+ time: this.timeFormatted(this.imageDetails.project.containerExpirationPolicy.nextRunAt),
+ }),
+ },
+ [SCHEDULED_STATUS]: { text: CLEANUP_SCHEDULED_TEXT, tooltip: CLEANUP_SCHEDULED_TOOLTIP },
+ [ONGOING_STATUS]: { text: CLEANUP_ONGOING_TEXT, tooltip: CLEANUP_ONGOING_TOOLTIP },
+ [UNFINISHED_STATUS]: { text: CLEANUP_UNFINISHED_TEXT, tooltip: CLEANUP_UNFINISHED_TOOLTIP },
+ }[this.imageDetails?.expirationPolicyCleanupStatus];
+ },
+ deleteButtonDisabled() {
+ return this.disabled || !this.imageDetails.canDelete;
+ },
+ rootImageTooltip() {
+ return !this.imageDetails.name ? ROOT_IMAGE_TOOLTIP : '';
+ },
+ imageName() {
+ return this.imageDetails.name || ROOT_IMAGE_TEXT;
+ },
+ },
+};
+</script>
+
+<template>
+ <title-area>
+ <template #title>
+ <span data-testid="title">
+ {{ imageName }}
+ </span>
+ <gl-icon
+ v-if="rootImageTooltip"
+ v-gl-tooltip="rootImageTooltip"
+ class="gl-text-blue-600"
+ name="information-o"
+ :aria-label="rootImageTooltip"
+ />
+ </template>
+ <template #metadata-tags-count>
+ <metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" />
+ </template>
+
+ <template #metadata-cleanup>
+ <metadata-item
+ icon="expire"
+ :text="cleanupTextAndTooltip.text"
+ :text-tooltip="cleanupTextAndTooltip.tooltip"
+ size="xl"
+ data-testid="cleanup"
+ />
+ </template>
+
+ <template #metadata-updated>
+ <metadata-item
+ :icon="visibilityIcon"
+ :text="updatedText"
+ size="xl"
+ data-testid="updated-and-visibility"
+ />
+ </template>
+ <template #right-actions>
+ <gl-dropdown
+ icon="ellipsis_v"
+ text="More actions"
+ :text-sr-only="true"
+ category="tertiary"
+ no-caret
+ right
+ >
+ <gl-dropdown-item
+ variant="danger"
+ :disabled="deleteButtonDisabled"
+ @click="$emit('delete')"
+ >
+ {{ __('Delete image repository') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </template>
+ </title-area>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue
new file mode 100644
index 00000000000..a16d95a6b30
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue
@@ -0,0 +1,44 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import {
+ NO_TAGS_TITLE,
+ NO_TAGS_MESSAGE,
+ MISSING_OR_DELETED_IMAGE_TITLE,
+ MISSING_OR_DELETED_IMAGE_MESSAGE,
+} from '../../constants/index';
+
+export default {
+ components: {
+ GlEmptyState,
+ },
+ props: {
+ noContainersImage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isEmptyImage: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ computed: {
+ title() {
+ return this.isEmptyImage ? MISSING_OR_DELETED_IMAGE_TITLE : NO_TAGS_TITLE;
+ },
+ description() {
+ return this.isEmptyImage ? MISSING_OR_DELETED_IMAGE_MESSAGE : NO_TAGS_MESSAGE;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :title="title"
+ :svg-path="noContainersImage"
+ :description="description"
+ class="gl-mx-auto gl-my-0"
+ />
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue
new file mode 100644
index 00000000000..12095655126
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui';
+
+import { DELETE_ALERT_TITLE, DELETE_ALERT_LINK_TEXT } from '../../constants/index';
+
+export default {
+ components: {
+ GlSprintf,
+ GlAlert,
+ GlLink,
+ },
+ props: {
+ runCleanupPoliciesHelpPagePath: { type: String, required: false, default: '' },
+ cleanupPoliciesHelpPagePath: { type: String, required: false, default: '' },
+ },
+ i18n: {
+ DELETE_ALERT_TITLE,
+ DELETE_ALERT_LINK_TEXT,
+ },
+};
+</script>
+
+<template>
+ <gl-alert variant="warning" :title="$options.i18n.DELETE_ALERT_TITLE" @dismiss="$emit('dismiss')">
+ <gl-sprintf :message="$options.i18n.DELETE_ALERT_LINK_TEXT">
+ <template #adminLink="{ content }">
+ <gl-link data-testid="run-link" :href="runCleanupPoliciesHelpPagePath" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ <template #docLink="{ content }">
+ <gl-link data-testid="help-link" :href="cleanupPoliciesHelpPagePath" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue
new file mode 100644
index 00000000000..fc1504f6c31
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import {
+ IMAGE_STATUS_MESSAGES,
+ IMAGE_STATUS_TITLES,
+ IMAGE_STATUS_ALERT_TYPE,
+ PACKAGE_DELETE_HELP_PAGE_PATH,
+} from '../../constants';
+
+export default {
+ components: {
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ },
+ props: {
+ status: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ message() {
+ return IMAGE_STATUS_MESSAGES[this.status];
+ },
+ title() {
+ return IMAGE_STATUS_TITLES[this.status];
+ },
+ variant() {
+ return IMAGE_STATUS_ALERT_TYPE[this.status];
+ },
+ },
+ links: {
+ PACKAGE_DELETE_HELP_PAGE_PATH,
+ },
+};
+</script>
+<template>
+ <gl-alert :title="title" :variant="variant">
+ <span data-testid="message">
+ <gl-sprintf :message="message">
+ <template #link="{ content }">
+ <gl-link :href="$options.links.PACKAGE_DELETE_HELP_PAGE_PATH" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
new file mode 100644
index 00000000000..3e19a646f53
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
@@ -0,0 +1,179 @@
+<script>
+import { GlButton, GlKeysetPagination } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { joinPaths } from '~/lib/utils/url_utility';
+import {
+ REMOVE_TAGS_BUTTON_TITLE,
+ TAGS_LIST_TITLE,
+ GRAPHQL_PAGE_SIZE,
+ FETCH_IMAGES_LIST_ERROR_MESSAGE,
+} from '../../constants/index';
+import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql';
+import EmptyState from './empty_state.vue';
+import TagsListRow from './tags_list_row.vue';
+import TagsLoader from './tags_loader.vue';
+
+export default {
+ name: 'TagsList',
+ components: {
+ GlButton,
+ GlKeysetPagination,
+ TagsListRow,
+ EmptyState,
+ TagsLoader,
+ },
+ inject: ['config'],
+ props: {
+ id: {
+ type: [Number, String],
+ required: true,
+ },
+ isMobile: {
+ type: Boolean,
+ default: true,
+ required: false,
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ isImageLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ i18n: {
+ REMOVE_TAGS_BUTTON_TITLE,
+ TAGS_LIST_TITLE,
+ },
+ apollo: {
+ containerRepository: {
+ query: getContainerRepositoryTagsQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ error() {
+ createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
+ },
+ },
+ },
+ data() {
+ return {
+ selectedItems: {},
+ containerRepository: {},
+ };
+ },
+ computed: {
+ tags() {
+ return this.containerRepository?.tags?.nodes || [];
+ },
+ tagsPageInfo() {
+ return this.containerRepository?.tags?.pageInfo;
+ },
+ queryVariables() {
+ return {
+ id: joinPaths(this.config.gidPrefix, `${this.id}`),
+ first: GRAPHQL_PAGE_SIZE,
+ };
+ },
+ hasSelectedItems() {
+ return this.tags.some((tag) => this.selectedItems[tag.name]);
+ },
+ showMultiDeleteButton() {
+ return this.tags.some((tag) => tag.canDelete) && !this.isMobile;
+ },
+ multiDeleteButtonIsDisabled() {
+ return !this.hasSelectedItems || this.disabled;
+ },
+ showPagination() {
+ return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage;
+ },
+ hasNoTags() {
+ return this.tags.length === 0;
+ },
+ isLoading() {
+ return this.isImageLoading || this.$apollo.queries.containerRepository.loading;
+ },
+ },
+ methods: {
+ updateSelectedItems(name) {
+ this.$set(this.selectedItems, name, !this.selectedItems[name]);
+ },
+ mapTagsToBeDleeted(items) {
+ return this.tags.filter((tag) => items[tag.name]);
+ },
+ fetchNextPage() {
+ this.$apollo.queries.containerRepository.fetchMore({
+ variables: {
+ after: this.tagsPageInfo?.endCursor,
+ first: GRAPHQL_PAGE_SIZE,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ },
+ fetchPreviousPage() {
+ this.$apollo.queries.containerRepository.fetchMore({
+ variables: {
+ first: null,
+ before: this.tagsPageInfo?.startCursor,
+ last: GRAPHQL_PAGE_SIZE,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <tags-loader v-if="isLoading" />
+ <template v-else>
+ <empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" />
+ <template v-else>
+ <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="multiDeleteButtonIsDisabled"
+ category="secondary"
+ variant="danger"
+ @click="$emit('delete', mapTagsToBeDleeted(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"
+ :selected="selectedItems[tag.name]"
+ :is-mobile="isMobile"
+ :disabled="disabled"
+ @select="updateSelectedItems(tag.name)"
+ @delete="$emit('delete', mapTagsToBeDleeted({ [tag.name]: true }))"
+ />
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ :has-next-page="tagsPageInfo.hasNextPage"
+ :has-previous-page="tagsPageInfo.hasPreviousPage"
+ class="gl-mt-3"
+ @prev="fetchPreviousPage"
+ @next="fetchNextPage"
+ />
+ </div>
+ </template>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
new file mode 100644
index 00000000000..0556fd298aa
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
@@ -0,0 +1,256 @@
+<script>
+import {
+ GlFormCheckbox,
+ GlTooltipDirective,
+ GlSprintf,
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+} from '@gitlab/ui';
+import { formatDate } from '~/lib/utils/datetime_utility';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { n__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import {
+ REMOVE_TAG_BUTTON_TITLE,
+ DIGEST_LABEL,
+ CREATED_AT_LABEL,
+ PUBLISHED_DETAILS_ROW_TEXT,
+ MANIFEST_DETAILS_ROW_TEST,
+ CONFIGURATION_DETAILS_ROW_TEST,
+ MISSING_MANIFEST_WARNING_TOOLTIP,
+ NOT_AVAILABLE_TEXT,
+ NOT_AVAILABLE_SIZE,
+ MORE_ACTIONS_TEXT,
+} from '../../constants/index';
+
+export default {
+ components: {
+ GlSprintf,
+ GlFormCheckbox,
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+ ListItem,
+ ClipboardButton,
+ TimeAgoTooltip,
+ DetailsRow,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ tag: {
+ type: Object,
+ required: true,
+ },
+ isMobile: {
+ type: Boolean,
+ default: true,
+ required: false,
+ },
+ selected: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ i18n: {
+ REMOVE_TAG_BUTTON_TITLE,
+ DIGEST_LABEL,
+ CREATED_AT_LABEL,
+ PUBLISHED_DETAILS_ROW_TEXT,
+ MANIFEST_DETAILS_ROW_TEST,
+ CONFIGURATION_DETAILS_ROW_TEST,
+ MISSING_MANIFEST_WARNING_TOOLTIP,
+ MORE_ACTIONS_TEXT,
+ },
+ computed: {
+ formattedSize() {
+ return this.tag.totalSize
+ ? numberToHumanSize(Number(this.tag.totalSize))
+ : NOT_AVAILABLE_SIZE;
+ },
+ layers() {
+ return this.tag.layers ? n__('%d layer', '%d layers', this.tag.layers) : '';
+ },
+ mobileClasses() {
+ return this.isMobile ? '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.createdAt, 'isoDate');
+ },
+ publishedTime() {
+ return formatDate(this.tag.createdAt, '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}`, '');
+ },
+ isInvalidTag() {
+ return !this.tag.digest;
+ },
+ isCheckboxDisabled() {
+ return this.isInvalidTag || this.disabled;
+ },
+ isDeleteDisabled() {
+ return this.isInvalidTag || this.disabled || !this.tag.canDelete;
+ },
+ },
+};
+</script>
+
+<template>
+ <list-item v-bind="$attrs" :selected="selected" :disabled="disabled">
+ <template #left-action>
+ <gl-form-checkbox
+ v-if="tag.canDelete"
+ :disabled="isCheckboxDisabled"
+ 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"
+ category="tertiary"
+ :disabled="disabled"
+ />
+
+ <gl-icon
+ v-if="isInvalidTag"
+ 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.createdAt" />
+ </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>
+ <gl-dropdown
+ :disabled="isDeleteDisabled"
+ icon="ellipsis_v"
+ :text="$options.i18n.MORE_ACTIONS_TEXT"
+ :text-sr-only="true"
+ category="tertiary"
+ no-caret
+ right
+ :class="{ 'gl-opacity-0 gl-pointer-events-none': isDeleteDisabled }"
+ data-testid="additional-actions"
+ data-qa-selector="more_actions_menu"
+ >
+ <gl-dropdown-item
+ variant="danger"
+ data-testid="single-delete-button"
+ data-qa-selector="tag_delete_button"
+ @click="$emit('delete')"
+ >
+ {{ $options.i18n.REMOVE_TAG_BUTTON_TITLE }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </template>
+
+ <template v-if="!isInvalidTag" #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="!isInvalidTag" #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"
+ category="tertiary"
+ size="small"
+ :disabled="disabled"
+ />
+ </details-row>
+ </template>
+ <template v-if="!isInvalidTag" #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"
+ category="tertiary"
+ size="small"
+ :disabled="disabled"
+ />
+ </details-row>
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue
new file mode 100644
index 00000000000..b7afa5fba33
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_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>