Commit d884f2bf authored by Sam Beckham's avatar Sam Beckham Committed by Natalia Tepluhina

Uses graphQL on the vulnerability list page

- Swaps out the REST endpoints for GraphQL data
- Uses the observer component to lazily load extra vulnerabiltiies
- Hooks up the error and loading states
- WIP: Starts work on the tests
parent 59bee145
<script>
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import VulnerabilitiesCountList from 'ee/security_dashboard/components/vulnerability_count_list.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import ProjectVulnerabilitiesApp from 'ee/vulnerabilities/components/project_vulnerabilities_app.vue';
export default {
......@@ -8,6 +9,7 @@ export default {
SecurityDashboardLayout,
ProjectVulnerabilitiesApp,
VulnerabilitiesCountList,
Filters,
},
props: {
dashboardDocumentation: {
......@@ -23,6 +25,16 @@ export default {
required: true,
},
},
data() {
return {
filters: {},
};
},
methods: {
handleFilterChange(filters) {
this.filters = filters;
},
},
};
</script>
......@@ -30,11 +42,13 @@ export default {
<security-dashboard-layout>
<template #header>
<vulnerabilities-count-list :project-full-path="projectFullPath" />
<filters @filterChange="handleFilterChange" />
</template>
<project-vulnerabilities-app
:dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath"
:project-full-path="projectFullPath"
:filters="filters"
/>
</security-dashboard-layout>
</template>
<script>
import { initFirstClassVulnerabilityFilters } from 'ee/security_dashboard/constants';
import { ALL } from 'ee/security_dashboard/store/modules/filters/constants';
import { setFilter } from 'ee/security_dashboard/store/modules/filters/utils';
import DashboardFilter from 'ee/security_dashboard/components/filter.vue';
export default {
components: {
DashboardFilter,
},
data() {
return {
filters: initFirstClassVulnerabilityFilters(),
};
},
methods: {
setFilter(options) {
const selectedFilters = {};
this.filters = setFilter(this.filters, options);
this.filters.forEach(({ id, selection }) => {
if (!selection.has(ALL)) {
selectedFilters[id] = Array.from(selection);
}
});
this.$emit('filterChange', selectedFilters);
},
},
};
</script>
<template>
<div class="dashboard-filters border-bottom bg-gray-light">
<div class="row mx-0 p-2">
<dashboard-filter
v-for="filter in filters"
:key="filter.id"
class="col-sm-6 col-md-4 col-lg-2 p-2"
:filter="filter"
@setFilter="setFilter"
/>
</div>
</div>
</template>
import { s__ } from '~/locale';
import { ALL, BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/constants';
import { REPORT_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
const parseOptions = obj =>
Object.entries(obj).map(([id, name]) => ({ id: id.toUpperCase(), name }));
export const initFirstClassVulnerabilityFilters = () => [
{
name: s__('SecurityDashboard|Status'),
id: 'state',
options: [
{ id: ALL, name: s__('VulnerabilityStatusTypes|All') },
...parseOptions(VULNERABILITY_STATES),
],
selection: new Set([ALL]),
},
{
name: s__('SecurityDashboard|Severity'),
id: 'severity',
options: [BASE_FILTERS.severity, ...parseOptions(SEVERITY_LEVELS)],
selection: new Set([ALL]),
},
{
name: s__('SecurityDashboard|Report type'),
id: 'reportType',
options: [BASE_FILTERS.report_type, ...parseOptions(REPORT_TYPES)],
selection: new Set([ALL]),
},
];
export default () => ({});
......@@ -2,7 +2,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import FirstClassProjectDashboard from './components/first_class_project_dashboard.vue';
import FirstClassProjectSecurityDashboard from './components/first_class_project_security_dashboard.vue';
const isRequired = message => {
throw new Error(message);
......@@ -28,7 +28,7 @@ export default (
// We'll add more of these for group and instance once we have the components
if (dashboardType === DASHBOARD_TYPES.PROJECT) {
element = FirstClassProjectDashboard;
element = FirstClassProjectSecurityDashboard;
props.projectFullPath = el.dataset.projectFullPath;
}
......
......@@ -7,8 +7,6 @@ export const SEVERITY_LEVELS = {
low: s__('severity|Low'),
unknown: s__('severity|Unknown'),
info: s__('severity|Info'),
undefined: s__('severity|Undefined'),
none: s__('severity|None'),
};
export const REPORT_TYPES = {
......
import * as types from './mutation_types';
import { ALL } from './constants';
import { isBaseFilterOption } from './utils';
import { setFilter } from './utils';
export default {
[types.SET_ALL_FILTERS](state, payload = {}) {
......@@ -22,29 +22,7 @@ export default {
state.hideDismissed = payload.scope !== 'all';
},
[types.SET_FILTER](state, payload) {
const { filterId, optionId } = payload;
const activeFilter = state.filters.find(filter => filter.id === filterId);
if (activeFilter) {
let selection = new Set(activeFilter.selection);
if (isBaseFilterOption(optionId)) {
selection = new Set([ALL]);
} else {
selection.delete(ALL);
if (selection.has(optionId)) {
selection.delete(optionId);
} else {
selection.add(optionId);
}
}
// This prevents us from selecting nothing at all
if (selection.size === 0) {
selection.add(ALL);
}
activeFilter.selection = selection;
}
state.filters = setFilter(state.filters, payload);
},
[types.SET_FILTER_OPTIONS](state, payload) {
const { filterId, options } = payload;
......
import { s__ } from '~/locale';
import { BASE_FILTERS } from './constants';
import { SEVERITY_LEVELS, REPORT_TYPES } from '../../constants';
import { s__ } from '~/locale';
const optionsObjectToArray = obj => Object.entries(obj).map(([id, name]) => ({ id, name }));
const severityLevelsWithoutNone = { ...SEVERITY_LEVELS };
delete severityLevelsWithoutNone.none;
export default () => ({
filters: [
{
name: s__('SecurityDashboard|Severity'),
id: 'severity',
options: [BASE_FILTERS.severity, ...optionsObjectToArray(severityLevelsWithoutNone)],
options: [BASE_FILTERS.severity, ...optionsObjectToArray(SEVERITY_LEVELS)],
hidden: false,
selection: new Set([BASE_FILTERS.severity.id]),
},
......
......@@ -11,3 +11,39 @@ export const isBaseFilterOption = id => id === ALL;
*/
export const hasValidSelection = ({ selection, options }) =>
isSubset(selection, new Set(options.map(({ id }) => id)));
/**
* Takes a filter array and a selected payload.
* It then either adds or removes that option from the appropriate selected filter.
* With a few extra exceptions around the `ALL` special case.
* @param {Array} filters the filters to mutate
* @param {Object} payload
* @param {String} payload.optionId the ID of the option that was just selected
* @param {String} payload.filterId the ID of the filter that the selected option belongs to
* @returns {Array} the mutated filters array
*/
export const setFilter = (filters, { optionId, filterId }) =>
filters.map(filter => {
if (filter.id === filterId) {
const { selection } = filter;
if (optionId === ALL) {
selection.clear();
} else if (selection.has(optionId)) {
selection.delete(optionId);
} else {
selection.delete(ALL);
selection.add(optionId);
}
if (selection.size === 0) {
selection.add(ALL);
}
return {
...filter,
selection,
};
}
return filter;
});
......@@ -9,7 +9,7 @@ import UsersCache from '~/lib/utils/users_cache';
import ResolutionAlert from './resolution_alert.vue';
import VulnerabilityStateDropdown from './vulnerability_state_dropdown.vue';
import StatusDescription from './status_description.vue';
import { VULNERABILITY_STATES } from '../constants';
import { VULNERABILITY_STATE_OBJECTS } from '../constants';
export default {
name: 'VulnerabilityManagementApp',
......@@ -53,7 +53,7 @@ export default {
computed: {
statusBoxStyle() {
// Get the badge variant based on the vulnerability state, defaulting to 'expired'.
return VULNERABILITY_STATES[this.vulnerability.state]?.statusBoxStyle || 'expired';
return VULNERABILITY_STATE_OBJECTS[this.vulnerability.state]?.statusBoxStyle || 'expired';
},
showResolutionAlert() {
return (
......
......@@ -27,6 +27,11 @@ export default {
type: String,
required: true,
},
filters: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
......@@ -42,6 +47,7 @@ export default {
return {
fullPath: this.projectFullPath,
first: VULNERABILITIES_PER_PAGE,
...this.filters,
};
},
update: ({ project }) => project.vulnerabilities.nodes,
......
<script>
import { GlDropdown, GlIcon, GlDeprecatedButton } from '@gitlab/ui';
import { VULNERABILITY_STATES } from '../constants';
import { VULNERABILITY_STATE_OBJECTS } from '../constants';
export default {
states: Object.values(VULNERABILITY_STATES),
states: Object.values(VULNERABILITY_STATE_OBJECTS),
components: { GlDropdown, GlIcon, GlDeprecatedButton },
props: {
......@@ -16,13 +16,13 @@ export default {
data() {
return {
// Vulnerability state that's picked in the dropdown. Defaults to the passed-in state.
selected: VULNERABILITY_STATES[this.initialState],
selected: VULNERABILITY_STATE_OBJECTS[this.initialState],
};
},
computed: {
initialStateItem() {
return VULNERABILITY_STATES[this.initialState];
return VULNERABILITY_STATE_OBJECTS[this.initialState];
},
},
......
import { s__ } from '~/locale';
export const VULNERABILITY_STATES = {
export const VULNERABILITY_STATE_OBJECTS = {
dismissed: {
action: 'dismiss',
statusBoxStyle: 'upcoming',
......@@ -21,4 +21,11 @@ export const VULNERABILITY_STATES = {
},
};
export const VULNERABILITY_STATES = {
detected: s__('VulnerabilityStatusTypes|Detected'),
confirmed: s__('VulnerabilityStatusTypes|Confirmed'),
dismissed: s__('VulnerabilityStatusTypes|Dismissed'),
resolved: s__('VulnerabilityStatusTypes|Resolved'),
};
export const VULNERABILITIES_PER_PAGE = 20;
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "./vulnerability.fragment.graphql"
query project($fullPath: ID!, $after: String, $first: Int) {
query project(
$fullPath: ID!
$after: String
$first: Int
$severity: [VulnerabilitySeverity!]
$reportType: [VulnerabilityReportType!]
$state: [VulnerabilityState!]
) {
project(fullPath: $fullPath) {
vulnerabilities(after:$after, first:$first){
vulnerabilities(
after:$after
first:$first
severity: $severity
reportType: $reportType
state: $state
){
nodes{
...Vulnerability
}
......
import { shallowMount } from '@vue/test-utils';
import FirstClassProjectSecurityDashboard from 'ee/security_dashboard/components/first_class_project_security_dashboard.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import ProjectVulnerabilitiesApp from 'ee/vulnerabilities/components/project_vulnerabilities_app.vue';
const drilledProps = {
dashboardDocumentation: '/help/docs',
emptyStateSvgPath: '/svgs/empty/svg',
projectFullPath: '/group/project',
};
const filters = { foo: 'bar' };
describe('First class Project Security Dashboard component', () => {
let wrapper;
const findFilters = () => wrapper.find(Filters);
const findVulnerabilities = () => wrapper.find(ProjectVulnerabilitiesApp);
const createComponent = options => {
wrapper = shallowMount(FirstClassProjectSecurityDashboard, {
propsData: drilledProps,
stubs: { SecurityDashboardLayout },
...options,
});
};
afterEach(() => {
wrapper.destroy();
});
describe('on render', () => {
beforeEach(() => {
createComponent();
});
it('should render the vulnerabilities', () => {
expect(findVulnerabilities().exists()).toBe(true);
});
it.each(Object.entries(drilledProps))(
'should pass down the %s prop to the vulnerabilities',
(key, value) => {
expect(findVulnerabilities().props(key)).toBe(value);
},
);
it('should render the filters component', () => {
expect(findFilters().exists()).toBe(true);
});
});
describe('with filter data', () => {
beforeEach(() => {
createComponent({
data() {
return { filters };
},
});
});
it('should pass the filter data down to the vulnerabilities', () => {
expect(findVulnerabilities().props().filters).toEqual(filters);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { initFirstClassVulnerabilityFilters } from 'ee/security_dashboard/constants';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import Filter from 'ee/security_dashboard/components/filter.vue';
describe('First class vulnerability filters component', () => {
let wrapper;
let filters;
const findFilters = () => wrapper.findAll(Filter);
const findFirstFilter = () => findFilters().at(0);
const createComponent = () => {
wrapper = shallowMount(Filters);
};
afterEach(() => {
wrapper.destroy();
});
describe('on render', () => {
beforeEach(() => {
createComponent();
filters = initFirstClassVulnerabilityFilters();
});
it('should render the filters', () => {
expect(findFilters().length).toBe(filters.length);
});
it('should pass down the filter information to the first filter', () => {
expect(findFirstFilter().props().filter).toEqual(filters[0]);
});
it('should call the setFilter mutation when setting a filter', () => {
const stub = jest.fn();
const options = { foo: 'bar' };
wrapper.setMethods({ setFilter: stub });
findFirstFilter().vm.$emit('setFilter', options);
expect(stub).toHaveBeenCalledWith(options);
});
describe('when setFilter is called', () => {
let filterId;
let optionId;
beforeEach(() => {
filterId = filters[0].id;
optionId = filters[0].options[1].id;
wrapper.vm.setFilter({ filterId, optionId });
});
it('should set the filters locally', () => {
const expectedFilters = initFirstClassVulnerabilityFilters();
expectedFilters[0].selection = new Set([optionId]);
expect(wrapper.vm.filters).toEqual(expectedFilters);
});
it('should emit selected filters when a filter is set', () => {
expect(wrapper.emitted().filterChange).toBeTruthy();
expect(wrapper.emitted().filterChange[0]).toEqual([{ [filterId]: [optionId] }]);
});
});
});
});
import { hasValidSelection } from 'ee/security_dashboard/store/modules/filters/utils';
import { ALL } from 'ee/security_dashboard/store/modules/filters/constants';
import { hasValidSelection, setFilter } from 'ee/security_dashboard/store/modules/filters/utils';
describe('filters module utils', () => {
describe('hasValidSelection', () => {
......@@ -26,4 +27,111 @@ describe('filters module utils', () => {
});
});
});
describe('setFilter', () => {
const filterId = 'foo';
const option1 = 'bar';
const option2 = 'baz';
const initFilters = (initiallySelected = [ALL]) => [
{ id: filterId, selection: new Set(initiallySelected) },
];
let filters;
let filter;
describe('when ALL is initially selected', () => {
beforeEach(() => {
filters = initFilters();
});
describe('when a valid filter is passed', () => {
beforeEach(() => {
[filter] = setFilter(filters, { filterId, optionId: option1 });
});
it('should select the passed option', () => {
expect(filter.selection.has(option1)).toBe(true);
});
it('should remove the `ALL` option', () => {
expect(filter.selection.has(ALL)).toBe(false);
});
});
describe('when an invalid filter is passed ', () => {
beforeEach(() => {
[filter] = setFilter(filters, { filterId: 'baz', optionId: option1 });
});
it('should not select the passed option', () => {
expect(filter.selection.has(option1)).toBe(false);
});
it('should not remove the `ALL` option', () => {
expect(filter.selection.has(ALL)).toBe(true);
});
});
});
describe('when an option is initially selected', () => {
beforeEach(() => {
filters = initFilters([option1]);
});
describe('when the selected option is passed', () => {
beforeEach(() => {
[filter] = setFilter(filters, { filterId, optionId: option1 });
});
it('should remove the passed option', () => {
expect(filter.selection.has(option1)).toBe(false);
});
it('should select the `ALL` option', () => {
expect(filter.selection.has(ALL)).toBe(true);
});
});
describe('when another option is passed ', () => {
beforeEach(() => {
[filter] = setFilter(filters, { filterId, optionId: option2 });
});
it('should not remove the initially selected option', () => {
expect(filter.selection.has(option1)).toBe(true);
});
it('should add the passed selected option', () => {
expect(filter.selection.has(option2)).toBe(true);
});
it('should not select the `ALL` option', () => {
expect(filter.selection.has(ALL)).toBe(false);
});
});
});
describe('when two options are initially selected', () => {
beforeEach(() => {
filters = initFilters([option1, option2]);
});
describe('when a selected option is passed', () => {
beforeEach(() => {
[filter] = setFilter(filters, { filterId, optionId: option1 });
});
it('should remove the passed option', () => {
expect(filter.selection.has(option1)).toBe(false);
});
it('should not remove the other option', () => {
expect(filter.selection.has(option2)).toBe(true);
});
it('should not select the `ALL` option', () => {
expect(filter.selection.has(ALL)).toBe(false);
});
});
});
});
});
......@@ -10,9 +10,9 @@ import App from 'ee/vulnerabilities/components/app.vue';
import StatusDescription from 'ee/vulnerabilities/components/status_description.vue';
import ResolutionAlert from 'ee/vulnerabilities/components/resolution_alert.vue';
import VulnerabilityStateDropdown from 'ee/vulnerabilities/components/vulnerability_state_dropdown.vue';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
const vulnerabilityStateEntries = Object.entries(VULNERABILITY_STATES);
const vulnerabilityStateEntries = Object.entries(VULNERABILITY_STATE_OBJECTS);
const mockAxios = new MockAdapter(axios);
jest.mock('~/flash');
......
......@@ -5,9 +5,9 @@ import UsersMockHelper from 'helpers/user_mock_data_helper';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import StatusText from 'ee/vulnerabilities/components/status_description.vue';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
const NON_DETECTED_STATES = Object.keys(VULNERABILITY_STATES);
const NON_DETECTED_STATES = Object.keys(VULNERABILITY_STATE_OBJECTS);
const ALL_STATES = ['detected', ...NON_DETECTED_STATES];
describe('Vulnerability status description component', () => {
......
import { shallowMount } from '@vue/test-utils';
import VulnerabilityStateDropdown from 'ee/vulnerabilities/components/vulnerability_state_dropdown.vue';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
const vulnerabilityStateEntries = Object.entries(VULNERABILITY_STATES);
const vulnerabilityStateEntries = Object.entries(VULNERABILITY_STATE_OBJECTS);
describe('Vulnerability state dropdown component', () => {
let wrapper;
......
......@@ -17817,6 +17817,9 @@ msgstr ""
msgid "SecurityDashboard|Severity"
msgstr ""
msgid "SecurityDashboard|Status"
msgstr ""
msgid "SecurityDashboard|The security dashboard displays the latest security findings for projects you wish to monitor. Select \"Edit dashboard\" to add and remove projects."
msgstr ""
......@@ -22672,6 +22675,21 @@ msgstr ""
msgid "VulnerabilityManagement|Will not fix or a false-positive"
msgstr ""
msgid "VulnerabilityStatusTypes|All"
msgstr ""
msgid "VulnerabilityStatusTypes|Confirmed"
msgstr ""
msgid "VulnerabilityStatusTypes|Detected"
msgstr ""
msgid "VulnerabilityStatusTypes|Dismissed"
msgstr ""
msgid "VulnerabilityStatusTypes|Resolved"
msgstr ""
msgid "Vulnerability|Class"
msgstr ""
......@@ -24775,9 +24793,6 @@ msgstr ""
msgid "severity|None"
msgstr ""
msgid "severity|Undefined"
msgstr ""
msgid "severity|Unknown"
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