Commit d79cdb40 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch 'afontaine/deployment-approval-comments' into 'master'

Add deployment approval comment field

See merge request gitlab-org/gitlab!82743
parents eb6b1bd8 5dfb9cab
......@@ -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();
......
......@@ -2164,6 +2164,9 @@ msgstr ""
msgid "Add comment to design"
msgstr ""
msgid "Add comment..."
msgstr ""
msgid "Add commit messages as comments to Asana tasks. %{docs_link}"
msgstr ""
......@@ -6880,6 +6883,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