Commit 3d274e1e authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch '344881-allow-deleting-a-project' into 'master'

Allow user unlinking a project

See merge request gitlab-org/gitlab!74998
parents 656c2b16 88880b76
<script>
import { GlButton, GlDropdown, GlSprintf, GlAlert, GlModal } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import assignSecurityPolicyProject from '../../graphql/mutations/assign_security_policy_project.mutation.graphql';
import linkSecurityPolicyProject from '../../graphql/mutations/link_security_policy_project.mutation.graphql';
import unlinkSecurityPolicyProject from '../../graphql/mutations/unlink_security_policy_project.mutation.graphql';
import InstanceProjectSelector from '../instance_project_selector.vue';
export default {
......@@ -12,9 +13,19 @@ export default {
header: s__('SecurityOrchestration|Select security project'),
},
save: {
ok: s__('SecurityOrchestration|Security policy project was linked successfully'),
error: s__('SecurityOrchestration|An error occurred assigning your security policy project'),
okLink: s__('SecurityOrchestration|Security policy project was linked successfully'),
okUnlink: s__('SecurityOrchestration|Security policy project was unlinked successfully'),
errorLink: s__(
'SecurityOrchestration|An error occurred assigning your security policy project',
),
errorUnlink: s__(
'SecurityOrchestration|An error occurred unassigning your security policy project',
),
},
unlinkButtonLabel: s__('SecurityOrchestration|Unlink project'),
unlinkWarning: s__(
'SecurityOrchestration|Unlinking a security project removes all policies stored in the linked security project. Save to confirm this action.',
),
disabledWarning: s__('SecurityOrchestration|Only owners can update Security Policy Project'),
description: s__(
'SecurityOrchestration|Select a project to store your security policies in. %{linkStart}More information.%{linkEnd}',
......@@ -45,9 +56,13 @@ export default {
return {
selectedProject: { ...this.assignedPolicyProject },
hasSelectedNewProject: false,
shouldShowUnlinkWarning: false,
};
},
computed: {
selectedProjects() {
return [this.selectedProject];
},
selectedProjectId() {
return this.selectedProject?.id || '';
},
......@@ -55,16 +70,18 @@ export default {
return this.selectedProject?.name || '';
},
isModalOkButtonDisabled() {
if (this.shouldShowUnlinkWarning) {
return false;
}
return this.disableSecurityPolicyProject || !this.hasSelectedNewProject;
},
},
methods: {
async saveChanges() {
this.$emit('updating-project');
async linkProject() {
try {
const { data } = await this.$apollo.mutate({
mutation: assignSecurityPolicyProject,
mutation: linkSecurityPolicyProject,
variables: {
input: {
projectPath: this.projectPath,
......@@ -77,19 +94,69 @@ export default {
throw new Error(data.securityPolicyProjectAssign.errors);
}
this.$emit('project-updated', { text: this.$options.i18n.save.ok, variant: 'success' });
this.$emit('project-updated', {
text: this.$options.i18n.save.okLink,
variant: 'success',
});
} catch {
this.$emit('project-updated', {
text: this.$options.i18n.save.errorLink,
variant: 'danger',
});
}
},
async unlinkProject() {
try {
const { data } = await this.$apollo.mutate({
mutation: unlinkSecurityPolicyProject,
variables: {
input: {
projectPath: this.projectPath,
},
},
});
if (data?.securityPolicyProjectUnassign?.errors?.length) {
throw new Error(data.securityPolicyProjectUnassign.errors);
}
this.shouldShowUnlinkWarning = false;
this.selectedProject = {};
this.$emit('project-updated', {
text: this.$options.i18n.save.okUnlink,
variant: 'success',
});
} catch {
this.$emit('project-updated', { text: this.$options.i18n.save.error, variant: 'danger' });
} finally {
this.hasSelectedNewProject = false;
this.$emit('project-updated', {
text: this.$options.i18n.save.errorUnlink,
variant: 'danger',
});
}
},
async saveChanges() {
this.$emit('updating-project');
if (this.shouldShowUnlinkWarning) {
await this.unlinkProject();
} else {
await this.linkProject();
}
this.hasSelectedNewProject = false;
},
setSelectedProject(data) {
this.shouldShowUnlinkWarning = false;
this.hasSelectedNewProject = true;
this.selectedProject = data;
this.$refs.dropdown.hide();
},
confirmDeletion() {
this.shouldShowUnlinkWarning = !this.shouldShowUnlinkWarning;
},
closeModal() {
this.shouldShowUnlinkWarning = false;
this.$emit('close');
},
},
......@@ -120,20 +187,37 @@ export default {
>
{{ $options.i18n.disabledWarning }}
</gl-alert>
<gl-dropdown
ref="dropdown"
class="gl-w-full gl-pb-5"
menu-class="gl-w-full! gl-max-w-full!"
:disabled="disableSecurityPolicyProject"
:text="selectedProjectName"
<gl-alert
v-if="shouldShowUnlinkWarning"
class="gl-mb-4"
variant="warning"
:dismissible="false"
>
<instance-project-selector
{{ $options.i18n.unlinkWarning }}
</gl-alert>
<div class="gl-display-flex gl-mb-3">
<gl-dropdown
ref="dropdown"
class="gl-w-full"
:max-list-height="$options.PROJECT_SELECTOR_HEIGHT"
:selected-projects="[selectedProject]"
@projectClicked="setSelectedProject"
menu-class="gl-w-full! gl-max-w-full!"
:disabled="disableSecurityPolicyProject"
:text="selectedProjectName"
>
<instance-project-selector
class="gl-w-full"
:max-list-height="$options.PROJECT_SELECTOR_HEIGHT"
:selected-projects="selectedProjects"
@projectClicked="setSelectedProject"
/>
</gl-dropdown>
<gl-button
v-if="selectedProjectId"
icon="remove"
class="gl-ml-3"
:aria-label="$options.i18n.unlinkButtonLabel"
@click="confirmDeletion"
/>
</gl-dropdown>
</div>
<div class="gl-pb-5">
<gl-sprintf :message="$options.i18n.description">
<template #link="{ content }">
......
mutation assignSecurityPolicyProject($input: SecurityPolicyProjectAssignInput!) {
mutation linkSecurityPolicyProject($input: SecurityPolicyProjectAssignInput!) {
securityPolicyProjectAssign(input: $input) {
errors
}
......
mutation securityPolicyProjectUnassign($input: SecurityPolicyProjectUnassignInput!) {
securityPolicyProjectUnassign(input: $input) {
errors
}
}
......@@ -3,11 +3,16 @@ import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import InstanceProjectSelector from 'ee/threat_monitoring/components/instance_project_selector.vue';
import ScanNewPolicyModal from 'ee/threat_monitoring/components/policies/scan_new_policy_modal.vue';
import assignSecurityPolicyProject from 'ee/threat_monitoring/graphql/mutations/assign_security_policy_project.mutation.graphql';
import linkSecurityPolicyProject from 'ee/threat_monitoring/graphql/mutations/link_security_policy_project.mutation.graphql';
import unlinkSecurityPolicyProject from 'ee/threat_monitoring/graphql/mutations/unlink_security_policy_project.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { mockAssignSecurityPolicyProjectResponses } from '../../mocks/mock_apollo';
import waitForPromises from 'helpers/wait_for_promises';
import {
mockLinkSecurityPolicyProjectResponses,
mockUnlinkSecurityPolicyProjectResponses,
} from '../../mocks/mock_apollo';
const localVue = createLocalVue();
localVue.use(VueApollo);
......@@ -18,6 +23,7 @@ describe('ScanNewPolicyModal Component', () => {
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findInstanceProjectSelector = () => wrapper.findComponent(InstanceProjectSelector);
const findUnlinkButton = () => wrapper.findByLabelText('Unlink project');
const findAlert = () => wrapper.findComponent(GlAlert);
const findModal = () => wrapper.findComponent(GlModal);
......@@ -34,12 +40,13 @@ describe('ScanNewPolicyModal Component', () => {
};
const createWrapper = ({
mutationResult = mockAssignSecurityPolicyProjectResponses.success,
mutationQuery = linkSecurityPolicyProject,
mutationResult = mockLinkSecurityPolicyProjectResponses.success,
provide = {},
} = {}) => {
wrapper = mountExtended(ScanNewPolicyModal, {
localVue,
apolloProvider: createMockApollo([[assignSecurityPolicyProject, mutationResult]]),
apolloProvider: createMockApollo([[mutationQuery, mutationResult]]),
stubs: {
GlModal: stubComponent(GlModal, {
template:
......@@ -100,6 +107,43 @@ describe('ScanNewPolicyModal Component', () => {
expect(wrapper.emitted('close')).toEqual([[]]);
});
describe('unlinking project', () => {
it.each`
mutationResult | expectedVariant | expectedText
${'success'} | ${'success'} | ${'okUnlink'}
${'failure'} | ${'danger'} | ${'errorUnlink'}
`(
'unlinks a project and handles $mutationResult case',
async ({ mutationResult, expectedVariant, expectedText }) => {
createWrapper({
mutationQuery: unlinkSecurityPolicyProject,
mutationResult: mockUnlinkSecurityPolicyProjectResponses[mutationResult],
provide: { assignedPolicyProject: { id: 'gid://gitlab/Project/0', name: 'Test 0' } },
});
// Initial state
expect(findModal().attributes('ok-disabled')).toBe('true');
expect(wrapper.findByText(wrapper.vm.$options.i18n.unlinkWarning).exists()).toBe(false);
// When we click on the delete button, the component should display a warning
findUnlinkButton().trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.findByText(wrapper.vm.$options.i18n.unlinkWarning).exists()).toBe(true);
expect(findModal().attributes('ok-disabled')).toBeUndefined();
// Clicking the OK button should submit a GraphQL query
findModal().vm.$emit('ok');
await waitForPromises();
expect(projectUpdatedListener).toHaveBeenCalledWith({
text: wrapper.vm.$options.i18n.save[expectedText],
variant: expectedVariant,
});
},
);
});
describe('project selection', () => {
it('enables the "Save" button only if a new project is selected', async () => {
createWrapper({
......@@ -129,7 +173,7 @@ describe('ScanNewPolicyModal Component', () => {
it('emits an event with an error message', async () => {
await createWrapperAndSelectProject({
mutationResult: mockAssignSecurityPolicyProjectResponses.failure,
mutationResult: mockLinkSecurityPolicyProjectResponses.failure,
});
expect(projectUpdatedListener).toHaveBeenCalledWith({
......
......@@ -52,9 +52,16 @@ export const scanExecutionPolicies = (nodes) =>
},
});
export const mockAssignSecurityPolicyProjectResponses = {
export const mockLinkSecurityPolicyProjectResponses = {
success: jest.fn().mockResolvedValue({ data: { securityPolicyProjectAssign: { errors: [] } } }),
failure: jest
.fn()
.mockResolvedValue({ data: { securityPolicyProjectAssign: { errors: ['mutation failed'] } } }),
};
export const mockUnlinkSecurityPolicyProjectResponses = {
success: jest.fn().mockResolvedValue({ data: { securityPolicyProjectUnassign: { errors: [] } } }),
failure: jest.fn().mockResolvedValue({
data: { securityPolicyProjectUnassign: { errors: ['mutation failed'] } },
}),
};
......@@ -30869,6 +30869,9 @@ msgstr ""
msgid "SecurityOrchestration|An error occurred assigning your security policy project"
msgstr ""
msgid "SecurityOrchestration|An error occurred unassigning your security policy project"
msgstr ""
msgid "SecurityOrchestration|Description"
msgstr ""
......@@ -30947,6 +30950,9 @@ msgstr ""
msgid "SecurityOrchestration|Security policy project was linked successfully"
msgstr ""
msgid "SecurityOrchestration|Security policy project was unlinked successfully"
msgstr ""
msgid "SecurityOrchestration|Select a project to store your security policies in. %{linkStart}More information.%{linkEnd}"
msgstr ""
......@@ -30968,6 +30974,12 @@ msgstr ""
msgid "SecurityOrchestration|To widen your search, change filters above or select a different security policy project."
msgstr ""
msgid "SecurityOrchestration|Unlink project"
msgstr ""
msgid "SecurityOrchestration|Unlinking a security project removes all policies stored in the linked security project. Save to confirm this action."
msgstr ""
msgid "SecurityOrchestration|Update scan execution policies"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment