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 { ...@@ -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();
......
...@@ -2164,6 +2164,9 @@ msgstr "" ...@@ -2164,6 +2164,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 ""
...@@ -6880,6 +6883,12 @@ msgstr "" ...@@ -6880,6 +6883,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