Commit d39dff98 authored by Andrew Fontaine's avatar Andrew Fontaine

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
......@@ -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"
......
......@@ -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">
......
......@@ -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"
......
......@@ -22,6 +22,7 @@ export default (el) => {
apolloProvider,
provide: {
projectPath: el.dataset.projectPath,
projectId: el.dataset.projectId,
defaultBranchName: el.dataset.defaultBranchName,
},
data() {
......
......@@ -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) {
......
......@@ -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 } }
......@@ -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:
......
......@@ -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);
},
};
<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 EE
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
......@@ -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 = {
],
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',
......
......@@ -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([[]]);
});
});
......@@ -4,15 +4,17 @@ require 'spec_helper'
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 @@ RSpec.describe DeploymentEntity do
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
......@@ -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 ""
......
......@@ -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() },
});
......
......@@ -48,6 +48,7 @@ describe('~/environments/components/new_environments_app.vue', () => {
canCreateEnvironment: true,
defaultBranchName: 'main',
helpPagePath: '/help',
projectId: '1',
...provide,
},
apolloProvider,
......
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