Commit 06355b17 authored by Peter Hegman's avatar Peter Hegman

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

Add deployment approval UI MVC

See merge request gitlab-org/gitlab!80759
parents 0ee0a889 d39dff98
......@@ -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
......@@ -12233,6 +12233,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 ""
......@@ -12295,6 +12322,9 @@ msgstr ""
msgid "Deployment|Latest Deployed"
msgstr ""
msgid "Deployment|Needs Approval"
msgstr ""
msgid "Deployment|Running"
msgstr ""
......@@ -30343,6 +30373,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