From cb5a157265b81502fbef7a2871ad02ab91e7d26f Mon Sep 17 00:00:00 2001
From: Andrew Fontaine <afontaine@gitlab.com>
Date: Wed, 28 Feb 2024 16:09:07 +0000
Subject: [PATCH] Show deployment approval comments

Adds a notable-style comment timeline for deployment approvals.
---
 .../components/show_deployment.vue            |   6 +
 .../components/deployment_timeline.vue        | 105 +++++++++++++++++
 .../javascripts/deployments/constants.js      |   4 +
 .../deployments/deployment_header.spec.js     |   2 +-
 .../deployments/deployment_timeline_spec.js   | 107 ++++++++++++++++++
 .../deployments/show_deployment_spec.js       |  66 +++++++++++
 locale/gitlab.pot                             |   9 ++
 7 files changed, 298 insertions(+), 1 deletion(-)
 create mode 100644 ee/app/assets/javascripts/deployments/components/deployment_timeline.vue
 create mode 100644 ee/app/assets/javascripts/deployments/constants.js
 create mode 100644 ee/spec/frontend/deployments/deployment_timeline_spec.js
 create mode 100644 ee/spec/frontend/deployments/show_deployment_spec.js

diff --git a/app/assets/javascripts/deployments/components/show_deployment.vue b/app/assets/javascripts/deployments/components/show_deployment.vue
index 71655e5c3ab3c..a3262d5bdae92 100644
--- a/app/assets/javascripts/deployments/components/show_deployment.vue
+++ b/app/assets/javascripts/deployments/components/show_deployment.vue
@@ -15,6 +15,7 @@ export default {
     DeploymentAside,
     DeploymentApprovals: () =>
       import('ee_component/deployments/components/deployment_approvals.vue'),
+    DeploymentTimeline: () => import('ee_component/deployments/components/deployment_timeline.vue'),
   },
   inject: ['projectPath', 'deploymentIid', 'environmentName'],
   apollo: {
@@ -86,6 +87,11 @@ export default {
           :deployment="deployment"
           class="gl-mt-8 gl-w-90p"
         />
+        <deployment-timeline
+          v-if="hasApprovalSummary"
+          :approval-summary="deployment.approvalSummary"
+          class="gl-w-90p"
+        />
       </div>
       <deployment-aside
         v-if="!hasError"
diff --git a/ee/app/assets/javascripts/deployments/components/deployment_timeline.vue b/ee/app/assets/javascripts/deployments/components/deployment_timeline.vue
new file mode 100644
index 0000000000000..110adacf065bc
--- /dev/null
+++ b/ee/app/assets/javascripts/deployments/components/deployment_timeline.vue
@@ -0,0 +1,105 @@
+<script>
+import { GlAvatarLink, GlAvatar, GlBadge, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { APPROVAL_STATUSES } from '../constants';
+
+export default {
+  components: {
+    GlAvatarLink,
+    GlAvatar,
+    GlBadge,
+    GlLink,
+    TimelineEntryItem,
+    TimeAgoTooltip,
+  },
+  props: {
+    approvalSummary: {
+      type: Object,
+      required: true,
+    },
+  },
+  computed: {
+    approvals() {
+      return this.approvalSummary.rules
+        .flatMap((rule) => rule.approvals)
+        .sort((a, b) => (a.createdAt <= b.createdAt ? -1 : 1))
+        .filter((approval) => approval.comment);
+    },
+    hasApprovals() {
+      return this.approvals.length > 0;
+    },
+  },
+  methods: {
+    getUserId({ user }) {
+      return getIdFromGraphQLId(user.id);
+    },
+    badgeVariant({ status }) {
+      return status === APPROVAL_STATUSES.APPROVED ? 'success' : 'danger';
+    },
+    badgeText({ status }) {
+      return status === APPROVAL_STATUSES.APPROVED
+        ? this.$options.i18n.approved
+        : this.$options.i18n.rejected;
+    },
+  },
+  i18n: {
+    header: s__('Deployment|Approval Comments'),
+    approved: s__('Deployment|Approved'),
+    rejected: s__('Deployment|Rejected'),
+  },
+};
+</script>
+<template>
+  <div v-if="hasApprovals" class="issuable-discussion">
+    <h3>{{ $options.i18n.header }}</h3>
+
+    <ul class="notes main-notes-list timeline">
+      <timeline-entry-item
+        v-for="(approval, i) in approvals"
+        :key="i"
+        :data-testid="`approval-${approval.user.username}`"
+        class="note note-wrapper note-comment"
+      >
+        <div class="timeline-avatar gl-float-left">
+          <gl-avatar-link :href="approval.user.webUrl">
+            <gl-avatar
+              :src="approval.user.avatarUrl"
+              :entity-name="approval.user.username"
+              :alt="approval.user.name"
+              :size="32"
+            />
+          </gl-avatar-link>
+        </div>
+        <div class="timeline-content">
+          <div class="note-header">
+            <div class="note-header-info">
+              <gl-link
+                :href="approval.user.webUrl"
+                :data-username="approval.user.username"
+                :data-user-id="getUserId(approval)"
+                class="js-user-link"
+              >
+                {{ approval.user.name }}
+                <span class="note-headline-light">@{{ approval.user.username }}</span>
+              </gl-link>
+              <span class="note-headline-light"> &middot; </span>
+              <gl-badge :variant="badgeVariant(approval)">
+                {{ badgeText(approval) }}
+              </gl-badge>
+              <span class="note-headline-light"> &middot; </span>
+              <span class="note-headline-light">
+                <time-ago-tooltip :time="approval.createdAt" />
+              </span>
+            </div>
+          </div>
+          <div class="timeline-discussion-body">
+            <div class="note-body">{{ approval.comment }}</div>
+          </div>
+        </div>
+      </timeline-entry-item>
+    </ul>
+  </div>
+</template>
diff --git a/ee/app/assets/javascripts/deployments/constants.js b/ee/app/assets/javascripts/deployments/constants.js
new file mode 100644
index 0000000000000..4229adab5dc51
--- /dev/null
+++ b/ee/app/assets/javascripts/deployments/constants.js
@@ -0,0 +1,4 @@
+export const APPROVAL_STATUSES = {
+  APPROVED: 'APPROVED',
+  REJECTED: 'REJECTED',
+};
diff --git a/ee/spec/frontend/deployments/deployment_header.spec.js b/ee/spec/frontend/deployments/deployment_header.spec.js
index f48e63c0aca98..0a01e212ea123 100644
--- a/ee/spec/frontend/deployments/deployment_header.spec.js
+++ b/ee/spec/frontend/deployments/deployment_header.spec.js
@@ -1,6 +1,6 @@
 import { GlBadge } from '@gitlab/ui';
 import { shallowMount } from '@vue/test-utils';
-import mockDeploymentFixture from 'test_fixtures/graphql/deployments/graphql/queries/deployment.query.graphql.json';
+import mockDeploymentFixture from 'test_fixtures/ee/graphql/deployments/graphql/queries/deployment.query.graphql.json';
 import mockEnvironmentFixture from 'test_fixtures/graphql/deployments/graphql/queries/environment.query.graphql.json';
 import DeploymentHeader from '~/deployments/components/deployment_header.vue';
 
diff --git a/ee/spec/frontend/deployments/deployment_timeline_spec.js b/ee/spec/frontend/deployments/deployment_timeline_spec.js
new file mode 100644
index 0000000000000..143d06351c609
--- /dev/null
+++ b/ee/spec/frontend/deployments/deployment_timeline_spec.js
@@ -0,0 +1,107 @@
+import { GlAvatarLink, GlAvatar, GlBadge } from '@gitlab/ui';
+import mockDeploymentFixture from 'test_fixtures/ee/graphql/deployments/graphql/queries/deployment.query.graphql.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import DeploymentTimeline from 'ee/deployments/components/deployment_timeline.vue';
+
+const { approvalSummary } = mockDeploymentFixture.data.project.deployment;
+
+describe('ee/deployments/components/deployment_timeline.vue', () => {
+  let wrapper;
+
+  const createComponent = ({ propsData = {} } = {}) => {
+    wrapper = shallowMountExtended(DeploymentTimeline, {
+      propsData: {
+        approvalSummary,
+        ...propsData,
+      },
+    });
+  };
+
+  const getAllApprovals = () => approvalSummary.rules.flatMap((rule) => rule.approvals);
+
+  describe('with approval', () => {
+    beforeEach(() => {
+      createComponent();
+    });
+
+    it('shows all approval comments', () => {
+      getAllApprovals().forEach((approval) => {
+        expect(wrapper.findByText(approval.comment).exists()).toBe(true);
+      });
+    });
+
+    it('shows the user who made the approval', () => {
+      getAllApprovals().forEach(({ user }) => {
+        const approvalBlock = wrapper.findByTestId(`approval-${user.username}`);
+        const avatarLink = approvalBlock.findComponent(GlAvatarLink);
+        expect(avatarLink.attributes('href')).toBe(user.webUrl);
+
+        const avatar = approvalBlock.findComponent(GlAvatar);
+        expect(avatar.attributes()).toMatchObject({
+          src: user.avatarUrl,
+          alt: user.name,
+        });
+        expect(avatar.props('entityName')).toBe(user.username);
+      });
+    });
+
+    it('shows when the comment was made', () => {
+      getAllApprovals().forEach((approval) => {
+        const approvalBlock = wrapper.findByTestId(`approval-${approval.user.username}`);
+
+        const timeago = approvalBlock.findComponent(TimeAgoTooltip);
+
+        expect(timeago.props('time')).toBe(approval.createdAt);
+      });
+    });
+
+    it('shows a badge showing if a comment is an approval', () => {
+      getAllApprovals().forEach((approval) => {
+        const approvalBlock = wrapper.findByTestId(`approval-${approval.user.username}`);
+
+        const badge = approvalBlock.findComponent(GlBadge);
+
+        expect(badge.text()).toBe('Approved');
+        expect(badge.props('variant')).toBe('success');
+      });
+    });
+  });
+
+  describe('with rejection', () => {
+    beforeEach(() => {
+      const [rule] = approvalSummary.rules;
+      const [approval] = rule.approvals;
+
+      createComponent({
+        propsData: {
+          approvalSummary: {
+            ...approvalSummary,
+            rules: [
+              {
+                ...rule,
+                approvals: [
+                  {
+                    ...approval,
+                    status: 'REJECTED',
+                  },
+                ],
+              },
+            ],
+          },
+        },
+      });
+    });
+
+    it('shows a badge showing if a comment is a rejection', () => {
+      getAllApprovals().forEach((approval) => {
+        const approvalBlock = wrapper.findByTestId(`approval-${approval.user.username}`);
+
+        const badge = approvalBlock.findComponent(GlBadge);
+
+        expect(badge.text()).toBe('Rejected');
+        expect(badge.props('variant')).toBe('danger');
+      });
+    });
+  });
+});
diff --git a/ee/spec/frontend/deployments/show_deployment_spec.js b/ee/spec/frontend/deployments/show_deployment_spec.js
new file mode 100644
index 0000000000000..c5128cd33f965
--- /dev/null
+++ b/ee/spec/frontend/deployments/show_deployment_spec.js
@@ -0,0 +1,66 @@
+import VueApollo from 'vue-apollo';
+import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import mockDeploymentFixture from 'test_fixtures/ee/graphql/deployments/graphql/queries/deployment.query.graphql.json';
+import mockEnvironmentFixture from 'test_fixtures/graphql/deployments/graphql/queries/environment.query.graphql.json';
+import ShowDeployment from '~/deployments/components/show_deployment.vue';
+import deploymentQuery from '~/deployments/graphql/queries/deployment.query.graphql';
+import environmentQuery from '~/deployments/graphql/queries/environment.query.graphql';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import DeploymentTimeline from 'ee/deployments/components/deployment_timeline.vue';
+import DeploymentApprovals from 'ee/deployments/components/deployment_approvals.vue';
+
+Vue.use(VueApollo);
+
+const { deployment } = mockDeploymentFixture.data.project;
+const PROJECT_PATH = 'group/project';
+const ENVIRONMENT_NAME = mockEnvironmentFixture.data.project.environment.name;
+const DEPLOYMENT_IID = deployment.iid;
+
+describe('~/deployments/components/show_deployment.vue', () => {
+  let wrapper;
+  let mockApollo;
+  let deploymentQueryResponse;
+  let environmentQueryResponse;
+
+  beforeEach(() => {
+    deploymentQueryResponse = jest.fn();
+    environmentQueryResponse = jest.fn();
+  });
+
+  const createComponent = () => {
+    mockApollo = createMockApollo([
+      [deploymentQuery, deploymentQueryResponse],
+      [environmentQuery, environmentQueryResponse],
+    ]);
+    wrapper = shallowMount(ShowDeployment, {
+      apolloProvider: mockApollo,
+      provide: {
+        projectPath: PROJECT_PATH,
+        environmentName: ENVIRONMENT_NAME,
+        deploymentIid: DEPLOYMENT_IID,
+      },
+    });
+    return waitForPromises();
+  };
+
+  beforeEach(() => {
+    deploymentQueryResponse.mockResolvedValue(mockDeploymentFixture);
+    environmentQueryResponse.mockResolvedValue(mockEnvironmentFixture);
+    return createComponent();
+  });
+
+  it('shows the deployment approval table', () => {
+    expect(wrapper.findComponent(DeploymentApprovals).props()).toEqual({
+      approvalSummary: deployment.approvalSummary,
+      deployment,
+    });
+  });
+
+  it('shows the deployment approvals timeline', () => {
+    expect(wrapper.findComponent(DeploymentTimeline).props()).toEqual({
+      approvalSummary: deployment.approvalSummary,
+    });
+  });
+});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 3035db1984210..e5d7c8d5f02da 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -17375,9 +17375,15 @@ msgstr ""
 msgid "Deployment|Add approval comment"
 msgstr ""
 
+msgid "Deployment|Approval Comments"
+msgstr ""
+
 msgid "Deployment|Approve deployment"
 msgstr ""
 
+msgid "Deployment|Approved"
+msgstr ""
+
 msgid "Deployment|Branch"
 msgstr ""
 
@@ -17432,6 +17438,9 @@ msgstr ""
 msgid "Deployment|Reject"
 msgstr ""
 
+msgid "Deployment|Rejected"
+msgstr ""
+
 msgid "Deployment|Related Tags"
 msgstr ""
 
-- 
GitLab