diff options
Diffstat (limited to 'app/assets/javascripts/packages_and_registries')
46 files changed, 1573 insertions, 628 deletions
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue new file mode 100644 index 00000000000..b55204de875 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue @@ -0,0 +1,95 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; +import { + NO_ARTIFACTS_TITLE, + NO_TAGS_MATCHING_FILTERS_TITLE, + NO_TAGS_MATCHING_FILTERS_DESCRIPTION, +} from '~/packages_and_registries/harbor_registry/constants'; +import ArtifactsListRow from '~/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue'; + +export default { + name: 'TagsList', + components: { + GlEmptyState, + ArtifactsListRow, + TagsLoader, + RegistryList, + }, + inject: ['noContainersImage'], + props: { + artifacts: { + type: Array, + required: true, + }, + filter: { + type: String, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + default: false, + required: false, + }, + }, + data() { + return { + tags: [], + tagsPageInfo: {}, + }; + }, + computed: { + hasNoTags() { + return this.artifacts.length === 0; + }, + emptyStateTitle() { + return this.filter ? NO_TAGS_MATCHING_FILTERS_TITLE : NO_ARTIFACTS_TITLE; + }, + emptyStateDescription() { + return this.filter ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : ''; + }, + }, + methods: { + fetchNextPage() { + this.$emit('next-page'); + }, + fetchPreviousPage() { + this.$emit('prev-page'); + }, + }, +}; +</script> + +<template> + <div> + <tags-loader v-if="isLoading" /> + <template v-else> + <gl-empty-state + v-if="hasNoTags" + :title="emptyStateTitle" + :svg-path="noContainersImage" + :description="emptyStateDescription" + class="gl-mx-auto gl-my-0" + /> + <template v-else> + <registry-list + :pagination="pageInfo" + :items="artifacts" + :hidden-delete="true" + id-property="name" + @prev-page="fetchPreviousPage" + @next-page="fetchNextPage" + > + <template #default="{ item }"> + <artifacts-list-row :artifact="item" /> + </template> + </registry-list> + </template> + </template> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue new file mode 100644 index 00000000000..b489f126f75 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue @@ -0,0 +1,133 @@ +<script> +import { GlTooltipDirective, GlSprintf, GlIcon } from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_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 TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { + DIGEST_LABEL, + CREATED_AT_LABEL, + NOT_AVAILABLE_TEXT, + NOT_AVAILABLE_SIZE, +} from '~/packages_and_registries/harbor_registry/constants'; +import { artifactPullCommand } from '~/packages_and_registries/harbor_registry/utils'; + +export default { + name: 'TagsListRow', + components: { + GlSprintf, + GlIcon, + ListItem, + ClipboardButton, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['repositoryUrl', 'harborIntegrationProjectName'], + props: { + artifact: { + type: Object, + required: true, + }, + }, + i18n: { + digestLabel: DIGEST_LABEL, + createdAtLabel: CREATED_AT_LABEL, + }, + computed: { + formattedSize() { + return this.artifact.size + ? numberToHumanSize(Number(this.artifact.size)) + : NOT_AVAILABLE_SIZE; + }, + tagsCountText() { + const count = this.artifact?.tags.length ? this.artifact?.tags.length : 0; + + return n__('%d tag', '%d tags', count); + }, + shortDigest() { + // remove sha256: from the string, and show only the first 7 char + const PREFIX_LENGTH = 'sha256:'.length; + const DIGEST_LENGTH = 7; + return ( + this.artifact.digest?.substring(PREFIX_LENGTH, PREFIX_LENGTH + DIGEST_LENGTH) ?? + NOT_AVAILABLE_TEXT + ); + }, + getPullCommand() { + if (this.artifact?.digest) { + const { image } = this.$route.params; + return artifactPullCommand({ + digest: this.artifact.digest, + imageName: image, + repositoryUrl: this.repositoryUrl, + harborProjectName: this.harborIntegrationProjectName, + }); + } + + return ''; + }, + linkTo() { + const { project, image } = this.$route.params; + + return { name: 'tags', params: { project, image, digest: this.artifact.digest } }; + }, + }, +}; +</script> + +<template> + <list-item v-bind="$attrs"> + <template #left-primary> + <div class="gl-display-flex gl-align-items-center"> + <router-link + class="gl-text-body gl-font-weight-bold gl-word-break-all" + data-testid="name" + :to="linkTo" + > + {{ artifact.digest }} + </router-link> + <clipboard-button + v-if="getPullCommand" + :title="getPullCommand" + :text="getPullCommand" + category="tertiary" + /> + </div> + </template> + + <template #left-secondary> + <span class="gl-mr-3" data-testid="size"> + {{ formattedSize }} + </span> + <span id="tagsCount" class="gl-display-flex gl-align-items-center" data-testid="tags-count"> + <gl-icon name="tag" class="gl-mr-2" /> + {{ tagsCountText }} + </span> + </template> + <template #right-primary> + <span data-testid="time"> + <gl-sprintf :message="$options.i18n.createdAtLabel"> + <template #timeInfo> + <time-ago-tooltip :time="artifact.pushTime" /> + </template> + </gl-sprintf> + </span> + </template> + <template #right-secondary> + <span data-testid="digest"> + <gl-sprintf :message="$options.i18n.digestLabel"> + <template #imageId>{{ shortDigest }}</template> + </gl-sprintf> + </span> + <clipboard-button + v-if="artifact.digest" + :title="artifact.digest" + :text="artifact.digest" + category="tertiary" + /> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue new file mode 100644 index 00000000000..bfb097601d5 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue @@ -0,0 +1,47 @@ +<script> +import { isEmpty } from 'lodash'; +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 { + ROOT_IMAGE_TEXT, + EMPTY_ARTIFACTS_LABEL, + artifactsLabel, +} from '~/packages_and_registries/harbor_registry/constants/index'; + +export default { + name: 'DetailsHeader', + components: { TitleArea, MetadataItem }, + mixins: [timeagoMixin], + props: { + imagesDetail: { + type: Object, + required: true, + }, + }, + computed: { + artifactCountText() { + if (isEmpty(this.imagesDetail)) { + return EMPTY_ARTIFACTS_LABEL; + } + return artifactsLabel(this.imagesDetail.artifactCount); + }, + repositoryFullName() { + return this.imagesDetail.name || ROOT_IMAGE_TEXT; + }, + }, +}; +</script> + +<template> + <title-area> + <template #title> + <span data-testid="title"> + {{ repositoryFullName }} + </span> + </template> + <template #metadata-tags-count> + <metadata-item icon="package" :text="artifactCountText" data-testid="artifacts-count" /> + </template> + </title-area> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue new file mode 100644 index 00000000000..ac1df5cf93f --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue @@ -0,0 +1,68 @@ +<script> +// Since app/assets/javascripts/packages_and_registries/shared/components/registry_breadcrumb.vue +// can only handle two levels of breadcrumbs, but we have three levels here. +// So we extended the registry_breadcrumb.vue component with harbor_registry_breadcrumb.vue to support multiple levels of breadcrumbs +import { GlBreadcrumb, GlIcon } from '@gitlab/ui'; +import { isArray, last } from 'lodash'; + +export default { + components: { + GlBreadcrumb, + GlIcon, + }, + computed: { + rootRoute() { + return this.$router.options.routes.find((r) => r.meta.root); + }, + isRootRoute() { + return this.$route.name === this.rootRoute.name; + }, + currentRoute() { + const currentName = this.$route.meta.nameGenerator(); + const currentHref = this.$route.meta.hrefGenerator(); + let routeInfoList = [ + { + text: currentName, + to: currentHref, + }, + ]; + + if (isArray(currentName) && isArray(currentHref)) { + routeInfoList = currentName.map((name, index) => { + return { + text: name, + to: currentHref[index], + }; + }); + } + + return routeInfoList; + }, + isLoaded() { + return this.isRootRoute || last(this.currentRoute).text; + }, + allCrumbs() { + let crumbs = [ + { + text: this.rootRoute.meta.nameGenerator(), + to: this.rootRoute.path, + }, + ]; + if (!this.isRootRoute) { + crumbs = crumbs.concat(this.currentRoute); + } + return crumbs; + }, + }, +}; +</script> + +<template> + <gl-breadcrumb :key="isLoaded" :items="allCrumbs"> + <template #separator> + <span class="gl-mx-n5"> + <gl-icon name="chevron-lg-right" :size="8" /> + </span> + </template> + </gl-breadcrumb> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue index 086b9c73d75..db66ebef937 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue @@ -5,6 +5,7 @@ import { HARBOR_REGISTRY_TITLE, LIST_INTRO_TEXT, imagesCountInfoText, + HARBOR_REGISTRY_HELP_PAGE_PATH, } from '~/packages_and_registries/harbor_registry/constants'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; @@ -20,11 +21,6 @@ export default { default: 0, required: false, }, - helpPagePath: { - type: String, - default: '', - required: false, - }, metadataLoading: { type: Boolean, required: false, @@ -32,7 +28,7 @@ export default { }, }, i18n: { - HARBOR_REGISTRY_TITLE, + harborRegistryTitle: HARBOR_REGISTRY_TITLE, }, computed: { imagesCountText() { @@ -40,7 +36,7 @@ export default { return sprintf(pluralisedString, { count: this.imagesCount }); }, infoMessages() { - return [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }]; + return [{ text: LIST_INTRO_TEXT, link: HARBOR_REGISTRY_HELP_PAGE_PATH }]; }, }, }; @@ -48,7 +44,7 @@ export default { <template> <title-area - :title="$options.i18n.HARBOR_REGISTRY_TITLE" + :title="$options.i18n.harborRegistryTitle" :info-messages="infoMessages" :metadata-loading="metadataLoading" > diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue index 258472fe16e..bfe0c250dd9 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue @@ -1,15 +1,14 @@ <script> -import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; +import { GlIcon, GlSkeletonLoader } from '@gitlab/ui'; import { n__ } from '~/locale'; - import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import { getNameFromParams } from '~/packages_and_registries/harbor_registry/utils'; export default { name: 'HarborListRow', components: { ClipboardButton, - GlSprintf, GlIcon, ListItem, GlSkeletonLoader, @@ -26,19 +25,18 @@ export default { }, }, computed: { - id() { - return this.item.id; + linkTo() { + const { projectName, imageName } = getNameFromParams(this.item.name); + + return { name: 'details', params: { project: projectName, image: imageName } }; }, artifactCountText() { return n__( - 'HarborRegistry|%{count} Tag', - 'HarborRegistry|%{count} Tags', + 'HarborRegistry|%d artifact', + 'HarborRegistry|%d artifacts', this.item.artifactCount, ); }, - imageName() { - return this.item.name; - }, }, }; </script> @@ -50,9 +48,9 @@ export default { class="gl-text-body gl-font-weight-bold" data-testid="details-link" data-qa-selector="registry_image_content" - :to="{ name: 'details', params: { id } }" + :to="linkTo" > - {{ imageName }} + {{ item.name }} </router-link> <clipboard-button v-if="item.location" @@ -63,13 +61,9 @@ export default { </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="artifactCountText"> - <template #count> - {{ item.artifactCount }} - </template> - </gl-sprintf> + <span class="gl-display-flex gl-align-items-center" data-testid="artifacts-count"> + <gl-icon name="package" class="gl-mr-2" /> + {{ artifactCountText }} </span> </template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue new file mode 100644 index 00000000000..e7f6989c49f --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue @@ -0,0 +1,54 @@ +<script> +import { isEmpty } from 'lodash'; +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 { + EMPTY_TAG_LABEL, + tagsCountText, +} from '~/packages_and_registries/harbor_registry/constants'; + +export default { + name: 'TagsHeader', + components: { + TitleArea, + MetadataItem, + }, + mixins: [timeagoMixin], + props: { + artifactDetail: { + type: Object, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + tagsLoading: { + type: Boolean, + required: true, + }, + }, + computed: { + tagCountText() { + if (isEmpty(this.pageInfo)) { + return EMPTY_TAG_LABEL; + } + return tagsCountText(this.pageInfo.total); + }, + }, +}; +</script> + +<template> + <title-area :metadata-loading="tagsLoading"> + <template #title> + <span class="gl-word-break-all" data-testid="title"> + {{ artifactDetail.digest }} + </span> + </template> + <template #metadata-tags-count> + <metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" /> + </template> + </title-area> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue new file mode 100644 index 00000000000..b34d3a950c0 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue @@ -0,0 +1,82 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; +import TagsListRow from '~/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue'; +import { + NO_ARTIFACTS_TITLE, + NO_TAGS_MATCHING_FILTERS_TITLE, + NO_TAGS_MATCHING_FILTERS_DESCRIPTION, +} from '~/packages_and_registries/harbor_registry/constants'; + +export default { + name: 'TagsList', + components: { + GlEmptyState, + TagsLoader, + TagsListRow, + RegistryList, + }, + inject: ['noContainersImage'], + props: { + tags: { + type: Array, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + hasNoTags() { + return this.tags.length === 0; + }, + emptyStateTitle() { + return this.filter ? NO_TAGS_MATCHING_FILTERS_TITLE : NO_ARTIFACTS_TITLE; + }, + emptyStateDescription() { + return this.filter ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : ''; + }, + }, + methods: { + fetchNextPage() { + this.$emit('next-page'); + }, + fetchPreviousPage() { + this.$emit('prev-page'); + }, + }, +}; +</script> + +<template> + <div> + <tags-loader v-if="isLoading" /> + <gl-empty-state + v-else-if="hasNoTags" + :title="emptyStateTitle" + :svg-path="noContainersImage" + :description="emptyStateDescription" + class="gl-mx-auto gl-my-0" + /> + <registry-list + v-else + :pagination="pageInfo" + :items="tags" + hidden-delete + id-property="name" + @prev-page="fetchPreviousPage" + @next-page="fetchNextPage" + > + <template #default="{ item }"> + <tags-list-row :tag="item" /> + </template> + </registry-list> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue new file mode 100644 index 00000000000..63e046c1abc --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue @@ -0,0 +1,74 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { CREATED_AT_LABEL } from '~/packages_and_registries/harbor_registry/constants'; +import { tagPullCommand } from '~/packages_and_registries/harbor_registry/utils'; + +export default { + name: 'TagsListRow', + components: { + GlSprintf, + ListItem, + ClipboardButton, + TimeAgoTooltip, + }, + inject: ['harborIntegrationProjectName', 'repositoryUrl'], + props: { + tag: { + type: Object, + required: true, + }, + }, + i18n: { + createdAtLabel: CREATED_AT_LABEL, + }, + methods: { + getPullCommand(tagName) { + if (tagName) { + const { image } = this.$route.params; + + return tagPullCommand({ + imageName: image, + tag: tagName, + repositoryUrl: this.repositoryUrl, + harborProjectName: this.harborIntegrationProjectName, + }); + } + + return ''; + }, + }, +}; +</script> + +<template> + <list-item v-bind="$attrs"> + <template #left-primary> + <div class="gl-display-flex gl-align-items-center"> + <div + data-testid="name" + class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap" + > + {{ tag.name }} + </div> + <clipboard-button + :title="getPullCommand(tag.name)" + :text="getPullCommand(tag.name)" + category="tertiary" + /> + </div> + </template> + + <template #right-primary> + <span data-testid="time"> + <gl-sprintf :message="$options.i18n.createdAtLabel"> + <template #timeInfo> + <time-ago-tooltip :time="tag.pushTime" /> + </template> + </gl-sprintf> + </span> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js index a7891821755..7f3c3da02b0 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js @@ -1,4 +1,5 @@ import { s__, __ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; export const ROOT_IMAGE_TEXT = s__('HarborRegistry|Root image'); export const NAME_SORT_FIELD = { orderBy: 'NAME', label: __('Name') }; @@ -16,14 +17,8 @@ export const SORT_FIELD_MAPPING = { CREATED: CREATED_SORT_FIELD_KEY, }; -/* eslint-disable @gitlab/require-i18n-strings */ -export const dockerBuildCommand = (repositoryUrl) => { - return `docker build -t ${repositoryUrl} .`; -}; -export const dockerPushCommand = (repositoryUrl) => { - return `docker push ${repositoryUrl}`; -}; -export const dockerLoginCommand = (registryHostUrlWithPort) => { - return `docker login ${registryHostUrlWithPort}`; -}; -/* eslint-enable @gitlab/require-i18n-strings */ +export const DEFAULT_PER_PAGE = 10; + +export const HARBOR_REGISTRY_HELP_PAGE_PATH = helpPagePath( + 'user/packages/harbor_container_registry/index', +); diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js index b62c51bd208..5b4b85ec31e 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js @@ -1,22 +1,10 @@ -import { s__, __ } from '~/locale'; +import { s__, __, n__ } from '~/locale'; -export const UPDATED_AT = s__('HarborRegistry|Last updated %{time}'); - -export const MISSING_OR_DELETED_IMAGE_TITLE = s__( - 'HarborRegistry|The image repository could not be found.', -); - -export const MISSING_OR_DELETED_IMAGE_MESSAGE = s__( - 'HarborRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page.', +export const FETCH_ARTIFACT_LIST_ERROR_MESSAGE = s__( + 'HarborRegistry|Something went wrong while fetching the artifact list.', ); -export const NO_TAGS_TITLE = s__('HarborRegistry|This image has no active tags'); - -export const NO_TAGS_MESSAGE = s__( - `HarborRegistry|The last tag related to this image was recently removed. -This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. -If you have any questions, contact your administrator.`, -); +export const NO_ARTIFACTS_TITLE = s__('HarborRegistry|This image has no artifacts'); export const NO_TAGS_MATCHING_FILTERS_TITLE = s__('HarborRegistry|The filter returned no results'); @@ -26,14 +14,24 @@ export const NO_TAGS_MATCHING_FILTERS_DESCRIPTION = s__( export const DIGEST_LABEL = s__('HarborRegistry|Digest: %{imageId}'); export const CREATED_AT_LABEL = s__('HarborRegistry|Published %{timeInfo}'); -export const PUBLISHED_DETAILS_ROW_TEXT = s__( - 'HarborRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}', -); -export const MANIFEST_DETAILS_ROW_TEST = s__('HarborRegistry|Manifest digest: %{digest}'); -export const CONFIGURATION_DETAILS_ROW_TEST = s__('HarborRegistry|Configuration digest: %{digest}'); -export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( - 'HarborRegistry|Invalid tag: missing manifest digest', -); export const NOT_AVAILABLE_TEXT = __('Not applicable.'); export const NOT_AVAILABLE_SIZE = __('0 bytes'); + +export const TOKEN_TYPE_TAG_NAME = 'tag_name'; + +export const FETCH_TAGS_ERROR_MESSAGE = s__( + 'HarborRegistry|Something went wrong while fetching the tags.', +); + +export const TAG_LABEL = s__('HarborRegistry|Tag'); +export const EMPTY_TAG_LABEL = s__('HarborRegistry|-- tags'); + +export const EMPTY_ARTIFACTS_LABEL = s__('HarborRegistry|-- artifacts'); +export const artifactsLabel = (count) => { + return n__('%d artifact', '%d artifacts', count); +}; + +export const tagsCountText = (count) => { + return n__('%d tag', '%d tags', count); +}; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js index a6cd59918ff..33950993125 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js @@ -7,8 +7,13 @@ export const HARBOR_REGISTRY_TITLE = s__('HarborRegistry|Harbor Registry'); export const CONNECTION_ERROR_TITLE = s__('HarborRegistry|Harbor connection error'); export const CONNECTION_ERROR_MESSAGE = s__( - `HarborRegistry|We are having trouble connecting to the Harbor Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`, + `HarborRegistry|We are having trouble connecting to the Harbor Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the documentation%{docLinkEnd}.`, ); + +export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__( + 'HarborRegistry|Something went wrong while fetching the repository list.', +); + export const LIST_INTRO_TEXT = s__( `HarborRegistry|With the Harbor Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`, ); @@ -26,6 +31,13 @@ export const EMPTY_RESULT_MESSAGE = s__( 'HarborRegistry|To widen your search, change or remove the filters above.', ); +export const EMPTY_IMAGES_TITLE = s__( + 'HarborRegistry|There are no harbor images stored for this project', +); +export const EMPTY_IMAGES_MESSAGE = s__( + 'HarborRegistry|With the Harbor Registry, every project can connect to a harbor space to store its Docker images.', +); + export const SORT_FIELDS = [ { orderBy: 'UPDATED', label: __('Updated') }, { orderBy: 'CREATED', label: __('Created') }, diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js index ecfefead61a..6185e4c7bc6 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js @@ -3,14 +3,8 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import PerformancePlugin from '~/performance/vue_performance_plugin'; import Translate from '~/vue_shared/translate'; -import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue'; +import RegistryBreadcrumb from '~/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue'; import { renderBreadcrumb } from '~/packages_and_registries/shared/utils'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { - dockerBuildCommand, - dockerPushCommand, - dockerLoginCommand, -} from '~/packages_and_registries/harbor_registry/constants'; import createRouter from './router'; import HarborRegistryExplorer from './pages/index.vue'; @@ -35,13 +29,27 @@ export default (id) => { return null; } - const { endpoint, connectionError, invalidPathError, isGroupPage, ...config } = el.dataset; + const { + endpoint, + connectionError, + invalidPathError, + isGroupPage, + noContainersImage, + containersErrorImage, + repositoryUrl, + harborIntegrationProjectName, + projectName, + } = el.dataset; const breadCrumbState = Vue.observable({ name: '', + href: '', updateName(value) { this.name = value; }, + updateHref(value) { + this.href = value; + }, }); const router = createRouter(endpoint, breadCrumbState); @@ -53,16 +61,15 @@ export default (id) => { provide() { return { breadCrumbState, - config: { - ...config, - connectionError: parseBoolean(connectionError), - invalidPathError: parseBoolean(invalidPathError), - isGroupPage: parseBoolean(isGroupPage), - helpPagePath: helpPagePath('user/packages/container_registry/index'), - }, - dockerBuildCommand: dockerBuildCommand(config.repositoryUrl), - dockerPushCommand: dockerPushCommand(config.repositoryUrl), - dockerLoginCommand: dockerLoginCommand(config.registryHostUrlWithPort), + endpoint, + connectionError: parseBoolean(connectionError), + invalidPathError: parseBoolean(invalidPathError), + isGroupPage: parseBoolean(isGroupPage), + repositoryUrl, + harborIntegrationProjectName, + projectName, + containersErrorImage, + noContainersImage, }; }, render(createElement) { diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js b/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js deleted file mode 100644 index 50c7df1483c..00000000000 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js +++ /dev/null @@ -1,200 +0,0 @@ -const mockRequestFn = (mockData) => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(mockData); - }, 2000); - }); -}; -export const harborListResponse = () => { - const harborListResponseData = { - repositories: [ - { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 25, - name: 'shao/flinkx', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - }, - { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 26, - name: 'shao/flinkx1', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - }, - { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 27, - name: 'shao/flinkx2', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - }, - ], - totalCount: 3, - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - }, - }; - - return mockRequestFn(harborListResponseData); -}; - -export const getHarborRegistryImageDetail = () => { - const harborRegistryImageDetailData = { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 25, - name: 'shao/flinkx', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - tagsCount: 10, - }; - - return mockRequestFn(harborRegistryImageDetailData); -}; - -export const harborTagsResponse = () => { - const harborTagsResponseData = { - tags: [ - { - digest: 'sha256:7f386a1844faf341353e1c20f2f39f11f397604fedc475435d13f756eeb235d1', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', - name: '02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', - revision: 'f53bde3d44699e04e11cf15fb415046a0913e2623d878d89bc21adb2cbda5255', - shortRevision: 'f53bde3d4', - createdAt: '2022-03-02T23:59:05+00:00', - totalSize: '6623124', - }, - { - digest: 'sha256:4554416b84c4568fe93086620b637064ed029737aabe7308b96d50e3d9d92ed7', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', - name: '02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', - revision: 'e1fe52d8bab66d71bd54a6b8784d3b9edbc68adbd6ea87f5fa44d9974144ef9e', - shortRevision: 'e1fe52d8b', - createdAt: '2022-02-10T01:09:56+00:00', - totalSize: '920760', - }, - { - digest: 'sha256:14f37b60e52b9ce0e9f8f7094b311d265384798592f783487c30aaa3d58e6345', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', - name: '03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', - revision: 'c72770c6eb93c421bc496964b4bffc742b1ec2e642cdab876be7afda1856029f', - shortRevision: 'c72770c6e', - createdAt: '2021-12-22T04:48:48+00:00', - totalSize: '48609053', - }, - { - digest: 'sha256:e925e3b8277ea23f387ed5fba5e78280cfac7cfb261a78cf046becf7b6a3faae', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', - name: '03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', - revision: '1ac2a43194f4e15166abdf3f26e6ec92215240490b9cac834d63de1a3d87494a', - shortRevision: '1ac2a4319', - createdAt: '2022-03-09T11:02:27+00:00', - totalSize: '35141894', - }, - { - digest: 'sha256:7d8303fd5c077787a8c879f8f66b69e2b5605f48ccd3f286e236fb0749fcc1ca', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', - name: '05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', - revision: 'cf8fee086701016e1a84e6824f0c896951fef4cce9d4745459558b87eec3232c', - shortRevision: 'cf8fee086', - createdAt: '2022-01-21T11:31:43+00:00', - totalSize: '48716070', - }, - { - digest: 'sha256:b33611cefe20e4a41a6e0dce356a5d7ef3c177ea7536a58652f5b3a4f2f83549', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', - name: '093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', - revision: '1a4b48198b13d55242c5164e64d41c4e9f75b5d9506bc6e0efc1534dd0dd1f15', - shortRevision: '1a4b48198', - createdAt: '2022-01-21T11:31:51+00:00', - totalSize: '6623127', - }, - { - digest: 'sha256:d25c3c020e2dbd4711a67b9fe308f4cbb7b0bb21815e722a02f91c570dc5d519', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', - name: '09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', - revision: '03e2e2777dde01c30469ee8c710973dd08a7a4f70494d7dc1583c24b525d7f61', - shortRevision: '03e2e2777', - createdAt: '2022-03-02T23:58:20+00:00', - totalSize: '911377', - }, - { - digest: 'sha256:fb760e4d2184e9e8e39d6917534d4610fe01009734698a5653b2de1391ba28f4', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', - name: '09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', - revision: '350e78d60646bf6967244448c6aaa14d21ecb9a0c6cf87e9ff0361cbe59b9012', - shortRevision: '350e78d60', - createdAt: '2022-01-19T13:49:14+00:00', - totalSize: '48710241', - }, - { - digest: 'sha256:407250f380cea92729cbc038c420e74900f53b852e11edc6404fe75a0fd2c402', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', - name: '0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', - revision: '76038370b7f3904364891457c4a6a234897255e6b9f45d0a852bf3a7e5257e18', - shortRevision: '76038370b', - createdAt: '2022-01-24T12:56:22+00:00', - totalSize: '280065', - }, - { - digest: 'sha256:ada87f25218542951ce6720c27f3d0758e90c2540bd129f5cfb9e15b31e07b07', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', - name: '0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', - revision: '3d4b49a7bbb36c48bb721f4d0e76e7950bec3878ee29cdfdd6da39f575d6d37f', - shortRevision: '3d4b49a7b', - createdAt: '2022-02-17T17:37:52+00:00', - totalSize: '48655767', - }, - ], - totalCount: 10, - pageInfo: { - hasNextPage: false, - hasPreviousPage: true, - }, - }; - - return mockRequestFn(harborTagsResponseData); -}; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue index e69de29bb2d..c6ab746b9f4 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue @@ -0,0 +1,156 @@ +<script> +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { + NAME_SORT_FIELD, + ROOT_IMAGE_TEXT, + DEFAULT_PER_PAGE, + FETCH_ARTIFACT_LIST_ERROR_MESSAGE, + TOKEN_TYPE_TAG_NAME, + TAG_LABEL, +} from '~/packages_and_registries/harbor_registry/constants/index'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { createAlert } from '~/flash'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; +import ArtifactsList from '~/packages_and_registries/harbor_registry/components/details/artifacts_list.vue'; +import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; +import DetailsHeader from '~/packages_and_registries/harbor_registry/components/details/details_header.vue'; +import { + extractSortingDetail, + parseFilter, + formatPagination, +} from '~/packages_and_registries/harbor_registry/utils'; +import { getHarborArtifacts } from '~/rest_api'; + +export default { + name: 'HarborDetailsPage', + components: { + ArtifactsList, + TagsLoader, + DetailsHeader, + PersistedSearch, + }, + inject: ['endpoint', 'breadCrumbState'], + searchConfig: { nameSortFields: [NAME_SORT_FIELD] }, + tokens: [ + { + type: TOKEN_TYPE_TAG_NAME, + icon: 'tag', + title: TAG_LABEL, + unique: true, + token: GlFilteredSearchToken, + operators: OPERATOR_IS_ONLY, + }, + ], + data() { + return { + artifactsList: [], + pageInfo: {}, + mutationLoading: false, + deleteAlertType: null, + isLoading: true, + filterString: '', + sorting: null, + }; + }, + computed: { + currentPage() { + return this.pageInfo.page || 1; + }, + imagesDetail() { + return { + name: this.fullName, + artifactCount: this.pageInfo?.total || 0, + }; + }, + fullName() { + const { project, image } = this.$route.params; + + if (project && image) { + return `${project}/${image}`; + } + return ''; + }, + }, + mounted() { + this.updateBreadcrumb(); + }, + methods: { + updateBreadcrumb() { + const name = this.fullName || ROOT_IMAGE_TEXT; + this.breadCrumbState.updateName(name); + this.breadCrumbState.updateHref(this.$route.path); + }, + handleSearchUpdate({ sort, filters }) { + this.sorting = sort; + this.filterString = parseFilter(filters, 'digest'); + + this.fetchArtifacts(1); + }, + fetchPrevPage() { + const prevPageNum = this.currentPage - 1; + this.fetchArtifacts(prevPageNum); + }, + fetchNextPage() { + const nextPageNum = this.currentPage + 1; + this.fetchArtifacts(nextPageNum); + }, + fetchArtifacts(requestPage) { + this.isLoading = true; + + const { orderBy, sort } = extractSortingDetail(this.sorting); + const sortOptions = `${orderBy} ${sort}`; + + const { image } = this.$route.params; + + const params = { + requestPath: this.endpoint, + repoName: image, + limit: DEFAULT_PER_PAGE, + page: requestPage, + sort: sortOptions, + search: this.filterString, + }; + + getHarborArtifacts(params) + .then((res) => { + this.pageInfo = formatPagination(res.headers); + + this.artifactsList = (res?.data || []).map((artifact) => { + return convertObjectPropsToCamelCase(artifact); + }); + }) + .catch(() => { + createAlert({ message: FETCH_ARTIFACT_LIST_ERROR_MESSAGE }); + }) + .finally(() => { + this.isLoading = false; + }); + }, + }, +}; +</script> + +<template> + <div class="gl-my-3"> + <details-header :images-detail="imagesDetail" /> + <persisted-search + class="gl-mb-5" + :sortable-fields="$options.searchConfig.nameSortFields" + :default-order="$options.searchConfig.nameSortFields[0].orderBy" + default-sort="asc" + :tokens="$options.tokens" + @update="handleSearchUpdate" + /> + <tags-loader v-if="isLoading" /> + <artifacts-list + v-else + :filter="filterString" + :is-loading="isLoading" + :artifacts="artifactsList" + :page-info="pageInfo" + @prev-page="fetchPrevPage" + @next-page="fetchNextPage" + /> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue new file mode 100644 index 00000000000..1323d347d10 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue @@ -0,0 +1,103 @@ +<script> +import TagsHeader from '~/packages_and_registries/harbor_registry/components/tags/tags_header.vue'; +import TagsList from '~/packages_and_registries/harbor_registry/components/tags/tags_list.vue'; +import { getHarborTags } from '~/rest_api'; +import { FETCH_TAGS_ERROR_MESSAGE } from '~/packages_and_registries/harbor_registry/constants'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { createAlert } from '~/flash'; +import { formatPagination } from '~/packages_and_registries/harbor_registry/utils'; + +export default { + name: 'HarborTagsPage', + components: { + TagsHeader, + TagsList, + }, + inject: ['endpoint', 'breadCrumbState'], + data() { + return { + tagsLoading: false, + pageInfo: {}, + tags: [], + }; + }, + computed: { + currentPage() { + return this.pageInfo?.page || 1; + }, + artifactDetail() { + const { project, image, digest } = this.$route.params; + + return { + project, + image, + digest, + }; + }, + }, + mounted() { + this.updateBreadcrumb(); + this.fetchTagsData(); + }, + methods: { + updateBreadcrumb() { + const artifactPath = `${this.artifactDetail.project}/${this.artifactDetail.image}`; + const nameList = [artifactPath, this.artifactDetail.digest]; + const hrefList = [`/${artifactPath}`, this.$route.path]; + + this.breadCrumbState.updateName(nameList); + this.breadCrumbState.updateHref(hrefList); + }, + fetchPrevPage() { + const prevPageNum = this.currentPage - 1; + this.fetchTagsData(prevPageNum); + }, + fetchNextPage() { + const nextPageNum = this.currentPage + 1; + this.fetchTagsData(nextPageNum); + }, + fetchTagsData(requestPage) { + this.tagsLoading = true; + + const params = { + page: requestPage, + requestPath: this.endpoint, + repoName: this.artifactDetail.image, + digest: this.artifactDetail.digest, + }; + + getHarborTags(params) + .then((res) => { + this.pageInfo = formatPagination(res.headers); + + this.tags = (res?.data || []).map((tagInfo) => { + return convertObjectPropsToCamelCase(tagInfo); + }); + }) + .catch(() => { + createAlert({ message: FETCH_TAGS_ERROR_MESSAGE }); + }) + .finally(() => { + this.tagsLoading = false; + }); + }, + }, +}; +</script> + +<template> + <div> + <tags-header + :artifact-detail="artifactDetail" + :page-info="pageInfo" + :tags-loading="tagsLoading" + /> + <tags-list + :tags="tags" + :is-loading="tagsLoading" + :page-info="pageInfo" + @prev-page="fetchPrevPage" + @next-page="fetchNextPage" + /> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue index 9c69059c968..931a99649cb 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue @@ -1,19 +1,32 @@ <script> import { GlEmptyState, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui'; -import { escape } from 'lodash'; import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { + extractSortingDetail, + formatPagination, + parseFilter, + dockerBuildCommand, + dockerPushCommand, + dockerLoginCommand, +} from '~/packages_and_registries/harbor_registry/utils'; +import { createAlert } from '~/flash'; import { SORT_FIELDS, CONNECTION_ERROR_TITLE, CONNECTION_ERROR_MESSAGE, EMPTY_RESULT_TITLE, EMPTY_RESULT_MESSAGE, + DEFAULT_PER_PAGE, + FETCH_IMAGES_LIST_ERROR_MESSAGE, + EMPTY_IMAGES_TITLE, + EMPTY_IMAGES_MESSAGE, + HARBOR_REGISTRY_HELP_PAGE_PATH, } from '~/packages_and_registries/harbor_registry/constants'; import Tracking from '~/tracking'; -import { harborListResponse } from '../mock_api'; +import { getHarborRepositoriesList } from '~/rest_api'; export default { name: 'HarborListPage', @@ -31,19 +44,28 @@ export default { ), }, mixins: [Tracking.mixin()], - inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'], + inject: [ + 'endpoint', + 'repositoryUrl', + 'harborIntegrationProjectName', + 'projectName', + 'isGroupPage', + 'connectionError', + 'invalidPathError', + 'containersErrorImage', + 'noContainersImage', + ], loader: { repeat: 10, width: 1000, height: 40, }, i18n: { - CONNECTION_ERROR_TITLE, - CONNECTION_ERROR_MESSAGE, - EMPTY_RESULT_TITLE, - EMPTY_RESULT_MESSAGE, + connectionErrorTitle: CONNECTION_ERROR_TITLE, + connectionErrorMessage: CONNECTION_ERROR_MESSAGE, }, searchConfig: SORT_FIELDS, + helpPagePath: HARBOR_REGISTRY_HELP_PAGE_PATH, data() { return { images: [], @@ -56,42 +78,81 @@ export default { }; }, computed: { + dockerCommand() { + return { + build: dockerBuildCommand({ + repositoryUrl: this.repositoryUrl, + harborProjectName: this.harborIntegrationProjectName, + projectName: this.projectName, + }), + push: dockerPushCommand({ + repositoryUrl: this.repositoryUrl, + harborProjectName: this.harborIntegrationProjectName, + projectName: this.projectName, + }), + login: dockerLoginCommand(this.repositoryUrl), + }; + }, showCommands() { - return !this.isLoading && !this.config?.isGroupPage && this.images?.length; + return !this.isLoading && !this.isGroupPage && this.images?.length; }, showConnectionError() { - return this.config.connectionError || this.config.invalidPathError; + return this.connectionError || this.invalidPathError; + }, + currentPage() { + return this.pageInfo.page || 1; + }, + emptyStateTexts() { + return { + title: this.name ? EMPTY_RESULT_TITLE : EMPTY_IMAGES_TITLE, + message: this.name ? EMPTY_RESULT_MESSAGE : EMPTY_IMAGES_MESSAGE, + }; }, }, methods: { - fetchHarborImages() { - // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777 + fetchHarborImages(requestPage) { this.isLoading = true; - harborListResponse() + const { orderBy, sort } = extractSortingDetail(this.sorting); + const sortOptions = `${orderBy} ${sort}`; + + const params = { + requestPath: this.endpoint, + limit: DEFAULT_PER_PAGE, + search: this.name, + page: requestPage, + sort: sortOptions, + }; + + getHarborRepositoriesList(params) .then((res) => { - this.images = res?.repositories || []; - this.totalCount = res?.totalCount || 0; - this.pageInfo = res?.pageInfo || {}; + this.images = (res?.data || []).map((item) => { + return convertObjectPropsToCamelCase(item); + }); + const pagination = formatPagination(res.headers); + + this.totalCount = pagination?.total || 0; + this.pageInfo = pagination; + this.isLoading = false; }) - .catch(() => {}); + .catch(() => { + createAlert({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); + }); }, handleSearchUpdate({ sort, filters }) { this.sorting = sort; + this.name = parseFilter(filters, 'name'); - const search = filters.find((i) => i.type === FILTERED_SEARCH_TERM); - this.name = escape(search?.value?.data); - - this.fetchHarborImages(); + this.fetchHarborImages(1); }, fetchPrevPage() { - // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777 - this.fetchHarborImages(); + const prevPageNum = this.currentPage - 1; + this.fetchHarborImages(prevPageNum); }, fetchNextPage() { - // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777 - this.fetchHarborImages(); + const nextPageNum = this.currentPage + 1; + this.fetchHarborImages(nextPageNum); }, }, }; @@ -101,14 +162,14 @@ export default { <div> <gl-empty-state v-if="showConnectionError" - :title="$options.i18n.CONNECTION_ERROR_TITLE" - :svg-path="config.containersErrorImage" + :title="$options.i18n.connectionErrorTitle" + :svg-path="containersErrorImage" > <template #description> <p> - <gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE"> + <gl-sprintf :message="$options.i18n.connectionErrorMessage"> <template #docLink="{ content }"> - <gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank"> + <gl-link :href="$options.helpPagePath" target="_blank"> {{ content }} </gl-link> </template> @@ -117,17 +178,13 @@ export default { </template> </gl-empty-state> <template v-else> - <harbor-list-header - :metadata-loading="isLoading" - :images-count="totalCount" - :help-page-path="config.helpPagePath" - > + <harbor-list-header :metadata-loading="isLoading" :images-count="totalCount"> <template #commands> <cli-commands v-if="showCommands" - :docker-build-command="dockerBuildCommand" - :docker-push-command="dockerPushCommand" - :docker-login-command="dockerLoginCommand" + :docker-build-command="dockerCommand.build" + :docker-push-command="dockerCommand.push" + :docker-login-command="dockerCommand.login" /> </template> </harbor-list-header> @@ -152,26 +209,24 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <template v-if="images.length > 0 || name"> - <harbor-list - v-if="images.length" - :images="images" - :meta-data-loading="isLoading" - :page-info="pageInfo" - @prev-page="fetchPrevPage" - @next-page="fetchNextPage" - /> - <gl-empty-state - v-else - :svg-path="config.noContainersImage" - data-testid="emptySearch" - :title="$options.i18n.EMPTY_RESULT_TITLE" - > - <template #description> - {{ $options.i18n.EMPTY_RESULT_MESSAGE }} - </template> - </gl-empty-state> - </template> + <harbor-list + v-if="images.length" + :images="images" + :metadata-loading="isLoading" + :page-info="pageInfo" + @prev-page="fetchPrevPage" + @next-page="fetchNextPage" + /> + <gl-empty-state + v-else + :svg-path="noContainersImage" + data-testid="emptySearch" + :title="emptyStateTexts.title" + > + <template #description> + {{ emptyStateTexts.message }} + </template> + </gl-empty-state> </template> </template> </div> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/router.js b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js index 572dd382be3..5a792e30c62 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/router.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js @@ -3,6 +3,7 @@ import VueRouter from 'vue-router'; import { HARBOR_REGISTRY_TITLE } from './constants/index'; import List from './pages/list.vue'; import Details from './pages/details.vue'; +import HarborTags from './pages/harbor_tags.vue'; Vue.use(VueRouter); @@ -22,10 +23,20 @@ export default function createRouter(base, breadCrumbState) { }, { name: 'details', - path: '/:id', + path: '/:project/:image', component: Details, meta: { nameGenerator: () => breadCrumbState.name, + hrefGenerator: () => breadCrumbState.href, + }, + }, + { + name: 'tags', + path: '/:project/:image/:digest', + component: HarborTags, + meta: { + nameGenerator: () => breadCrumbState.name, + hrefGenerator: () => breadCrumbState.href, }, }, ], diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js new file mode 100644 index 00000000000..13df303cffe --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js @@ -0,0 +1,84 @@ +import { isFinite } from 'lodash'; +import { + SORT_FIELD_MAPPING, + TOKEN_TYPE_TAG_NAME, +} from '~/packages_and_registries/harbor_registry/constants'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; + +export const extractSortingDetail = (parsedSorting = '') => { + const [orderBy, sortOrder] = parsedSorting.split('_'); + if (orderBy && sortOrder) { + return { + orderBy: SORT_FIELD_MAPPING[orderBy], + sort: sortOrder.toLowerCase(), + }; + } + + return { + orderBy: '', + sort: '', + }; +}; + +export const parseFilter = (filters = [], defaultPrefix = '') => { + /* eslint-disable @gitlab/require-i18n-strings */ + const prefixMap = { + [FILTERED_SEARCH_TERM]: `${defaultPrefix}=`, + [TOKEN_TYPE_TAG_NAME]: 'tags=', + }; + /* eslint-enable @gitlab/require-i18n-strings */ + const filterList = []; + filters.forEach((i) => { + if (i.value?.data) { + const filterVal = i.value?.data; + const prefix = prefixMap[i.type]; + const filterString = `${prefix}${filterVal}`; + + filterList.push(filterString); + } + }); + + return filterList.join(','); +}; + +export const getNameFromParams = (fullName) => { + const names = fullName.split('/'); + return { + projectName: names[0] || '', + imageName: names[1] || '', + }; +}; + +export const formatPagination = (headers) => { + const pagination = parseIntPagination(normalizeHeaders(headers)) || {}; + + if (pagination.nextPage || pagination.previousPage) { + pagination.hasNextPage = isFinite(pagination.nextPage); + pagination.hasPreviousPage = isFinite(pagination.previousPage); + } + + return pagination; +}; + +/* eslint-disable @gitlab/require-i18n-strings */ +export const dockerBuildCommand = ({ repositoryUrl, harborProjectName, projectName = '' }) => { + return `docker build -t ${repositoryUrl}/${harborProjectName}/${projectName} .`; +}; + +export const dockerPushCommand = ({ repositoryUrl, harborProjectName, projectName = '' }) => { + return `docker push ${repositoryUrl}/${harborProjectName}/${projectName}`; +}; + +export const dockerLoginCommand = (repositoryUrl) => { + return `docker login ${repositoryUrl}`; +}; + +export const artifactPullCommand = ({ repositoryUrl, harborProjectName, imageName, digest }) => { + return `docker pull ${repositoryUrl}/${harborProjectName}/${imageName}@${digest}`; +}; + +export const tagPullCommand = ({ repositoryUrl, harborProjectName, imageName, tag }) => { + return `docker pull ${repositoryUrl}/${harborProjectName}/${imageName}:${tag}`; +}; +/* eslint-enable @gitlab/require-i18n-strings */ diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue index 425fb4596fd..fd099ee4e69 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue @@ -114,7 +114,7 @@ export default { deleteModalContent: s__( `PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`, ), - deleteFileModalTitle: s__(`PackageRegistry|Delete Package File`), + deleteFileModalTitle: s__(`PackageRegistry|Delete package asset`), deleteFileModalContent: s__( `PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`, ), diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue index 28bfb82093c..e45b88bc6d5 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue @@ -84,14 +84,14 @@ export default { }, }, i18n: { - deleteFile: __('Delete file'), + deleteFile: __('Delete asset'), }, }; </script> <template> <div> - <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3> + <h3 class="gl-font-lg gl-mt-5">{{ __('Assets') }}</h3> <gl-table :fields="filesTableHeaderFields" :items="filesTableRows" diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue index a465fea0b74..dab4a051d0c 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue @@ -98,7 +98,7 @@ export default { </div> <template v-else> - <div data-qa-selector="packages-table"> + <div data-testid="packages-table"> <packages-list-row v-for="packageEntity in list" :key="packageEntity.id" diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue index 3c6b8344c34..cc52235eaf3 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue @@ -78,7 +78,7 @@ export default { </script> <template> - <list-item data-qa-selector="package_row" :disabled="disabledRow"> + <list-item data-testid="package-row" :disabled="disabledRow"> <template #left-primary> <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"> <gl-link diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue index 122d444e859..f581469b12b 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue @@ -14,16 +14,17 @@ import NpmInstallation from './npm_installation.vue'; import NugetInstallation from './nuget_installation.vue'; import PypiInstallation from './pypi_installation.vue'; +const components = { + [PACKAGE_TYPE_CONAN]: ConanInstallation, + [PACKAGE_TYPE_MAVEN]: MavenInstallation, + [PACKAGE_TYPE_NPM]: NpmInstallation, + [PACKAGE_TYPE_NUGET]: NugetInstallation, + [PACKAGE_TYPE_PYPI]: PypiInstallation, + [PACKAGE_TYPE_COMPOSER]: ComposerInstallation, +}; + export default { name: 'InstallationCommands', - components: { - [PACKAGE_TYPE_CONAN]: ConanInstallation, - [PACKAGE_TYPE_MAVEN]: MavenInstallation, - [PACKAGE_TYPE_NPM]: NpmInstallation, - [PACKAGE_TYPE_NUGET]: NugetInstallation, - [PACKAGE_TYPE_PYPI]: PypiInstallation, - [PACKAGE_TYPE_COMPOSER]: ComposerInstallation, - }, props: { packageEntity: { type: Object, @@ -32,7 +33,7 @@ export default { }, computed: { installationComponent() { - return this.$options.components[this.packageEntity.packageType]; + return components[this.packageEntity.packageType]; }, }, }; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue index b872294d2cf..8eb8654cddd 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue @@ -139,7 +139,7 @@ export default { }, }, i18n: { - deleteFile: __('Delete file'), + deleteFile: __('Delete asset'), deleteSelected: s__('PackageRegistry|Delete selected'), moreActionsText: __('More actions'), }, @@ -149,7 +149,7 @@ export default { <template> <div class="gl-pt-6"> <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> - <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3> + <h3 class="gl-font-lg gl-mt-5">{{ __('Assets') }}</h3> <gl-button v-if="canDelete" :disabled="isLoading || !areFilesSelected" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue index 04faff1a75b..7a000aca0f2 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -90,7 +90,7 @@ export default { </script> <template> - <list-item data-qa-selector="package_row"> + <list-item data-testid="package-row"> <template #left-primary> <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"> <router-link diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue index a6ac2eb1b2b..e84f181e9b2 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue @@ -151,7 +151,7 @@ export default { @primaryAction="showConfirmationModal" >{{ $options.i18n.errorMessageBodyAlert }}</gl-alert > - <div data-qa-selector="packages-table"> + <div data-testid="packages-table"> <packages-list-row v-for="packageEntity in list" :key="packageEntity.id" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js index 5b2a347a4ee..06a04ee248a 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js +++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js @@ -79,10 +79,10 @@ export const TRACKING_LABEL_PACKAGE_HISTORY = 'package_history'; export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert'; export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( - 'PackageRegistry|Something went wrong while deleting the package file.', + 'PackageRegistry|Something went wrong while deleting the package asset.', ); export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__( - 'PackageRegistry|Package file deleted successfully', + 'PackageRegistry|Package asset deleted successfully', ); export const DELETE_PACKAGE_FILES_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting the package assets.', diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue index e83962bb608..c10fc914d56 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue @@ -256,7 +256,7 @@ export default { deleteModalContent: s__( `PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`, ), - deleteFileModalTitle: s__(`PackageRegistry|Delete Package File`), + deleteFileModalTitle: s__(`PackageRegistry|Delete package asset`), deleteFileModalContent: s__( `PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`, ), diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue deleted file mode 100644 index 51a97aead49..00000000000 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue +++ /dev/null @@ -1,103 +0,0 @@ -<script> -import { GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui'; -import { isEqual } from 'lodash'; - -import { - DUPLICATES_TOGGLE_LABEL, - DUPLICATES_SETTING_EXCEPTION_TITLE, - DUPLICATES_SETTINGS_EXCEPTION_LEGEND, -} from '~/packages_and_registries/settings/group/constants'; - -export default { - name: 'DuplicatesSettings', - i18n: { - DUPLICATES_TOGGLE_LABEL, - DUPLICATES_SETTING_EXCEPTION_TITLE, - DUPLICATES_SETTINGS_EXCEPTION_LEGEND, - }, - components: { - GlToggle, - GlFormGroup, - GlFormInput, - }, - props: { - loading: { - type: Boolean, - required: false, - default: false, - }, - duplicatesAllowed: { - type: Boolean, - default: false, - required: false, - }, - duplicateExceptionRegex: { - type: String, - default: '', - required: false, - }, - duplicateExceptionRegexError: { - type: String, - default: '', - required: false, - }, - modelNames: { - type: Object, - required: true, - validator(value) { - return isEqual(Object.keys(value), ['allowed', 'exception']); - }, - }, - toggleQaSelector: { - type: String, - required: false, - default: null, - }, - labelQaSelector: { - type: String, - required: false, - default: null, - }, - }, - computed: { - isExceptionRegexValid() { - return !this.duplicateExceptionRegexError; - }, - }, - methods: { - update(type, value) { - this.$emit('update', { [type]: value }); - }, - }, -}; -</script> - -<template> - <form> - <gl-toggle - :data-qa-selector="toggleQaSelector" - :label="$options.i18n.DUPLICATES_TOGGLE_LABEL" - :value="!duplicatesAllowed" - :disabled="loading" - @change="update(modelNames.allowed, !$event)" - /> - <gl-form-group - v-if="!duplicatesAllowed" - class="gl-mt-4" - :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE" - label-size="sm" - :state="isExceptionRegexValid" - :invalid-feedback="duplicateExceptionRegexError" - :description="$options.i18n.DUPLICATES_SETTINGS_EXCEPTION_LEGEND" - label-for="maven-duplicated-settings-regex-input" - > - <gl-form-input - id="maven-duplicated-settings-regex-input" - :disabled="loading" - size="lg" - :value="duplicateExceptionRegex" - @change="update(modelNames.exception, $event)" - /> - </gl-form-group> - </form> -</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue new file mode 100644 index 00000000000..9ac1673dbf3 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue @@ -0,0 +1,79 @@ +<script> +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; + +import { + DUPLICATES_SETTING_EXCEPTION_TITLE, + DUPLICATES_SETTINGS_EXCEPTION_LEGEND, +} from '~/packages_and_registries/settings/group/constants'; + +export default { + name: 'ExceptionsInput', + i18n: { + DUPLICATES_SETTING_EXCEPTION_TITLE, + DUPLICATES_SETTINGS_EXCEPTION_LEGEND, + }, + components: { + GlFormGroup, + GlFormInput, + }, + props: { + loading: { + type: Boolean, + required: false, + default: false, + }, + duplicatesAllowed: { + type: Boolean, + default: false, + required: false, + }, + duplicateExceptionRegex: { + type: String, + default: '', + required: false, + }, + duplicateExceptionRegexError: { + type: String, + default: '', + required: false, + }, + id: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + }, + computed: { + isExceptionRegexValid() { + return !this.duplicateExceptionRegexError; + }, + }, + methods: { + update(type, value) { + this.$emit('update', { [type]: value }); + }, + }, +}; +</script> + +<template> + <gl-form-group + class="gl-mb-0" + :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE" + label-sr-only + :invalid-feedback="duplicateExceptionRegexError" + :label-for="id" + > + <gl-form-input + :id="id" + :disabled="duplicatesAllowed || loading" + size="lg" + :value="duplicateExceptionRegex" + :state="isExceptionRegexValid" + @change="update(name, $event)" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue deleted file mode 100644 index e5f63fe8d0d..00000000000 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue +++ /dev/null @@ -1,26 +0,0 @@ -<script> -import { s__ } from '~/locale'; -import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; - -export default { - name: 'GenericSettings', - components: { - SettingsTitles, - }, - i18n: { - title: s__('PackageRegistry|Generic'), - subTitle: s__('PackageRegistry|Settings for Generic packages'), - }, - modelNames: { - allowed: 'genericDuplicatesAllowed', - exception: 'genericDuplicateExceptionRegex', - }, -}; -</script> - -<template> - <div> - <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" /> - <slot :model-names="$options.modelNames"></slot> - </div> -</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue deleted file mode 100644 index a1cbd695f34..00000000000 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue +++ /dev/null @@ -1,26 +0,0 @@ -<script> -import { s__ } from '~/locale'; -import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue'; - -export default { - name: 'MavenSettings', - components: { - SettingsTitles, - }, - i18n: { - title: s__('PackageRegistry|Maven'), - subTitle: s__('PackageRegistry|Settings for Maven packages'), - }, - modelNames: { - allowed: 'mavenDuplicatesAllowed', - exception: 'mavenDuplicateExceptionRegex', - }, -}; -</script> - -<template> - <div> - <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" /> - <slot :model-names="$options.modelNames"></slot> - </div> -</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue index abb9f02d290..de087a8fcc5 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue @@ -1,27 +1,50 @@ <script> -import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue'; -import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue'; -import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue'; +import { GlTableLite, GlToggle } from '@gitlab/ui'; import { + GENERIC_PACKAGE_FORMAT, + MAVEN_PACKAGE_FORMAT, + PACKAGE_FORMATS_TABLE_HEADER, PACKAGE_SETTINGS_HEADER, PACKAGE_SETTINGS_DESCRIPTION, + DUPLICATES_SETTING_EXCEPTION_TITLE, + DUPLICATES_TOGGLE_LABEL, } from '~/packages_and_registries/settings/group/constants'; import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql'; import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update'; import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses'; import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; +import ExceptionsInput from '~/packages_and_registries/settings/group/components/exceptions_input.vue'; export default { name: 'PackageSettings', i18n: { PACKAGE_SETTINGS_HEADER, PACKAGE_SETTINGS_DESCRIPTION, + DUPLICATES_SETTING_EXCEPTION_TITLE, + DUPLICATES_TOGGLE_LABEL, }, + tableHeaderFields: [ + { + key: 'packageFormat', + label: PACKAGE_FORMATS_TABLE_HEADER, + thClass: 'gl-bg-gray-10!', + }, + { + key: 'allowDuplicates', + label: DUPLICATES_TOGGLE_LABEL, + thClass: 'gl-bg-gray-10!', + }, + { + key: 'exceptions', + label: DUPLICATES_SETTING_EXCEPTION_TITLE, + thClass: 'gl-bg-gray-10!', + }, + ], components: { SettingsBlock, - MavenSettings, - GenericSettings, - DuplicatesSettings, + GlTableLite, + GlToggle, + ExceptionsInput, }, inject: ['groupPath'], props: { @@ -40,6 +63,37 @@ export default { errors: {}, }; }, + computed: { + tableRows() { + return [ + { + id: 'maven-duplicated-settings-regex-input', + format: MAVEN_PACKAGE_FORMAT, + duplicatesAllowed: this.packageSettings.mavenDuplicatesAllowed, + duplicateExceptionRegex: this.packageSettings.mavenDuplicateExceptionRegex, + duplicateExceptionRegexError: this.errors.mavenDuplicateExceptionRegex, + modelNames: { + allowed: 'mavenDuplicatesAllowed', + exception: 'mavenDuplicateExceptionRegex', + }, + testid: 'maven-settings', + dataQaSelector: 'allow_duplicates_toggle', + }, + { + id: 'generic-duplicated-settings-regex-input', + format: GENERIC_PACKAGE_FORMAT, + duplicatesAllowed: this.packageSettings.genericDuplicatesAllowed, + duplicateExceptionRegex: this.packageSettings.genericDuplicateExceptionRegex, + duplicateExceptionRegexError: this.errors.genericDuplicateExceptionRegex, + modelNames: { + allowed: 'genericDuplicatesAllowed', + exception: 'genericDuplicateExceptionRegex', + }, + testid: 'generic-settings', + }, + ]; + }, + }, methods: { async updateSettings(payload) { this.errors = {}; @@ -79,6 +133,9 @@ export default { this.$emit('error'); } }, + update(type, value) { + this.updateSettings({ [type]: value }); + }, }, }; </script> @@ -92,32 +149,40 @@ export default { </span> </template> <template #default> - <maven-settings data-testid="maven-settings"> - <template #default="{ modelNames }"> - <duplicates-settings - :duplicates-allowed="packageSettings.mavenDuplicatesAllowed" - :duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex" - :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex" - :model-names="modelNames" - :loading="isLoading" - toggle-qa-selector="reject_duplicates_toggle" - label-qa-selector="reject_duplicates_label" - @update="updateSettings" - /> - </template> - </maven-settings> - <generic-settings class="gl-mt-6" data-testid="generic-settings"> - <template #default="{ modelNames }"> - <duplicates-settings - :duplicates-allowed="packageSettings.genericDuplicatesAllowed" - :duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex" - :duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex" - :model-names="modelNames" - :loading="isLoading" - @update="updateSettings" - /> - </template> - </generic-settings> + <form> + <gl-table-lite + :fields="$options.tableHeaderFields" + :items="tableRows" + stacked="sm" + :tbody-tr-attr="(item) => ({ 'data-testid': item.testid })" + > + <template #cell(packageFormat)="{ item }"> + <span class="gl-md-pt-3">{{ item.format }}</span> + </template> + <template #cell(allowDuplicates)="{ item }"> + <gl-toggle + :data-qa-selector="item.dataQaSelector" + :label="$options.i18n.DUPLICATES_TOGGLE_LABEL" + :value="item.duplicatesAllowed" + :disabled="isLoading" + label-position="hidden" + class="gl-align-items-flex-end gl-sm-align-items-flex-start" + @change="update(item.modelNames.allowed, $event)" + /> + </template> + <template #cell(exceptions)="{ item }"> + <exceptions-input + :id="item.id" + :duplicates-allowed="item.duplicatesAllowed" + :duplicate-exception-regex="item.duplicateExceptionRegex" + :duplicate-exception-regex-error="item.duplicateExceptionRegexError" + :name="item.modelNames.exception" + :loading="isLoading" + @update="updateSettings" + /> + </template> + </gl-table-lite> + </form> </template> </settings-block> </template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue deleted file mode 100644 index 1e93875c1e3..00000000000 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue +++ /dev/null @@ -1,26 +0,0 @@ -<script> -export default { - name: 'SettingsTitle', - props: { - title: { - type: String, - required: true, - }, - subTitle: { - type: String, - required: false, - default: '', - }, - }, -}; -</script> - -<template> - <div> - <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200 gl-pb-3"> - {{ title }} - </h5> - <p v-if="subTitle">{{ subTitle }}</p> - <slot></slot> - </div> -</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js index 34764663892..2dd6d3f76f6 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -5,10 +5,11 @@ export const PACKAGE_SETTINGS_HEADER = s__('PackageRegistry|Duplicate packages') export const PACKAGE_SETTINGS_DESCRIPTION = s__( 'PackageRegistry|Allow packages with the same name and version to be uploaded to the registry. The newest version of a package is always used when installing.', ); +export const PACKAGE_FORMATS_TABLE_HEADER = s__('PackageRegistry|Package formats'); +export const MAVEN_PACKAGE_FORMAT = s__('PackageRegistry|Maven'); +export const GENERIC_PACKAGE_FORMAT = s__('PackageRegistry|Generic'); -export const DUPLICATES_TOGGLE_LABEL = s__( - 'PackageRegistry|Reject packages with the same name and version', -); +export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates'); export const DUPLICATES_SETTING_EXCEPTION_TITLE = __('Exceptions'); export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__( 'PackageRegistry|Publish packages if their name or version matches this regex.', diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue new file mode 100644 index 00000000000..72e68aca070 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue @@ -0,0 +1,112 @@ +<script> +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { isEqual, get, isEmpty } from 'lodash'; +import { + CONTAINER_CLEANUP_POLICY_TITLE, + CONTAINER_CLEANUP_POLICY_DESCRIPTION, + FETCH_SETTINGS_ERROR_MESSAGE, + UNAVAILABLE_FEATURE_TITLE, + UNAVAILABLE_FEATURE_INTRO_TEXT, + UNAVAILABLE_USER_FEATURE_TEXT, + UNAVAILABLE_ADMIN_FEATURE_TEXT, +} from '~/packages_and_registries/settings/project/constants'; +import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; + +import ContainerExpirationPolicyForm from './container_expiration_policy_form.vue'; + +export default { + components: { + GlAlert, + GlSprintf, + GlLink, + ContainerExpirationPolicyForm, + }, + inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries', 'helpPagePath'], + i18n: { + CONTAINER_CLEANUP_POLICY_TITLE, + CONTAINER_CLEANUP_POLICY_DESCRIPTION, + UNAVAILABLE_FEATURE_TITLE, + UNAVAILABLE_FEATURE_INTRO_TEXT, + FETCH_SETTINGS_ERROR_MESSAGE, + }, + apollo: { + containerExpirationPolicy: { + query: expirationPolicyQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update: (data) => data.project?.containerExpirationPolicy, + result({ data }) { + this.workingCopy = { ...get(data, 'project.containerExpirationPolicy', {}) }; + }, + error(e) { + this.fetchSettingsError = e; + }, + }, + }, + data() { + return { + fetchSettingsError: false, + containerExpirationPolicy: null, + workingCopy: {}, + }; + }, + computed: { + isEnabled() { + return this.containerExpirationPolicy || this.enableHistoricEntries; + }, + showDisabledFormMessage() { + return !this.isEnabled && !this.fetchSettingsError; + }, + unavailableFeatureMessage() { + return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT; + }, + isEdited() { + if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) { + return false; + } + return !isEqual(this.containerExpirationPolicy, this.workingCopy); + }, + }, +}; +</script> + +<template> + <div data-testid="container-expiration-policy-project-settings"> + <h4 data-testid="title">{{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}</h4> + <p data-testid="description"> + <gl-sprintf :message="$options.i18n.CONTAINER_CLEANUP_POLICY_DESCRIPTION"> + <template #link="{ content }"> + <gl-link :href="helpPagePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <container-expiration-policy-form + v-if="isEnabled" + v-model="workingCopy" + :is-loading="$apollo.queries.containerExpirationPolicy.loading" + :is-edited="isEdited" + /> + <template v-else> + <gl-alert + v-if="showDisabledFormMessage" + :dismissible="false" + :title="$options.i18n.UNAVAILABLE_FEATURE_TITLE" + variant="tip" + > + {{ $options.i18n.UNAVAILABLE_FEATURE_INTRO_TEXT }} + + <gl-sprintf :message="unavailableFeatureMessage"> + <template #link="{ content }"> + <gl-link :href="adminSettingsPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + <gl-alert v-else-if="fetchSettingsError" variant="warning" :dismissible="false"> + <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" /> + </gl-alert> + </template> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue index 1c44d2bc38b..b003b6aeb6b 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue @@ -1,9 +1,11 @@ <script> -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { isEqual, get, isEmpty } from 'lodash'; +import { GlAlert, GlSprintf, GlLink, GlCard, GlButton } from '@gitlab/ui'; import { CONTAINER_CLEANUP_POLICY_TITLE, CONTAINER_CLEANUP_POLICY_DESCRIPTION, + CONTAINER_CLEANUP_POLICY_EDIT_RULES, + CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION, + CONTAINER_CLEANUP_POLICY_SET_RULES, FETCH_SETTINGS_ERROR_MESSAGE, UNAVAILABLE_FEATURE_TITLE, UNAVAILABLE_FEATURE_INTRO_TEXT, @@ -13,20 +15,29 @@ import { import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql'; import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue'; -import ContainerExpirationPolicyForm from './container_expiration_policy_form.vue'; - export default { components: { SettingsBlock, GlAlert, GlSprintf, GlLink, - ContainerExpirationPolicyForm, + GlCard, + GlButton, }, - inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries', 'helpPagePath'], + inject: [ + 'projectPath', + 'isAdmin', + 'adminSettingsPath', + 'enableHistoricEntries', + 'helpPagePath', + 'cleanupSettingsPath', + ], i18n: { CONTAINER_CLEANUP_POLICY_TITLE, CONTAINER_CLEANUP_POLICY_DESCRIPTION, + CONTAINER_CLEANUP_POLICY_EDIT_RULES, + CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION, + CONTAINER_CLEANUP_POLICY_SET_RULES, UNAVAILABLE_FEATURE_TITLE, UNAVAILABLE_FEATURE_INTRO_TEXT, FETCH_SETTINGS_ERROR_MESSAGE, @@ -40,9 +51,6 @@ export default { }; }, update: (data) => data.project?.containerExpirationPolicy, - result({ data }) { - this.workingCopy = { ...get(data, 'project.containerExpirationPolicy', {}) }; - }, error(e) { this.fetchSettingsError = e; }, @@ -52,29 +60,25 @@ export default { return { fetchSettingsError: false, containerExpirationPolicy: null, - workingCopy: {}, }; }, computed: { - isDisabled() { - return !(this.containerExpirationPolicy || this.enableHistoricEntries); + isCleanupEnabled() { + return this.containerExpirationPolicy?.enabled ?? false; + }, + isEnabled() { + return this.containerExpirationPolicy || this.enableHistoricEntries; }, showDisabledFormMessage() { - return this.isDisabled && !this.fetchSettingsError; + return !this.isEnabled && !this.fetchSettingsError; }, unavailableFeatureMessage() { return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT; }, - isEdited() { - if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) { - return false; - } - return !isEqual(this.containerExpirationPolicy, this.workingCopy); - }, - }, - methods: { - restoreOriginal() { - this.workingCopy = { ...this.containerExpirationPolicy }; + cleanupRulesButtonText() { + return this.isCleanupEnabled + ? this.$options.i18n.CONTAINER_CLEANUP_POLICY_EDIT_RULES + : this.$options.i18n.CONTAINER_CLEANUP_POLICY_SET_RULES; }, }, }; @@ -93,13 +97,19 @@ export default { </span> </template> <template #default> - <container-expiration-policy-form - v-if="!isDisabled" - v-model="workingCopy" - :is-loading="$apollo.queries.containerExpirationPolicy.loading" - :is-edited="isEdited" - @reset="restoreOriginal" - /> + <gl-card v-if="isEnabled"> + <p data-testid="description"> + {{ $options.i18n.CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION }} + </p> + <gl-button + data-testid="rules-button" + :href="cleanupSettingsPath" + category="secondary" + variant="confirm" + > + {{ cleanupRulesButtonText }} + </gl-button> + </gl-card> <template v-else> <gl-alert v-if="showDisabledFormMessage" diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue index ae2d5f4fbc5..11d8732426d 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue @@ -1,8 +1,9 @@ <script> import { GlCard, GlButton, GlSprintf } from '@gitlab/ui'; +import { objectToQuery, visitUrl } from '~/lib/utils/url_utility'; import { UPDATE_SETTINGS_ERROR_MESSAGE, - UPDATE_SETTINGS_SUCCESS_MESSAGE, + SHOW_SETUP_SUCCESS_ALERT, SET_CLEANUP_POLICY_BUTTON, KEEP_HEADER_TEXT, KEEP_INFO_TEXT, @@ -37,7 +38,7 @@ export default { ExpirationRunText, }, mixins: [Tracking.mixin()], - inject: ['projectPath'], + inject: ['projectPath', 'projectSettingsPath'], props: { value: { type: Object, @@ -95,10 +96,10 @@ export default { return Object.values(this.localErrors).every((error) => error); }, isSubmitButtonDisabled() { - return !this.fieldsAreValid || this.showLoadingIcon; + return !this.isEdited || !this.fieldsAreValid || this.showLoadingIcon; }, isCancelButtonDisabled() { - return !this.isEdited || this.isLoading || this.mutationLoading; + return this.isLoading || this.mutationLoading; }, isFieldDisabled() { return this.showLoadingIcon || !this.value.enabled; @@ -119,12 +120,6 @@ export default { findDefaultOption(option) { return this.value[option] || this.$options.formOptions[option].find((f) => f.default)?.key; }, - reset() { - this.track('reset_form'); - this.apiErrors = {}; - this.localErrors = {}; - this.$emit('reset'); - }, setApiErrors(response) { this.apiErrors = response.graphQLErrors.reduce((acc, curr) => { curr.extensions.problems.forEach((item) => { @@ -168,7 +163,7 @@ export default { const customError = this.encapsulateError('nameRegex', errorMessage); throw customError; } else { - this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE); + this.navigateToSettingsWithSuccessAlert(); } }) .catch((error) => { @@ -183,12 +178,17 @@ export default { this.$emit('input', { ...this.value, [model]: newValue }); this.apiErrors[model] = undefined; }, + navigateToSettingsWithSuccessAlert() { + const alertQuery = objectToQuery({ [SHOW_SETUP_SUCCESS_ALERT]: true }); + + visitUrl(`${this.projectSettingsPath}?${alertQuery}`); + }, }, }; </script> <template> - <form ref="form-element" @submit.prevent="submit" @reset.prevent="reset"> + <form @submit.prevent="submit"> <expiration-toggle :value="prefilledForm.enabled" :disabled="showLoadingIcon" @@ -199,7 +199,7 @@ export default { <div class="gl-display-flex gl-mt-7"> <expiration-dropdown - v-model="prefilledForm.cadence" + :value="prefilledForm.cadence" :disabled="isFieldDisabled" :form-options="$options.formOptions.cadence" :label="$options.i18n.CADENCE_LABEL" @@ -231,7 +231,7 @@ export default { </gl-sprintf> </p> <expiration-dropdown - v-model="prefilledForm.keepN" + :value="prefilledForm.keepN" :disabled="isFieldDisabled" :form-options="$options.formOptions.keepN" :label="$options.i18n.KEEP_N_LABEL" @@ -270,7 +270,7 @@ export default { </gl-sprintf> </p> <expiration-dropdown - v-model="prefilledForm.olderThan" + :value="prefilledForm.olderThan" :disabled="isFieldDisabled" :form-options="$options.formOptions.olderThan" :label="$options.i18n.EXPIRATION_SCHEDULE_LABEL" @@ -306,7 +306,7 @@ export default { </gl-button> <gl-button data-testid="cancel-button" - type="reset" + :href="projectSettingsPath" :disabled="isCancelButtonDisabled" class="gl-mr-4" > diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue index 710cfe7b1eb..2c1368262f2 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue @@ -1,18 +1,57 @@ <script> +import { GlAlert } from '@gitlab/ui'; +import { historyReplaceState } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; +import { + SHOW_SETUP_SUCCESS_ALERT, + UPDATE_SETTINGS_SUCCESS_MESSAGE, +} from '~/packages_and_registries/settings/project/constants'; import ContainerExpirationPolicy from './container_expiration_policy.vue'; import PackagesCleanupPolicy from './packages_cleanup_policy.vue'; export default { components: { ContainerExpirationPolicy, + GlAlert, PackagesCleanupPolicy, }, inject: ['showContainerRegistrySettings', 'showPackageRegistrySettings'], + i18n: { + UPDATE_SETTINGS_SUCCESS_MESSAGE, + }, + data() { + return { + showAlert: false, + }; + }, + mounted() { + this.checkAlert(); + }, + methods: { + checkAlert() { + const showAlert = getParameterByName(SHOW_SETUP_SUCCESS_ALERT); + + if (showAlert) { + this.showAlert = true; + const cleanUrl = window.location.href.split('?')[0]; + historyReplaceState(cleanUrl); + } + }, + }, }; </script> <template> <div> + <gl-alert + v-if="showAlert" + variant="success" + class="gl-mt-5" + dismissible + @dismiss="showAlert = false" + > + {{ $options.i18n.UPDATE_SETTINGS_SUCCESS_MESSAGE }} + </gl-alert> <packages-cleanup-policy v-if="showPackageRegistrySettings" /> <container-expiration-policy v-if="showContainerRegistrySettings" /> </div> diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js index fcb4a8ee297..a9b47cbd343 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -4,6 +4,13 @@ export const CONTAINER_CLEANUP_POLICY_TITLE = s__(`ContainerRegistry|Clean up im export const CONTAINER_CLEANUP_POLICY_DESCRIPTION = s__( `ContainerRegistry|Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}`, ); +export const CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION = s__( + 'ContainerRegistry|Set rules to automatically remove unused packages to save storage space.', +); +export const CONTAINER_CLEANUP_POLICY_EDIT_RULES = s__('ContainerRegistry|Edit cleanup rules'); +export const CONTAINER_CLEANUP_POLICY_SET_RULES = s__('ContainerRegistry|Set cleanup rules'); +export const SHOW_SETUP_SUCCESS_ALERT = 'showSetupSuccessAlert'; + export const SET_CLEANUP_POLICY_BUTTON = __('Save changes'); export const UNAVAILABLE_FEATURE_TITLE = s__( `ContainerRegistry|Cleanup policy for tags is disabled`, diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js index daf1da6eac8..57c8d07e620 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js @@ -18,6 +18,7 @@ export default () => { enableHistoricEntries, projectPath, adminSettingsPath, + cleanupSettingsPath, tagsRegexHelpPagePath, helpPagePath, showContainerRegistrySettings, @@ -34,6 +35,7 @@ export default () => { enableHistoricEntries: parseBoolean(enableHistoricEntries), projectPath, adminSettingsPath, + cleanupSettingsPath, tagsRegexHelpPagePath, helpPagePath, showContainerRegistrySettings: parseBoolean(showContainerRegistrySettings), diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js new file mode 100644 index 00000000000..b1401c448a1 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js @@ -0,0 +1,41 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import Translate from '~/vue_shared/translate'; +import CleanupImageTags from './components/cleanup_image_tags.vue'; +import { apolloProvider } from './graphql/index'; + +Vue.use(GlToast); +Vue.use(Translate); + +export default () => { + const el = document.getElementById('js-registry-settings-cleanup-image-tags'); + if (!el) { + return null; + } + const { + isAdmin, + enableHistoricEntries, + projectPath, + adminSettingsPath, + projectSettingsPath, + tagsRegexHelpPagePath, + helpPagePath, + } = el.dataset; + return new Vue({ + el, + apolloProvider, + provide: { + isAdmin: parseBoolean(isAdmin), + enableHistoricEntries: parseBoolean(enableHistoricEntries), + projectPath, + adminSettingsPath, + projectSettingsPath, + tagsRegexHelpPagePath, + helpPagePath, + }, + render(createElement) { + return createElement(CleanupImageTags, {}); + }, + }); +}; diff --git a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue index b2b1d2c8212..363304c20ce 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue @@ -18,6 +18,11 @@ export default { type: String, required: true, }, + tokens: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -68,7 +73,7 @@ export default { v-if="mountRegistrySearch" :filters="filters" :sorting="sorting" - :tokens="$options.tokens" + :tokens="tokens" :sortable-fields="sortableFields" @sorting:changed="updateSortingAndEmitUpdate" @filter:changed="updateFilters" diff --git a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue index 0458b914b58..7740924b058 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue @@ -1,7 +1,7 @@ <template> <section class="settings gl-py-7"> - <div class="gl-lg-display-flex gl-gap-6"> - <div class="gl-lg-w-40p gl-pr-10 gl-flex-shrink-0"> + <div class="row"> + <div class="col-lg-4"> <h4> <slot name="title"></slot> </h4> @@ -9,7 +9,7 @@ <slot name="description"></slot> </p> </div> - <div class="gl-pt-3 gl-flex-grow-1"> + <div class="col-lg-8 gl-pt-3"> <slot></slot> </div> </div> diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js index 6744e821565..7fd440d0b27 100644 --- a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js +++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js @@ -33,10 +33,10 @@ export const DELETE_PACKAGE_ERROR_MESSAGE = s__( 'PackageRegistry|Something went wrong while deleting the package.', ); export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__( - 'PackageRegistry|Something went wrong while deleting the package file.', + 'PackageRegistry|Something went wrong while deleting the package asset.', ); export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__( - 'PackageRegistry|Package file deleted successfully', + 'PackageRegistry|Package asset deleted successfully', ); export const PACKAGE_ERROR_STATUS = 'error'; |