Commit ebb87c96 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '213598-multi-dismiss-frontend-vulnerability-list' into 'master'

Allow vulnerability selection in project dashboard

See merge request gitlab-org/gitlab!29293
parents 24b51f5c e4a602ee
...@@ -83,6 +83,9 @@ export default { ...@@ -83,6 +83,9 @@ export default {
}); });
} }
}, },
refetchVulnerabilities() {
this.$apollo.queries.vulnerabilities.refetch();
},
}, },
emptyStateDescription: s__( emptyStateDescription: s__(
`While it's rare to have no vulnerabilities for your project, it can happen. In any event, we ask that you double check your settings to make sure you've set up your dashboard correctly.`, `While it's rare to have no vulnerabilities for your project, it can happen. In any event, we ask that you double check your settings to make sure you've set up your dashboard correctly.`,
...@@ -104,7 +107,10 @@ export default { ...@@ -104,7 +107,10 @@ export default {
:is-loading="isLoadingFirstVulnerabilities" :is-loading="isLoadingFirstVulnerabilities"
:dashboard-documentation="dashboardDocumentation" :dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath" :empty-state-svg-path="emptyStateSvgPath"
:filters="filters"
should-show-selection
:vulnerabilities="vulnerabilities" :vulnerabilities="vulnerabilities"
@refetch-vulnerabilities="refetchVulnerabilities"
> >
<template #emptyState> <template #emptyState>
<gl-empty-state <gl-empty-state
......
<script> <script>
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { GlEmptyState, GlLink, GlSkeletonLoading, GlTable } from '@gitlab/ui'; import { GlEmptyState, GlFormCheckbox, GlLink, GlSkeletonLoading, GlTable } from '@gitlab/ui';
import RemediatedBadge from './remediated_badge.vue'; import RemediatedBadge from './remediated_badge.vue';
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 SelectionSummary from 'ee/security_dashboard/components/selection_summary.vue';
import { VULNERABILITIES_PER_PAGE } from '../constants'; import { VULNERABILITIES_PER_PAGE } from '../constants';
export default { export default {
name: 'VulnerabilityList', name: 'VulnerabilityList',
components: { components: {
GlEmptyState, GlEmptyState,
GlFormCheckbox,
GlLink, GlLink,
GlSkeletonLoading, GlSkeletonLoading,
GlTable, GlTable,
RemediatedBadge, RemediatedBadge,
SelectionSummary,
SeverityBadge, SeverityBadge,
}, },
props: { props: {
...@@ -24,6 +27,16 @@ export default { ...@@ -24,6 +27,16 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
filters: {
type: Object,
required: false,
default: null,
},
shouldShowSelection: {
type: Boolean,
required: false,
default: false,
},
vulnerabilities: { vulnerabilities: {
type: Array, type: Array,
required: true, required: true,
...@@ -34,70 +47,149 @@ export default { ...@@ -34,70 +47,149 @@ export default {
default: false, default: false,
}, },
}, },
fields: [ data() {
{ return {
key: 'state', selectedVulnerabilities: {},
label: s__('Vulnerability|Status'), };
thClass: 'gl-w-64', },
}, computed: {
{ hasSelectedAllVulnerabilities() {
key: 'severity', return this.numOfSelectedVulnerabilities === this.vulnerabilities.length;
label: s__('Vulnerability|Severity'), },
thClass: 'gl-w-64', numOfSelectedVulnerabilities() {
}, return Object.keys(this.selectedVulnerabilities).length;
{ },
key: 'title', shouldShowSelectionSummary() {
label: __('Description'), return this.shouldShowSelection && Boolean(this.numOfSelectedVulnerabilities);
}, },
], checkboxClass() {
return this.shouldShowSelection ? '' : 'd-none';
},
fields() {
return [
{
key: 'checkbox',
class: this.checkboxClass,
},
{
key: 'state',
label: s__('Vulnerability|Status'),
thClass: 'gl-w-64',
},
{
key: 'severity',
label: s__('Vulnerability|Severity'),
thClass: 'gl-w-64',
},
{
key: 'title',
label: __('Description'),
},
];
},
},
watch: {
filters() {
this.selectedVulnerabilities = {};
},
},
methods: {
deselectAllVulnerabilities() {
this.selectedVulnerabilities = {};
},
isSelected(vulnerability = {}) {
return Boolean(this.selectedVulnerabilities[vulnerability.id]);
},
selectAllVulnerabilities() {
this.selectedVulnerabilities = this.vulnerabilities.reduce((acc, curr) => {
acc[curr.id] = curr;
return acc;
}, {});
},
toggleAllVulnerabilities() {
if (this.hasSelectedAllVulnerabilities) {
this.deselectAllVulnerabilities();
} else {
this.selectAllVulnerabilities();
}
},
toggleVulnerability(vulnerability) {
if (!vulnerability) return;
if (this.selectedVulnerabilities[vulnerability.id]) {
this.$delete(this.selectedVulnerabilities, `${vulnerability.id}`);
} else {
this.$set(this.selectedVulnerabilities, `${vulnerability.id}`, vulnerability);
}
},
},
VULNERABILITIES_PER_PAGE, VULNERABILITIES_PER_PAGE,
}; };
</script> </script>
<template> <template>
<gl-table <div>
:busy="isLoading" <selection-summary
:fields="$options.fields" v-if="shouldShowSelectionSummary"
:items="vulnerabilities" :selected-vulnerabilities="Object.values(selectedVulnerabilities)"
stacked="sm" @deselect-all-vulnerabilities="deselectAllVulnerabilities"
show-empty @refetch-vulnerabilities="$emit('refetch-vulnerabilities')"
responsive />
> <gl-table
<template #cell(state)="{ item }"> :busy="isLoading"
<span class="text-capitalize js-status">{{ item.state.toLowerCase() }}</span> :fields="fields"
</template> :items="vulnerabilities"
stacked="sm"
show-empty
responsive
>
<template #head(checkbox)>
<gl-form-checkbox
:checked="hasSelectedAllVulnerabilities"
@change="toggleAllVulnerabilities"
/>
</template>
<template #cell(checkbox)="{ item }">
<gl-form-checkbox :checked="isSelected(item)" @change="toggleVulnerability(item)" />
</template>
<template #cell(severity)="{ item }"> <template #cell(state)="{ item }">
<severity-badge class="js-severity" :severity="item.severity" /> <span class="text-capitalize js-status">{{ item.state.toLowerCase() }}</span>
</template> </template>
<template #cell(title)="{ item }"> <template #cell(severity)="{ item }">
<gl-link class="text-body js-description" :href="item.vulnerabilityPath"> <severity-badge class="js-severity" :severity="item.severity" />
{{ item.title }} </template>
</gl-link>
<remediated-badge v-if="item.resolved_on_default_branch" class="ml-2" />
</template>
<template #table-busy> <template #cell(title)="{ item }">
<gl-skeleton-loading <gl-link class="text-body js-description" :href="item.vulnerabilityPath">
v-for="n in $options.VULNERABILITIES_PER_PAGE" {{ item.title }}
:key="n" </gl-link>
class="m-2 js-skeleton-loader" <remediated-badge v-if="item.resolved_on_default_branch" class="ml-2" />
:lines="2" </template>
/>
</template>
<template #empty> <template #table-busy>
<slot name="emptyState"> <gl-skeleton-loading
<gl-empty-state v-for="n in $options.VULNERABILITIES_PER_PAGE"
:title="s__(`We've found no vulnerabilities`)" :key="n"
:description=" class="m-2 js-skeleton-loader"
s__( :lines="2"
`While it's rare to have no vulnerabilities, it can happen. In any event, we ask that you please double check your settings to make sure you've set up your dashboard correctly.`,
)
"
/> />
</slot> </template>
</template>
</gl-table> <template #empty>
<slot name="emptyState">
<gl-empty-state
:title="s__(`We've found no vulnerabilities`)"
:description="
__(
`While it's rare to have no vulnerabilities, it can happen. In any event, we ask that you please double check your settings to make sure you've set up your dashboard correctly.`,
)
"
/>
</slot>
</template>
</gl-table>
</div>
</template> </template>
...@@ -50,7 +50,9 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => { ...@@ -50,7 +50,9 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
expect(findVulnerabilities().props()).toEqual({ expect(findVulnerabilities().props()).toEqual({
dashboardDocumentation, dashboardDocumentation,
emptyStateSvgPath, emptyStateSvgPath,
filters: null,
isLoading: true, isLoading: true,
shouldShowSelection: false,
vulnerabilities: [], vulnerabilities: [],
}); });
}); });
...@@ -138,7 +140,9 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => { ...@@ -138,7 +140,9 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
expect(findVulnerabilities().props()).toEqual({ expect(findVulnerabilities().props()).toEqual({
dashboardDocumentation, dashboardDocumentation,
emptyStateSvgPath, emptyStateSvgPath,
filters: null,
isLoading: false, isLoading: false,
shouldShowSelection: false,
vulnerabilities, vulnerabilities,
}); });
}); });
......
...@@ -48,7 +48,9 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => { ...@@ -48,7 +48,9 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
expect(findVulnerabilities().props()).toEqual({ expect(findVulnerabilities().props()).toEqual({
dashboardDocumentation, dashboardDocumentation,
emptyStateSvgPath, emptyStateSvgPath,
filters: null,
isLoading: true, isLoading: true,
shouldShowSelection: false,
vulnerabilities: [], vulnerabilities: [],
}); });
}); });
...@@ -127,7 +129,9 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => { ...@@ -127,7 +129,9 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
expect(findVulnerabilities().props()).toEqual({ expect(findVulnerabilities().props()).toEqual({
dashboardDocumentation, dashboardDocumentation,
emptyStateSvgPath, emptyStateSvgPath,
filters: null,
isLoading: false, isLoading: false,
shouldShowSelection: false,
vulnerabilities, vulnerabilities,
}); });
}); });
......
...@@ -102,7 +102,6 @@ describe('Selection Summary component', () => { ...@@ -102,7 +102,6 @@ describe('Selection Summary component', () => {
it('should show toast with the right message if all calls were successful', () => { it('should show toast with the right message if all calls were successful', () => {
dismissButton().trigger('submit'); dismissButton().trigger('submit');
setImmediate(() => { setImmediate(() => {
// return wrapper.vm.$nextTick().then(() => {
expect(toast).toHaveBeenCalledWith('2 vulnerabilities dismissed'); expect(toast).toHaveBeenCalledWith('2 vulnerabilities dismissed');
}); });
}); });
......
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { GlEmptyState, GlSkeletonLoading } from '@gitlab/ui'; import { GlEmptyState, GlSkeletonLoading } from '@gitlab/ui';
import RemediatedBadge from 'ee/vulnerabilities/components/remediated_badge.vue'; import RemediatedBadge from 'ee/vulnerabilities/components/remediated_badge.vue';
import SelectionSummary from 'ee/security_dashboard/components/selection_summary.vue';
import VulnerabilityList from 'ee/vulnerabilities/components/vulnerability_list.vue'; import VulnerabilityList from 'ee/vulnerabilities/components/vulnerability_list.vue';
import { generateVulnerabilities } from './mock_data'; import { generateVulnerabilities, vulnerabilities } from './mock_data';
describe('Vulnerability list component', () => { describe('Vulnerability list component', () => {
let wrapper; let wrapper;
const createWrapper = props => { const defaultData = {
selectedVulnerabilities: {},
};
const createWrapper = ({ props = {}, data = defaultData }) => {
return mount(VulnerabilityList, { return mount(VulnerabilityList, {
propsData: { propsData: {
dashboardDocumentation: '#', dashboardDocumentation: '#',
...@@ -18,52 +23,84 @@ describe('Vulnerability list component', () => { ...@@ -18,52 +23,84 @@ describe('Vulnerability list component', () => {
stubs: { stubs: {
GlPopover: true, GlPopover: true,
}, },
data: () => data,
attachToDocument: true, attachToDocument: true,
}); });
}; };
const findCell = label => wrapper.find(`.js-${label}`); const findCell = label => wrapper.find(`.js-${label}`);
const findRow = (index = 0) => wrapper.findAll('tbody tr').at(index); const findRow = (index = 0) => wrapper.findAll('tbody tr').at(index);
const findSelectionSummary = () => wrapper.find(SelectionSummary);
const findCheckAllCheckboxCell = () => wrapper.find('thead tr th');
const findFirstCheckboxCell = () => wrapper.find('tbody tr td');
afterEach(() => wrapper.destroy()); afterEach(() => wrapper.destroy());
describe('with vulnerabilities', () => { describe('with vulnerabilities', () => {
let vulnerabilities; let newVulnerabilities;
beforeEach(() => { beforeEach(() => {
vulnerabilities = generateVulnerabilities(); newVulnerabilities = generateVulnerabilities();
wrapper = createWrapper({ vulnerabilities }); wrapper = createWrapper({ props: { vulnerabilities: newVulnerabilities } });
}); });
it('should render a list of vulnerabilities', () => { it('should render a list of vulnerabilities', () => {
expect(wrapper.findAll('.js-status')).toHaveLength(vulnerabilities.length); expect(wrapper.findAll('.js-status')).toHaveLength(newVulnerabilities.length);
}); });
it('should correctly render the status', () => { it('should correctly render the status', () => {
const cell = findCell('status'); const cell = findCell('status');
expect(cell.text()).toEqual(vulnerabilities[0].state); expect(cell.text()).toBe(newVulnerabilities[0].state);
}); });
it('should correctly render the severity', () => { it('should correctly render the severity', () => {
const cell = findCell('severity'); const cell = findCell('severity');
expect(cell.text().toLowerCase()).toEqual(vulnerabilities[0].severity); expect(cell.text().toLowerCase()).toBe(newVulnerabilities[0].severity);
}); });
it('should correctly render the description', () => { it('should correctly render the description', () => {
const cell = findCell('description'); const cell = findCell('description');
expect(cell.text()).toEqual(vulnerabilities[0].title); expect(cell.text()).toBe(newVulnerabilities[0].title);
});
it('should not show the selection summary if no vulnerabilities are selected', () => {
expect(findSelectionSummary().exists()).toBe(false);
});
it('should not show the checkboxes if shouldShowSelection is passed in', () => {
expect(findCheckAllCheckboxCell().classes()).toContain('d-none');
expect(findFirstCheckboxCell().classes()).toContain('d-none');
});
it('should show the selection summary when a checkbox is selected', () => {
wrapper = createWrapper({
props: { vulnerabilities, shouldShowSelection: true },
});
const checkbox = findFirstCheckboxCell().find('input');
checkbox.setChecked();
return wrapper.vm.$nextTick().then(() => {
expect(findSelectionSummary().exists()).toBe(true);
});
});
it('should show the checkboxes if shouldShowSelection is passed in', () => {
wrapper = createWrapper({
props: { vulnerabilities, shouldShowSelection: true },
});
expect(findCheckAllCheckboxCell().classes()).not.toContain('d-none');
expect(findFirstCheckboxCell().classes()).not.toContain('d-none');
}); });
}); });
describe('when a vulnerability is resolved on the default branch', () => { describe('when a vulnerability is resolved on the default branch', () => {
let vulnerabilities; let newVulnerabilities;
beforeEach(() => { beforeEach(() => {
vulnerabilities = generateVulnerabilities(); newVulnerabilities = generateVulnerabilities();
vulnerabilities[0].resolved_on_default_branch = true; newVulnerabilities[0].resolved_on_default_branch = true;
wrapper = createWrapper({ vulnerabilities }); wrapper = createWrapper({ props: { vulnerabilities: newVulnerabilities } });
}); });
it('should render the remediated info badge on the first vulnerability', () => { it('should render the remediated info badge on the first vulnerability', () => {
...@@ -83,7 +120,7 @@ describe('Vulnerability list component', () => { ...@@ -83,7 +120,7 @@ describe('Vulnerability list component', () => {
describe('when loading', () => { describe('when loading', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createWrapper({ isLoading: true }); wrapper = createWrapper({ props: { isLoading: true } });
}); });
it('should show the loading state', () => { it('should show the loading state', () => {
...@@ -94,7 +131,7 @@ describe('Vulnerability list component', () => { ...@@ -94,7 +131,7 @@ describe('Vulnerability list component', () => {
describe('with no vulnerabilities', () => { describe('with no vulnerabilities', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createWrapper(); wrapper = createWrapper({});
}); });
it('should show the empty state', () => { it('should show the empty state', () => {
......
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