Commit 536adae2 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '35816-dismiss-multiple-vulnerabilities' into 'master'

Add ability to dismiss multiple vulnerabilities at once

Closes #35816

See merge request gitlab-org/gitlab!21480
parents cb360440 83492e74
...@@ -65,6 +65,7 @@ ...@@ -65,6 +65,7 @@
// Classes using mixins coming from @gitlab-ui // Classes using mixins coming from @gitlab-ui
// can be removed once https://gitlab.com/gitlab-org/gitlab/merge_requests/19021 has been merged // can be removed once https://gitlab.com/gitlab-org/gitlab/merge_requests/19021 has been merged
.gl-bg-blue-50 { @include gl-bg-blue-50; }
.gl-bg-red-100 { @include gl-bg-red-100; } .gl-bg-red-100 { @include gl-bg-red-100; }
.gl-bg-orange-100 { @include gl-bg-orange-100; } .gl-bg-orange-100 { @include gl-bg-orange-100; }
.gl-bg-gray-100 { @include gl-bg-gray-100; } .gl-bg-gray-100 { @include gl-bg-gray-100; }
......
<script> <script>
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { GlEmptyState } from '@gitlab/ui'; import { GlEmptyState, GlFormCheckbox } from '@gitlab/ui';
import Pagination from '~/vue_shared/components/pagination_links.vue'; import Pagination from '~/vue_shared/components/pagination_links.vue';
import SecurityDashboardTableRow from './security_dashboard_table_row.vue'; import SecurityDashboardTableRow from './security_dashboard_table_row.vue';
import SelectionSummary from './selection_summary.vue';
export default { export default {
name: 'SecurityDashboardTable', name: 'SecurityDashboardTable',
components: { components: {
GlEmptyState, GlEmptyState,
GlFormCheckbox,
Pagination, Pagination,
SecurityDashboardTableRow, SecurityDashboardTableRow,
SelectionSummary,
}, },
computed: { computed: {
...mapState('vulnerabilities', [ ...mapState('vulnerabilities', [
'errorLoadingVulnerabilities', 'errorLoadingVulnerabilities',
'errorLoadingVulnerabilitiesCount', 'errorLoadingVulnerabilitiesCount',
'isLoadingVulnerabilities', 'isLoadingVulnerabilities',
'isDismissingVulnerabilities',
'pageInfo', 'pageInfo',
'vulnerabilities', 'vulnerabilities',
]), ]),
...mapGetters('filters', ['activeFilters']), ...mapGetters('filters', ['activeFilters']),
...mapGetters('vulnerabilities', ['dashboardListError']), ...mapGetters('vulnerabilities', [
'dashboardListError',
'hasSelectedAllVulnerabilities',
'isSelectingVulnerabilities',
]),
showEmptyState() { showEmptyState() {
return ( return (
this.vulnerabilities && this.vulnerabilities &&
...@@ -34,20 +42,38 @@ export default { ...@@ -34,20 +42,38 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions('vulnerabilities', ['fetchVulnerabilities', 'openModal']), ...mapActions('vulnerabilities', [
'deselectAllVulnerabilities',
'fetchVulnerabilities',
'openModal',
'selectAllVulnerabilities',
]),
fetchPage(page) { fetchPage(page) {
this.fetchVulnerabilities({ ...this.activeFilters, page }); this.fetchVulnerabilities({ ...this.activeFilters, page });
}, },
handleSelectAll() {
return this.hasSelectedAllVulnerabilities
? this.deselectAllVulnerabilities()
: this.selectAllVulnerabilities();
},
}, },
}; };
</script> </script>
<template> <template>
<div class="ci-table js-security-dashboard-table" data-qa-selector="security_report_content"> <div class="ci-table js-security-dashboard-table" data-qa-selector="security_report_content">
<selection-summary v-if="isSelectingVulnerabilities" />
<div <div
class="gl-responsive-table-row table-row-header vulnerabilities-row-header px-2" class="gl-responsive-table-row table-row-header vulnerabilities-row-header px-2"
role="row" role="row"
> >
<div class="table-section">
<gl-form-checkbox
:checked="hasSelectedAllVulnerabilities"
class="my-0 ml-1 mr-3"
@change="handleSelectAll"
/>
</div>
<div class="table-section section-10" role="rowheader">{{ s__('Reports|Severity') }}</div> <div class="table-section section-10" role="rowheader">{{ s__('Reports|Severity') }}</div>
<div class="table-section flex-grow-1" role="rowheader"> <div class="table-section flex-grow-1" role="rowheader">
{{ s__('Reports|Vulnerability') }} {{ s__('Reports|Vulnerability') }}
...@@ -67,7 +93,7 @@ export default { ...@@ -67,7 +93,7 @@ export default {
</div> </div>
</div> </div>
<template v-if="isLoadingVulnerabilities"> <template v-if="isLoadingVulnerabilities || isDismissingVulnerabilities">
<security-dashboard-table-row v-for="n in 10" :key="n" :is-loading="true" /> <security-dashboard-table-row v-for="n in 10" :key="n" :is-loading="true" />
</template> </template>
...@@ -106,8 +132,8 @@ export default { ...@@ -106,8 +132,8 @@ export default {
font-size: 14px; font-size: 14px;
} }
.vulnerabilities-row .table-section, .vulnerabilities-row .section-10,
.vulnerabilities-row-header .table-section { .vulnerabilities-row-header .section-10 {
min-width: 120px; min-width: 120px;
} }
</style> </style>
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapActions, mapState } from 'vuex';
import { GlButton, GlSkeletonLoading } from '@gitlab/ui'; import { GlButton, GlSkeletonLoading, GlFormCheckbox } from '@gitlab/ui';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue'; import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import VulnerabilityActionButtons from './vulnerability_action_buttons.vue'; import VulnerabilityActionButtons from './vulnerability_action_buttons.vue';
...@@ -11,6 +11,7 @@ export default { ...@@ -11,6 +11,7 @@ export default {
name: 'SecurityDashboardTableRow', name: 'SecurityDashboardTableRow',
components: { components: {
GlButton, GlButton,
GlFormCheckbox,
GlSkeletonLoading, GlSkeletonLoading,
Icon, Icon,
SeverityBadge, SeverityBadge,
...@@ -30,6 +31,8 @@ export default { ...@@ -30,6 +31,8 @@ export default {
}, },
}, },
computed: { computed: {
...mapState(['dashboardType']),
...mapState('vulnerabilities', ['selectedVulnerabilities']),
severity() { severity() {
return this.vulnerability.severity || ' '; return this.vulnerability.severity || ' ';
}, },
...@@ -56,16 +59,35 @@ export default { ...@@ -56,16 +59,35 @@ export default {
const path = this.vulnerability.create_vulnerability_feedback_issue_path; const path = this.vulnerability.create_vulnerability_feedback_issue_path;
return Boolean(path) && !this.hasIssue; return Boolean(path) && !this.hasIssue;
}, },
...mapState(['dashboardType']), isSelected() {
return Boolean(this.selectedVulnerabilities[this.vulnerability.id]);
},
}, },
methods: { methods: {
...mapActions('vulnerabilities', ['openModal']), ...mapActions('vulnerabilities', ['openModal', 'selectVulnerability', 'deselectVulnerability']),
toggleVulnerability() {
if (this.isSelected) {
return this.deselectVulnerability(this.vulnerability);
}
return this.selectVulnerability(this.vulnerability);
},
}, },
}; };
</script> </script>
<template> <template>
<div class="gl-responsive-table-row vulnerabilities-row p-2" :class="{ dismissed: isDismissed }"> <div
class="gl-responsive-table-row vulnerabilities-row p-2"
:class="{ dismissed: isDismissed, 'gl-bg-blue-50': isSelected }"
>
<div class="table-section">
<gl-form-checkbox
:checked="isSelected"
class="my-0 ml-1 mr-3"
@change="toggleVulnerability"
/>
</div>
<div class="table-section section-10"> <div class="table-section section-10">
<div class="table-mobile-header" role="rowheader">{{ s__('Reports|Severity') }}</div> <div class="table-mobile-header" role="rowheader">{{ s__('Reports|Severity') }}</div>
<div class="table-mobile-content"><severity-badge :severity="severity" /></div> <div class="table-mobile-content"><severity-badge :severity="severity" /></div>
......
<script>
import { __, n__ } from '~/locale';
import { mapActions, mapGetters } from 'vuex';
import { GlButton, GlFormSelect } from '@gitlab/ui';
const REASON_NONE = __('[No reason]');
const REASON_WONT_FIX = __("Won't fix / Accept risk");
const REASON_FALSE_POSITIVE = __('False positive');
export default {
name: 'SelectionSummary',
components: {
GlButton,
GlFormSelect,
},
data: () => ({
dismissalReason: null,
}),
computed: {
...mapGetters('vulnerabilities', ['selectedVulnerabilitiesCount']),
canDismissVulnerability() {
return this.dismissalReason && this.selectedVulnerabilitiesCount > 0;
},
message() {
return n__(
'Dismiss %d selected vulnerability as',
'Dismiss %d selected vulnerabilities as',
this.selectedVulnerabilitiesCount,
);
},
},
methods: {
...mapActions('vulnerabilities', ['dismissSelectedVulnerabilities']),
handleDismiss() {
if (!this.canDismissVulnerability) {
return;
}
if (this.dismissalReason === REASON_NONE) {
this.dismissSelectedVulnerabilities();
} else {
this.dismissSelectedVulnerabilities({ comment: this.dismissalReason });
}
},
},
dismissalReasons: [
{ value: null, text: __('Select a reason') },
REASON_FALSE_POSITIVE,
REASON_WONT_FIX,
REASON_NONE,
],
};
</script>
<template>
<div class="card">
<form class="card-body d-flex align-items-center" @submit.prevent="handleDismiss">
<span>{{ message }}</span>
<gl-form-select
v-model="dismissalReason"
class="mx-3 w-auto"
:options="$options.dismissalReasons"
/>
<gl-button type="submit" variant="close" :disabled="!canDismissVulnerability">{{
__('Dismiss Selected')
}}</gl-button>
</form>
</div>
</template>
import $ from 'jquery'; import $ from 'jquery';
import _ from 'lodash';
import downloadPatchHelper from 'ee/vue_shared/security_reports/store/utils/download_patch_helper'; import downloadPatchHelper from 'ee/vue_shared/security_reports/store/utils/download_patch_helper';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { s__, sprintf } from '~/locale'; import { s__, n__, sprintf } from '~/locale';
import createFlash from '~/flash'; import createFlash from '~/flash';
import toast from '~/vue_shared/plugins/global_toast'; import toast from '~/vue_shared/plugins/global_toast';
import * as types from './mutation_types'; import * as types from './mutation_types';
...@@ -93,7 +94,12 @@ export const requestVulnerabilities = ({ commit }) => { ...@@ -93,7 +94,12 @@ export const requestVulnerabilities = ({ commit }) => {
export const receiveVulnerabilitiesSuccess = ({ commit }, { headers, data }) => { export const receiveVulnerabilitiesSuccess = ({ commit }, { headers, data }) => {
const normalizedHeaders = normalizeHeaders(headers); const normalizedHeaders = normalizeHeaders(headers);
const pageInfo = parseIntPagination(normalizedHeaders); const pageInfo = parseIntPagination(normalizedHeaders);
const vulnerabilities = data; // Vulnerabilities on pipelines don't have IDs.
// We need to add dummy IDs here to avoid rendering issues.
const vulnerabilities = data.map(vulnerability => ({
...vulnerability,
id: vulnerability.id || _.uniqueId('client_'),
}));
commit(types.RECEIVE_VULNERABILITIES_SUCCESS, { pageInfo, vulnerabilities }); commit(types.RECEIVE_VULNERABILITIES_SUCCESS, { pageInfo, vulnerabilities });
}; };
...@@ -115,7 +121,6 @@ export const createIssue = ({ dispatch }, { vulnerability, flashError }) => { ...@@ -115,7 +121,6 @@ export const createIssue = ({ dispatch }, { vulnerability, flashError }) => {
vulnerability_feedback: { vulnerability_feedback: {
feedback_type: 'issue', feedback_type: 'issue',
category: vulnerability.report_type, category: vulnerability.report_type,
project_fingerprint: vulnerability.project_fingerprint,
vulnerability_data: { vulnerability_data: {
...vulnerability, ...vulnerability,
category: vulnerability.report_type, category: vulnerability.report_type,
...@@ -150,6 +155,77 @@ export const receiveCreateIssueError = ({ commit }, { flashError }) => { ...@@ -150,6 +155,77 @@ export const receiveCreateIssueError = ({ commit }, { flashError }) => {
} }
}; };
export const selectAllVulnerabilities = ({ commit }) => {
commit(types.SELECT_ALL_VULNERABILITIES);
};
export const deselectAllVulnerabilities = ({ commit }) => {
commit(types.DESELECT_ALL_VULNERABILITIES);
};
export const selectVulnerability = ({ commit }, { id }) => {
commit(types.SELECT_VULNERABILITY, id);
};
export const deselectVulnerability = ({ commit }, { id }) => {
commit(types.DESELECT_VULNERABILITY, id);
};
export const dismissSelectedVulnerabilities = ({ dispatch, state }, { comment } = {}) => {
const { vulnerabilities, selectedVulnerabilities } = state;
const dismissableVulnerabilties = vulnerabilities.filter(({ id }) => selectedVulnerabilities[id]);
dispatch('requestDismissSelectedVulnerabilities');
const promises = dismissableVulnerabilties.map(vulnerability =>
axios.post(vulnerability.create_vulnerability_feedback_dismissal_path, {
vulnerability_feedback: {
category: vulnerability.report_type,
comment,
feedback_type: 'dismissal',
project_fingerprint: vulnerability.project_fingerprint,
vulnerability_data: {
id: vulnerability.id,
},
},
}),
);
Promise.all(promises)
.then(() => {
dispatch('receiveDismissSelectedVulnerabilitiesSuccess');
})
.catch(() => {
dispatch('receiveDismissSelectedVulnerabilitiesError', { flashError: true });
});
};
export const requestDismissSelectedVulnerabilities = ({ commit }) => {
commit(types.REQUEST_DISMISS_SELECTED_VULNERABILITIES);
};
export const receiveDismissSelectedVulnerabilitiesSuccess = ({ commit, getters }) => {
toast(
n__(
'%d vulnerability dismissed',
'%d vulnerabilities dismissed',
getters.selectedVulnerabilitiesCount,
),
);
commit(types.RECEIVE_DISMISS_SELECTED_VULNERABILITIES_SUCCESS);
};
export const receiveDismissSelectedVulnerabilitiesError = ({ commit }, { flashError }) => {
commit(types.RECEIVE_DISMISS_SELECTED_VULNERABILITIES_ERROR);
if (flashError) {
createFlash(
s__('Security Reports|There was an error dismissing the vulnerabilities.'),
'alert',
document.querySelector('.ci-table'),
);
}
};
export const dismissVulnerability = ( export const dismissVulnerability = (
{ dispatch, state, rootState }, { dispatch, state, rootState },
{ vulnerability, flashError, comment }, { vulnerability, flashError, comment },
......
...@@ -36,4 +36,14 @@ export const getFilteredVulnerabilitiesHistory = (state, getters) => name => { ...@@ -36,4 +36,14 @@ export const getFilteredVulnerabilitiesHistory = (state, getters) => name => {
}); });
}; };
export const selectedVulnerabilitiesCount = state =>
Object.keys(state.selectedVulnerabilities).length;
export const isSelectingVulnerabilities = (state, getters) =>
getters.selectedVulnerabilitiesCount > 0;
export const hasSelectedAllVulnerabilities = (state, getters) =>
getters.isSelectingVulnerabilities &&
getters.selectedVulnerabilitiesCount === state.vulnerabilities.length;
export default () => {}; export default () => {};
...@@ -27,6 +27,17 @@ export const REQUEST_DISMISS_VULNERABILITY = 'REQUEST_DISMISS_VULNERABILITY'; ...@@ -27,6 +27,17 @@ export const REQUEST_DISMISS_VULNERABILITY = 'REQUEST_DISMISS_VULNERABILITY';
export const RECEIVE_DISMISS_VULNERABILITY_SUCCESS = 'RECEIVE_DISMISS_VULNERABILITY_SUCCESS'; export const RECEIVE_DISMISS_VULNERABILITY_SUCCESS = 'RECEIVE_DISMISS_VULNERABILITY_SUCCESS';
export const RECEIVE_DISMISS_VULNERABILITY_ERROR = 'RECEIVE_DISMISS_VULNERABILITY_ERROR'; export const RECEIVE_DISMISS_VULNERABILITY_ERROR = 'RECEIVE_DISMISS_VULNERABILITY_ERROR';
export const REQUEST_DISMISS_SELECTED_VULNERABILITIES = 'REQUEST_DISMISS_SELECTED_VULNERABILITIES';
export const RECEIVE_DISMISS_SELECTED_VULNERABILITIES_SUCCESS =
'RECEIVE_DISMISS_SELECTED_VULNERABILITIES_SUCCESS';
export const RECEIVE_DISMISS_SELECTED_VULNERABILITIES_ERROR =
'RECEIVE_DISMISS_SELECTED_VULNERABILITIES_ERROR';
export const SELECT_VULNERABILITY = 'SELECT_VULNERABILITY';
export const DESELECT_VULNERABILITY = 'DESELECT_VULNERABILITY';
export const SELECT_ALL_VULNERABILITIES = 'SELECT_ALL_VULNERABILITIES';
export const DESELECT_ALL_VULNERABILITIES = 'DESELECT_ALL_VULNERABILITIES';
export const REQUEST_ADD_DISMISSAL_COMMENT = 'REQUEST_ADD_DISMISSAL_COMMENT'; export const REQUEST_ADD_DISMISSAL_COMMENT = 'REQUEST_ADD_DISMISSAL_COMMENT';
export const RECEIVE_ADD_DISMISSAL_COMMENT_SUCCESS = 'RECEIVE_ADD_DISMISSAL_COMMENT_SUCCESS'; export const RECEIVE_ADD_DISMISSAL_COMMENT_SUCCESS = 'RECEIVE_ADD_DISMISSAL_COMMENT_SUCCESS';
export const RECEIVE_ADD_DISMISSAL_COMMENT_ERROR = 'RECEIVE_ADD_DISMISSAL_COMMENT_ERROR'; export const RECEIVE_ADD_DISMISSAL_COMMENT_ERROR = 'RECEIVE_ADD_DISMISSAL_COMMENT_ERROR';
......
...@@ -24,6 +24,7 @@ export default { ...@@ -24,6 +24,7 @@ export default {
state.isLoadingVulnerabilities = false; state.isLoadingVulnerabilities = false;
state.pageInfo = payload.pageInfo; state.pageInfo = payload.pageInfo;
state.vulnerabilities = payload.vulnerabilities; state.vulnerabilities = payload.vulnerabilities;
state.selectedVulnerabilities = {};
}, },
[types.RECEIVE_VULNERABILITIES_ERROR](state, errorCode = null) { [types.RECEIVE_VULNERABILITIES_ERROR](state, errorCode = null) {
state.isLoadingVulnerabilities = false; state.isLoadingVulnerabilities = false;
...@@ -135,6 +136,35 @@ export default { ...@@ -135,6 +136,35 @@ export default {
s__('Security Reports|There was an error dismissing the vulnerability.'), s__('Security Reports|There was an error dismissing the vulnerability.'),
); );
}, },
[types.REQUEST_DISMISS_SELECTED_VULNERABILITIES](state) {
state.isDismissingVulnerabilities = true;
},
[types.RECEIVE_DISMISS_SELECTED_VULNERABILITIES_SUCCESS](state) {
state.isDismissingVulnerabilities = false;
state.selectedVulnerabilities = {};
},
[types.RECEIVE_DISMISS_SELECTED_VULNERABILITIES_ERROR](state) {
state.isDismissingVulnerabilities = false;
},
[types.SELECT_VULNERABILITY](state, id) {
if (state.selectedVulnerabilities[id]) {
return;
}
Vue.set(state.selectedVulnerabilities, id, true);
},
[types.DESELECT_VULNERABILITY](state, id) {
Vue.delete(state.selectedVulnerabilities, id);
},
[types.SELECT_ALL_VULNERABILITIES](state) {
state.selectedVulnerabilities = state.vulnerabilities.reduce(
(acc, { id }) => Object.assign(acc, { [id]: true }),
{},
);
},
[types.DESELECT_ALL_VULNERABILITIES](state) {
state.selectedVulnerabilities = {};
},
[types.REQUEST_ADD_DISMISSAL_COMMENT](state) { [types.REQUEST_ADD_DISMISSAL_COMMENT](state) {
state.isDismissingVulnerability = true; state.isDismissingVulnerability = true;
Vue.set(state.modal, 'isDismissingVulnerability', true); Vue.set(state.modal, 'isDismissingVulnerability', true);
......
...@@ -27,6 +27,8 @@ export default () => ({ ...@@ -27,6 +27,8 @@ export default () => ({
isCommentingOnDismissal: false, isCommentingOnDismissal: false,
isShowingDeleteButtons: false, isShowingDeleteButtons: false,
}, },
isDismissingVulnerabilities: false,
selectedVulnerabilities: {},
isCreatingIssue: false, isCreatingIssue: false,
isCreatingMergeRequest: false, isCreatingMergeRequest: false,
}); });
import * as filtersMutationTypes from '../modules/filters/mutation_types'; import * as filtersMutationTypes from '../modules/filters/mutation_types';
import * as vulnerabilitiesMutationTypes from '../modules/vulnerabilities/mutation_types';
export default store => { export default store => {
const refreshVulnerabilities = payload => { const refreshVulnerabilities = payload => {
...@@ -7,7 +8,7 @@ export default store => { ...@@ -7,7 +8,7 @@ export default store => {
store.dispatch('vulnerabilities/fetchVulnerabilitiesHistory', payload); store.dispatch('vulnerabilities/fetchVulnerabilitiesHistory', payload);
}; };
store.subscribe(({ type, payload }) => { store.subscribe(({ type, payload = {} }) => {
switch (type) { switch (type) {
// SET_ALL_FILTERS mutations are triggered by navigation events, in such case we // SET_ALL_FILTERS mutations are triggered by navigation events, in such case we
// want to preserve the page number that was set in the sync_with_router plugin // want to preserve the page number that was set in the sync_with_router plugin
...@@ -17,8 +18,9 @@ export default store => { ...@@ -17,8 +18,9 @@ export default store => {
page: store.state.vulnerabilities.pageInfo.page, page: store.state.vulnerabilities.pageInfo.page,
}); });
break; break;
// SET_FILTER and SET_TOGGLE_VALUE mutations happen when users interact with the UI, // These mutations happen when users interact with the UI,
// in that case we want to reset the page number // in that case we want to reset the page number
case `vulnerabilities/${vulnerabilitiesMutationTypes.RECEIVE_DISMISS_SELECTED_VULNERABILITIES_SUCCESS}`:
case `filters/${filtersMutationTypes.SET_FILTER}`: case `filters/${filtersMutationTypes.SET_FILTER}`:
case `filters/${filtersMutationTypes.SET_TOGGLE_VALUE}`: { case `filters/${filtersMutationTypes.SET_TOGGLE_VALUE}`: {
if (!payload.lazy) { if (!payload.lazy) {
......
---
title: Adds the ability to dismiss multiple vulnerabilities
merge_request: 21480
author:
type: added
import Vuex from 'vuex'; import Vuex from 'vuex';
import { GlFormCheckbox } from '@gitlab/ui';
import SecurityDashboardTableRow from 'ee/security_dashboard/components/security_dashboard_table_row.vue'; import SecurityDashboardTableRow from 'ee/security_dashboard/components/security_dashboard_table_row.vue';
import createStore from 'ee/security_dashboard/store'; import createStore from 'ee/security_dashboard/store';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils'; import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
...@@ -12,8 +13,7 @@ describe('Security Dashboard Table Row', () => { ...@@ -12,8 +13,7 @@ describe('Security Dashboard Table Row', () => {
let wrapper; let wrapper;
let store; let store;
const createComponent = (mountFunc, { props = {}, storeParams = {} } = {}) => { const createComponent = (mountFunc, { props = {} } = {}) => {
store = createStore(storeParams);
wrapper = mountFunc(SecurityDashboardTableRow, { wrapper = mountFunc(SecurityDashboardTableRow, {
localVue, localVue,
store, store,
...@@ -23,6 +23,11 @@ describe('Security Dashboard Table Row', () => { ...@@ -23,6 +23,11 @@ describe('Security Dashboard Table Row', () => {
}); });
}; };
beforeEach(() => {
store = createStore();
jest.spyOn(store, 'dispatch');
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
...@@ -31,6 +36,8 @@ describe('Security Dashboard Table Row', () => { ...@@ -31,6 +36,8 @@ describe('Security Dashboard Table Row', () => {
const findLoader = () => wrapper.find('.js-skeleton-loader'); const findLoader = () => wrapper.find('.js-skeleton-loader');
const findContent = i => wrapper.findAll('.table-mobile-content').at(i); const findContent = i => wrapper.findAll('.table-mobile-content').at(i);
const findAllIssueCreated = () => wrapper.findAll('.ic-issue-created'); const findAllIssueCreated = () => wrapper.findAll('.ic-issue-created');
const hasSelectedClass = () => wrapper.classes('gl-bg-blue-50');
const findCheckbox = () => wrapper.find(GlFormCheckbox);
describe('when loading', () => { describe('when loading', () => {
beforeEach(() => { beforeEach(() => {
...@@ -93,9 +100,10 @@ describe('Security Dashboard Table Row', () => { ...@@ -93,9 +100,10 @@ describe('Security Dashboard Table Row', () => {
describe('Group Security Dashboard', () => { describe('Group Security Dashboard', () => {
beforeEach(() => { beforeEach(() => {
store.state.dashboardType = DASHBOARD_TYPES.GROUP;
createComponent(shallowMount, { createComponent(shallowMount, {
props: { vulnerability }, props: { vulnerability },
storeParams: { dashboardType: DASHBOARD_TYPES.GROUP },
}); });
}); });
...@@ -168,5 +176,46 @@ describe('Security Dashboard Table Row', () => { ...@@ -168,5 +176,46 @@ describe('Security Dashboard Table Row', () => {
it('should not have a `ic-issue-created` class', () => { it('should not have a `ic-issue-created` class', () => {
expect(findAllIssueCreated()).toHaveLength(0); expect(findAllIssueCreated()).toHaveLength(0);
}); });
it('should be unselected', () => {
expect(hasSelectedClass()).toBe(false);
expect(findCheckbox().attributes('checked')).toBeFalsy();
});
describe('when checked', () => {
beforeEach(() => {
findCheckbox().vm.$emit('change');
});
it('should be selected', () => {
expect(hasSelectedClass()).toBe(true);
expect(findCheckbox().attributes('checked')).toBe('true');
});
it('should update store', () => {
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/selectVulnerability',
vulnerability,
);
});
describe('when unchecked', () => {
beforeEach(() => {
findCheckbox().vm.$emit('change');
});
it('should be unselected', () => {
expect(hasSelectedClass()).toBe(false);
expect(findCheckbox().attributes('checked')).toBeFalsy();
});
it('should update store', () => {
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/deselectVulnerability',
vulnerability,
);
});
});
});
}); });
}); });
import Vuex from 'vuex'; import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui'; import { GlEmptyState, GlFormCheckbox } from '@gitlab/ui';
import SecurityDashboardTable from 'ee/security_dashboard/components/security_dashboard_table.vue'; import SecurityDashboardTable from 'ee/security_dashboard/components/security_dashboard_table.vue';
import SecurityDashboardTableRow from 'ee/security_dashboard/components/security_dashboard_table_row.vue'; import SecurityDashboardTableRow from 'ee/security_dashboard/components/security_dashboard_table_row.vue';
import SelectionSummary from 'ee/security_dashboard/components/selection_summary.vue';
import createStore from 'ee/security_dashboard/store'; import createStore from 'ee/security_dashboard/store';
import { import {
...@@ -35,6 +36,8 @@ describe('Security Dashboard Table', () => { ...@@ -35,6 +36,8 @@ describe('Security Dashboard Table', () => {
wrapper.destroy(); wrapper.destroy();
}); });
const findCheckbox = () => wrapper.find(GlFormCheckbox);
describe('while loading', () => { describe('while loading', () => {
beforeEach(() => { beforeEach(() => {
store.commit(`vulnerabilities/${REQUEST_VULNERABILITIES}`); store.commit(`vulnerabilities/${REQUEST_VULNERABILITIES}`);
...@@ -58,6 +61,28 @@ describe('Security Dashboard Table', () => { ...@@ -58,6 +61,28 @@ describe('Security Dashboard Table', () => {
mockDataVulnerabilities.length, mockDataVulnerabilities.length,
); );
}); });
it('should not show the multi select box', () => {
expect(wrapper.find(SelectionSummary).exists()).toBe(false);
});
it('should show the select all as unchecked', () => {
expect(findCheckbox().attributes('checked')).toBeFalsy();
});
describe('with vulnerabilities selected', () => {
beforeEach(() => {
findCheckbox().vm.$emit('change');
});
it('should show the multi select box', () => {
expect(wrapper.find(SelectionSummary).exists()).toBe(true);
});
it('should show the select all as checked', () => {
expect(findCheckbox().attributes('checked')).toBe('true');
});
});
}); });
describe('with no vulnerabilties', () => { describe('with no vulnerabilties', () => {
......
import createStore from 'ee/security_dashboard/store/index'; import createStore from 'ee/security_dashboard/store/index';
import * as filtersMutationTypes from 'ee/security_dashboard/store/modules/filters/mutation_types'; import * as filtersMutationTypes from 'ee/security_dashboard/store/modules/filters/mutation_types';
import * as vulnerabilityMutationTypes from 'ee/security_dashboard/store/modules/vulnerabilities/mutation_types';
function expectRefreshDispatches(store, payload) {
expect(store.dispatch).toHaveBeenCalledTimes(3);
expect(store.dispatch).toHaveBeenCalledWith('vulnerabilities/fetchVulnerabilities', payload);
expect(store.dispatch).toHaveBeenCalledWith('vulnerabilities/fetchVulnerabilitiesCount', payload);
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/fetchVulnerabilitiesHistory',
payload,
);
}
describe('mediator', () => { describe('mediator', () => {
let store; let store;
...@@ -10,25 +23,10 @@ describe('mediator', () => { ...@@ -10,25 +23,10 @@ describe('mediator', () => {
}); });
it('triggers fetching vulnerabilities after one filter changes', () => { it('triggers fetching vulnerabilities after one filter changes', () => {
const activeFilters = store.getters['filters/activeFilters'];
store.commit(`filters/${filtersMutationTypes.SET_FILTER}`, {}); store.commit(`filters/${filtersMutationTypes.SET_FILTER}`, {});
const activeFilters = store.getters['filters/activeFilters'];
expect(store.dispatch).toHaveBeenCalledTimes(3); expectRefreshDispatches(store, activeFilters);
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/fetchVulnerabilities',
activeFilters,
);
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/fetchVulnerabilitiesCount',
activeFilters,
);
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/fetchVulnerabilitiesHistory',
activeFilters,
);
}); });
it('does not fetch vulnerabilities after one filter changes with lazy = true', () => { it('does not fetch vulnerabilities after one filter changes with lazy = true', () => {
...@@ -45,18 +43,18 @@ describe('mediator', () => { ...@@ -45,18 +43,18 @@ describe('mediator', () => {
store.commit(`filters/${filtersMutationTypes.SET_ALL_FILTERS}`, {}); store.commit(`filters/${filtersMutationTypes.SET_ALL_FILTERS}`, {});
expect(store.dispatch).toHaveBeenCalledTimes(3); expectRefreshDispatches(store, payload);
expect(store.dispatch).toHaveBeenCalledWith('vulnerabilities/fetchVulnerabilities', payload); });
expect(store.dispatch).toHaveBeenCalledWith( it('triggers fetching vulnerabilities multiple vulnerabilities have been dismissed', () => {
'vulnerabilities/fetchVulnerabilitiesCount', const activeFilters = store.getters['filters/activeFilters'];
payload,
);
expect(store.dispatch).toHaveBeenCalledWith( store.commit(
'vulnerabilities/fetchVulnerabilitiesHistory', `vulnerabilities/${vulnerabilityMutationTypes.RECEIVE_DISMISS_SELECTED_VULNERABILITIES_SUCCESS}`,
payload, {},
); );
expectRefreshDispatches(store, activeFilters);
}); });
it('triggers fetching vulnerabilities after "Hide dismissed" toggle changes', () => { it('triggers fetching vulnerabilities after "Hide dismissed" toggle changes', () => {
...@@ -64,21 +62,7 @@ describe('mediator', () => { ...@@ -64,21 +62,7 @@ describe('mediator', () => {
store.commit(`filters/${filtersMutationTypes.SET_TOGGLE_VALUE}`, {}); store.commit(`filters/${filtersMutationTypes.SET_TOGGLE_VALUE}`, {});
expect(store.dispatch).toHaveBeenCalledTimes(3); expectRefreshDispatches(store, activeFilters);
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/fetchVulnerabilities',
activeFilters,
);
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/fetchVulnerabilitiesCount',
activeFilters,
);
expect(store.dispatch).toHaveBeenCalledWith(
'vulnerabilities/fetchVulnerabilitiesHistory',
activeFilters,
);
}); });
it('does not fetch vulnerabilities after "Hide dismissed" toggle changes with lazy = true', () => { it('does not fetch vulnerabilities after "Hide dismissed" toggle changes with lazy = true', () => {
......
...@@ -1170,6 +1170,171 @@ describe('add vulnerability dismissal comment', () => { ...@@ -1170,6 +1170,171 @@ describe('add vulnerability dismissal comment', () => {
}); });
}); });
describe('dismiss multiple vulnerabilities', () => {
let state;
let selectedVulnerabilities;
beforeEach(() => {
state = initialState();
state.vulnerabilities = mockDataVulnerabilities;
selectedVulnerabilities = {
[state.vulnerabilities[0].id]: true,
[state.vulnerabilities[1].id]: true,
};
state.selectedVulnerabilities = selectedVulnerabilities;
});
describe('dismissSelectedVulnerabilities', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
it('should fire the dismissSelected mutations when all is well', done => {
mock
.onPost(state.vulnerabilities[0].create_vulnerability_feedback_dismissal_path)
.replyOnce(200)
.onPost(state.vulnerabilities[1].create_vulnerability_feedback_dismissal_path)
.replyOnce(200);
testAction(
actions.dismissSelectedVulnerabilities,
{},
state,
[],
[
{ type: 'requestDismissSelectedVulnerabilities' },
{
type: 'receiveDismissSelectedVulnerabilitiesSuccess',
},
],
() => {
expect(mock.history.post).toHaveLength(2);
expect(mock.history.post[0].url).toEqual(
state.vulnerabilities[0].create_vulnerability_feedback_dismissal_path,
);
done();
},
);
});
it('should trigger the error state when something goes wrong', done => {
mock
.onPost(state.vulnerabilities[0].create_vulnerability_feedback_dismissal_path)
.replyOnce(200)
.onPost(state.vulnerabilities[1].create_vulnerability_feedback_dismissal_path)
.replyOnce(500);
testAction(
actions.dismissSelectedVulnerabilities,
{},
state,
[],
[
{ type: 'requestDismissSelectedVulnerabilities' },
{ type: 'receiveDismissSelectedVulnerabilitiesError', payload: { flashError: true } },
],
done,
);
});
describe('receiveDismissSelectedVulnerabilitiesSuccess', () => {
it(`should commit ${types.RECEIVE_DISMISS_SELECTED_VULNERABILITIES_SUCCESS}`, done => {
testAction(
actions.receiveDismissSelectedVulnerabilitiesSuccess,
{ selectedVulnerabilities },
state,
[{ type: types.RECEIVE_DISMISS_SELECTED_VULNERABILITIES_SUCCESS }],
[],
done,
);
});
});
describe('receiveDismissSelectedVulnerabilitiesError', () => {
it(`should commit ${types.RECEIVE_DISMISS_SELECTED_VULNERABILITIES_ERROR}`, done => {
testAction(
actions.receiveDismissSelectedVulnerabilitiesError,
{},
state,
[{ type: types.RECEIVE_DISMISS_SELECTED_VULNERABILITIES_ERROR }],
[],
done,
);
});
});
});
});
describe('selecting vulnerabilities', () => {
let state;
beforeEach(() => {
state = initialState();
});
describe('selectVulnerability', () => {
it(`selectVulnerability should commit ${types.SELECT_VULNERABILITY}`, done => {
const id = 1234;
testAction(
actions.selectVulnerability,
{ id },
state,
[{ type: types.SELECT_VULNERABILITY, payload: id }],
[],
done,
);
});
});
describe('deselectVulnerability', () => {
it(`should commit ${types.DESELECT_VULNERABILITY}`, done => {
const id = 1234;
testAction(
actions.deselectVulnerability,
{ id },
state,
[{ type: types.DESELECT_VULNERABILITY, payload: id }],
[],
done,
);
});
});
describe('selectAllVulnerabilities', () => {
it(`should commit ${types.SELECT_ALL_VULNERABILITIES}`, done => {
testAction(
actions.selectAllVulnerabilities,
{},
state,
[{ type: types.SELECT_ALL_VULNERABILITIES }],
[],
done,
);
});
});
describe('deselectAllVulnerabilities', () => {
it(`should commit ${types.DESELECT_ALL_VULNERABILITIES}`, done => {
testAction(
actions.deselectAllVulnerabilities,
{},
state,
[{ type: types.DESELECT_ALL_VULNERABILITIES }],
[],
done,
);
});
});
});
describe('showDismissalDeleteButtons', () => { describe('showDismissalDeleteButtons', () => {
let state; let state;
......
...@@ -104,4 +104,71 @@ describe('vulnerabilities module getters', () => { ...@@ -104,4 +104,71 @@ describe('vulnerabilities module getters', () => {
expect(filteredResults.length).toEqual(88); expect(filteredResults.length).toEqual(88);
}); });
}); });
describe('isSelectingVulnerabilities', () => {
it('should return true if we have selected vulnerabilities', () => {
const mockedGetters = { selectedVulnerabilitiesCount: 3 };
const result = getters.isSelectingVulnerabilities({}, mockedGetters);
expect(result).toBe(true);
});
it('should return false if no vulnerabilites are selected', () => {
const mockedGetters = { selectedVulnerabilitiesCount: 0 };
const result = getters.isSelectingVulnerabilities({}, mockedGetters);
expect(result).toBe(false);
});
});
describe('selectedVulnerabilitiesCount', () => {
it('should return the amount of selected vulnerabilities', () => {
const state = { selectedVulnerabilities: { 1: true, 2: true, 3: true } };
const result = getters.selectedVulnerabilitiesCount(state);
expect(result).toBe(3);
});
it('should return 0 when no vulnerabilities are selected', () => {
const state = { selectedVulnerabilities: {} };
const result = getters.selectedVulnerabilitiesCount(state);
expect(result).toBe(0);
});
});
describe('hasSelectedAllVulnerabilities', () => {
it('should should return true when all the vulnerabilities are selected', () => {
const state = { vulnerabilities: [1, 2, 3] };
const mockedGetters = {
isSelectingVulnerabilities: true,
selectedVulnerabilitiesCount: state.vulnerabilities.length,
};
const result = getters.hasSelectedAllVulnerabilities(state, mockedGetters);
expect(result).toBe(true);
});
it('should should return false when only not all the vulnerabilities are selected', () => {
const state = { vulnerabilities: [1, 2, 3] };
const mockedGetters = {
isSelectingVulnerabilities: true,
selectedVulnerabilitiesCount: state.vulnerabilities.length - 1,
};
const result = getters.hasSelectedAllVulnerabilities(state, mockedGetters);
expect(result).toBe(false);
});
it('should should return false when not selecting vulnerabilities', () => {
const state = { vulnerabilities: [] };
const mockedGetters = {
isSelectingVulnerabilities: false,
selectedVulnerabilitiesCount: 0,
};
const result = getters.hasSelectedAllVulnerabilities(state, mockedGetters);
expect(result).toBe(false);
});
});
}); });
...@@ -453,6 +453,105 @@ describe('vulnerabilities module mutations', () => { ...@@ -453,6 +453,105 @@ describe('vulnerabilities module mutations', () => {
}); });
}); });
describe('REQUEST_DISMISS_SELECTED_VULNERABILITIES', () => {
beforeEach(() => {
mutations[types.REQUEST_DISMISS_SELECTED_VULNERABILITIES](state);
});
it('should set isDismissingVulnerabilities to true', () => {
expect(state.isDismissingVulnerabilities).toBe(true);
});
});
describe('RECEIVE_DISMISS_SELECTED_VULNERABILITIES_SUCCESS', () => {
beforeEach(() => {
mutations[types.RECEIVE_DISMISS_SELECTED_VULNERABILITIES_SUCCESS](state);
});
it('should set isDismissingVulnerabilities to false', () => {
expect(state.isDismissingVulnerabilities).toBe(false);
});
it('should remove all selected vulnerabilities', () => {
expect(Object.keys(state.selectedVulnerabilities)).toHaveLength(0);
});
});
describe('RECEIVE_DISMISS_SELECTED_VULNERABILITIES_ERROR', () => {
beforeEach(() => {
mutations[types.RECEIVE_DISMISS_SELECTED_VULNERABILITIES_ERROR](state);
});
it('should set isDismissingVulnerabilties to false', () => {
expect(state.isDismissingVulnerabilities).toBe(false);
});
});
describe('SELECT_VULNERABILITY', () => {
const id = 1234;
beforeEach(() => {
mutations[types.SELECT_VULNERABILITY](state, id);
});
it('should add the vulnerability to selected vulnerabilities', () => {
expect(state.selectedVulnerabilities[id]).toBeTruthy();
});
it('should not add a duplicate id to the selected vulnerabilities', () => {
expect(state.selectedVulnerabilities).toHaveLength(1);
mutations[types.SELECT_VULNERABILITY](state, id);
expect(state.selectedVulnerabilities).toHaveLength(1);
});
});
describe('DESELECT_VULNERABILITY', () => {
beforeEach(() => {
state.selectedVulnerabilities = { 12: true, 34: true, 56: true };
});
it('should remove the vulnerability from selected vulnerabilities', () => {
const vulnerabilityId = 12;
expect(state.selectedVulnerabilities[vulnerabilityId]).toBeTruthy();
mutations[types.DESELECT_VULNERABILITY](state, vulnerabilityId);
expect(state.selectedVulnerabilities[vulnerabilityId]).toBeFalsy();
});
});
describe('SELECT_ALL_VULNERABILITIES', () => {
beforeEach(() => {
state.vulnerabilities = [{ id: 12 }, { id: 34 }, { id: 56 }];
state.selectedVulnerabilites = {};
});
it('should add all the vulnerabilities when none are selected', () => {
mutations[types.SELECT_ALL_VULNERABILITIES](state);
expect(Object.keys(state.selectedVulnerabilities)).toHaveLength(state.vulnerabilities.length);
});
it('should add all the vulnerabilities when some are already selected', () => {
state.selectedVulnerabilites = { 12: true, 13: true };
mutations[types.SELECT_ALL_VULNERABILITIES](state);
expect(Object.keys(state.selectedVulnerabilities)).toHaveLength(state.vulnerabilities.length);
});
});
describe('DESELECT_ALL_VULNERABILITIES', () => {
beforeEach(() => {
state.selectedVulnerabilities = { 12: true, 34: true, 56: true };
mutations[types.DESELECT_ALL_VULNERABILITIES](state);
});
it('should remove all selected vulnerabilities', () => {
expect(Object.keys(state.selectedVulnerabilities)).toHaveLength(0);
});
});
describe('REQUEST_DELETE_DISMISSAL_COMMENT', () => { describe('REQUEST_DELETE_DISMISSAL_COMMENT', () => {
beforeEach(() => { beforeEach(() => {
mutations[types.REQUEST_DELETE_DISMISSAL_COMMENT](state); mutations[types.REQUEST_DELETE_DISMISSAL_COMMENT](state);
......
...@@ -196,6 +196,11 @@ msgid_plural "%d unstaged changes" ...@@ -196,6 +196,11 @@ msgid_plural "%d unstaged changes"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "%d vulnerability dismissed"
msgid_plural "%d vulnerabilities dismissed"
msgstr[0] ""
msgstr[1] ""
msgid "%s additional commit has been omitted to prevent performance issues." msgid "%s additional commit has been omitted to prevent performance issues."
msgid_plural "%s additional commits have been omitted to prevent performance issues." msgid_plural "%s additional commits have been omitted to prevent performance issues."
msgstr[0] "" msgstr[0] ""
...@@ -6826,12 +6831,20 @@ msgstr "" ...@@ -6826,12 +6831,20 @@ msgstr ""
msgid "Dismiss" msgid "Dismiss"
msgstr "" msgstr ""
msgid "Dismiss %d selected vulnerability as"
msgid_plural "Dismiss %d selected vulnerabilities as"
msgstr[0] ""
msgstr[1] ""
msgid "Dismiss DevOps Score introduction" msgid "Dismiss DevOps Score introduction"
msgstr "" msgstr ""
msgid "Dismiss Merge Request promotion" msgid "Dismiss Merge Request promotion"
msgstr "" msgstr ""
msgid "Dismiss Selected"
msgstr ""
msgid "Dismiss Value Stream Analytics introduction box" msgid "Dismiss Value Stream Analytics introduction box"
msgstr "" msgstr ""
...@@ -8296,6 +8309,9 @@ msgstr "" ...@@ -8296,6 +8309,9 @@ msgstr ""
msgid "Failure" msgid "Failure"
msgstr "" msgstr ""
msgid "False positive"
msgstr ""
msgid "Fast-forward merge is not possible. Rebase the source branch onto the target branch or merge target branch into source branch to allow this merge request to be merged." msgid "Fast-forward merge is not possible. Rebase the source branch onto the target branch or merge target branch into source branch to allow this merge request to be merged."
msgstr "" msgstr ""
...@@ -17062,6 +17078,9 @@ msgstr "" ...@@ -17062,6 +17078,9 @@ msgstr ""
msgid "Security Reports|There was an error deleting the comment." msgid "Security Reports|There was an error deleting the comment."
msgstr "" msgstr ""
msgid "Security Reports|There was an error dismissing the vulnerabilities."
msgstr ""
msgid "Security Reports|There was an error dismissing the vulnerability." msgid "Security Reports|There was an error dismissing the vulnerability."
msgstr "" msgstr ""
...@@ -17218,6 +17237,9 @@ msgstr "" ...@@ -17218,6 +17237,9 @@ msgstr ""
msgid "Select a project to read Insights configuration file" msgid "Select a project to read Insights configuration file"
msgstr "" msgstr ""
msgid "Select a reason"
msgstr ""
msgid "Select a repository" msgid "Select a repository"
msgstr "" msgstr ""
...@@ -22179,6 +22201,9 @@ msgstr "" ...@@ -22179,6 +22201,9 @@ msgstr ""
msgid "Withdraw Access Request" msgid "Withdraw Access Request"
msgstr "" msgstr ""
msgid "Won't fix / Accept risk"
msgstr ""
msgid "Work in progress Limit" msgid "Work in progress Limit"
msgstr "" msgstr ""
...@@ -22767,6 +22792,9 @@ msgstr "" ...@@ -22767,6 +22792,9 @@ msgstr ""
msgid "Zoom meeting removed" msgid "Zoom meeting removed"
msgstr "" msgstr ""
msgid "[No reason]"
msgstr ""
msgid "a deleted user" msgid "a deleted user"
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