Skip to content
Snippets Groups Projects
Commit d39dff98 authored by Andrew Fontaine's avatar Andrew Fontaine
Browse files

Add deployment approval UI MVC

Sets up the ability for users to approve and reject deployments from
within the environments page.

Current information shown is how many approvals are needed and how many
are done, as well as who has approved and when.

A rejection short cuts to killing the deployment.

This is currently only in the new environment UI, as the old one is
going away.

Changelog: added
EE: true
parent ff84aced
No related branches found
No related tags found
1 merge request!80759Add deployment approval UI MVC
Showing
with 562 additions and 11 deletions
...@@ -102,6 +102,9 @@ export default { ...@@ -102,6 +102,9 @@ export default {
refPath() { refPath() {
return this.ref?.refPath; return this.ref?.refPath;
}, },
needsApproval() {
return this.deployment.pendingApprovalCount > 0;
},
}, },
methods: { methods: {
toggleCollapse() { toggleCollapse() {
...@@ -116,6 +119,7 @@ export default { ...@@ -116,6 +119,7 @@ export default {
showDetails: __('Show details'), showDetails: __('Show details'),
hideDetails: __('Hide details'), hideDetails: __('Hide details'),
triggerer: s__('Deployment|Triggerer'), triggerer: s__('Deployment|Triggerer'),
needsApproval: s__('Deployment|Needs Approval'),
job: __('Job'), job: __('Job'),
api: __('API'), api: __('API'),
branch: __('Branch'), branch: __('Branch'),
...@@ -153,6 +157,9 @@ export default { ...@@ -153,6 +157,9 @@ export default {
<div :class="$options.headerDetailsClasses"> <div :class="$options.headerDetailsClasses">
<div :class="$options.deploymentStatusClasses"> <div :class="$options.deploymentStatusClasses">
<deployment-status-badge v-if="status" :status="status" /> <deployment-status-badge v-if="status" :status="status" />
<gl-badge v-if="needsApproval" variant="warning">
{{ $options.i18n.needsApproval }}
</gl-badge>
<gl-badge v-if="latest" variant="info">{{ $options.i18n.latestBadge }}</gl-badge> <gl-badge v-if="latest" variant="info">{{ $options.i18n.latestBadge }}</gl-badge>
</div> </div>
<div class="gl-display-flex gl-align-items-center gl-gap-x-5"> <div class="gl-display-flex gl-align-items-center gl-gap-x-5">
...@@ -199,6 +206,7 @@ export default { ...@@ -199,6 +206,7 @@ export default {
</gl-button> </gl-button>
</div> </div>
<commit v-if="commit" :commit="commit" class="gl-mt-3" /> <commit v-if="commit" :commit="commit" class="gl-mt-3" />
<div class="gl-mt-3"><slot name="approval"></slot></div>
<gl-collapse :visible="visible"> <gl-collapse :visible="visible">
<div <div
class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0" class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0"
......
...@@ -41,6 +41,8 @@ export default { ...@@ -41,6 +41,8 @@ export default {
TimeAgoTooltip, TimeAgoTooltip,
Delete, Delete,
EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'), EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'),
EnvironmentApproval: () =>
import('ee_component/environments/components/environment_approval.vue'),
}, },
directives: { directives: {
GlTooltip, GlTooltip,
...@@ -305,7 +307,11 @@ export default { ...@@ -305,7 +307,11 @@ export default {
:deployment="upcomingDeployment" :deployment="upcomingDeployment"
:class="{ 'gl-ml-7': inFolder }" :class="{ 'gl-ml-7': inFolder }"
class="gl-pl-4" class="gl-pl-4"
/> >
<template #approval>
<environment-approval :environment="environment" @change="$emit('change')" />
</template>
</deployment>
</div> </div>
</template> </template>
<div v-else :class="$options.deploymentClasses"> <div v-else :class="$options.deploymentClasses">
......
...@@ -175,11 +175,10 @@ export default { ...@@ -175,11 +175,10 @@ export default {
}, },
resetPolling() { resetPolling() {
this.$apollo.queries.environmentApp.stopPolling(); this.$apollo.queries.environmentApp.stopPolling();
this.$apollo.queries.environmentApp.refetch();
this.$nextTick(() => { this.$nextTick(() => {
if (this.interval) { if (this.interval) {
this.$apollo.queries.environmentApp.startPolling(this.interval); this.$apollo.queries.environmentApp.startPolling(this.interval);
} else {
this.$apollo.queries.environmentApp.refetch({ scope: this.scope, page: this.page });
} }
}); });
}, },
...@@ -233,6 +232,7 @@ export default { ...@@ -233,6 +232,7 @@ export default {
:key="environment.name" :key="environment.name"
class="gl-mb-3 gl-border-gray-100 gl-border-1 gl-border-b-solid" class="gl-mb-3 gl-border-gray-100 gl-border-1 gl-border-b-solid"
:environment="environment.latest" :environment="environment.latest"
@change="resetPolling"
/> />
<gl-pagination <gl-pagination
align="center" align="center"
......
...@@ -22,6 +22,7 @@ export default (el) => { ...@@ -22,6 +22,7 @@ export default (el) => {
apolloProvider, apolloProvider,
provide: { provide: {
projectPath: el.dataset.projectPath, projectPath: el.dataset.projectPath,
projectId: el.dataset.projectId,
defaultBranchName: el.dataset.defaultBranchName, defaultBranchName: el.dataset.defaultBranchName,
}, },
data() { data() {
......
...@@ -15,6 +15,7 @@ export default (el) => { ...@@ -15,6 +15,7 @@ export default (el) => {
helpPagePath, helpPagePath,
projectPath, projectPath,
defaultBranchName, defaultBranchName,
projectId,
} = el.dataset; } = el.dataset;
return new Vue({ return new Vue({
...@@ -26,6 +27,7 @@ export default (el) => { ...@@ -26,6 +27,7 @@ export default (el) => {
endpoint, endpoint,
newEnvironmentPath, newEnvironmentPath,
helpPagePath, helpPagePath,
projectId,
canCreateEnvironment: parseBoolean(canCreateEnvironment), canCreateEnvironment: parseBoolean(canCreateEnvironment),
}, },
render(h) { render(h) {
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
"new-environment-path" => new_project_environment_path(@project), "new-environment-path" => new_project_environment_path(@project),
"help-page-path" => help_page_path("ci/environments/index.md"), "help-page-path" => help_page_path("ci/environments/index.md"),
"project-path" => @project.full_path, "project-path" => @project.full_path,
"project-id" => @project.id,
"default-branch-name" => @project.default_branch_or_main } } "default-branch-name" => @project.default_branch_or_main } }
- else - else
#environments-list-view{ data: { environments_data: environments_list_data, #environments-list-view{ data: { environments_data: environments_list_data,
...@@ -16,4 +17,5 @@ ...@@ -16,4 +17,5 @@
"new-environment-path" => new_project_environment_path(@project), "new-environment-path" => new_project_environment_path(@project),
"help-page-path" => help_page_path("ci/environments/index.md"), "help-page-path" => help_page_path("ci/environments/index.md"),
"project-path" => @project.full_path, "project-path" => @project.full_path,
"project-id" => @project.id,
"default-branch-name" => @project.default_branch_or_main } } "default-branch-name" => @project.default_branch_or_main } }
...@@ -84,7 +84,12 @@ This functionality is currently only available through the API. UI is planned fo ...@@ -84,7 +84,12 @@ This functionality is currently only available through the API. UI is planned fo
A blocked deployment is enqueued as soon as it receives the required number of approvals. A single rejection causes the deployment to fail. The creator of a deployment cannot approve it, even if they have permission to deploy. A blocked deployment is enqueued as soon as it receives the required number of approvals. A single rejection causes the deployment to fail. The creator of a deployment cannot approve it, even if they have permission to deploy.
Using the [Deployments API](../../api/deployments.md#approve-or-reject-a-blocked-deployment), users who are allowed to deploy to the protected environment can approve or reject a blocked deployment. There are two ways to approve or reject a deployment to a protected environment:
1. Using the [UI](index.md#view-environments-and-deployments):
1. Select **Approval options** (**{thumb-up}**)
1. Select **Approve** or **Reject**
1. Using the [Deployments API](../../api/deployments.md#approve-or-reject-a-blocked-deployment), users who are allowed to deploy to the protected environment can approve or reject a blocked deployment.
Example: Example:
......
...@@ -43,6 +43,7 @@ export default { ...@@ -43,6 +43,7 @@ export default {
issueMetricImagesPath: '/api/:version/projects/:id/issues/:issue_iid/metric_images', issueMetricImagesPath: '/api/:version/projects/:id/issues/:issue_iid/metric_images',
issueMetricSingleImagePath: issueMetricSingleImagePath:
'/api/:version/projects/:id/issues/:issue_iid/metric_images/:image_id', '/api/:version/projects/:id/issues/:issue_iid/metric_images/:image_id',
environmentApprovalPath: '/api/:version/projects/:id/deployments/:deployment_id/approval',
userSubscription(namespaceId) { userSubscription(namespaceId) {
const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId)); const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
...@@ -387,4 +388,19 @@ export default { ...@@ -387,4 +388,19 @@ export default {
return data; return data;
}); });
}, },
deploymentApproval(id, deploymentId, approve) {
const url = Api.buildUrl(this.environmentApprovalPath)
.replace(':id', encodeURIComponent(id))
.replace(':deployment_id', encodeURIComponent(deploymentId));
return axios.post(url, { status: approve ? 'approved' : 'rejected' });
},
approveDeployment(id, deploymentId) {
return this.deploymentApproval(id, deploymentId, true);
},
rejectDeployment(id, deploymentId) {
return this.deploymentApproval(id, deploymentId, false);
},
}; };
<script>
import { GlButton, GlButtonGroup, GlLink, GlPopover, GlSprintf } 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';
export default {
components: {
GlButton,
GlButtonGroup,
GlLink,
GlPopover,
GlSprintf,
TimeAgoTooltip,
},
inject: ['projectId'],
props: {
environment: {
required: true,
type: Object,
},
},
data() {
return {
id: uniqueId('environment-approval'),
loading: false,
show: false,
};
},
computed: {
title() {
return sprintf(this.$options.i18n.title, {
deploymentIid: this.deploymentIid,
});
},
upcomingDeployment() {
return this.environment?.upcomingDeployment;
},
needsApproval() {
return this.upcomingDeployment.pendingApprovalCount > 0;
},
deploymentIid() {
return this.upcomingDeployment.iid;
},
totalApprovals() {
return this.environment.requiredApprovalCount;
},
currentApprovals() {
return this.totalApprovals - this.upcomingDeployment.pendingApprovalCount;
},
currentUserHasApproved() {
return this.upcomingDeployment?.approvals.find(
({ user }) => user.username === gon.current_username,
);
},
canApproveDeployment() {
return this.upcomingDeployment.canApproveDeployment && !this.currentUserHasApproved;
},
deployableName() {
return this.upcomingDeployment.deployable?.name;
},
},
methods: {
showPopover() {
this.show = true;
},
approve() {
return this.actOnDeployment(Api.approveDeployment.bind(Api));
},
reject() {
return this.actOnDeployment(Api.rejectDeployment.bind(Api));
},
actOnDeployment(action) {
this.loading = true;
this.show = false;
action(this.projectId, this.upcomingDeployment.id)
.catch((err) => {
if (err.response) {
createAlert({ message: err.response.data.message });
}
})
.finally(() => {
this.loading = false;
this.$emit('change');
});
},
approvalText({ user }) {
if (user.username === gon.current_username) {
return this.$options.i18n.approvalByMe;
}
return this.$options.i18n.approval;
},
},
i18n: {
button: s__('DeploymentApproval|Approval options'),
title: s__('DeploymentApproval|Approve or reject deployment #%{deploymentIid}'),
message: s__(
'DeploymentApproval|Approving will run the manual job from deployment #%{deploymentIid}. Rejecting will fail the manual job.',
),
environment: s__('DeploymentApproval|Environment: %{environment}'),
tier: s__('DeploymentApproval|Deployment tier: %{tier}'),
job: s__('DeploymentApproval|Manual job: %{jobName}'),
current: s__('DeploymentApproval| Current approvals: %{current}'),
approval: s__('DeploymentApproval|Approved by %{user} %{time}'),
approvalByMe: s__('DeploymentApproval|Approved by you %{time}'),
approve: __('Approve'),
reject: __('Reject'),
},
};
</script>
<template>
<gl-button-group v-if="needsApproval">
<gl-button :id="id" ref="button" :loading="loading" icon="thumb-up" @click="showPopover">
{{ $options.i18n.button }}
</gl-button>
<gl-popover :target="id" triggers="click blur" placement="top" :title="title" :show="show">
<p>
<gl-sprintf :message="$options.i18n.message">
<template #deploymentIid>{{ deploymentIid }}</template>
</gl-sprintf>
</p>
<div>
<gl-sprintf :message="$options.i18n.environment">
<template #environment>
<span class="gl-font-weight-bold">{{ environment.name }}</span>
</template>
</gl-sprintf>
</div>
<div v-if="environment.tier">
<gl-sprintf :message="$options.i18n.tier">
<template #tier>
<span class="gl-font-weight-bold">{{ environment.tier }}</span>
</template>
</gl-sprintf>
</div>
<div>
<gl-sprintf v-if="deployableName" :message="$options.i18n.job">
<template #jobName>
<span class="gl-font-weight-bold">
{{ deployableName }}
</span>
</template>
</gl-sprintf>
</div>
<div class="gl-mt-4 gl-pt-4">
<gl-sprintf :message="$options.i18n.current">
<template #current>
<span class="gl-font-weight-bold"> {{ currentApprovals }}/{{ totalApprovals }}</span>
</template>
</gl-sprintf>
</div>
<p v-for="(approval, index) in upcomingDeployment.approvals" :key="index">
<gl-sprintf :message="approvalText(approval)">
<template #user>
<gl-link :href="approval.user.webUrl">@{{ approval.user.username }}</gl-link>
</template>
<template #time><time-ago-tooltip :time="approval.createdAt" /></template>
</gl-sprintf>
</p>
<div v-if="canApproveDeployment" class="gl-mt-4 gl-pt-4">
<gl-button ref="approve" :loading="loading" variant="confirm" @click="approve">
{{ $options.i18n.approve }}
</gl-button>
<gl-button ref="reject" :loading="loading" @click="reject">
{{ $options.i18n.reject }}
</gl-button>
</div>
</gl-popover>
</gl-button-group>
</template>
...@@ -7,6 +7,10 @@ module DeploymentEntity ...@@ -7,6 +7,10 @@ module DeploymentEntity
prepended do prepended do
expose :pending_approval_count expose :pending_approval_count
expose :approvals, using: ::API::Entities::Deployments::Approval expose :approvals, using: ::API::Entities::Deployments::Approval
expose :can_approve_deployment do |deployment|
can?(request.current_user, :update_deployment, deployment)
end
end end
end end
end end
...@@ -749,4 +749,28 @@ describe('Api', () => { ...@@ -749,4 +749,28 @@ describe('Api', () => {
}); });
}); });
}); });
describe('deployment approvals', () => {
const projectId = 1;
const deploymentId = 2;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/deployments/${deploymentId}/approval`;
it('sends an approval when approve is true', async () => {
mock.onPost(expectedUrl, { status: 'approved' }).replyOnce(httpStatus.OK);
await Api.deploymentApproval(projectId, deploymentId, true);
expect(mock.history.post.length).toBe(1);
expect(mock.history.post[0].data).toBe(JSON.stringify({ status: 'approved' }));
});
it('sends a rejection when approve is false', async () => {
mock.onPost(expectedUrl, { status: 'rejected' }).replyOnce(httpStatus.OK);
await Api.deploymentApproval(projectId, deploymentId, false);
expect(mock.history.post.length).toBe(1);
expect(mock.history.post[0].data).toBe(JSON.stringify({ status: 'rejected' }));
});
});
}); });
import { GlButton, GlPopover } from '@gitlab/ui';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import { trimText } from 'helpers/text_helper';
import waitForPromises from 'helpers/wait_for_promises';
import EnvironmentApproval from 'ee/environments/components/environment_approval.vue';
import Api from 'ee/api';
import { __, s__, sprintf } from '~/locale';
import { createAlert } from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { environment as mockEnvironment } from './mock_data';
jest.mock('ee/api.js');
jest.mock('~/flash');
describe('ee/environments/components/environment_approval.vue', () => {
let wrapper;
const environment = convertObjectPropsToCamelCase(mockEnvironment, { deep: true });
const createWrapper = ({ propsData = {} } = {}) =>
mountExtended(EnvironmentApproval, {
propsData: { environment, ...propsData },
provide: { projectId: '5' },
});
afterEach(() => {
wrapper.destroy();
});
const findPopover = () => extendedWrapper(wrapper.findComponent(GlPopover));
const findButton = () => extendedWrapper(wrapper.findComponent(GlButton));
it('should link the popover to the button', () => {
wrapper = createWrapper();
const popover = findPopover();
const button = findButton();
expect(popover.props('target')).toBe(button.attributes('id'));
});
describe('popover', () => {
let popover;
beforeEach(async () => {
wrapper = createWrapper();
await findButton().trigger('click');
popover = findPopover();
});
it('should set the popover title', () => {
expect(popover.props('title')).toBe(
sprintf(s__('DeploymentApproval|Approve or reject deployment #%{deploymentIid}'), {
deploymentIid: environment.upcomingDeployment.iid,
}),
);
});
it('should show the popover after clicking the button', () => {
expect(popover.attributes('show')).toBe('true');
});
it('should show which deployment this is approving', () => {
const main = sprintf(
s__(
'DeploymentApproval|Approving will run the manual job from deployment #%{deploymentIid}. Rejecting will fail the manual job.',
),
{
deploymentIid: environment.upcomingDeployment.iid,
},
);
expect(popover.findByText(main).exists()).toBe(true);
});
describe('showing details about the environment', () => {
it.each`
detail | text
${'environment name'} | ${sprintf(s__('DeploymentApproval|Environment: %{environment}'), { environment: environment.name })}
${'environment tier'} | ${sprintf(s__('DeploymentApproval|Deployment tier: %{tier}'), { tier: environment.tier })}
${'job name'} | ${sprintf(s__('DeploymentApproval|Manual job: %{jobName}'), { jobName: environment.upcomingDeployment.deployable.name })}
`('should show information on $detail', ({ text }) => {
expect(trimText(popover.text())).toContain(text);
});
it('shows the number of current approvals as well as the number of total approvals needed', () => {
expect(trimText(popover.text())).toContain(
sprintf(s__('DeploymentApproval| Current approvals: %{current}'), {
current: '5/10',
}),
);
});
});
describe('permissions', () => {
beforeAll(() => {
gon.current_username = 'root';
});
it.each`
scenario | username | approvals | canApproveDeployment | visible
${'user can approve, no approvals'} | ${'root'} | ${[]} | ${true} | ${true}
${'user cannot approve, no approvals'} | ${'root'} | ${[]} | ${false} | ${false}
${'user can approve, has approved'} | ${'root'} | ${[{ user: { username: 'root' }, createdAt: Date.now() }]} | ${true} | ${false}
${'user can approve, someone else approved'} | ${'root'} | ${[{ user: { username: 'foo' }, createdAt: Date.now() }]} | ${true} | ${true}
${'user cannot approve, has already approved'} | ${'root'} | ${[{ user: { username: 'root' }, createdAt: Date.now() }]} | ${false} | ${false}
`(
'should have buttons visible when $scenario: $visible',
({ approvals, canApproveDeployment, visible }) => {
wrapper = createWrapper({
propsData: {
environment: {
...environment,
upcomingDeployment: {
...environment.upcomingDeployment,
approvals,
canApproveDeployment,
},
},
},
});
expect(wrapper.findComponent({ ref: 'approve' }).exists()).toBe(visible);
expect(wrapper.findComponent({ ref: 'reject' }).exists()).toBe(visible);
},
);
});
describe.each`
ref | api | text
${'approve'} | ${Api.approveDeployment} | ${__('Approve')}
${'reject'} | ${Api.rejectDeployment} | ${__('Reject')}
`('$ref', ({ ref, api, text }) => {
let button;
beforeEach(() => {
button = wrapper.findComponent({ ref });
});
it('should show the correct text', () => {
expect(button.text()).toBe(text);
});
it('should approve the deployment when Approve is clicked', async () => {
api.mockResolvedValue();
await button.trigger('click');
expect(api).toHaveBeenCalledWith('5', environment.upcomingDeployment.id);
await waitForPromises();
expect(wrapper.emitted('change')).toEqual([[]]);
});
it('should show an error on failure', async () => {
api.mockRejectedValue({ response: { data: { message: 'oops' } } });
await button.trigger('click');
expect(createAlert).toHaveBeenCalledWith({ message: 'oops' });
});
it('should set loading to true after click', async () => {
await button.trigger('click');
expect(button.props('loading')).toBe(true);
});
it('should stop showing the popover once resolved', async () => {
api.mockResolvedValue();
await button.trigger('click');
expect(popover.attributes('show')).toBeUndefined();
});
});
});
});
...@@ -58,6 +58,69 @@ export const environment = { ...@@ -58,6 +58,69 @@ export const environment = {
], ],
deployed_at: '2016-11-29T18:11:58.430Z', deployed_at: '2016-11-29T18:11:58.430Z',
}, },
upcoming_deployment: {
id: 66,
iid: 6,
sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
ref: {
name: 'main',
ref_url: 'root/ci-folders/tree/main',
},
tag: true,
'last?': true,
user: {
name: 'Administrator',
username: 'root',
id: 1,
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
commit: {
id: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
short_id: '500aabcb',
title: 'Update .gitlab-ci.yml',
author_name: 'Administrator',
author_email: 'admin@example.com',
created_at: '2016-11-07T18:28:13.000+00:00',
message: 'Update .gitlab-ci.yml',
author: {
name: 'Administrator',
username: 'root',
id: 1,
state: 'active',
avatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
},
deployable: {
id: 1279,
name: 'deploy',
build_path: '/root/ci-folders/builds/1279',
retry_path: '/root/ci-folders/builds/1279/retry',
created_at: '2016-11-29T18:11:58.430Z',
updated_at: '2016-11-29T18:11:58.430Z',
status: {
text: 'success',
icon: 'status_success',
},
},
manual_actions: [
{
name: 'action',
play_path: '/play',
},
],
approvals: [],
can_approve_deployment: true,
deployed_at: '2016-11-29T18:11:58.430Z',
pending_approval_count: 5,
},
required_approval_count: 10,
tier: 'production',
has_stop_action: true, has_stop_action: true,
environment_path: 'root/ci-folders/environments/31', environment_path: 'root/ci-folders/environments/31',
log_path: 'root/ci-folders/environments/31/logs', log_path: 'root/ci-folders/environments/31/logs',
......
...@@ -5,6 +5,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; ...@@ -5,6 +5,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
import { stubTransition } from 'helpers/stub_transition'; import { stubTransition } from 'helpers/stub_transition';
import EnvironmentItem from '~/environments/components/new_environment_item.vue'; import EnvironmentItem from '~/environments/components/new_environment_item.vue';
import EnvironmentAlert from 'ee/environments/components/environment_alert.vue'; import EnvironmentAlert from 'ee/environments/components/environment_alert.vue';
import EnvironmentApproval from 'ee/environments/components/environment_approval.vue';
import alertQuery from 'ee/environments/graphql/queries/environment.query.graphql'; import alertQuery from 'ee/environments/graphql/queries/environment.query.graphql';
import { resolvedEnvironment } from 'jest/environments/graphql/mock_data'; import { resolvedEnvironment } from 'jest/environments/graphql/mock_data';
...@@ -13,6 +14,7 @@ Vue.use(VueApollo); ...@@ -13,6 +14,7 @@ Vue.use(VueApollo);
describe('~/environments/components/new_environment_item.vue', () => { describe('~/environments/components/new_environment_item.vue', () => {
let wrapper; let wrapper;
let alert; let alert;
let approval;
const createApolloProvider = () => { const createApolloProvider = () => {
return createMockApollo([ return createMockApollo([
...@@ -43,14 +45,16 @@ describe('~/environments/components/new_environment_item.vue', () => { ...@@ -43,14 +45,16 @@ describe('~/environments/components/new_environment_item.vue', () => {
wrapper = mountExtended(EnvironmentItem, { wrapper = mountExtended(EnvironmentItem, {
apolloProvider, apolloProvider,
propsData: { environment: resolvedEnvironment, ...propsData }, propsData: { environment: resolvedEnvironment, ...propsData },
provide: { helpPagePath: '/help' }, provide: { helpPagePath: '/help', projectId: '1' },
stubs: { transition: stubTransition() }, stubs: { transition: stubTransition() },
}); });
await nextTick(); await nextTick();
alert = wrapper.findComponent(EnvironmentAlert); alert = wrapper.findComponent(EnvironmentAlert);
approval = wrapper.findComponent(EnvironmentApproval);
}; };
it('shows an alert if one is opened', async () => { it('shows an alert if one is opened', async () => {
const environment = { ...resolvedEnvironment, hasOpenedAlert: true }; const environment = { ...resolvedEnvironment, hasOpenedAlert: true };
await createWrapper({ propsData: { environment }, apolloProvider: createApolloProvider() }); await createWrapper({ propsData: { environment }, apolloProvider: createApolloProvider() });
...@@ -62,7 +66,16 @@ describe('~/environments/components/new_environment_item.vue', () => { ...@@ -62,7 +66,16 @@ describe('~/environments/components/new_environment_item.vue', () => {
it('does not show an alert if one is opened', async () => { it('does not show an alert if one is opened', async () => {
await createWrapper({ apolloProvider: createApolloProvider() }); await createWrapper({ apolloProvider: createApolloProvider() });
alert = wrapper.findComponent(EnvironmentAlert);
expect(alert.exists()).toBe(false); expect(alert.exists()).toBe(false);
}); });
it('emits a change if approval changes', async () => {
const upcomingDeployment = resolvedEnvironment.lastDeployment;
const environment = { ...resolvedEnvironment, lastDeployment: null, upcomingDeployment };
await createWrapper({ propsData: { environment }, apolloProvider: createApolloProvider() });
approval.vm.$emit('change');
expect(wrapper.emitted('change')).toEqual([[]]);
});
}); });
...@@ -4,15 +4,17 @@ ...@@ -4,15 +4,17 @@
RSpec.describe DeploymentEntity do RSpec.describe DeploymentEntity do
let_it_be(:project) { create(:project, :repository) } let_it_be(:project) { create(:project, :repository) }
let_it_be(:environment) { create(:environment, project: project) } let_it_be(:current_user) { create(:user) }
let_it_be(:deployment) { create(:deployment, :blocked, project: project, environment: environment) } let_it_be(:request) { EntityRequest.new(project: project, current_user: current_user) }
let_it_be(:request) { EntityRequest.new(project: project, current_user: create(:user)) }
let(:deployment) { create(:deployment, :blocked, project: project, environment: environment) }
let(:environment) { create(:environment, project: project) }
let!(:protected_environment) { create(:protected_environment, name: environment.name, project: project, required_approval_count: 3) }
subject { described_class.new(deployment, request: request).as_json } subject { described_class.new(deployment, request: request).as_json }
before do before do
stub_licensed_features(protected_environments: true) stub_licensed_features(protected_environments: true)
create(:protected_environment, name: environment.name, project: project, required_approval_count: 3)
create(:deployment_approval, deployment: deployment) create(:deployment_approval, deployment: deployment)
end end
...@@ -27,4 +29,23 @@ ...@@ -27,4 +29,23 @@
expect(subject[:approvals].length).to eq(1) expect(subject[:approvals].length).to eq(1)
end end
end end
describe '#can_approve_deployment' do
context 'when user has permission to update deployment' do
before do
project.add_maintainer(current_user)
create(:protected_environment_deploy_access_level, protected_environment: protected_environment, user: current_user)
end
it 'returns true' do
expect(subject[:can_approve_deployment]).to be(true)
end
end
context 'when user does not have permission to update deployment' do
it 'returns false' do
expect(subject[:can_approve_deployment]).to be(false)
end
end
end
end end
...@@ -12227,6 +12227,33 @@ msgstr "" ...@@ -12227,6 +12227,33 @@ msgstr ""
msgid "Deployment frequency" msgid "Deployment frequency"
msgstr "" msgstr ""
   
msgid "DeploymentApproval| Current approvals: %{current}"
msgstr ""
msgid "DeploymentApproval|Approval options"
msgstr ""
msgid "DeploymentApproval|Approve or reject deployment #%{deploymentIid}"
msgstr ""
msgid "DeploymentApproval|Approved by %{user} %{time}"
msgstr ""
msgid "DeploymentApproval|Approved by you %{time}"
msgstr ""
msgid "DeploymentApproval|Approving will run the manual job from deployment #%{deploymentIid}. Rejecting will fail the manual job."
msgstr ""
msgid "DeploymentApproval|Deployment tier: %{tier}"
msgstr ""
msgid "DeploymentApproval|Environment: %{environment}"
msgstr ""
msgid "DeploymentApproval|Manual job: %{jobName}"
msgstr ""
msgid "DeploymentTarget|GitLab Pages" msgid "DeploymentTarget|GitLab Pages"
msgstr "" msgstr ""
   
...@@ -12289,6 +12316,9 @@ msgstr "" ...@@ -12289,6 +12316,9 @@ msgstr ""
msgid "Deployment|Latest Deployed" msgid "Deployment|Latest Deployed"
msgstr "" msgstr ""
   
msgid "Deployment|Needs Approval"
msgstr ""
msgid "Deployment|Running" msgid "Deployment|Running"
msgstr "" msgstr ""
   
...@@ -30352,6 +30382,9 @@ msgstr "" ...@@ -30352,6 +30382,9 @@ msgstr ""
msgid "Reindexing Status: %{status} (Slice multiplier: %{multiplier}, Maximum running slices: %{max_slices})" msgid "Reindexing Status: %{status} (Slice multiplier: %{multiplier}, Maximum running slices: %{max_slices})"
msgstr "" msgstr ""
   
msgid "Reject"
msgstr ""
msgid "Rejected (closed)" msgid "Rejected (closed)"
msgstr "" msgstr ""
   
......
...@@ -24,7 +24,7 @@ describe('~/environments/components/new_environment_item.vue', () => { ...@@ -24,7 +24,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
mountExtended(EnvironmentItem, { mountExtended(EnvironmentItem, {
apolloProvider, apolloProvider,
propsData: { environment: resolvedEnvironment, ...propsData }, propsData: { environment: resolvedEnvironment, ...propsData },
provide: { helpPagePath: '/help' }, provide: { helpPagePath: '/help', projectId: '1' },
stubs: { transition: stubTransition() }, stubs: { transition: stubTransition() },
}); });
......
...@@ -48,6 +48,7 @@ describe('~/environments/components/new_environments_app.vue', () => { ...@@ -48,6 +48,7 @@ describe('~/environments/components/new_environments_app.vue', () => {
canCreateEnvironment: true, canCreateEnvironment: true,
defaultBranchName: 'main', defaultBranchName: 'main',
helpPagePath: '/help', helpPagePath: '/help',
projectId: '1',
...provide, ...provide,
}, },
apolloProvider, apolloProvider,
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment