From 363d75ed5296098779aa3700a4c4e0026b611165 Mon Sep 17 00:00:00 2001 From: Rahul Chanila <rchanila@gitlab.com> Date: Mon, 5 Feb 2024 11:12:50 +0000 Subject: [PATCH] Adds header component to google artifact registry list page Resolves apollo data using local resolvers Adds unit & features specs EE: true --- .../components/list/header.vue | 2 +- .../components/list/table.vue | 144 ++++++++++++++++ .../queries/get_artifacts.query.graphql | 13 +- .../graphql/resolvers.js | 22 ++- .../google_artifact_registry/pages/list.vue | 18 +- .../components/list/header_spec.js | 2 +- .../components/list/table_spec.js | 154 ++++++++++++++++++ .../google_artifact_registry/mock_data.js | 15 +- .../pages/list_spec.js | 34 +++- locale/gitlab.pot | 20 +++ 10 files changed, 413 insertions(+), 11 deletions(-) create mode 100644 ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/components/list/table.vue create mode 100644 ee/spec/frontend/packages_and_registries/google_artifact_registry/components/list/table_spec.js diff --git a/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/components/list/header.vue b/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/components/list/header.vue index 79e85738f9c80..27f4ffca1060b 100644 --- a/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/components/list/header.vue +++ b/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/components/list/header.vue @@ -81,7 +81,7 @@ export default { <metadata-item data-testid="project-id" icon="project" - :text="data.project" + :text="data.projectId" :text-tooltip="s__('GoogleArtifactRegistry|Project ID')" size="xl" /> diff --git a/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/components/list/table.vue b/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/components/list/table.vue new file mode 100644 index 0000000000000..5d3b4e1c357bd --- /dev/null +++ b/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/components/list/table.vue @@ -0,0 +1,144 @@ +<script> +import { GlBadge, GlTable, GlTruncate, GlTooltipDirective } from '@gitlab/ui'; +import { s__, n__, sprintf } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +export default { + name: 'ListTable', + components: { + ClipboardButton, + GlBadge, + GlTable, + GlTruncate, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + data: { + type: Object, + required: false, + default: () => ({}), + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + images() { + return this.data?.nodes ?? []; + }, + }, + methods: { + getShortDigest(digest) { + // remove sha256: from the string, and show only the first 12 char + return digest.substring(7, 19); + }, + getImageNameAndDigest(item) { + return `${item.image}@${item.digest}`; + }, + getImageNameAndShortDigest(item) { + return `${item.image}@${this.getShortDigest(item.digest)}`; + }, + getTagsToShow(item) { + const { tags = [] } = item; + return tags.slice(0, 2); + }, + getHiddenTagCountWithTooltip(item) { + const { tags = [] } = item; + const extraTags = tags.slice(2); + if (extraTags.length) { + return { + label: `+${extraTags.length}`, + tooltipText: sprintf( + n__( + 'GoogleArtifactRegistry|%d more tag', + 'GoogleArtifactRegistry|%d more tags', + extraTags.length, + ), + ), + }; + } + return extraTags.length; + }, + }, + filesTableHeaderFields: [ + { + key: 'image', + label: s__('GoogleArtifactRegistry|Name'), + thClass: 'gl-w-40p', + tdClass: 'gl-pt-3!', + }, + { + key: 'tags', + label: s__('GoogleArtifactRegistry|Tags'), + tdClass: 'gl-pt-4!', + }, + { + key: 'buildTime', + label: s__('GoogleArtifactRegistry|Created'), + }, + { + key: 'updateTime', + label: s__('GoogleArtifactRegistry|Updated'), + }, + ], +}; +</script> + +<template> + <gl-table + :busy="isLoading" + :fields="$options.filesTableHeaderFields" + :items="images" + show-empty + stacked="md" + table-class="gl-table-layout-fixed" + > + <template #cell(image)="{ item }"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-end gl-md-justify-content-start" + > + <gl-truncate + class="gl-font-weight-bold" + position="middle" + :text="getImageNameAndShortDigest(item)" + :with-tooltip="true" + /> + <clipboard-button + :title="s__('GoogleArtifactRegistry|Copy image name')" + :text="getImageNameAndDigest(item)" + category="tertiary" + /> + </div> + </template> + <template #cell(tags)="{ item }"> + <div + class="gl-display-flex gl-flex-wrap gl-gap-2 gl-justify-content-end gl-md-justify-content-start" + > + <gl-badge v-for="tag in getTagsToShow(item)" :key="tag" class="gl-max-w-12"> + <gl-truncate class="gl-max-w-80p" :text="tag" :with-tooltip="true" /> </gl-badge + ><gl-badge + v-if="getHiddenTagCountWithTooltip(item)" + v-gl-tooltip + :title="getHiddenTagCountWithTooltip(item).tooltipText" + aria-hidden="true" + ><span>{{ getHiddenTagCountWithTooltip(item).label }}</span> + </gl-badge> + <span v-if="getHiddenTagCountWithTooltip(item)" class="gl-sr-only">{{ + getHiddenTagCountWithTooltip(item).tooltipText + }}</span> + </div> + </template> + <template #cell(buildTime)="{ item }"> + <time-ago-tooltip :time="item.buildTime" /> + </template> + <template #cell(updateTime)="{ item }"> + <time-ago-tooltip :time="item.updateTime" /> + </template> + </gl-table> +</template> diff --git a/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/graphql/queries/get_artifacts.query.graphql b/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/graphql/queries/get_artifacts.query.graphql index 6982a778cfb0e..192c02fb02cb7 100644 --- a/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/graphql/queries/get_artifacts.query.graphql +++ b/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/graphql/queries/get_artifacts.query.graphql @@ -1,10 +1,17 @@ -query getArtifacts($fullPath: ID!) { +query getArtifacts($fullPath: ID!, $first: Int) { project(fullPath: $fullPath) { id - googleCloudPlatformArtifactRegistryRepositoryArtifacts @client { - project + googleCloudPlatformArtifactRegistryRepositoryArtifacts(first: $first) @client { + projectId repository gcpRepositoryUrl + nodes { + image + digest + tags + buildTime + updateTime + } } } } diff --git a/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/graphql/resolvers.js b/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/graphql/resolvers.js index a0fb24dca71b0..8dd060d5f2bb5 100644 --- a/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/graphql/resolvers.js +++ b/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/graphql/resolvers.js @@ -1,10 +1,30 @@ const resolvers = { Project: { googleCloudPlatformArtifactRegistryRepositoryArtifacts: () => ({ - project: 'dev-package-container-96a3ff34', + projectId: 'dev-package-container-96a3ff34', repository: 'myrepo', gcpRepositoryUrl: 'https://console.cloud.google.com/artifacts/docker/dev-package-container-96a3ff34/us-east1/myrepo', + nodes: [ + { + name: + 'projects/dev-package-container-96a3ff34/locations/us-east1/repositories/myrepo/dockerImages/alpine@sha256:6a0657acfef760bd9e293361c9b558e98e7d740ed0dffca823d17098a4ffddf5', + uri: + 'us-east1-docker.pkg.dev/dev-package-container-96a3ff34/myrepo/alpine@sha256:6a0657acfef760bd9e293361c9b558e98e7d740ed0dffca823d17098a4ffddf5', + buildTime: '2022-12-07T11:48:50.840751Z', + updateTime: '2023-12-07T11:48:50.840751Z', + image: + 'alpinealpinealpinealpinealpinealpinealpinealpinealpinealpinealpinealpinealpinealpinealpinealpinealpinealpinealpinealpine', + digest: 'sha256:6a0657acfef760bd9e293361c9b558e98e7d740ed0dffca823d17098a4ffddf5', + tags: [ + '6a0657acfef760bd9e293361c9b558e98e7d740ed0dffca823d17098a4ffddf5', + '6a0657acfef760bd9e293361c9b558e98e7d740ed0dffca823d17098a4ffddf5', + '3.15', + '3.15.11', + '3.15.12', + ], + }, + ], }), }, }; diff --git a/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/pages/list.vue b/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/pages/list.vue index e458b954d19e2..d64212be5d68c 100644 --- a/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/pages/list.vue +++ b/ee/app/assets/javascripts/packages_and_registries/google_artifact_registry/pages/list.vue @@ -1,12 +1,16 @@ <script> import * as Sentry from '~/sentry/sentry_browser_wrapper'; import ListHeader from 'ee_component/packages_and_registries/google_artifact_registry/components/list/header.vue'; +import ListTable from 'ee_component/packages_and_registries/google_artifact_registry/components/list/table.vue'; import getArtifactsQuery from 'ee_component/packages_and_registries/google_artifact_registry/graphql/queries/get_artifacts.query.graphql'; +const PAGE_SIZE = 20; + export default { name: 'ArtifactRegistryListPage', components: { ListHeader, + ListTable, }, inject: ['fullPath'], apollo: { @@ -15,6 +19,7 @@ export default { variables() { return { fullPath: this.fullPath, + first: PAGE_SIZE, }; }, update(data) { @@ -34,10 +39,10 @@ export default { }, computed: { headerData() { - const { project, repository, gcpRepositoryUrl } = this.artifacts; - if (project && repository) { + const { projectId, repository, gcpRepositoryUrl } = this.artifacts; + if (projectId && repository) { return { - project, + projectId, repository, gcpRepositoryUrl, }; @@ -47,6 +52,12 @@ export default { isLoading() { return this.$apollo.queries.artifacts.loading; }, + tableData() { + const { nodes = [] } = this.artifacts; + return { + nodes, + }; + }, }, }; </script> @@ -54,5 +65,6 @@ export default { <template> <div data-testid="artifact-registry-list-page"> <list-header :data="headerData" :is-loading="isLoading" :show-error="failedToLoad" /> + <list-table v-if="!failedToLoad" :data="tableData" :is-loading="isLoading" /> </div> </template> diff --git a/ee/spec/frontend/packages_and_registries/google_artifact_registry/components/list/header_spec.js b/ee/spec/frontend/packages_and_registries/google_artifact_registry/components/list/header_spec.js index 55d8ac6c71176..0edd52de48a83 100644 --- a/ee/spec/frontend/packages_and_registries/google_artifact_registry/components/list/header_spec.js +++ b/ee/spec/frontend/packages_and_registries/google_artifact_registry/components/list/header_spec.js @@ -116,7 +116,7 @@ describe('Google Artifact Registry list page header', () => { createComponent(); expect(findProjectIDSubHeader().props()).toMatchObject({ - text: defaultProps.data.project, + text: defaultProps.data.projectId, textTooltip: 'Project ID', icon: 'project', size: 'xl', diff --git a/ee/spec/frontend/packages_and_registries/google_artifact_registry/components/list/table_spec.js b/ee/spec/frontend/packages_and_registries/google_artifact_registry/components/list/table_spec.js new file mode 100644 index 0000000000000..efec923d2db2d --- /dev/null +++ b/ee/spec/frontend/packages_and_registries/google_artifact_registry/components/list/table_spec.js @@ -0,0 +1,154 @@ +import { GlBadge, GlTable, GlTruncate } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { useFakeDate } from 'helpers/fake_date'; +import ListTable from 'ee_component/packages_and_registries/google_artifact_registry/components/list/table.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { imageData } from '../../mock_data'; + +describe('ListTable', () => { + let wrapper; + + const defaultProps = { + data: { + nodes: [imageData], + }, + }; + + useFakeDate(2020, 1, 1); + + const findTable = () => wrapper.findComponent(GlTable); + const findClipboardButton = () => wrapper.findComponent(ClipboardButton); + const findCells = () => wrapper.findAllByRole('cell'); + const findImageName = () => wrapper.findComponent(GlTruncate); + const findBadges = () => wrapper.findAllComponents(GlBadge); + const findFirstTag = () => findBadges().at(0).findComponent(GlTruncate); + const findSecondTag = () => findBadges().at(1).findComponent(GlTruncate); + const findMoreTagsBadge = () => findBadges().at(2); + + const createComponent = (propsData = defaultProps) => { + wrapper = mountExtended(ListTable, { + propsData, + stubs: { + GlTruncate: true, + ClipboardButton: true, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders a table with the correct header fields', () => { + expect(findTable().props('fields')).toEqual([ + { + key: 'image', + label: 'Name', + thClass: 'gl-w-40p', + tdClass: 'gl-pt-3!', + }, + { + key: 'tags', + label: 'Tags', + tdClass: 'gl-pt-4!', + }, + { + key: 'buildTime', + label: 'Created', + }, + { + key: 'updateTime', + label: 'Updated', + }, + ]); + }); + + it('renders the image name and digest', () => { + expect(findImageName().props('text')).toEqual('alpine@1234567890ab'); + }); + + it('renders the clipboard button', () => { + expect(findClipboardButton().props()).toMatchObject({ + text: 'alpine@sha256:1234567890abcdef1234567890abcdef12345678', + title: 'Copy image name', + }); + }); + + describe('tags', () => { + it('renders first tag', () => { + expect(findFirstTag().props()).toMatchObject({ + text: 'latest', + withTooltip: true, + }); + }); + + it('renders second tag', () => { + expect(findSecondTag().props()).toMatchObject({ + text: 'v1.0.0', + withTooltip: true, + }); + }); + + it('renders more tags badge when there is only one tag', () => { + createComponent(); + expect(findMoreTagsBadge().text()).toEqual('+1'); + expect(findMoreTagsBadge().attributes('title')).toEqual('1 more tag'); + }); + + it('renders more tags badge when there is more than one tag', () => { + createComponent({ + data: { + nodes: [ + { + ...imageData, + tags: ['latest', 'v1.0.0', 'v1.0.1', 'v1.0.2'], + }, + ], + }, + }); + + expect(findMoreTagsBadge().text()).toEqual('+2'); + expect(findMoreTagsBadge().attributes('title')).toEqual('2 more tags'); + }); + + it('does not render more tags badge', () => { + createComponent({ + data: { + nodes: [ + { + ...imageData, + tags: ['latest', 'v1.0.0'], + }, + ], + }, + }); + + expect(findBadges()).toHaveLength(2); + }); + + it('does not render any tags', () => { + createComponent({ + data: { + nodes: [ + { + ...imageData, + tags: [], + }, + ], + }, + }); + + expect(findBadges()).toHaveLength(0); + }); + }); + + it('renders the created time in the third column', () => { + const createTimeCell = findCells().at(2); + expect(createTimeCell.text()).toContain('1 year ago'); + }); + + it('renders the update time in the fourth column', () => { + const updateTimeCell = findCells().at(3); + expect(updateTimeCell.text()).toContain('1 month ago'); + }); +}); diff --git a/ee/spec/frontend/packages_and_registries/google_artifact_registry/mock_data.js b/ee/spec/frontend/packages_and_registries/google_artifact_registry/mock_data.js index cf18fc0cd18e7..5c69b867bde4c 100644 --- a/ee/spec/frontend/packages_and_registries/google_artifact_registry/mock_data.js +++ b/ee/spec/frontend/packages_and_registries/google_artifact_registry/mock_data.js @@ -1,10 +1,18 @@ export const headerData = { - project: 'dev-package-container-96a3ff34', + projectId: 'dev-package-container-96a3ff34', repository: 'myrepo', gcpRepositoryUrl: 'https://console.cloud.google.com/artifacts/docker/dev-package-container-96a3ff34/us-east1/myrepo', }; +export const imageData = { + image: 'alpine', + digest: 'sha256:1234567890abcdef1234567890abcdef12345678', + tags: ['latest', 'v1.0.0', 'v1.0.1'], + buildTime: '2019-01-01T00:00:00Z', + updateTime: '2020-01-01T00:00:00Z', +}; + export const getArtifactsQueryResponse = { data: { project: { @@ -12,6 +20,11 @@ export const getArtifactsQueryResponse = { id: 'gid://gitlab/Project/1', googleCloudPlatformArtifactRegistryRepositoryArtifacts: { ...headerData, + nodes: [ + { + ...imageData, + }, + ], }, }, }, diff --git a/ee/spec/frontend/packages_and_registries/google_artifact_registry/pages/list_spec.js b/ee/spec/frontend/packages_and_registries/google_artifact_registry/pages/list_spec.js index 5279443e8ae51..0b0a8f3313742 100644 --- a/ee/spec/frontend/packages_and_registries/google_artifact_registry/pages/list_spec.js +++ b/ee/spec/frontend/packages_and_registries/google_artifact_registry/pages/list_spec.js @@ -5,8 +5,9 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import List from 'ee_component/packages_and_registries/google_artifact_registry/pages/list.vue'; import ListHeader from 'ee_component/packages_and_registries/google_artifact_registry/components/list/header.vue'; +import ListTable from 'ee_component/packages_and_registries/google_artifact_registry/components/list/table.vue'; import getArtifactsQuery from 'ee_component/packages_and_registries/google_artifact_registry/graphql/queries/get_artifacts.query.graphql'; -import { headerData, getArtifactsQueryResponse } from '../mock_data'; +import { headerData, getArtifactsQueryResponse, imageData } from '../mock_data'; Vue.use(VueApollo); @@ -19,6 +20,7 @@ describe('List', () => { }; const findListHeader = () => wrapper.findComponent(ListHeader); + const findListTable = () => wrapper.findComponent(ListTable); const createComponent = ({ resolver = jest.fn().mockResolvedValue(getArtifactsQueryResponse), @@ -68,4 +70,34 @@ describe('List', () => { }); }); }); + + describe('list table', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the list table with loading prop', () => { + expect(findListTable().props()).toMatchObject({ + data: {}, + isLoading: true, + }); + }); + + it('renders the list table with data prop', async () => { + await waitForPromises(); + + expect(findListTable().props()).toMatchObject({ + data: { nodes: [imageData] }, + isLoading: false, + }); + }); + + it('hides the list table when resolve fails error', async () => { + const resolver = jest.fn().mockRejectedValue(new Error('error')); + createComponent({ resolver }); + await waitForPromises(); + + expect(findListTable().exists()).toBe(false); + }); + }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f87c2ef3fb031..9d204666501bf 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -23158,12 +23158,26 @@ msgstr "" msgid "Google Play service account key." msgstr "" +msgid "GoogleArtifactRegistry|%d more tag" +msgid_plural "GoogleArtifactRegistry|%d more tags" +msgstr[0] "" +msgstr[1] "" + msgid "GoogleArtifactRegistry|An error occurred while fetching the artifacts." msgstr "" msgid "GoogleArtifactRegistry|Configure in settings" msgstr "" +msgid "GoogleArtifactRegistry|Copy image name" +msgstr "" + +msgid "GoogleArtifactRegistry|Created" +msgstr "" + +msgid "GoogleArtifactRegistry|Name" +msgstr "" + msgid "GoogleArtifactRegistry|Open in Google Cloud" msgstr "" @@ -23173,6 +23187,12 @@ msgstr "" msgid "GoogleArtifactRegistry|Repository name" msgstr "" +msgid "GoogleArtifactRegistry|Tags" +msgstr "" + +msgid "GoogleArtifactRegistry|Updated" +msgstr "" + msgid "GoogleCloudPlatformService|Connect Google Cloud Artifact Registry to GitLab." msgstr "" -- GitLab