Commit 5dfb9cab authored by Andrew Fontaine's avatar Andrew Fontaine

Add deployment approval comment field

A comment field allows users to describe why a deployment is approved or
rejected.

It shows a count of remaining characters. The count turns orange when 30
or less are left, and turns red once the comment is over the limit.

This counter should be added to GitLab UI (and made pajamas compliant),
but is good here for now.

Changelog: added
EE: true
parent b945eefd
...@@ -389,18 +389,18 @@ export default { ...@@ -389,18 +389,18 @@ export default {
}); });
}, },
deploymentApproval(id, deploymentId, approve) { deploymentApproval({ id, deploymentId, approve, comment }) {
const url = Api.buildUrl(this.environmentApprovalPath) const url = Api.buildUrl(this.environmentApprovalPath)
.replace(':id', encodeURIComponent(id)) .replace(':id', encodeURIComponent(id))
.replace(':deployment_id', encodeURIComponent(deploymentId)); .replace(':deployment_id', encodeURIComponent(deploymentId));
return axios.post(url, { status: approve ? 'approved' : 'rejected' }); return axios.post(url, { status: approve ? 'approved' : 'rejected', comment });
}, },
approveDeployment(id, deploymentId) { approveDeployment({ id, deploymentId, comment }) {
return this.deploymentApproval(id, deploymentId, true); return this.deploymentApproval({ id, deploymentId, approve: true, comment });
}, },
rejectDeployment(id, deploymentId) { rejectDeployment({ id, deploymentId, comment }) {
return this.deploymentApproval(id, deploymentId, false); return this.deploymentApproval({ id, deploymentId, approve: false, comment });
}, },
}; };
<script> <script>
import { GlButton, GlButtonGroup, GlLink, GlPopover, GlSprintf } from '@gitlab/ui'; import {
GlButton,
GlButtonGroup,
GlFormGroup,
GlFormTextarea,
GlLink,
GlPopover,
GlSprintf,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import Api from 'ee/api'; import Api from 'ee/api';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { createAlert } from '~/flash'; import { createAlert } from '~/flash';
import { __, s__, sprintf } from '~/locale'; import { __, s__, sprintf } from '~/locale';
const MAX_CHARACTER_COUNT = 250;
const WARNING_CHARACTERS_LEFT = 30;
export default { export default {
components: { components: {
GlButton, GlButton,
GlButtonGroup, GlButtonGroup,
GlFormGroup,
GlFormTextarea,
GlLink, GlLink,
GlPopover, GlPopover,
GlSprintf, GlSprintf,
TimeAgoTooltip, TimeAgoTooltip,
}, },
directives: {
GlTooltip,
},
inject: ['projectId'], inject: ['projectId'],
props: { props: {
environment: { environment: {
...@@ -25,8 +42,10 @@ export default { ...@@ -25,8 +42,10 @@ export default {
data() { data() {
return { return {
id: uniqueId('environment-approval'), id: uniqueId('environment-approval'),
commentId: uniqueId('environment-approval-comment'),
loading: false, loading: false,
show: false, show: false,
comment: '',
}; };
}, },
computed: { computed: {
...@@ -61,6 +80,25 @@ export default { ...@@ -61,6 +80,25 @@ export default {
deployableName() { deployableName() {
return this.upcomingDeployment.deployable?.name; return this.upcomingDeployment.deployable?.name;
}, },
isCommentValid() {
return this.comment.length <= MAX_CHARACTER_COUNT;
},
commentCharacterCountClasses() {
return {
'gl-text-orange-500':
this.remainingCharacterCount <= WARNING_CHARACTERS_LEFT &&
this.remainingCharacterCount >= 0,
'gl-text-red-500': this.remainingCharacterCount < 0,
};
},
characterCountTooltip() {
return this.isCommentValid
? this.$options.i18n.charactersLeft
: this.$options.i18n.charactersOverLimit;
},
remainingCharacterCount() {
return MAX_CHARACTER_COUNT - this.comment.length;
},
}, },
methods: { methods: {
showPopover() { showPopover() {
...@@ -75,7 +113,11 @@ export default { ...@@ -75,7 +113,11 @@ export default {
actOnDeployment(action) { actOnDeployment(action) {
this.loading = true; this.loading = true;
this.show = false; this.show = false;
action(this.projectId, this.upcomingDeployment.id) action({
projectId: this.projectId,
deploymentId: this.upcomingDeployment.id,
comment: this.comment,
})
.catch((err) => { .catch((err) => {
if (err.response) { if (err.response) {
createAlert({ message: err.response.data.message }); createAlert({ message: err.response.data.message });
...@@ -106,6 +148,11 @@ export default { ...@@ -106,6 +148,11 @@ export default {
current: s__('DeploymentApproval| Current approvals: %{current}'), current: s__('DeploymentApproval| Current approvals: %{current}'),
approval: s__('DeploymentApproval|Approved by %{user} %{time}'), approval: s__('DeploymentApproval|Approved by %{user} %{time}'),
approvalByMe: s__('DeploymentApproval|Approved by you %{time}'), approvalByMe: s__('DeploymentApproval|Approved by you %{time}'),
charactersLeft: __('Characters left'),
charactersOverLimit: __('Characters over limit'),
commentLabel: __('Comment'),
optional: __('(optional)'),
description: __('Add comment...'),
approve: __('Approve'), approve: __('Approve'),
reject: __('Reject'), reject: __('Reject'),
}, },
...@@ -163,6 +210,30 @@ export default { ...@@ -163,6 +210,30 @@ export default {
</gl-sprintf> </gl-sprintf>
</p> </p>
<div v-if="canApproveDeployment" class="gl-mt-4 gl-pt-4"> <div v-if="canApproveDeployment" class="gl-mt-4 gl-pt-4">
<div class="gl-display-flex gl-flex-direction-column gl-mb-5">
<gl-form-group
:label="$options.i18n.commentLabel"
:label-for="commentId"
:optional-text="$options.i18n.optional"
class="gl-mb-0"
optional
>
<gl-form-textarea
:id="commentId"
v-model="comment"
:placeholder="$options.i18n.description"
:state="isCommentValid"
/>
</gl-form-group>
<span
v-gl-tooltip
:title="characterCountTooltip"
:class="commentCharacterCountClasses"
class="gl-mt-2 gl-align-self-end"
>
{{ remainingCharacterCount }}
</span>
</div>
<gl-button ref="approve" :loading="loading" variant="confirm" @click="approve"> <gl-button ref="approve" :loading="loading" variant="confirm" @click="approve">
{{ $options.i18n.approve }} {{ $options.i18n.approve }}
</gl-button> </gl-button>
......
...@@ -754,23 +754,24 @@ describe('Api', () => { ...@@ -754,23 +754,24 @@ describe('Api', () => {
const projectId = 1; const projectId = 1;
const deploymentId = 2; const deploymentId = 2;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/deployments/${deploymentId}/approval`; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/deployments/${deploymentId}/approval`;
const comment = 'comment';
it('sends an approval when approve is true', async () => { it('sends an approval when approve is true', async () => {
mock.onPost(expectedUrl, { status: 'approved' }).replyOnce(httpStatus.OK); mock.onPost(expectedUrl, { status: 'approved', comment }).replyOnce(httpStatus.OK);
await Api.deploymentApproval(projectId, deploymentId, true); await Api.deploymentApproval({ id: projectId, deploymentId, approve: true, comment });
expect(mock.history.post.length).toBe(1); expect(mock.history.post.length).toBe(1);
expect(mock.history.post[0].data).toBe(JSON.stringify({ status: 'approved' })); expect(mock.history.post[0].data).toBe(JSON.stringify({ status: 'approved', comment }));
}); });
it('sends a rejection when approve is false', async () => { it('sends a rejection when approve is false', async () => {
mock.onPost(expectedUrl, { status: 'rejected' }).replyOnce(httpStatus.OK); mock.onPost(expectedUrl, { status: 'rejected', comment }).replyOnce(httpStatus.OK);
await Api.deploymentApproval(projectId, deploymentId, false); await Api.deploymentApproval({ id: projectId, deploymentId, approve: false, comment });
expect(mock.history.post.length).toBe(1); expect(mock.history.post.length).toBe(1);
expect(mock.history.post[0].data).toBe(JSON.stringify({ status: 'rejected' })); expect(mock.history.post[0].data).toBe(JSON.stringify({ status: 'rejected', comment }));
}); });
}); });
}); });
...@@ -30,6 +30,11 @@ describe('ee/environments/components/environment_approval.vue', () => { ...@@ -30,6 +30,11 @@ describe('ee/environments/components/environment_approval.vue', () => {
const findPopover = () => extendedWrapper(wrapper.findComponent(GlPopover)); const findPopover = () => extendedWrapper(wrapper.findComponent(GlPopover));
const findButton = () => extendedWrapper(wrapper.findComponent(GlButton)); const findButton = () => extendedWrapper(wrapper.findComponent(GlButton));
const setComment = (comment) =>
wrapper
.findByRole('textbox', { name: (content) => content.startsWith(__('Comment')) })
.setValue(comment);
it('should link the popover to the button', () => { it('should link the popover to the button', () => {
wrapper = createWrapper(); wrapper = createWrapper();
const popover = findPopover(); const popover = findPopover();
...@@ -90,6 +95,42 @@ describe('ee/environments/components/environment_approval.vue', () => { ...@@ -90,6 +95,42 @@ describe('ee/environments/components/environment_approval.vue', () => {
}); });
}); });
describe('comment', () => {
const max = 250;
const closeToFull = Array(max - 30)
.fill('a')
.join('');
const full = Array(max).fill('a').join('');
const over = Array(max + 1)
.fill('a')
.join('');
it.each`
comment | tooltip | classes
${'hello'} | ${__('Characters left')} | ${{ 'gl-text-orange-500': false, 'gl-text-red-500': false }}
${closeToFull} | ${__('Characters left')} | ${{ 'gl-text-orange-500': true, 'gl-text-red-500': false }}
${full} | ${__('Characters left')} | ${{ 'gl-text-orange-500': true, 'gl-text-red-500': false }}
${over} | ${__('Characters over limit')} | ${{ 'gl-text-orange-500': false, 'gl-text-red-500': true }}
`(
'shows remaining length with tooltip $tooltip when comment length is $comment.length, coloured appropriately',
async ({ comment, tooltip, classes }) => {
await setComment(comment);
const counter = wrapper.findByTitle(tooltip);
expect(counter.text()).toBe((max - comment.length).toString());
Object.entries(classes).forEach(([klass, present]) => {
if (present) {
expect(counter.classes()).toContain(klass);
} else {
expect(counter.classes()).not.toContain(klass);
}
});
},
);
});
describe('permissions', () => { describe('permissions', () => {
beforeAll(() => { beforeAll(() => {
gon.current_username = 'root'; gon.current_username = 'root';
...@@ -139,12 +180,18 @@ describe('ee/environments/components/environment_approval.vue', () => { ...@@ -139,12 +180,18 @@ describe('ee/environments/components/environment_approval.vue', () => {
expect(button.text()).toBe(text); expect(button.text()).toBe(text);
}); });
it('should approve the deployment when Approve is clicked', async () => { it(`should ${ref} the deployment when ${text} is clicked`, async () => {
api.mockResolvedValue(); api.mockResolvedValue();
setComment('comment');
await button.trigger('click'); await button.trigger('click');
expect(api).toHaveBeenCalledWith('5', environment.upcomingDeployment.id); expect(api).toHaveBeenCalledWith({
projectId: '5',
deploymentId: environment.upcomingDeployment.id,
comment: 'comment',
});
await waitForPromises(); await waitForPromises();
......
...@@ -2163,6 +2163,9 @@ msgstr "" ...@@ -2163,6 +2163,9 @@ msgstr ""
msgid "Add comment to design" msgid "Add comment to design"
msgstr "" msgstr ""
msgid "Add comment..."
msgstr ""
msgid "Add commit messages as comments to Asana tasks. %{docs_link}" msgid "Add commit messages as comments to Asana tasks. %{docs_link}"
msgstr "" msgstr ""
...@@ -6885,6 +6888,12 @@ msgstr "" ...@@ -6885,6 +6888,12 @@ msgstr ""
msgid "Changing any setting here requires an application restart" msgid "Changing any setting here requires an application restart"
msgstr "" msgstr ""
msgid "Characters left"
msgstr ""
msgid "Characters over limit"
msgstr ""
msgid "Charts can't be displayed as the request for data has timed out. %{documentationLink}" msgid "Charts can't be displayed as the request for data has timed out. %{documentationLink}"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment