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 {
});
},
deploymentApproval(id, deploymentId, approve) {
deploymentApproval({ id, deploymentId, approve, comment }) {
const url = Api.buildUrl(this.environmentApprovalPath)
.replace(':id', encodeURIComponent(id))
.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) {
return this.deploymentApproval(id, deploymentId, true);
approveDeployment({ id, deploymentId, comment }) {
return this.deploymentApproval({ id, deploymentId, approve: true, comment });
},
rejectDeployment(id, deploymentId) {
return this.deploymentApproval(id, deploymentId, false);
rejectDeployment({ id, deploymentId, comment }) {
return this.deploymentApproval({ id, deploymentId, approve: false, comment });
},
};
<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 Api from 'ee/api';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { createAlert } from '~/flash';
import { __, s__, sprintf } from '~/locale';
const MAX_CHARACTER_COUNT = 250;
const WARNING_CHARACTERS_LEFT = 30;
export default {
components: {
GlButton,
GlButtonGroup,
GlFormGroup,
GlFormTextarea,
GlLink,
GlPopover,
GlSprintf,
TimeAgoTooltip,
},
directives: {
GlTooltip,
},
inject: ['projectId'],
props: {
environment: {
......@@ -25,8 +42,10 @@ export default {
data() {
return {
id: uniqueId('environment-approval'),
commentId: uniqueId('environment-approval-comment'),
loading: false,
show: false,
comment: '',
};
},
computed: {
......@@ -61,6 +80,25 @@ export default {
deployableName() {
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: {
showPopover() {
......@@ -75,7 +113,11 @@ export default {
actOnDeployment(action) {
this.loading = true;
this.show = false;
action(this.projectId, this.upcomingDeployment.id)
action({
projectId: this.projectId,
deploymentId: this.upcomingDeployment.id,
comment: this.comment,
})
.catch((err) => {
if (err.response) {
createAlert({ message: err.response.data.message });
......@@ -106,6 +148,11 @@ export default {
current: s__('DeploymentApproval| Current approvals: %{current}'),
approval: s__('DeploymentApproval|Approved by %{user} %{time}'),
approvalByMe: s__('DeploymentApproval|Approved by you %{time}'),
charactersLeft: __('Characters left'),
charactersOverLimit: __('Characters over limit'),
commentLabel: __('Comment'),
optional: __('(optional)'),
description: __('Add comment...'),
approve: __('Approve'),
reject: __('Reject'),
},
......@@ -163,6 +210,30 @@ export default {
</gl-sprintf>
</p>
<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">
{{ $options.i18n.approve }}
</gl-button>
......
......@@ -754,23 +754,24 @@ describe('Api', () => {
const projectId = 1;
const deploymentId = 2;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/deployments/${deploymentId}/approval`;
const comment = 'comment';
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[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 () => {
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[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', () => {
const findPopover = () => extendedWrapper(wrapper.findComponent(GlPopover));
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', () => {
wrapper = createWrapper();
const popover = findPopover();
......@@ -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', () => {
beforeAll(() => {
gon.current_username = 'root';
......@@ -139,12 +180,18 @@ describe('ee/environments/components/environment_approval.vue', () => {
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();
setComment('comment');
await button.trigger('click');
expect(api).toHaveBeenCalledWith('5', environment.upcomingDeployment.id);
expect(api).toHaveBeenCalledWith({
projectId: '5',
deploymentId: environment.upcomingDeployment.id,
comment: 'comment',
});
await waitForPromises();
......
......@@ -2163,6 +2163,9 @@ msgstr ""
msgid "Add comment to design"
msgstr ""
msgid "Add comment..."
msgstr ""
msgid "Add commit messages as comments to Asana tasks. %{docs_link}"
msgstr ""
......@@ -6885,6 +6888,12 @@ msgstr ""
msgid "Changing any setting here requires an application restart"
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}"
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