diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue index f98edb6bb7de1b4773651a800794c4f2a3d8e8d5..19284b26d51348c4bcdeb90fc6094a843eb175a1 100644 --- a/app/assets/javascripts/environments/components/deployment.vue +++ b/app/assets/javascripts/environments/components/deployment.vue @@ -102,6 +102,9 @@ export default { refPath() { return this.ref?.refPath; }, + needsApproval() { + return this.deployment.pendingApprovalCount > 0; + }, }, methods: { toggleCollapse() { @@ -116,6 +119,7 @@ export default { showDetails: __('Show details'), hideDetails: __('Hide details'), triggerer: s__('Deployment|Triggerer'), + needsApproval: s__('Deployment|Needs Approval'), job: __('Job'), api: __('API'), branch: __('Branch'), @@ -153,6 +157,9 @@ export default { <div :class="$options.headerDetailsClasses"> <div :class="$options.deploymentStatusClasses"> <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> </div> <div class="gl-display-flex gl-align-items-center gl-gap-x-5"> @@ -199,6 +206,7 @@ export default { </gl-button> </div> <commit v-if="commit" :commit="commit" class="gl-mt-3" /> + <div class="gl-mt-3"><slot name="approval"></slot></div> <gl-collapse :visible="visible"> <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" diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue index c039a663a68f9cf0f20c663525c57fb1778c4b1d..80d9b300d3fd873dd265d3702c07d7a8e8800573 100644 --- a/app/assets/javascripts/environments/components/new_environment_item.vue +++ b/app/assets/javascripts/environments/components/new_environment_item.vue @@ -41,6 +41,8 @@ export default { TimeAgoTooltip, Delete, EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'), + EnvironmentApproval: () => + import('ee_component/environments/components/environment_approval.vue'), }, directives: { GlTooltip, @@ -305,7 +307,11 @@ export default { :deployment="upcomingDeployment" :class="{ 'gl-ml-7': inFolder }" class="gl-pl-4" - /> + > + <template #approval> + <environment-approval :environment="environment" @change="$emit('change')" /> + </template> + </deployment> </div> </template> <div v-else :class="$options.deploymentClasses"> diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue index 3699f39b611508aa1f331c919c1372c35169253b..67fd6ffd9754d219c13791101f6fd05164f4b504 100644 --- a/app/assets/javascripts/environments/components/new_environments_app.vue +++ b/app/assets/javascripts/environments/components/new_environments_app.vue @@ -175,11 +175,10 @@ export default { }, resetPolling() { this.$apollo.queries.environmentApp.stopPolling(); + this.$apollo.queries.environmentApp.refetch(); this.$nextTick(() => { if (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 { :key="environment.name" class="gl-mb-3 gl-border-gray-100 gl-border-1 gl-border-b-solid" :environment="environment.latest" + @change="resetPolling" /> <gl-pagination align="center" diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index 3b1d35c1f22687a203f1e9ebbf8dc1ec122a3f55..5805fe6b1c0d3c0f0c31beba1ce8f141c3e55a61 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -22,6 +22,7 @@ export default (el) => { apolloProvider, provide: { projectPath: el.dataset.projectPath, + projectId: el.dataset.projectId, defaultBranchName: el.dataset.defaultBranchName, }, data() { diff --git a/app/assets/javascripts/environments/new_index.js b/app/assets/javascripts/environments/new_index.js index dd5c709c75a4b8cc41cc9eee0274e5959f5e6bc5..d30ceb80f21b3e1d4b7ddb958d262ecb6caecd51 100644 --- a/app/assets/javascripts/environments/new_index.js +++ b/app/assets/javascripts/environments/new_index.js @@ -15,6 +15,7 @@ export default (el) => { helpPagePath, projectPath, defaultBranchName, + projectId, } = el.dataset; return new Vue({ @@ -26,6 +27,7 @@ export default (el) => { endpoint, newEnvironmentPath, helpPagePath, + projectId, canCreateEnvironment: parseBoolean(canCreateEnvironment), }, render(h) { diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 2b05ffe3eea0fe7d5d257fba1e6a4f104f516389..77b2fc25c9a0d662a0adc3806e4e443d1e5c3185 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -8,6 +8,7 @@ "new-environment-path" => new_project_environment_path(@project), "help-page-path" => help_page_path("ci/environments/index.md"), "project-path" => @project.full_path, + "project-id" => @project.id, "default-branch-name" => @project.default_branch_or_main } } - else #environments-list-view{ data: { environments_data: environments_list_data, @@ -16,4 +17,5 @@ "new-environment-path" => new_project_environment_path(@project), "help-page-path" => help_page_path("ci/environments/index.md"), "project-path" => @project.full_path, + "project-id" => @project.id, "default-branch-name" => @project.default_branch_or_main } } diff --git a/doc/ci/environments/deployment_approvals.md b/doc/ci/environments/deployment_approvals.md index d8e6bb629c360258f066fa7f5d6e45eee1e20faa..b5cbc24aa99b28b80325f7b3cb1f42a51774f192 100644 --- a/doc/ci/environments/deployment_approvals.md +++ b/doc/ci/environments/deployment_approvals.md @@ -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. -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: diff --git a/ee/app/assets/javascripts/api.js b/ee/app/assets/javascripts/api.js index 9d015c86990eaf0c70f743a2bde7ba3137622c3e..ea19b584a44f44e983adf15b64c68928803aa4c7 100644 --- a/ee/app/assets/javascripts/api.js +++ b/ee/app/assets/javascripts/api.js @@ -43,6 +43,7 @@ export default { issueMetricImagesPath: '/api/:version/projects/:id/issues/:issue_iid/metric_images', issueMetricSingleImagePath: '/api/:version/projects/:id/issues/:issue_iid/metric_images/:image_id', + environmentApprovalPath: '/api/:version/projects/:id/deployments/:deployment_id/approval', userSubscription(namespaceId) { const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId)); @@ -387,4 +388,19 @@ export default { 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); + }, }; diff --git a/ee/app/assets/javascripts/environments/components/environment_approval.vue b/ee/app/assets/javascripts/environments/components/environment_approval.vue new file mode 100644 index 0000000000000000000000000000000000000000..f1632d96a72f87c50e894ee695de0c4717c7461e --- /dev/null +++ b/ee/app/assets/javascripts/environments/components/environment_approval.vue @@ -0,0 +1,175 @@ +<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> diff --git a/ee/app/serializers/ee/deployment_entity.rb b/ee/app/serializers/ee/deployment_entity.rb index 84f8e4a3cd4b6c7ad7b388fea6035ddd394a802a..254b0d3c8ce69f4faa673234b3e89afdba995515 100644 --- a/ee/app/serializers/ee/deployment_entity.rb +++ b/ee/app/serializers/ee/deployment_entity.rb @@ -7,6 +7,10 @@ module DeploymentEntity prepended do expose :pending_approval_count expose :approvals, using: ::API::Entities::Deployments::Approval + + expose :can_approve_deployment do |deployment| + can?(request.current_user, :update_deployment, deployment) + end end end end diff --git a/ee/spec/frontend/api_spec.js b/ee/spec/frontend/api_spec.js index 979b0dceaf95e6686dbc345ae780784b39f293d9..d7ca4952ff6393bbd8b2742e6958f01291856788 100644 --- a/ee/spec/frontend/api_spec.js +++ b/ee/spec/frontend/api_spec.js @@ -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' })); + }); + }); }); diff --git a/ee/spec/frontend/environments/environment_approval_spec.js b/ee/spec/frontend/environments/environment_approval_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..44bbac6b2b12963fb90f4a6176dcf5773f224a36 --- /dev/null +++ b/ee/spec/frontend/environments/environment_approval_spec.js @@ -0,0 +1,177 @@ +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(); + }); + }); + }); +}); diff --git a/ee/spec/frontend/environments/mock_data.js b/ee/spec/frontend/environments/mock_data.js index f9b8024ec4d367ce14cda6720d3160436d6ca74f..a773fb535261ac24c88399baa218965d0ba0b0b6 100644 --- a/ee/spec/frontend/environments/mock_data.js +++ b/ee/spec/frontend/environments/mock_data.js @@ -58,6 +58,69 @@ export const environment = { ], 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, environment_path: 'root/ci-folders/environments/31', log_path: 'root/ci-folders/environments/31/logs', diff --git a/ee/spec/frontend/environments/new_environment_item_spec.js b/ee/spec/frontend/environments/new_environment_item_spec.js index 61a83e800e3447e767cb0c3027e20e80c0b56d22..b50b09a3bba3e66ab21d3e1fa2b346761590240d 100644 --- a/ee/spec/frontend/environments/new_environment_item_spec.js +++ b/ee/spec/frontend/environments/new_environment_item_spec.js @@ -5,6 +5,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import { stubTransition } from 'helpers/stub_transition'; import EnvironmentItem from '~/environments/components/new_environment_item.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 { resolvedEnvironment } from 'jest/environments/graphql/mock_data'; @@ -13,6 +14,7 @@ Vue.use(VueApollo); describe('~/environments/components/new_environment_item.vue', () => { let wrapper; let alert; + let approval; const createApolloProvider = () => { return createMockApollo([ @@ -43,14 +45,16 @@ describe('~/environments/components/new_environment_item.vue', () => { wrapper = mountExtended(EnvironmentItem, { apolloProvider, propsData: { environment: resolvedEnvironment, ...propsData }, - provide: { helpPagePath: '/help' }, + provide: { helpPagePath: '/help', projectId: '1' }, stubs: { transition: stubTransition() }, }); await nextTick(); alert = wrapper.findComponent(EnvironmentAlert); + approval = wrapper.findComponent(EnvironmentApproval); }; + it('shows an alert if one is opened', async () => { const environment = { ...resolvedEnvironment, hasOpenedAlert: true }; await createWrapper({ propsData: { environment }, apolloProvider: createApolloProvider() }); @@ -62,7 +66,16 @@ describe('~/environments/components/new_environment_item.vue', () => { it('does not show an alert if one is opened', async () => { await createWrapper({ apolloProvider: createApolloProvider() }); - alert = wrapper.findComponent(EnvironmentAlert); 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([[]]); + }); }); diff --git a/ee/spec/serializers/ee/deployment_entity_spec.rb b/ee/spec/serializers/ee/deployment_entity_spec.rb index e2b91ea983a82acbb7d5038b2793eee300428f10..fa420909e07c428826ea3754e2698008d3a305f3 100644 --- a/ee/spec/serializers/ee/deployment_entity_spec.rb +++ b/ee/spec/serializers/ee/deployment_entity_spec.rb @@ -4,15 +4,17 @@ RSpec.describe DeploymentEntity do let_it_be(:project) { create(:project, :repository) } - let_it_be(:environment) { create(:environment, project: project) } - let_it_be(:deployment) { create(:deployment, :blocked, project: project, environment: environment) } - let_it_be(:request) { EntityRequest.new(project: project, current_user: create(:user)) } + let_it_be(:current_user) { create(:user) } + let_it_be(:request) { EntityRequest.new(project: project, current_user: current_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 } before do stub_licensed_features(protected_environments: true) - create(:protected_environment, name: environment.name, project: project, required_approval_count: 3) create(:deployment_approval, deployment: deployment) end @@ -27,4 +29,23 @@ expect(subject[:approvals].length).to eq(1) 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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 134bcfc240d884b6565eaf0a2dc6ff2a6793b50a..b94e32447b52f7aaab1b5290779b6a2b575110c6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -12227,6 +12227,33 @@ msgstr "" msgid "Deployment frequency" 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" msgstr "" @@ -12289,6 +12316,9 @@ msgstr "" msgid "Deployment|Latest Deployed" msgstr "" +msgid "Deployment|Needs Approval" +msgstr "" + msgid "Deployment|Running" msgstr "" @@ -30352,6 +30382,9 @@ msgstr "" msgid "Reindexing Status: %{status} (Slice multiplier: %{multiplier}, Maximum running slices: %{max_slices})" msgstr "" +msgid "Reject" +msgstr "" + msgid "Rejected (closed)" msgstr "" diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js index db596688dad396494b908cd9564d23be708cd8f3..1d7a33fb95bbf67ecdedfda4bce8ac584b417e6c 100644 --- a/spec/frontend/environments/new_environment_item_spec.js +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -24,7 +24,7 @@ describe('~/environments/components/new_environment_item.vue', () => { mountExtended(EnvironmentItem, { apolloProvider, propsData: { environment: resolvedEnvironment, ...propsData }, - provide: { helpPagePath: '/help' }, + provide: { helpPagePath: '/help', projectId: '1' }, stubs: { transition: stubTransition() }, }); diff --git a/spec/frontend/environments/new_environments_app_spec.js b/spec/frontend/environments/new_environments_app_spec.js index 42e3608109b909258f8348506dd49176da28d1bc..2cc1c18d325ee65264b2d165c0ce1ac5548a6909 100644 --- a/spec/frontend/environments/new_environments_app_spec.js +++ b/spec/frontend/environments/new_environments_app_spec.js @@ -48,6 +48,7 @@ describe('~/environments/components/new_environments_app.vue', () => { canCreateEnvironment: true, defaultBranchName: 'main', helpPagePath: '/help', + projectId: '1', ...provide, }, apolloProvider,