diff options
Diffstat (limited to 'app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page')
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">·</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> |