diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-18 13:16:36 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-18 13:16:36 +0000 |
commit | 311b0269b4eb9839fa63f80c8d7a58f32b8138a0 (patch) | |
tree | 07e7870bca8aed6d61fdcc810731c50d2c40af47 /app/assets/javascripts/packages_and_registries/container_registry/explorer/components | |
parent | 27909cef6c4170ed9205afa7426b8d3de47cbb0c (diff) | |
download | gitlab-ce-311b0269b4eb9839fa63f80c8d7a58f32b8138a0.tar.gz |
Add latest changes from gitlab-org/gitlab@14-5-stable-eev14.5.0-rc42
Diffstat (limited to 'app/assets/javascripts/packages_and_registries/container_registry/explorer/components')
19 files changed, 1731 insertions, 0 deletions
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue new file mode 100644 index 00000000000..e4a1a1a8266 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue @@ -0,0 +1,56 @@ +<script> +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; + +export default { + name: 'DeleteButton', + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + title: { + type: String, + required: true, + }, + tooltipTitle: { + type: String, + required: true, + }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + tooltipDisabled: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + tooltipConfiguration() { + return { + disabled: this.tooltipDisabled, + title: this.tooltipTitle, + }; + }, + }, +}; +</script> + +<template> + <div v-gl-tooltip="tooltipConfiguration"> + <gl-button + v-gl-tooltip + :disabled="disabled" + :title="title" + :aria-label="title" + variant="danger" + category="secondary" + icon="remove" + @click="$emit('delete')" + /> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_image.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_image.vue new file mode 100644 index 00000000000..a313854f5e4 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_image.vue @@ -0,0 +1,75 @@ +<script> +import { produce } from 'immer'; +import { GRAPHQL_PAGE_SIZE } from '../constants/index'; +import deleteContainerRepositoryMutation from '../graphql/mutations/delete_container_repository.mutation.graphql'; +import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; + +export default { + props: { + id: { + type: String, + required: false, + default: null, + }, + useUpdateFn: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + updateImageStatus(store, { data: { destroyContainerRepository } }) { + const variables = { + id: this.id, + first: GRAPHQL_PAGE_SIZE, + }; + const sourceData = store.readQuery({ + query: getContainerRepositoryDetailsQuery, + variables, + }); + + const data = produce(sourceData, (draftState) => { + draftState.containerRepository.status = + destroyContainerRepository.containerRepository.status; + }); + + store.writeQuery({ + query: getContainerRepositoryDetailsQuery, + variables, + data, + }); + }, + doDelete() { + this.$emit('start'); + return this.$apollo + .mutate({ + mutation: deleteContainerRepositoryMutation, + variables: { + id: this.id, + }, + update: this.useUpdateFn ? this.updateImageStatus : undefined, + }) + .then(({ data }) => { + if (data?.destroyContainerRepository?.errors[0]) { + this.$emit('error', data?.destroyContainerRepository?.errors); + return; + } + this.$emit('success'); + }) + .catch((e) => { + // note: we are adding an array to follow the same format of the error raised above + this.$emit('error', [e]); + }) + .finally(() => { + this.$emit('end'); + }); + }, + }, + render() { + if (this.$scopedSlots?.default) { + return this.$scopedSlots.default({ doDelete: this.doDelete }); + } + return null; + }, +}; +</script> 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> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue new file mode 100644 index 00000000000..1f52e319ad0 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue @@ -0,0 +1,71 @@ +<script> +import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { + CLEANUP_TIMED_OUT_ERROR_MESSAGE, + CLEANUP_STATUS_SCHEDULED, + CLEANUP_STATUS_ONGOING, + CLEANUP_STATUS_UNFINISHED, + UNFINISHED_STATUS, + UNSCHEDULED_STATUS, + SCHEDULED_STATUS, + ONGOING_STATUS, +} from '../../constants/index'; + +export default { + name: 'CleanupStatus', + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + status: { + type: String, + required: true, + validator(value) { + return [UNFINISHED_STATUS, UNSCHEDULED_STATUS, SCHEDULED_STATUS, ONGOING_STATUS].includes( + value, + ); + }, + }, + }, + i18n: { + CLEANUP_STATUS_SCHEDULED, + CLEANUP_STATUS_ONGOING, + CLEANUP_STATUS_UNFINISHED, + CLEANUP_TIMED_OUT_ERROR_MESSAGE, + }, + computed: { + showStatus() { + return this.status !== UNSCHEDULED_STATUS; + }, + failedDelete() { + return this.status === UNFINISHED_STATUS; + }, + statusText() { + return this.$options.i18n[`CLEANUP_STATUS_${this.status}`]; + }, + expireIconClass() { + return this.failedDelete ? 'gl-text-orange-500' : ''; + }, + }, +}; +</script> + +<template> + <div v-if="showStatus" class="gl-display-inline-flex gl-align-items-center"> + <gl-icon name="expire" data-testid="main-icon" :class="expireIconClass" /> + <span class="gl-mx-2"> + {{ statusText }} + </span> + <gl-icon + v-if="failedDelete" + v-gl-tooltip="{ title: $options.i18n.CLEANUP_TIMED_OUT_ERROR_MESSAGE }" + :size="14" + class="gl-text-black-normal" + data-testid="extra-info" + name="information" + /> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue new file mode 100644 index 00000000000..07ee3c6083b --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue @@ -0,0 +1,71 @@ +<script> +import { GlDropdown } from '@gitlab/ui'; +import Tracking from '~/tracking'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; +import { + QUICK_START, + LOGIN_COMMAND_LABEL, + COPY_LOGIN_TITLE, + BUILD_COMMAND_LABEL, + COPY_BUILD_TITLE, + PUSH_COMMAND_LABEL, + COPY_PUSH_TITLE, +} from '../../constants/index'; + +const trackingLabel = 'quickstart_dropdown'; + +export default { + components: { + GlDropdown, + CodeInstruction, + }, + mixins: [Tracking.mixin({ label: trackingLabel })], + inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'], + trackingLabel, + i18n: { + QUICK_START, + LOGIN_COMMAND_LABEL, + COPY_LOGIN_TITLE, + BUILD_COMMAND_LABEL, + COPY_BUILD_TITLE, + PUSH_COMMAND_LABEL, + COPY_PUSH_TITLE, + }, +}; +</script> +<template> + <gl-dropdown + :text="$options.i18n.QUICK_START" + variant="info" + right + @shown="track('click_dropdown')" + > + <!-- This li is used as a container since gl-dropdown produces a root ul, this mimics the functionality exposed by b-dropdown-form --> + <li role="presentation" class="px-2 py-1"> + <code-instruction + :label="$options.i18n.LOGIN_COMMAND_LABEL" + :instruction="dockerLoginCommand" + :copy-text="$options.i18n.COPY_LOGIN_TITLE" + tracking-action="click_copy_login" + :tracking-label="$options.trackingLabel" + /> + + <code-instruction + :label="$options.i18n.BUILD_COMMAND_LABEL" + :instruction="dockerBuildCommand" + :copy-text="$options.i18n.COPY_BUILD_TITLE" + tracking-action="click_copy_build" + :tracking-label="$options.trackingLabel" + /> + + <code-instruction + class="mb-0" + :label="$options.i18n.PUSH_COMMAND_LABEL" + :instruction="dockerPushCommand" + :copy-text="$options.i18n.COPY_PUSH_TITLE" + tracking-action="click_copy_push" + :tracking-label="$options.trackingLabel" + /> + </li> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue new file mode 100644 index 00000000000..a68c4de5aa6 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue @@ -0,0 +1,35 @@ +<script> +import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; + +export default { + name: 'GroupEmptyState', + components: { + GlEmptyState, + GlSprintf, + GlLink, + }, + inject: ['config'], +}; +</script> +<template> + <gl-empty-state + :title="s__('ContainerRegistry|There are no container images available in this group')" + :svg-path="config.noContainersImage" + > + <template #description> + <p> + <gl-sprintf + :message=" + s__( + `ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}`, + ) + " + > + <template #docLink="{ content }"> + <gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue new file mode 100644 index 00000000000..5bd13322ebb --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue @@ -0,0 +1,54 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; +import ImageListRow from './image_list_row.vue'; + +export default { + name: 'ImageList', + components: { + GlKeysetPagination, + ImageListRow, + }, + props: { + images: { + type: Array, + required: true, + }, + metadataLoading: { + type: Boolean, + default: false, + required: false, + }, + pageInfo: { + type: Object, + required: true, + }, + }, + computed: { + showPagination() { + return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column"> + <image-list-row + v-for="(listItem, index) in images" + :key="index" + :item="listItem" + :metadata-loading="metadataLoading" + @delete="$emit('delete', $event)" + /> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-if="showPagination" + :has-next-page="pageInfo.hasNextPage" + :has-previous-page="pageInfo.hasPreviousPage" + class="gl-mt-3" + @prev="$emit('prev-page')" + @next="$emit('next-page')" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue new file mode 100644 index 00000000000..c1ec523574a --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue @@ -0,0 +1,153 @@ +<script> +import { GlTooltipDirective, GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { n__ } from '~/locale'; + +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import { + ASYNC_DELETE_IMAGE_ERROR_MESSAGE, + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + ROW_SCHEDULED_FOR_DELETION, + CLEANUP_TIMED_OUT_ERROR_MESSAGE, + IMAGE_DELETE_SCHEDULED_STATUS, + IMAGE_FAILED_DELETED_STATUS, + ROOT_IMAGE_TEXT, +} from '../../constants/index'; +import DeleteButton from '../delete_button.vue'; +import CleanupStatus from './cleanup_status.vue'; + +export default { + name: 'ImageListRow', + components: { + ClipboardButton, + DeleteButton, + GlSprintf, + GlIcon, + ListItem, + GlSkeletonLoader, + CleanupStatus, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + item: { + type: Object, + required: true, + }, + metadataLoading: { + type: Boolean, + default: false, + required: false, + }, + }, + i18n: { + LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, + ROW_SCHEDULED_FOR_DELETION, + }, + computed: { + disabledDelete() { + return !this.item.canDelete || this.deleting; + }, + id() { + return getIdFromGraphQLId(this.item.id); + }, + deleting() { + return this.item.status === IMAGE_DELETE_SCHEDULED_STATUS; + }, + failedDelete() { + return this.item.status === IMAGE_FAILED_DELETED_STATUS; + }, + tagsCountText() { + return n__( + 'ContainerRegistry|%{count} Tag', + 'ContainerRegistry|%{count} Tags', + this.item.tagsCount, + ); + }, + warningIconText() { + if (this.failedDelete) { + return ASYNC_DELETE_IMAGE_ERROR_MESSAGE; + } + if (this.item.expirationPolicyStartedAt) { + return CLEANUP_TIMED_OUT_ERROR_MESSAGE; + } + return null; + }, + imageName() { + return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`; + }, + routerLinkEvent() { + return this.deleting ? '' : 'click'; + }, + }, +}; +</script> + +<template> + <list-item + v-gl-tooltip="{ + placement: 'left', + disabled: !deleting, + title: $options.i18n.ROW_SCHEDULED_FOR_DELETION, + }" + v-bind="$attrs" + :disabled="deleting" + > + <template #left-primary> + <router-link + class="gl-text-body gl-font-weight-bold" + data-testid="details-link" + data-qa-selector="registry_image_content" + :event="routerLinkEvent" + :to="{ name: 'details', params: { id } }" + > + {{ imageName }} + </router-link> + <clipboard-button + v-if="item.location" + :disabled="deleting" + :text="item.location" + :title="item.location" + category="tertiary" + /> + </template> + <template #left-secondary> + <template v-if="!metadataLoading"> + <span class="gl-display-flex gl-align-items-center" data-testid="tags-count"> + <gl-icon name="tag" class="gl-mr-2" /> + <gl-sprintf :message="tagsCountText"> + <template #count> + {{ item.tagsCount }} + </template> + </gl-sprintf> + </span> + + <cleanup-status + v-if="item.expirationPolicyCleanupStatus" + class="ml-2" + :status="item.expirationPolicyCleanupStatus" + /> + </template> + + <div v-else class="gl-w-full"> + <gl-skeleton-loader :width="900" :height="16" preserve-aspect-ratio="xMinYMax meet"> + <circle cx="6" cy="8" r="6" /> + <rect x="16" y="4" width="100" height="8" rx="4" /> + </gl-skeleton-loader> + </div> + </template> + <template #right-action> + <delete-button + :title="$options.i18n.REMOVE_REPOSITORY_LABEL" + :disabled="disabledDelete" + :tooltip-disabled="item.canDelete" + :tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED" + @delete="$emit('delete', item)" + /> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue new file mode 100644 index 00000000000..5aa04419ca0 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue @@ -0,0 +1,111 @@ +<script> +import { GlEmptyState, GlSprintf, GlLink, GlFormInputGroup, GlFormInput } from '@gitlab/ui'; +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/index'; + +export default { + name: 'ProjectEmptyState', + components: { + ClipboardButton, + GlEmptyState, + GlSprintf, + GlLink, + GlFormInputGroup, + GlFormInput, + }, + inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'], + i18n: { + quickStart: QUICK_START, + copyLoginTitle: COPY_LOGIN_TITLE, + copyBuildTitle: COPY_BUILD_TITLE, + copyPushTitle: COPY_PUSH_TITLE, + introText: s__( + `ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`, + ), + notLoggedInMessage: s__( + `ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password.`, + ), + addImageText: s__( + 'ContainerRegistry|You can add an image to this registry with the following commands:', + ), + }, +}; +</script> +<template> + <gl-empty-state + :title="s__('ContainerRegistry|There are no container images stored for this project')" + :svg-path="config.noContainersImage" + > + <template #description> + <p> + <gl-sprintf :message="$options.i18n.introText"> + <template #docLink="{ content }"> + <gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <h5>{{ $options.i18n.quickStart }}</h5> + <p> + <gl-sprintf :message="$options.i18n.notLoggedInMessage"> + <template #twofaDocLink="{ content }"> + <gl-link :href="config.twoFactorAuthHelpLink" target="_blank">{{ content }}</gl-link> + </template> + <template #personalAccessTokensDocLink="{ content }"> + <gl-link :href="config.personalAccessTokensHelpLink" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </p> + <gl-form-input-group class="gl-mb-4"> + <gl-form-input + :value="dockerLoginCommand" + readonly + type="text" + class="gl-font-monospace!" + /> + <template #append> + <clipboard-button + :text="dockerLoginCommand" + :title="$options.i18n.copyLoginTitle" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + <p class="gl-mb-4"> + {{ $options.i18n.addImageText }} + </p> + <gl-form-input-group class="gl-mb-4"> + <gl-form-input + :value="dockerBuildCommand" + readonly + type="text" + class="gl-font-monospace!" + /> + <template #append> + <clipboard-button + :text="dockerBuildCommand" + :title="$options.i18n.copyBuildTitle" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + <gl-form-input-group> + <gl-form-input :value="dockerPushCommand" readonly type="text" class="gl-font-monospace!" /> + <template #append> + <clipboard-button + :text="dockerPushCommand" + :title="$options.i18n.copyPushTitle" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue new file mode 100644 index 00000000000..6d2ff9ea7b6 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue @@ -0,0 +1,110 @@ +<script> +import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility'; +import { n__, sprintf } from '~/locale'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; + +import { + CONTAINER_REGISTRY_TITLE, + LIST_INTRO_TEXT, + EXPIRATION_POLICY_WILL_RUN_IN, + EXPIRATION_POLICY_DISABLED_TEXT, +} from '../../constants/index'; + +export default { + name: 'ListHeader', + components: { + TitleArea, + MetadataItem, + }, + props: { + expirationPolicy: { + type: Object, + default: () => ({}), + required: false, + }, + imagesCount: { + type: Number, + default: 0, + required: false, + }, + helpPagePath: { + type: String, + default: '', + required: false, + }, + hideExpirationPolicyData: { + type: Boolean, + required: false, + default: false, + }, + metadataLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + loader: { + repeat: 10, + width: 1000, + height: 40, + }, + i18n: { + CONTAINER_REGISTRY_TITLE, + }, + computed: { + imagesCountText() { + const pluralisedString = n__( + 'ContainerRegistry|%{count} Image repository', + 'ContainerRegistry|%{count} Image repositories', + this.imagesCount, + ); + return sprintf(pluralisedString, { count: this.imagesCount }); + }, + timeTillRun() { + const difference = calculateRemainingMilliseconds(this.expirationPolicy?.next_run_at); + return approximateDuration(difference / 1000); + }, + expirationPolicyEnabled() { + return this.expirationPolicy?.enabled; + }, + expirationPolicyText() { + return this.expirationPolicyEnabled + ? sprintf(EXPIRATION_POLICY_WILL_RUN_IN, { time: this.timeTillRun }) + : EXPIRATION_POLICY_DISABLED_TEXT; + }, + infoMessages() { + return [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }]; + }, + }, +}; +</script> + +<template> + <title-area + :title="$options.i18n.CONTAINER_REGISTRY_TITLE" + :info-messages="infoMessages" + :metadata-loading="metadataLoading" + > + <template #right-actions> + <slot name="commands"></slot> + </template> + <template #metadata-count> + <metadata-item + v-if="imagesCount" + data-testid="images-count" + icon="container-image" + :text="imagesCountText" + /> + </template> + <template #metadata-exp-policies> + <metadata-item + v-if="!hideExpirationPolicyData" + data-testid="expiration-policy" + icon="expire" + :text="expirationPolicyText" + size="xl" + /> + </template> + </title-area> +</template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue new file mode 100644 index 00000000000..e77eda31596 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue @@ -0,0 +1,51 @@ +<script> +// We are using gl-breadcrumb only at the last child of the handwritten breadcrumb +// until this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079 +// +// See the CSS workaround in app/assets/stylesheets/pages/registry.scss when this file is changed. +import { GlBreadcrumb, GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlBreadcrumb, + GlIcon, + }, + computed: { + rootRoute() { + return this.$router.options.routes.find((r) => r.meta.root); + }, + detailsRoute() { + return this.$router.options.routes.find((r) => r.name === 'details'); + }, + isRootRoute() { + return this.$route.name === this.rootRoute.name; + }, + isLoaded() { + return this.isRootRoute || this.$store?.state.imageDetails?.name; + }, + allCrumbs() { + const crumbs = [ + { + text: this.rootRoute.meta.nameGenerator(), + to: this.rootRoute.path, + }, + ]; + if (!this.isRootRoute) { + crumbs.push({ + text: this.detailsRoute.meta.nameGenerator(), + href: this.detailsRoute.meta.path, + }); + } + return crumbs; + }, + }, +}; +</script> + +<template> + <gl-breadcrumb :key="isLoaded" :items="allCrumbs"> + <template #separator> + <gl-icon name="angle-right" :size="8" /> + </template> + </gl-breadcrumb> +</template> |