Commit e2669506 authored by Savas Vedova's avatar Savas Vedova

Merge branch '338790-more-flexible-filters' into 'master'

Make filters more reusable and use it on new group vulnerability report

See merge request gitlab-org/gitlab!73057
parents 099a5bf9 3a8de459
...@@ -8,14 +8,14 @@ import { s__ } from '~/locale'; ...@@ -8,14 +8,14 @@ import { s__ } from '~/locale';
import DashboardNotConfiguredGroup from '../shared/empty_states/group_dashboard_not_configured.vue'; import DashboardNotConfiguredGroup from '../shared/empty_states/group_dashboard_not_configured.vue';
import VulnerabilityCounts from '../shared/vulnerability_report/vulnerability_counts.vue'; import VulnerabilityCounts from '../shared/vulnerability_report/vulnerability_counts.vue';
import VulnerabilityList from '../shared/vulnerability_report/vulnerability_list.vue'; import VulnerabilityList from '../shared/vulnerability_report/vulnerability_list.vue';
import { FIELDS } from '../shared/vulnerability_report/constants'; import VulnerabilityFilters from '../shared/vulnerability_report/vulnerability_filters.vue';
import { FIELDS, FILTERS } from '../shared/vulnerability_report/constants';
const { CHECKBOX, DETECTED, STATUS, SEVERITY, DESCRIPTION, IDENTIFIER, TOOL, ACTIVITY } = FIELDS;
export default { export default {
components: { components: {
VulnerabilityCounts, VulnerabilityCounts,
VulnerabilityList, VulnerabilityList,
VulnerabilityFilters,
GlIntersectionObserver, GlIntersectionObserver,
GlLoadingIcon, GlLoadingIcon,
DashboardNotConfiguredGroup, DashboardNotConfiguredGroup,
...@@ -25,6 +25,7 @@ export default { ...@@ -25,6 +25,7 @@ export default {
return { return {
counts: {}, counts: {},
vulnerabilities: [], vulnerabilities: [],
filters: [],
sort: undefined, sort: undefined,
pageInfo: undefined, pageInfo: undefined,
}; };
...@@ -37,6 +38,7 @@ export default { ...@@ -37,6 +38,7 @@ export default {
return { return {
fullPath: this.groupFullPath, fullPath: this.groupFullPath,
isGroup: true, isGroup: true,
...this.filters,
}; };
}, },
update({ group }) { update({ group }) {
...@@ -58,6 +60,7 @@ export default { ...@@ -58,6 +60,7 @@ export default {
fullPath: this.groupFullPath, fullPath: this.groupFullPath,
sort: this.sort, sort: this.sort,
vetEnabled: this.canViewFalsePositive, vetEnabled: this.canViewFalsePositive,
...this.filters,
}; };
}, },
update({ group }) { update({ group }) {
...@@ -91,14 +94,14 @@ export default { ...@@ -91,14 +94,14 @@ export default {
fields() { fields() {
return [ return [
// Add the checkbox field if the user can use the bulk select feature. // Add the checkbox field if the user can use the bulk select feature.
...[this.canAdminVulnerability ? CHECKBOX : []], ...[this.canAdminVulnerability ? FIELDS.CHECKBOX : []],
DETECTED, FIELDS.DETECTED,
STATUS, FIELDS.STATUS,
SEVERITY, FIELDS.SEVERITY,
DESCRIPTION, FIELDS.DESCRIPTION,
IDENTIFIER, FIELDS.IDENTIFIER,
TOOL, FIELDS.TOOL,
ACTIVITY, FIELDS.ACTIVITY,
]; ];
}, },
}, },
...@@ -108,6 +111,11 @@ export default { ...@@ -108,6 +111,11 @@ export default {
this.vulnerabilities = []; this.vulnerabilities = [];
this.sort = sort; this.sort = sort;
}, },
updateFilters(filters) {
// Clear out the vulnerabilities so that the skeleton loader is shown.
this.vulnerabilities = [];
this.filters = filters;
},
fetchNextPage() { fetchNextPage() {
this.$apollo.queries.vulnerabilities.fetchMore({ this.$apollo.queries.vulnerabilities.fetchMore({
variables: { after: this.pageInfo.endCursor }, variables: { after: this.pageInfo.endCursor },
...@@ -122,6 +130,13 @@ export default { ...@@ -122,6 +130,13 @@ export default {
}); });
}, },
}, },
filtersToShow: [
FILTERS.STATUS,
FILTERS.SEVERITY,
FILTERS.TOOL_SIMPLE,
FILTERS.ACTIVITY,
FILTERS.PROJECT,
],
}; };
</script> </script>
...@@ -131,6 +146,12 @@ export default { ...@@ -131,6 +146,12 @@ export default {
<div v-else class="gl-mt-5"> <div v-else class="gl-mt-5">
<vulnerability-counts :counts="counts" :is-loading="isLoadingCounts" /> <vulnerability-counts :counts="counts" :is-loading="isLoadingCounts" />
<vulnerability-filters
:filters="$options.filtersToShow"
class="security-dashboard-filters gl-mt-7"
@filters-changed="updateFilters"
/>
<vulnerability-list <vulnerability-list
class="gl-mt-5" class="gl-mt-5"
:is-loading="isLoadingInitialVulnerabilities" :is-loading="isLoadingInitialVulnerabilities"
......
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import {
stateFilter,
severityFilter,
activityFilter,
simpleScannerFilter,
vendorScannerFilter,
getProjectFilter,
} from 'ee/security_dashboard/helpers';
export const FIELDS = { export const FIELDS = {
CHECKBOX: { CHECKBOX: {
...@@ -47,3 +55,12 @@ export const FIELDS = { ...@@ -47,3 +55,12 @@ export const FIELDS = {
class: 'activity', class: 'activity',
}, },
}; };
export const FILTERS = {
STATUS: stateFilter,
SEVERITY: severityFilter,
ACTIVITY: activityFilter,
TOOL_SIMPLE: simpleScannerFilter,
TOOL_VENDOR: vendorScannerFilter,
PROJECT: getProjectFilter(),
};
<script> <script>
import { debounce, cloneDeep, isEqual } from 'lodash'; import { debounce, cloneDeep, isEqual } from 'lodash';
import {
stateFilter,
severityFilter,
vendorScannerFilter,
simpleScannerFilter,
activityFilter,
getProjectFilter,
} from 'ee/security_dashboard/helpers';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ActivityFilter from '../filters/activity_filter.vue'; import ActivityFilter from '../filters/activity_filter.vue';
import ProjectFilter from '../filters/project_filter.vue'; import ProjectFilter from '../filters/project_filter.vue';
import ProjectFilterDeprecated from '../filters/project_filter_deprecated.vue'; import ProjectFilterDeprecated from '../filters/project_filter_deprecated.vue';
import ScannerFilter from '../filters/scanner_filter.vue'; import ScannerFilter from '../filters/scanner_filter.vue';
import SimpleFilter from '../filters/simple_filter.vue'; import SimpleFilter from '../filters/simple_filter.vue';
import { FILTERS } from './constants';
const { ACTIVITY, TOOL_VENDOR, PROJECT } = FILTERS;
export default { export default {
components: { components: {
...@@ -24,44 +17,34 @@ export default { ...@@ -24,44 +17,34 @@ export default {
ProjectFilter, ProjectFilter,
ProjectFilterDeprecated, ProjectFilterDeprecated,
}, },
mixins: [glFeatureFlagsMixin()],
inject: ['dashboardType'],
props: { props: {
projects: { type: Array, required: false, default: undefined }, filters: {
type: Array,
required: true,
},
}, },
data() { data() {
return { return {
filterQuery: {}, filterQuery: {},
}; };
}, },
computed: {
isProjectDashboard() {
return this.dashboardType === DASHBOARD_TYPES.PROJECT;
},
isPipeline() {
return this.dashboardType === DASHBOARD_TYPES.PIPELINE;
},
isGroupDashboard() {
return this.dashboardType === DASHBOARD_TYPES.GROUP;
},
isInstanceDashboard() {
return this.dashboardType === DASHBOARD_TYPES.INSTANCE;
},
shouldShowProjectFilter() {
return this.isGroupDashboard || this.isInstanceDashboard;
},
shouldShowNewProjectFilter() {
return this.glFeatures.vulnReportNewProjectFilter && this.shouldShowProjectFilter;
},
projectFilter() {
return getProjectFilter(this.projects);
},
},
methods: { methods: {
getComponentType(filter) {
switch (filter) {
case TOOL_VENDOR:
return ScannerFilter;
case ACTIVITY:
return ActivityFilter;
case PROJECT:
return ProjectFilter;
default:
return SimpleFilter;
}
},
updateFilterQuery(query) { updateFilterQuery(query) {
const oldQuery = cloneDeep(this.filterQuery); const oldQuery = cloneDeep(this.filterQuery);
this.filterQuery = { ...this.filterQuery, ...query }; this.filterQuery = { ...this.filterQuery, ...query };
// Don't emit if the filters didn't change because it will trigger the GraphQL queries to run.
if (!isEqual(oldQuery, this.filterQuery)) { if (!isEqual(oldQuery, this.filterQuery)) {
this.emitFilterChange(); this.emitFilterChange();
} }
...@@ -70,13 +53,9 @@ export default { ...@@ -70,13 +53,9 @@ export default {
// the same time, which will trigger this method multiple times. We'll debounce it so that it // the same time, which will trigger this method multiple times. We'll debounce it so that it
// only runs once. // only runs once.
emitFilterChange: debounce(function emit() { emitFilterChange: debounce(function emit() {
this.$emit('filterChange', this.filterQuery); this.$emit('filters-changed', this.filterQuery);
}), }),
}, },
simpleFilters: [stateFilter, severityFilter],
vendorScannerFilter,
simpleScannerFilter,
activityFilter,
}; };
</script> </script>
...@@ -84,42 +63,13 @@ export default { ...@@ -84,42 +63,13 @@ export default {
<div <div
class="vulnerability-report-filters gl-p-5 gl-bg-gray-10 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" class="vulnerability-report-filters gl-p-5 gl-bg-gray-10 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
> >
<simple-filter <component
v-for="filter in $options.simpleFilters" :is="getComponentType(filter)"
v-for="filter in filters"
:key="filter.id" :key="filter.id"
:filter="filter" :filter="filter"
:data-testid="filter.id" :data-testid="filter.id"
@filter-changed="updateFilterQuery" @filter-changed="updateFilterQuery"
/> />
<scanner-filter
v-if="isProjectDashboard"
:filter="$options.vendorScannerFilter"
@filter-changed="updateFilterQuery"
/>
<simple-filter
v-else
:filter="$options.simpleScannerFilter"
:data-testid="$options.simpleScannerFilter.id"
@filter-changed="updateFilterQuery"
/>
<activity-filter
v-if="!isPipeline"
:filter="$options.activityFilter"
@filter-changed="updateFilterQuery"
/>
<project-filter
v-if="shouldShowNewProjectFilter"
:filter="projectFilter"
@filter-changed="updateFilterQuery"
/>
<project-filter-deprecated
v-else-if="shouldShowProjectFilter"
:filter="projectFilter"
:data-testid="projectFilter.id"
@filter-changed="updateFilterQuery"
/>
</div> </div>
</template> </template>
...@@ -11,7 +11,9 @@ $selection-summary-with-error-height: 118px; ...@@ -11,7 +11,9 @@ $selection-summary-with-error-height: 118px;
} }
.security-dashboard-filters { .security-dashboard-filters {
@include gl-sticky;
@include sticky-top-positioning(); @include sticky-top-positioning();
@include gl-z-index-1;
} }
.vulnerability-list { .vulnerability-list {
......
...@@ -5,6 +5,7 @@ import { GlIntersectionObserver } from '@gitlab/ui'; ...@@ -5,6 +5,7 @@ import { GlIntersectionObserver } from '@gitlab/ui';
import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue'; import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue';
import VulnerabilityReportDevelopment from 'ee/security_dashboard/components/group/vulnerability_report_development.vue'; import VulnerabilityReportDevelopment from 'ee/security_dashboard/components/group/vulnerability_report_development.vue';
import VulnerabilityCounts from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_counts.vue'; import VulnerabilityCounts from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_counts.vue';
import VulnerabilityFilters from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_filters.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import countsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql'; import countsQuery from 'ee/security_dashboard/graphql/queries/vulnerability_severities_count.query.graphql';
import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql'; import vulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql';
...@@ -207,4 +208,24 @@ describe('Vulnerability counts component', () => { ...@@ -207,4 +208,24 @@ describe('Vulnerability counts component', () => {
expect(findIntersectionObserver().exists()).toBe(false); expect(findIntersectionObserver().exists()).toBe(false);
}); });
}); });
describe('vulnerability filters component', () => {
it('will pass data from filters-changed event to GraphQL queries', async () => {
const countsHandler = jest.fn().mockResolvedValue();
const vulnerabilitiesHandler = jest.fn().mockResolvedValue();
createWrapper({ countsHandler, vulnerabilitiesHandler });
// Sanity check, the report component will call these the first time it's mounted.
expect(countsHandler).toHaveBeenCalledTimes(1);
expect(vulnerabilitiesHandler).toHaveBeenCalledTimes(1);
const data = { a: 1 };
wrapper.findComponent(VulnerabilityFilters).vm.$emit('filters-changed', data);
await nextTick();
expect(countsHandler).toHaveBeenCalledTimes(2);
expect(countsHandler).toHaveBeenCalledWith(expect.objectContaining(data));
expect(vulnerabilitiesHandler).toHaveBeenCalledTimes(2);
expect(vulnerabilitiesHandler).toHaveBeenCalledWith(expect.objectContaining(data));
});
});
}); });
import { shallowMount } from '@vue/test-utils';
import ActivityFilter from 'ee/security_dashboard/components/shared/filters/activity_filter.vue'; import ActivityFilter from 'ee/security_dashboard/components/shared/filters/activity_filter.vue';
import VulnerabilityFilters from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_filters.vue'; import VulnerabilityFilters from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_filters.vue';
import ProjectFilter from 'ee/security_dashboard/components/shared/filters/project_filter.vue'; import ProjectFilter from 'ee/security_dashboard/components/shared/filters/project_filter.vue';
import ScannerFilter from 'ee/security_dashboard/components/shared/filters/scanner_filter.vue'; import ScannerFilter from 'ee/security_dashboard/components/shared/filters/scanner_filter.vue';
import SimpleFilter from 'ee/security_dashboard/components/shared/filters/simple_filter.vue'; import SimpleFilter from 'ee/security_dashboard/components/shared/filters/simple_filter.vue';
import { getProjectFilter, simpleScannerFilter } from 'ee/security_dashboard/helpers'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants'; import { FILTERS } from 'ee/security_dashboard/components/shared/vulnerability_report/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
describe('First class vulnerability filters component', () => { const { ACTIVITY, PROJECT, SEVERITY, STATUS, TOOL_SIMPLE, TOOL_VENDOR } = FILTERS;
let wrapper;
const projects = [
{ id: 'gid://gitlab/Project/11', name: 'GitLab Org' },
{ id: 'gid://gitlab/Project/12', name: 'GitLab Com' },
];
const findSimpleFilters = () => wrapper.findAllComponents(SimpleFilter); describe('Vulnerability filters component', () => {
const findSimpleScannerFilter = () => wrapper.findByTestId(simpleScannerFilter.id); let wrapper;
const findVendorScannerFilter = () => wrapper.findComponent(ScannerFilter);
const findActivityFilter = () => wrapper.findComponent(ActivityFilter);
const findProjectFilter = () => wrapper.findByTestId(getProjectFilter([]).id);
const findNewProjectFilter = () => wrapper.findComponent(ProjectFilter);
const createComponent = ({ props, provide } = {}) => { const createWrapper = ({ filters }) => {
return extendedWrapper( wrapper = shallowMountExtended(VulnerabilityFilters, {
shallowMount(VulnerabilityFilters, { propsData: { filters },
propsData: props, });
provide: {
dashboardType: DASHBOARD_TYPES.PROJECT,
...provide,
},
}),
);
}; };
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
describe('on render without project filter', () => { it('emits filters-changed event when filter is changed', () => {
beforeEach(() => { createWrapper({ filters: [STATUS] });
wrapper = createComponent(); const filter = wrapper.findComponent(SimpleFilter);
}); const data = { a: 1 };
filter.vm.$emit('filter-changed', data);
it('should render the default filters', () => {
expect(findSimpleFilters()).toHaveLength(2);
expect(findActivityFilter().exists()).toBe(true);
expect(findProjectFilter().exists()).toBe(false);
});
it('should emit filterChange when a filter is changed', () => {
const options = { foo: 'bar' };
findActivityFilter().vm.$emit('filter-changed', options);
expect(wrapper.emitted('filterChange')[0][0]).toEqual(options);
});
});
describe('project filter', () => {
it.each`
dashboardType | isShown
${DASHBOARD_TYPES.PROJECT} | ${false}
${DASHBOARD_TYPES.PIPELINE} | ${false}
${DASHBOARD_TYPES.GROUP} | ${true}
${DASHBOARD_TYPES.INSTANCE} | ${true}
`(
'on the $dashboardType report the project filter shown is $isShown',
({ dashboardType, isShown }) => {
wrapper = createComponent({ provide: { dashboardType } });
expect(findProjectFilter().exists()).toBe(isShown); expect(wrapper.emitted('filters-changed')[0][0]).toMatchObject(data);
},
);
it('should render the project filter with the expected options', () => {
wrapper = createComponent({
provide: { dashboardType: DASHBOARD_TYPES.GROUP },
props: { projects },
});
expect(findProjectFilter().props('filter').options).toEqual([
{ id: '11', name: projects[0].name },
{ id: '12', name: projects[1].name },
]);
});
it.each`
featureFlag | isProjectFilterShown | isNewProjectFilterShown
${false} | ${true} | ${false}
${true} | ${false} | ${true}
`(
'should show the correct project filter when vulnReportNewProjectFilter feature flag is $featureFlag',
({ featureFlag, isProjectFilterShown, isNewProjectFilterShown }) => {
wrapper = createComponent({
provide: {
dashboardType: DASHBOARD_TYPES.GROUP,
glFeatures: { vulnReportNewProjectFilter: featureFlag },
},
});
expect(findProjectFilter().exists()).toBe(isProjectFilterShown);
expect(findNewProjectFilter().exists()).toBe(isNewProjectFilterShown);
},
);
});
describe('activity filter', () => {
beforeEach(() => {
wrapper = createComponent({ provide: { dashboardType: DASHBOARD_TYPES.PIPELINE } });
});
it('does not display on the pipeline dashboard', () => {
expect(findActivityFilter().exists()).toBe(false);
});
}); });
describe('scanner filter', () => { it.each`
it.each` name | filters | expectedComponent
type | dashboardType ${'activity'} | ${[ACTIVITY]} | ${ActivityFilter}
${'vendor'} | ${DASHBOARD_TYPES.PROJECT} ${'project'} | ${[PROJECT]} | ${ProjectFilter}
${'simple'} | ${DASHBOARD_TYPES.GROUP} ${'severity'} | ${[SEVERITY]} | ${SimpleFilter}
${'simple'} | ${DASHBOARD_TYPES.INSTANCE} ${'status'} | ${[STATUS]} | ${SimpleFilter}
${'simple'} | ${DASHBOARD_TYPES.PIPELINE} ${'tool_simple'} | ${[TOOL_SIMPLE]} | ${SimpleFilter}
`('shows the $type scanner filter on the $dashboardType report', ({ type, dashboardType }) => { ${'tool_vendor'} | ${[TOOL_VENDOR]} | ${ScannerFilter}
wrapper = createComponent({ provide: { dashboardType } }); `(`shows the expected component for filter '$name'`, ({ filters, expectedComponent }) => {
createWrapper({ filters });
expect(findSimpleScannerFilter().exists()).toBe(type === 'simple');
expect(findVendorScannerFilter().exists()).toBe(type === 'vendor'); expect(wrapper.findComponent(expectedComponent).exists()).toBe(true);
});
}); });
}); });
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