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