Commit 314f1267 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '287847-add-delete-compliance-framework-button' into 'master'

Add ability to delete a compliance framework via groups settings

See merge request gitlab-org/gitlab!54219
parents f9178221 dab7d081
<script>
import { GlModal, GlSprintf } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { __, s__, sprintf } from '~/locale';
import deleteComplianceFrameworkMutation from '../graphql/mutations/delete_compliance_framework.mutation.graphql';
import getComplianceFrameworkQuery from '../graphql/queries/get_compliance_framework.query.graphql';
export default {
components: {
GlModal,
GlSprintf,
},
props: {
name: {
type: String,
required: false,
default: null,
},
id: {
type: String,
required: false,
default: null,
},
groupPath: {
type: String,
required: true,
},
},
computed: {
title() {
return sprintf(this.$options.i18n.title, { framework: this.name });
},
},
methods: {
async deleteFramework() {
this.reportDeleting();
try {
const {
data: { destroyComplianceFramework },
} = await this.$apollo.mutate({
mutation: deleteComplianceFrameworkMutation,
variables: {
input: {
id: this.id,
},
},
awaitRefetchQueries: true,
refetchQueries: [
{
query: getComplianceFrameworkQuery,
variables: {
fullPath: this.groupPath,
},
},
],
});
const [error] = destroyComplianceFramework.errors;
if (error) {
this.reportError(new Error(error));
} else {
this.reportSuccess();
}
} catch (error) {
this.reportError(error);
}
},
reportDeleting() {
this.$emit('deleting');
},
reportError(error) {
Sentry.captureException(error);
this.$emit('error');
},
reportSuccess() {
this.$emit('delete');
},
show() {
this.$refs.modal.show();
},
},
i18n: {
title: s__('ComplianceFrameworks|Delete compliance framework %{framework}'),
message: s__(
'ComplianceFrameworks|You are about to permanently delete the compliance framework %{framework} from all projects which currently have it applied, which may remove other functionality. This cannot be undone.',
),
},
buttonProps: {
primary: {
text: s__('ComplianceFrameworks|Delete framework'),
attributes: [{ category: 'primary' }, { variant: 'danger' }],
},
cancel: {
text: __('Cancel'),
},
},
};
</script>
<template>
<gl-modal
ref="modal"
:title="title"
modal-id="delete-framework-modal"
:action-primary="$options.buttonProps.primary"
:action-cancel="$options.buttonProps.cancel"
@primary="deleteFramework"
>
<gl-sprintf :message="$options.i18n.message">
<template #framework>
<strong>{{ name }}</strong>
</template>
</gl-sprintf>
</gl-modal>
</template>
......@@ -5,12 +5,15 @@ import * as Sentry from '@sentry/browser';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
import { DANGER, INFO } from '../constants';
import getComplianceFrameworkQuery from '../graphql/queries/get_compliance_framework.query.graphql';
import DeleteModal from './delete_modal.vue';
import EmptyState from './list_empty_state.vue';
import ListItem from './list_item.vue';
export default {
components: {
DeleteModal,
EmptyState,
GlAlert,
GlLoadingIcon,
......@@ -30,8 +33,11 @@ export default {
},
data() {
return {
markedForDeletion: {},
deletingFramework: null,
complianceFrameworks: [],
error: '',
message: '',
};
},
apollo: {
......@@ -44,7 +50,6 @@ export default {
},
update(data) {
const nodes = data.namespace?.complianceFrameworks?.nodes;
return (
nodes?.map((framework) => ({
...framework,
......@@ -60,7 +65,7 @@ export default {
},
computed: {
isLoading() {
return this.$apollo.loading;
return this.$apollo.loading && !this.deletingFramework;
},
hasLoaded() {
return !this.isLoading && !this.error;
......@@ -77,8 +82,40 @@ export default {
regulatedCount() {
return 0;
},
alertDismissible() {
return !this.error;
},
alertVariant() {
return this.error ? DANGER : INFO;
},
alertMessage() {
return this.error || this.message;
},
},
methods: {
markForDeletion(framework) {
this.markedForDeletion = framework;
this.$refs.modal.show();
},
onError() {
this.error = this.$options.i18n.deleteError;
},
onDelete() {
this.message = this.$options.i18n.deleteMessage;
this.deletingFramework = null;
},
onDeleting() {
this.deletingFramework = this.markedForDeletion;
},
isDeleting(framework) {
return this.deletingFramework === framework;
},
},
i18n: {
deleteMessage: s__('ComplianceFrameworks|Compliance framework deleted successfully'),
deleteError: s__(
'ComplianceFrameworks|Error deleting the compliance framework. Please try again',
),
fetchError: s__(
'ComplianceFrameworks|Error fetching compliance frameworks data. Please refresh the page',
),
......@@ -89,8 +126,13 @@ export default {
</script>
<template>
<div class="gl-border-t-1 gl-border-t-solid gl-border-t-gray-100">
<gl-alert v-if="error" class="gl-mt-5" variant="danger" :dismissible="false">
{{ error }}
<gl-alert
v-if="alertMessage"
class="gl-mt-5"
:variant="alertVariant"
:dismissible="alertDismissible"
>
{{ alertMessage }}
</gl-alert>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-5" />
<empty-state v-if="isEmpty" :image-path="emptyStateSvgPath" />
......@@ -101,9 +143,21 @@ export default {
v-for="framework in complianceFrameworks"
:key="framework.parsedId"
:framework="framework"
:loading="isDeleting(framework)"
@delete="markForDeletion"
/>
</gl-tab>
<gl-tab disabled :title="$options.i18n.regulatedTab" />
</gl-tabs>
<delete-modal
v-if="hasFrameworks"
:id="markedForDeletion.id"
ref="modal"
:name="markedForDeletion.name"
:group-path="groupPath"
@deleting="onDeleting"
@delete="onDelete"
@error="onError"
/>
</div>
</template>
<script>
import { GlLabel } from '@gitlab/ui';
import { GlLabel, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlButton,
GlLabel,
},
props: {
......@@ -10,12 +15,19 @@ export default {
type: Object,
required: true,
},
loading: {
type: Boolean,
required: true,
},
},
computed: {
isScoped() {
return this.framework.name.includes('::');
},
},
i18n: {
deleteFramework: s__('ComplianceFrameworks|Delete framework'),
},
};
</script>
<template>
......@@ -28,10 +40,23 @@ export default {
:background-color="framework.color"
:title="framework.name"
:scoped="isScoped"
:disabled="loading"
/>
</div>
<p class="gl-w-full gl-m-0!" data-testid="compliance-framework-description">
{{ framework.description }}
</p>
<div>
<gl-button
v-gl-tooltip="$options.i18n.deleteFramework"
:loading="loading"
:disabled="loading"
:aria-label="$options.i18n.deleteFramework"
data-testid="compliance-framework-delete-button"
icon="remove"
category="tertiary"
@click="$emit('delete', framework)"
/>
</div>
</div>
</template>
import { s__ } from '~/locale';
export const DANGER = 'danger';
export const INFO = 'info';
export const FETCH_ERROR = s__(
'ComplianceFrameworks|Error fetching compliance frameworks data. Please refresh the page',
);
......
mutation destroyComplianceFramework($input: DestroyComplianceFrameworkInput!) {
destroyComplianceFramework(input: $input) {
errors
}
}
......@@ -7,7 +7,7 @@ import Form from './components/list.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
const createComplianceFrameworksListApp = (el) => {
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DeleteModal component layout matches the snapshot 1`] = `
<gl-modal-stub
actioncancel="[object Object]"
actionprimary="[object Object]"
dismisslabel="Close"
modalclass=""
modalid="delete-framework-modal"
size="md"
title="Delete compliance framework GDPR"
titletag="h4"
>
You are about to permanently delete the compliance framework
<strong>
GDPR
</strong>
from all projects which currently have it applied, which may remove other functionality. This cannot be undone.
</gl-modal-stub>
`;
import { GlSprintf, GlModal } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import DeleteModal from 'ee/groups/settings/compliance_frameworks/components/delete_modal.vue';
import deleteComplianceFrameworkMutation from 'ee/groups/settings/compliance_frameworks/graphql/mutations/delete_compliance_framework.mutation.graphql';
import getComplianceFrameworkQuery from 'ee/groups/settings/compliance_frameworks/graphql/queries/get_compliance_framework.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
validFetchResponse,
validDeleteResponse,
errorDeleteResponse,
frameworkFoundResponse,
} from '../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('DeleteModal', () => {
let wrapper;
const fetchSuccess = jest.fn().mockResolvedValue(validFetchResponse);
const networkError = new Error('network error');
const deleteSuccess = jest.fn().mockResolvedValue(validDeleteResponse);
const deleteError = jest.fn().mockResolvedValue(errorDeleteResponse);
const deleteNetworkError = jest.fn().mockRejectedValue(networkError);
const findModal = () => wrapper.findComponent(GlModal);
const clickDeleteFramework = () => findModal().vm.$emit('primary');
function createMockApolloProvider(resolverMock) {
localVue.use(VueApollo);
const requestHandlers = [
[getComplianceFrameworkQuery, fetchSuccess],
[deleteComplianceFrameworkMutation, resolverMock],
];
return createMockApollo(requestHandlers);
}
const createComponent = (resolverMock) => {
wrapper = shallowMount(DeleteModal, {
localVue,
apolloProvider: createMockApolloProvider(resolverMock),
propsData: {
name: frameworkFoundResponse.name,
id: frameworkFoundResponse.id,
groupPath: 'group-1',
},
stubs: {
GlSprintf,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('component layout', () => {
beforeEach(() => {
createComponent();
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('sets the modal id', () => {
expect(findModal().props('modalId')).toBe('delete-framework-modal');
});
it('sets the modal primary button attributes', () => {
const actionPrimary = findModal().props('actionPrimary');
expect(actionPrimary.text).toBe('Delete framework');
expect(actionPrimary.attributes[1].variant).toBe('danger');
});
it('sets the modal cancel button attributes', () => {
expect(findModal().props('actionCancel').text).toBe('Cancel');
});
});
describe('clickDeleteFramework', () => {
it('emits "deleting" event when busy deleting', () => {
createComponent();
clickDeleteFramework();
expect(wrapper.emitted('deleting')).toHaveLength(1);
});
it('calls the delete mutation with the framework ID', async () => {
createComponent(deleteSuccess);
clickDeleteFramework();
await waitForPromises();
expect(deleteSuccess).toHaveBeenCalledWith({ input: { id: frameworkFoundResponse.id } });
});
it('calls the fetch query with the groupPath', async () => {
createComponent(deleteSuccess);
clickDeleteFramework();
await waitForPromises();
expect(fetchSuccess).toHaveBeenCalledWith({ fullPath: 'group-1' });
});
it('emits "delete" event when the framework is successfully deleted', async () => {
createComponent(deleteSuccess);
clickDeleteFramework();
await waitForPromises();
expect(wrapper.emitted('delete')).toHaveLength(1);
});
it('emits "error" event and reports to Sentry when there is a network error', async () => {
jest.spyOn(Sentry, 'captureException');
createComponent(deleteNetworkError);
clickDeleteFramework();
await waitForPromises();
expect(wrapper.emitted('error')).toHaveLength(1);
expect(Sentry.captureException.mock.calls[0][0].networkError).toStrictEqual(networkError);
});
it('emits "error" event and reports to Sentry when there is a graphql error', async () => {
jest.spyOn(Sentry, 'captureException');
createComponent(deleteError);
clickDeleteFramework();
await waitForPromises();
expect(wrapper.emitted('error')).toHaveLength(1);
expect(Sentry.captureException.mock.calls[0][0]).toStrictEqual(new Error('graphql error'));
});
});
});
......@@ -2,6 +2,7 @@ import { GlLabel } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ListItem from 'ee/groups/settings/compliance_frameworks/components/list_item.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
describe('ListItem', () => {
let wrapper;
......@@ -9,13 +10,18 @@ describe('ListItem', () => {
const framework = { name: 'framework', description: 'a framework', color: '#112233' };
const findLabel = () => wrapper.find(GlLabel);
const findDescription = () => wrapper.find('[data-testid="compliance-framework-description"]');
const findDeleteButton = () => wrapper.find('[data-testid="compliance-framework-delete-button"]');
const createComponent = (props = {}) => {
wrapper = shallowMount(ListItem, {
propsData: {
framework,
loading: false,
...props,
},
directives: {
GlTooltip: createMockDirective(),
},
});
};
......@@ -41,5 +47,42 @@ describe('ListItem', () => {
expect(findLabel().props('title')).toBe('scoped::framework');
expect(findLabel().props('scoped')).toBe(true);
expect(findLabel().props('disabled')).toBe(false);
});
it('displays a delete button', () => {
createComponent();
const button = findDeleteButton();
const tooltip = getBinding(button.element, 'gl-tooltip');
expect(button.props('icon')).toBe('remove');
expect(button.props('disabled')).toBe(false);
expect(button.props('loading')).toBe(false);
expect(button.attributes('aria-label')).toBe('Delete framework');
expect(tooltip.value).toBe('Delete framework');
});
it('emits "delete" event when the delete button is clicked', async () => {
createComponent();
findDeleteButton().vm.$emit('click');
expect(wrapper.emitted('delete')[0]).toStrictEqual([framework]);
});
describe('when loading', () => {
beforeEach(() => {
createComponent({ loading: true });
});
it('disables the label', () => {
expect(findLabel().props('disabled')).toBe(true);
});
it('disables the delete button and shows loading', () => {
expect(findDeleteButton().props('disabled')).toBe(true);
expect(findDeleteButton().props('loading')).toBe(true);
});
});
});
......@@ -3,6 +3,7 @@ import * as Sentry from '@sentry/browser';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import DeleteModal from 'ee/groups/settings/compliance_frameworks/components/delete_modal.vue';
import List from 'ee/groups/settings/compliance_frameworks/components/list.vue';
import EmptyState from 'ee/groups/settings/compliance_frameworks/components/list_empty_state.vue';
import ListItem from 'ee/groups/settings/compliance_frameworks/components/list_item.vue';
......@@ -26,6 +27,7 @@ describe('List', () => {
const fetchWithErrors = jest.fn().mockRejectedValue(sentryError);
const findAlert = () => wrapper.find(GlAlert);
const findDeleteModal = () => wrapper.findComponent(DeleteModal);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findEmptyState = () => wrapper.find(EmptyState);
const findTabs = () => wrapper.findAll(GlTab);
......@@ -82,6 +84,8 @@ describe('List', () => {
});
it('shows the alert', () => {
expect(findAlert().props('dismissible')).toBe(false);
expect(findAlert().props('variant')).toBe('danger');
expect(findAlert().text()).toBe(
'Error fetching compliance frameworks data. Please refresh the page',
);
......@@ -120,6 +124,7 @@ describe('List', () => {
expect(findAlert().exists()).toBe(false);
expect(findLoadingIcon().exists()).toBe(false);
expect(findTabsContainer().exists()).toBe(false);
expect(findDeleteModal().exists()).toBe(false);
});
});
......@@ -166,9 +171,74 @@ describe('List', () => {
),
color: expect.stringMatching(/^#([0-9A-F]{3}){1,2}$/i),
},
loading: false,
}),
),
);
});
it('renders the delete modal', () => {
expect(findDeleteModal().exists()).toBe(true);
});
});
describe('delete framework', () => {
describe('when an item is marked for deletion', () => {
let framework;
const findListItem = () => findListItems().at(0);
beforeEach(async () => {
wrapper = createComponentWithApollo(fetch);
await waitForPromises();
framework = findListItem().props('framework');
findDeleteModal().vm.show = jest.fn();
findListItem().vm.$emit('delete', framework);
});
it('shows the modal when there is a "delete" event from a list item', () => {
expect(findDeleteModal().props('id')).toBe(framework.id);
expect(findDeleteModal().props('name')).toBe(framework.name);
expect(findDeleteModal().vm.show).toHaveBeenCalled();
});
describe('and the item is being deleted', () => {
beforeEach(() => {
findDeleteModal().vm.$emit('deleting');
});
it('sets "loading" to true on the marked list item', () => {
expect(findListItem().props('loading')).toBe(true);
});
describe('and an error occurred', () => {
beforeEach(() => {
findDeleteModal().vm.$emit('error');
});
it('shows the alert for the error', () => {
expect(findAlert().props('dismissible')).toBe(false);
expect(findAlert().props('variant')).toBe('danger');
expect(findAlert().text()).toBe(
'Error deleting the compliance framework. Please try again',
);
});
});
describe('and the item was successfully deleted', () => {
beforeEach(async () => {
findDeleteModal().vm.$emit('delete');
await waitForPromises();
});
it('shows the alert for the success message', () => {
expect(findAlert().props('dismissible')).toBe(true);
expect(findAlert().props('variant')).toBe('info');
expect(findAlert().text()).toBe('Compliance framework deleted successfully');
});
});
});
});
});
});
......@@ -127,3 +127,23 @@ export const errorUpdateResponse = {
},
},
};
export const validDeleteResponse = {
data: {
destroyComplianceFramework: {
clientMutationId: null,
errors: [],
__typename: 'DestroyComplianceFrameworkPayload',
},
},
};
export const errorDeleteResponse = {
data: {
destroyComplianceFramework: {
clientMutationId: null,
errors: ['graphql error'],
__typename: 'DestroyComplianceFrameworkPayload',
},
},
};
......@@ -7596,12 +7596,24 @@ msgstr ""
msgid "ComplianceFrameworks|Combines with the CI configuration at runtime."
msgstr ""
msgid "ComplianceFrameworks|Compliance framework deleted successfully"
msgstr ""
msgid "ComplianceFrameworks|Compliance pipeline configuration location (optional)"
msgstr ""
msgid "ComplianceFrameworks|Could not find this configuration location, please try a different location"
msgstr ""
msgid "ComplianceFrameworks|Delete compliance framework %{framework}"
msgstr ""
msgid "ComplianceFrameworks|Delete framework"
msgstr ""
msgid "ComplianceFrameworks|Error deleting the compliance framework. Please try again"
msgstr ""
msgid "ComplianceFrameworks|Error fetching compliance frameworks data. Please refresh the page"
msgstr ""
......@@ -7623,6 +7635,9 @@ msgstr ""
msgid "ComplianceFrameworks|Use %{codeStart}::%{codeEnd} to create a %{linkStart}scoped set%{linkEnd} (eg. %{codeStart}SOX::AWS%{codeEnd})"
msgstr ""
msgid "ComplianceFrameworks|You are about to permanently delete the compliance framework %{framework} from all projects which currently have it applied, which may remove other functionality. This cannot be undone."
msgstr ""
msgid "ComplianceFrameworks|e.g. include-gitlab.ci.yml@group-name/project-name"
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