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"> · </span> + <gl-badge :variant="badgeVariant(approval)"> + {{ badgeText(approval) }} + </gl-badge> + <span class="note-headline-light"> · </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