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> <script>
import { GlButton, GlDropdown, GlSprintf, GlAlert, GlModal } from '@gitlab/ui'; import { GlButton, GlDropdown, GlSprintf, GlAlert, GlModal } from '@gitlab/ui';
import { s__, __ } from '~/locale'; 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'; import InstanceProjectSelector from '../instance_project_selector.vue';
export default { export default {
...@@ -12,9 +13,19 @@ export default { ...@@ -12,9 +13,19 @@ export default {
header: s__('SecurityOrchestration|Select security project'), header: s__('SecurityOrchestration|Select security project'),
}, },
save: { save: {
ok: s__('SecurityOrchestration|Security policy project was linked successfully'), okLink: s__('SecurityOrchestration|Security policy project was linked successfully'),
error: s__('SecurityOrchestration|An error occurred assigning your security policy project'), 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'), disabledWarning: s__('SecurityOrchestration|Only owners can update Security Policy Project'),
description: s__( description: s__(
'SecurityOrchestration|Select a project to store your security policies in. %{linkStart}More information.%{linkEnd}', 'SecurityOrchestration|Select a project to store your security policies in. %{linkStart}More information.%{linkEnd}',
...@@ -45,9 +56,13 @@ export default { ...@@ -45,9 +56,13 @@ export default {
return { return {
selectedProject: { ...this.assignedPolicyProject }, selectedProject: { ...this.assignedPolicyProject },
hasSelectedNewProject: false, hasSelectedNewProject: false,
shouldShowUnlinkWarning: false,
}; };
}, },
computed: { computed: {
selectedProjects() {
return [this.selectedProject];
},
selectedProjectId() { selectedProjectId() {
return this.selectedProject?.id || ''; return this.selectedProject?.id || '';
}, },
...@@ -55,16 +70,18 @@ export default { ...@@ -55,16 +70,18 @@ export default {
return this.selectedProject?.name || ''; return this.selectedProject?.name || '';
}, },
isModalOkButtonDisabled() { isModalOkButtonDisabled() {
if (this.shouldShowUnlinkWarning) {
return false;
}
return this.disableSecurityPolicyProject || !this.hasSelectedNewProject; return this.disableSecurityPolicyProject || !this.hasSelectedNewProject;
}, },
}, },
methods: { methods: {
async saveChanges() { async linkProject() {
this.$emit('updating-project');
try { try {
const { data } = await this.$apollo.mutate({ const { data } = await this.$apollo.mutate({
mutation: assignSecurityPolicyProject, mutation: linkSecurityPolicyProject,
variables: { variables: {
input: { input: {
projectPath: this.projectPath, projectPath: this.projectPath,
...@@ -77,19 +94,69 @@ export default { ...@@ -77,19 +94,69 @@ export default {
throw new Error(data.securityPolicyProjectAssign.errors); 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 { } catch {
this.$emit('project-updated', { text: this.$options.i18n.save.error, variant: 'danger' }); this.$emit('project-updated', {
} finally { text: this.$options.i18n.save.errorUnlink,
this.hasSelectedNewProject = false; variant: 'danger',
});
}
},
async saveChanges() {
this.$emit('updating-project');
if (this.shouldShowUnlinkWarning) {
await this.unlinkProject();
} else {
await this.linkProject();
} }
this.hasSelectedNewProject = false;
}, },
setSelectedProject(data) { setSelectedProject(data) {
this.shouldShowUnlinkWarning = false;
this.hasSelectedNewProject = true; this.hasSelectedNewProject = true;
this.selectedProject = data; this.selectedProject = data;
this.$refs.dropdown.hide(); this.$refs.dropdown.hide();
}, },
confirmDeletion() {
this.shouldShowUnlinkWarning = !this.shouldShowUnlinkWarning;
},
closeModal() { closeModal() {
this.shouldShowUnlinkWarning = false;
this.$emit('close'); this.$emit('close');
}, },
}, },
...@@ -120,20 +187,37 @@ export default { ...@@ -120,20 +187,37 @@ export default {
> >
{{ $options.i18n.disabledWarning }} {{ $options.i18n.disabledWarning }}
</gl-alert> </gl-alert>
<gl-dropdown <gl-alert
ref="dropdown" v-if="shouldShowUnlinkWarning"
class="gl-w-full gl-pb-5" class="gl-mb-4"
menu-class="gl-w-full! gl-max-w-full!" variant="warning"
:disabled="disableSecurityPolicyProject" :dismissible="false"
:text="selectedProjectName"
> >
<instance-project-selector {{ $options.i18n.unlinkWarning }}
</gl-alert>
<div class="gl-display-flex gl-mb-3">
<gl-dropdown
ref="dropdown"
class="gl-w-full" class="gl-w-full"
:max-list-height="$options.PROJECT_SELECTOR_HEIGHT" menu-class="gl-w-full! gl-max-w-full!"
:selected-projects="[selectedProject]" :disabled="disableSecurityPolicyProject"
@projectClicked="setSelectedProject" :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"> <div class="gl-pb-5">
<gl-sprintf :message="$options.i18n.description"> <gl-sprintf :message="$options.i18n.description">
<template #link="{ content }"> <template #link="{ content }">
......
mutation assignSecurityPolicyProject($input: SecurityPolicyProjectAssignInput!) { mutation linkSecurityPolicyProject($input: SecurityPolicyProjectAssignInput!) {
securityPolicyProjectAssign(input: $input) { securityPolicyProjectAssign(input: $input) {
errors errors
} }
......
mutation securityPolicyProjectUnassign($input: SecurityPolicyProjectUnassignInput!) {
securityPolicyProjectUnassign(input: $input) {
errors
}
}
...@@ -3,11 +3,16 @@ import { createLocalVue } from '@vue/test-utils'; ...@@ -3,11 +3,16 @@ import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import InstanceProjectSelector from 'ee/threat_monitoring/components/instance_project_selector.vue'; 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 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 createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import { mountExtended } from 'helpers/vue_test_utils_helper'; 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(); const localVue = createLocalVue();
localVue.use(VueApollo); localVue.use(VueApollo);
...@@ -18,6 +23,7 @@ describe('ScanNewPolicyModal Component', () => { ...@@ -18,6 +23,7 @@ describe('ScanNewPolicyModal Component', () => {
const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdown = () => wrapper.findComponent(GlDropdown);
const findInstanceProjectSelector = () => wrapper.findComponent(InstanceProjectSelector); const findInstanceProjectSelector = () => wrapper.findComponent(InstanceProjectSelector);
const findUnlinkButton = () => wrapper.findByLabelText('Unlink project');
const findAlert = () => wrapper.findComponent(GlAlert); const findAlert = () => wrapper.findComponent(GlAlert);
const findModal = () => wrapper.findComponent(GlModal); const findModal = () => wrapper.findComponent(GlModal);
...@@ -34,12 +40,13 @@ describe('ScanNewPolicyModal Component', () => { ...@@ -34,12 +40,13 @@ describe('ScanNewPolicyModal Component', () => {
}; };
const createWrapper = ({ const createWrapper = ({
mutationResult = mockAssignSecurityPolicyProjectResponses.success, mutationQuery = linkSecurityPolicyProject,
mutationResult = mockLinkSecurityPolicyProjectResponses.success,
provide = {}, provide = {},
} = {}) => { } = {}) => {
wrapper = mountExtended(ScanNewPolicyModal, { wrapper = mountExtended(ScanNewPolicyModal, {
localVue, localVue,
apolloProvider: createMockApollo([[assignSecurityPolicyProject, mutationResult]]), apolloProvider: createMockApollo([[mutationQuery, mutationResult]]),
stubs: { stubs: {
GlModal: stubComponent(GlModal, { GlModal: stubComponent(GlModal, {
template: template:
...@@ -100,6 +107,43 @@ describe('ScanNewPolicyModal Component', () => { ...@@ -100,6 +107,43 @@ describe('ScanNewPolicyModal Component', () => {
expect(wrapper.emitted('close')).toEqual([[]]); 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', () => { describe('project selection', () => {
it('enables the "Save" button only if a new project is selected', async () => { it('enables the "Save" button only if a new project is selected', async () => {
createWrapper({ createWrapper({
...@@ -129,7 +173,7 @@ describe('ScanNewPolicyModal Component', () => { ...@@ -129,7 +173,7 @@ describe('ScanNewPolicyModal Component', () => {
it('emits an event with an error message', async () => { it('emits an event with an error message', async () => {
await createWrapperAndSelectProject({ await createWrapperAndSelectProject({
mutationResult: mockAssignSecurityPolicyProjectResponses.failure, mutationResult: mockLinkSecurityPolicyProjectResponses.failure,
}); });
expect(projectUpdatedListener).toHaveBeenCalledWith({ expect(projectUpdatedListener).toHaveBeenCalledWith({
......
...@@ -52,9 +52,16 @@ export const scanExecutionPolicies = (nodes) => ...@@ -52,9 +52,16 @@ export const scanExecutionPolicies = (nodes) =>
}, },
}); });
export const mockAssignSecurityPolicyProjectResponses = { export const mockLinkSecurityPolicyProjectResponses = {
success: jest.fn().mockResolvedValue({ data: { securityPolicyProjectAssign: { errors: [] } } }), success: jest.fn().mockResolvedValue({ data: { securityPolicyProjectAssign: { errors: [] } } }),
failure: jest failure: jest
.fn() .fn()
.mockResolvedValue({ data: { securityPolicyProjectAssign: { errors: ['mutation failed'] } } }), .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 "" ...@@ -30869,6 +30869,9 @@ msgstr ""
msgid "SecurityOrchestration|An error occurred assigning your security policy project" msgid "SecurityOrchestration|An error occurred assigning your security policy project"
msgstr "" msgstr ""
msgid "SecurityOrchestration|An error occurred unassigning your security policy project"
msgstr ""
msgid "SecurityOrchestration|Description" msgid "SecurityOrchestration|Description"
msgstr "" msgstr ""
...@@ -30947,6 +30950,9 @@ msgstr "" ...@@ -30947,6 +30950,9 @@ msgstr ""
msgid "SecurityOrchestration|Security policy project was linked successfully" msgid "SecurityOrchestration|Security policy project was linked successfully"
msgstr "" 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}" msgid "SecurityOrchestration|Select a project to store your security policies in. %{linkStart}More information.%{linkEnd}"
msgstr "" msgstr ""
...@@ -30968,6 +30974,12 @@ msgstr "" ...@@ -30968,6 +30974,12 @@ msgstr ""
msgid "SecurityOrchestration|To widen your search, change filters above or select a different security policy project." msgid "SecurityOrchestration|To widen your search, change filters above or select a different security policy project."
msgstr "" 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" msgid "SecurityOrchestration|Update scan execution policies"
msgstr "" 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