Commit e4a602ee authored by Alexander Turinske's avatar Alexander Turinske Committed by Natalia Tepluhina

Allow vulnerability selection in project dashboard

- add selection summary component to vulnerability list
  so that a user can dismiss multiple vulnerabilities
- add additional checkbox column to table for vulnerability
  selection and local state/computed/methods to assist with
  that
- conditionally allow selection of vulnerabilities per props
  passed into the vulnerability list component
- move fields into a computed property
- add watch on filters to clear selected
  vulnerabilities if a filter changes
- update css classes to be mobile friendly
- update GlNewButton to GlButton
- add tests
parent d0ccf9f2
......@@ -83,6 +83,9 @@ export default {
});
}
},
refetchVulnerabilities() {
this.$apollo.queries.vulnerabilities.refetch();
},
},
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.`,
......@@ -104,7 +107,10 @@ export default {
:is-loading="isLoadingFirstVulnerabilities"
:dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath"
:filters="filters"
should-show-selection
:vulnerabilities="vulnerabilities"
@refetch-vulnerabilities="refetchVulnerabilities"
>
<template #emptyState>
<gl-empty-state
......
<script>
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 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';
export default {
name: 'VulnerabilityList',
components: {
GlEmptyState,
GlFormCheckbox,
GlLink,
GlSkeletonLoading,
GlTable,
RemediatedBadge,
SelectionSummary,
SeverityBadge,
},
props: {
......@@ -24,6 +27,16 @@ export default {
type: String,
required: true,
},
filters: {
type: Object,
required: false,
default: null,
},
shouldShowSelection: {
type: Boolean,
required: false,
default: false,
},
vulnerabilities: {
type: Array,
required: true,
......@@ -34,70 +47,149 @@ export default {
default: false,
},
},
fields: [
{
key: 'state',
label: s__('Vulnerability|Status'),
thClass: 'gl-w-64',
},
{
key: 'severity',
label: s__('Vulnerability|Severity'),
thClass: 'gl-w-64',
},
{
key: 'title',
label: __('Description'),
},
],
data() {
return {
selectedVulnerabilities: {},
};
},
computed: {
hasSelectedAllVulnerabilities() {
return this.numOfSelectedVulnerabilities === this.vulnerabilities.length;
},
numOfSelectedVulnerabilities() {
return Object.keys(this.selectedVulnerabilities).length;
},
shouldShowSelectionSummary() {
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,
};
</script>
<template>
<gl-table
:busy="isLoading"
:fields="$options.fields"
:items="vulnerabilities"
stacked="sm"
show-empty
responsive
>
<template #cell(state)="{ item }">
<span class="text-capitalize js-status">{{ item.state.toLowerCase() }}</span>
</template>
<div>
<selection-summary
v-if="shouldShowSelectionSummary"
:selected-vulnerabilities="Object.values(selectedVulnerabilities)"
@deselect-all-vulnerabilities="deselectAllVulnerabilities"
@refetch-vulnerabilities="$emit('refetch-vulnerabilities')"
/>
<gl-table
:busy="isLoading"
:fields="fields"
: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 }">
<severity-badge class="js-severity" :severity="item.severity" />
</template>
<template #cell(state)="{ item }">
<span class="text-capitalize js-status">{{ item.state.toLowerCase() }}</span>
</template>
<template #cell(title)="{ item }">
<gl-link class="text-body js-description" :href="item.vulnerabilityPath">
{{ item.title }}
</gl-link>
<remediated-badge v-if="item.resolved_on_default_branch" class="ml-2" />
</template>
<template #cell(severity)="{ item }">
<severity-badge class="js-severity" :severity="item.severity" />
</template>
<template #table-busy>
<gl-skeleton-loading
v-for="n in $options.VULNERABILITIES_PER_PAGE"
:key="n"
class="m-2 js-skeleton-loader"
:lines="2"
/>
</template>
<template #cell(title)="{ item }">
<gl-link class="text-body js-description" :href="item.vulnerabilityPath">
{{ item.title }}
</gl-link>
<remediated-badge v-if="item.resolved_on_default_branch" class="ml-2" />
</template>
<template #empty>
<slot name="emptyState">
<gl-empty-state
:title="s__(`We've found no vulnerabilities`)"
:description="
s__(
`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.`,
)
"
<template #table-busy>
<gl-skeleton-loading
v-for="n in $options.VULNERABILITIES_PER_PAGE"
:key="n"
class="m-2 js-skeleton-loader"
:lines="2"
/>
</slot>
</template>
</gl-table>
</template>
<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>
......@@ -50,7 +50,9 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
expect(findVulnerabilities().props()).toEqual({
dashboardDocumentation,
emptyStateSvgPath,
filters: null,
isLoading: true,
shouldShowSelection: false,
vulnerabilities: [],
});
});
......@@ -138,7 +140,9 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
expect(findVulnerabilities().props()).toEqual({
dashboardDocumentation,
emptyStateSvgPath,
filters: null,
isLoading: false,
shouldShowSelection: false,
vulnerabilities,
});
});
......
......@@ -48,7 +48,9 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
expect(findVulnerabilities().props()).toEqual({
dashboardDocumentation,
emptyStateSvgPath,
filters: null,
isLoading: true,
shouldShowSelection: false,
vulnerabilities: [],
});
});
......@@ -127,7 +129,9 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
expect(findVulnerabilities().props()).toEqual({
dashboardDocumentation,
emptyStateSvgPath,
filters: null,
isLoading: false,
shouldShowSelection: false,
vulnerabilities,
});
});
......
......@@ -102,7 +102,6 @@ describe('Selection Summary component', () => {
it('should show toast with the right message if all calls were successful', () => {
dismissButton().trigger('submit');
setImmediate(() => {
// return wrapper.vm.$nextTick().then(() => {
expect(toast).toHaveBeenCalledWith('2 vulnerabilities dismissed');
});
});
......
import { mount } from '@vue/test-utils';
import { GlEmptyState, GlSkeletonLoading } from '@gitlab/ui';
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 { generateVulnerabilities } from './mock_data';
import { generateVulnerabilities, vulnerabilities } from './mock_data';
describe('Vulnerability list component', () => {
let wrapper;
const createWrapper = props => {
const defaultData = {
selectedVulnerabilities: {},
};
const createWrapper = ({ props = {}, data = defaultData }) => {
return mount(VulnerabilityList, {
propsData: {
dashboardDocumentation: '#',
......@@ -18,52 +23,84 @@ describe('Vulnerability list component', () => {
stubs: {
GlPopover: true,
},
data: () => data,
attachToDocument: true,
});
};
const findCell = label => wrapper.find(`.js-${label}`);
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());
describe('with vulnerabilities', () => {
let vulnerabilities;
let newVulnerabilities;
beforeEach(() => {
vulnerabilities = generateVulnerabilities();
wrapper = createWrapper({ vulnerabilities });
newVulnerabilities = generateVulnerabilities();
wrapper = createWrapper({ props: { vulnerabilities: newVulnerabilities } });
});
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', () => {
const cell = findCell('status');
expect(cell.text()).toEqual(vulnerabilities[0].state);
expect(cell.text()).toBe(newVulnerabilities[0].state);
});
it('should correctly render the 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', () => {
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', () => {
let vulnerabilities;
let newVulnerabilities;
beforeEach(() => {
vulnerabilities = generateVulnerabilities();
vulnerabilities[0].resolved_on_default_branch = true;
wrapper = createWrapper({ vulnerabilities });
newVulnerabilities = generateVulnerabilities();
newVulnerabilities[0].resolved_on_default_branch = true;
wrapper = createWrapper({ props: { vulnerabilities: newVulnerabilities } });
});
it('should render the remediated info badge on the first vulnerability', () => {
......@@ -83,7 +120,7 @@ describe('Vulnerability list component', () => {
describe('when loading', () => {
beforeEach(() => {
wrapper = createWrapper({ isLoading: true });
wrapper = createWrapper({ props: { isLoading: true } });
});
it('should show the loading state', () => {
......@@ -94,7 +131,7 @@ describe('Vulnerability list component', () => {
describe('with no vulnerabilities', () => {
beforeEach(() => {
wrapper = createWrapper();
wrapper = createWrapper({});
});
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