Commit 6eccf7b1 authored by Dmitriy Zaporozhets (DZ)'s avatar Dmitriy Zaporozhets (DZ)

Merge branch '284471-better-project-filter' into 'master'

Add more robust vulnerability report project filter

See merge request gitlab-org/gitlab!55745
parents 078b4ff1 ab76aa69
---
name: vuln_report_new_project_filter
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55745
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/334380
milestone: '14.2'
type: development
group: group::threat insights
default_enabled: false
<script>
import { debounce } from 'lodash';
import { debounce, cloneDeep, isEqual } from 'lodash';
import {
stateFilter,
severityFilter,
......@@ -9,12 +9,15 @@ import {
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 './activity_filter.vue';
import ProjectFilter from './project_filter.vue';
import ScannerFilter from './scanner_filter.vue';
import SimpleFilter from './simple_filter.vue';
export default {
components: { SimpleFilter, ScannerFilter, ActivityFilter },
components: { SimpleFilter, ScannerFilter, ActivityFilter, ProjectFilter },
mixins: [glFeatureFlagsMixin()],
inject: ['dashboardType'],
props: {
projects: { type: Array, required: false, default: undefined },
......@@ -31,8 +34,17 @@ export default {
isPipeline() {
return this.dashboardType === DASHBOARD_TYPES.PIPELINE;
},
isGroupDashboard() {
return this.dashboardType === DASHBOARD_TYPES.GROUP;
},
isInstanceDashboard() {
return this.dashboardType === DASHBOARD_TYPES.INSTANCE;
},
shouldShowProjectFilter() {
return Boolean(this.projects?.length);
return this.isGroupDashboard || this.isInstanceDashboard;
},
shouldShowNewProjectFilter() {
return this.glFeatures.vulnReportNewProjectFilter && this.shouldShowProjectFilter;
},
projectFilter() {
return getProjectFilter(this.projects);
......@@ -40,8 +52,12 @@ export default {
},
methods: {
updateFilterQuery(query) {
const oldQuery = cloneDeep(this.filterQuery);
this.filterQuery = { ...this.filterQuery, ...query };
if (!isEqual(oldQuery, this.filterQuery)) {
this.emitFilterChange();
}
},
// When this component is first shown, every filter will emit its own @filter-changed event at
// the same time, which will trigger this method multiple times. We'll debounce it so that it
......@@ -86,8 +102,14 @@ export default {
:filter="$options.activityFilter"
@filter-changed="updateFilterQuery"
/>
<project-filter
v-if="shouldShowNewProjectFilter"
:filter="projectFilter"
@filter-changed="updateFilterQuery"
/>
<simple-filter
v-if="shouldShowProjectFilter"
v-else-if="shouldShowProjectFilter"
:filter="projectFilter"
:data-testid="projectFilter.id"
@filter-changed="updateFilterQuery"
......
<script>
import {
GlDropdownDivider,
GlDropdownText,
GlLoadingIcon,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { escapeRegExp, has, xorBy } from 'lodash';
import { DASHBOARD_TYPES } from 'ee/security_dashboard/store/constants';
import createFlash from '~/flash';
import { convertToGraphQLIds } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import groupProjectsQuery from '../../../graphql/queries/group_projects.query.graphql';
import instanceProjectsQuery from '../../../graphql/queries/instance_projects.query.graphql';
import { mapProjects, PROJECT_LOADING_ERROR_MESSAGE } from '../../../helpers';
import FilterBody from './filter_body.vue';
import FilterItem from './filter_item.vue';
import SimpleFilter from './simple_filter.vue';
const SEARCH_TERM_MINIMUM_LENGTH = 3;
const SELECTED_PROJECTS_MAX_COUNT = 100;
const PROJECT_ENTITY_NAME = 'Project';
const QUERY_CONFIGS = {
[DASHBOARD_TYPES.GROUP]: {
query: groupProjectsQuery,
property: 'group',
},
[DASHBOARD_TYPES.INSTANCE]: {
query: instanceProjectsQuery,
property: 'instanceSecurityDashboard',
},
};
export default {
components: {
FilterBody,
FilterItem,
GlDropdownDivider,
GlLoadingIcon,
GlDropdownText,
},
directives: { SafeHtml },
extends: SimpleFilter,
inject: ['groupFullPath', 'dashboardType'],
data: () => ({
projectsCache: {},
projects: [],
hasDropdownBeenOpened: false,
}),
computed: {
options() {
// Return the projects that exist.
return Object.values(this.projectsCache).filter(Boolean);
},
selectedSet() {
return new Set(this.selectedOptions.map((x) => x.id));
},
selectableProjects() {
// When searching, we want the "select in place" behavior when a dropdown item is clicked, so
// we show all the projects. If not, we want the "move the selected item to the top" behavior,
// so we show only unselected projects:
return this.isSearching ? this.projects : this.projects.filter((x) => !this.isSelected(x.id));
},
isLoadingProjects() {
return this.$apollo.queries.projects.loading;
},
isLoadingProjectsById() {
return this.$apollo.queries.projectsById.loading;
},
isSearchTooShort() {
return this.isSearching && this.searchTerm.length < SEARCH_TERM_MINIMUM_LENGTH;
},
isSearching() {
return this.searchTerm.length > 0;
},
showSelectedProjectsSection() {
return this.selectedOptions?.length && !this.isSearching;
},
showAllOption() {
return !this.isLoadingProjects && !this.isSearching && !this.isMaxProjectsSelected;
},
isMaxProjectsSelected() {
return this.selectedOptions?.length >= SELECTED_PROJECTS_MAX_COUNT;
},
uncachedIds() {
const ids = this.querystringIds.includes(this.filter.allOption.id) ? [] : this.querystringIds;
return ids.filter((id) => !has(this.projectsCache, id));
},
queryConfig() {
return QUERY_CONFIGS[this.dashboardType];
},
},
apollo: {
// Gets the projects from the project IDs in the querystring and adds them to the cache.
projectsById: {
query() {
return this.queryConfig.query;
},
manual: true,
variables() {
return {
pageSize: 20,
fullPath: this.groupFullPath,
// The IDs have to be in the format "gid://gitlab/Project/${projectId}"
ids: convertToGraphQLIds(PROJECT_ENTITY_NAME, this.uncachedIds),
};
},
result({ data }) {
// Add an entry to the cache for each uncached ID. We need to do this because the backend
// won't return a record for invalid IDs, so we need to record which IDs were queried for.
this.uncachedIds.forEach((id) => {
this.$set(this.projectsCache, id, undefined);
});
const property = data[this.queryConfig.property];
const projects = mapProjects(property.projects.nodes);
this.saveProjectsToCache(projects);
// Now that we have the project for each uncached ID, set the selected options.
this.selectedOptions = this.querystringOptions;
},
error() {
createFlash({ message: PROJECT_LOADING_ERROR_MESSAGE });
},
skip() {
// Skip if we've already cached all the projects for every querystring ID.
return !this.uncachedIds.length;
},
},
// Gets the projects for the group with an optional search, to show as dropdown options.
projects: {
query() {
return this.queryConfig.query;
},
variables() {
return {
fullPath: this.groupFullPath,
search: this.searchTerm,
};
},
update(data) {
const property = data[this.queryConfig.property];
return mapProjects(property.projects.nodes);
},
result() {
this.saveProjectsToCache(this.projects);
},
error() {
createFlash({ message: PROJECT_LOADING_ERROR_MESSAGE });
},
skip() {
return !this.hasDropdownBeenOpened || this.isSearchTooShort || this.isMaxProjectsSelected;
},
},
},
methods: {
processQuerystringIds() {
if (this.uncachedIds.length) {
this.emitFilterChanged({ [this.filter.id]: this.querystringIds });
} else {
this.selectedOptions = this.querystringOptions;
}
},
toggleOption(option) {
// Toggle the option's existence in the array.
this.selectedOptions = xorBy(this.selectedOptions, [option], (x) => x.id);
this.updateQuerystring();
},
setDropdownOpened() {
this.hasDropdownBeenOpened = true;
},
highlightSearchTerm(name) {
// If we use the regex with no search term, it will wrap every character with <b>, i.e.
// '<b>1</b><b>2</b><b>3</b>'.
if (!this.isSearching) {
return name;
}
// If the search term is "sec rep", split it into "sec|rep" so that a project with the name
// "Security Reports" is highlighted as "SECurity REPorts".
const terms = escapeRegExp(this.searchTerm).split(' ').join('|');
const regex = new RegExp(`(${terms})`, 'gi');
return name.replace(regex, '<b>$1</b>');
},
saveProjectsToCache(projects) {
projects.forEach((project) => this.$set(this.projectsCache, project.id, project));
},
},
i18n: {
enterMoreCharactersToSearch: __('Enter at least three characters to search'),
maxProjectsSelected: s__('SecurityReports|Maximum selected projects limit reached'),
noMatchingResults: __('No matching results'),
},
};
</script>
<template>
<filter-body
v-model.trim="searchTerm"
:name="filter.name"
:selected-options="selectedOptionsOrAll"
:show-search-box="true"
:loading="isLoadingProjectsById"
@dropdown-show="setDropdownOpened"
>
<div v-if="showSelectedProjectsSection" data-testid="selected-projects-section">
<filter-item
v-for="project in selectedOptions"
:key="project.id"
:is-checked="true"
:text="project.name"
@click="toggleOption(project)"
/>
<gl-dropdown-divider />
</div>
<filter-item
v-if="showAllOption"
:is-checked="isNoOptionsSelected"
:text="filter.allOption.name"
data-testid="allOption"
@click="deselectAllOptions"
/>
<gl-loading-icon v-if="isLoadingProjects" size="md" class="gl-mt-4 gl-mb-3" />
<gl-dropdown-text v-else-if="isMaxProjectsSelected">
{{ $options.i18n.maxProjectsSelected }}
</gl-dropdown-text>
<gl-dropdown-text v-else-if="isSearchTooShort">
{{ $options.i18n.enterMoreCharactersToSearch }}
</gl-dropdown-text>
<gl-dropdown-text v-else-if="!projects.length">
{{ $options.i18n.noMatchingResults }}
</gl-dropdown-text>
<template v-else>
<filter-item
v-for="project in selectableProjects"
:key="project.id"
:is-checked="isSelected(project.id)"
@click="toggleOption(project)"
>
<div v-safe-html="highlightSearchTerm(project.name)" class="gl-text-truncate"></div>
</filter-item>
</template>
</filter-body>
</template>
......@@ -36,10 +36,10 @@ export default {
return new Set(this.selectedOptions);
},
isNoOptionsSelected() {
return this.selectedOptions.length <= 0;
return this.selectedOptions?.length <= 0;
},
selectedOptionsOrAll() {
return this.selectedOptions.length ? this.selectedOptions : [this.filter.allOption];
return this.selectedOptions?.length ? this.selectedOptions : [this.filter.allOption];
},
filterObject() {
// This is passed to the vulnerability list's GraphQL query as a variable.
......@@ -52,7 +52,9 @@ export default {
},
querystringIds() {
const ids = this.$route?.query[this.filter.id] || [];
return Array.isArray(ids) ? ids : [ids];
const idArray = Array.isArray(ids) ? ids : [ids];
return idArray.sort();
},
querystringOptions() {
// If the querystring IDs includes the All option, return an empty array. We'll do this even
......@@ -75,15 +77,13 @@ export default {
},
watch: {
selectedOptions() {
this.$emit('filter-changed', this.filterObject);
this.emitFilterChanged(this.filterObject);
},
},
created() {
this.selectedOptions = this.querystringOptions;
this.processQuerystringIds();
// When the user clicks the forward/back browser buttons, update the selected options.
window.addEventListener('popstate', () => {
this.selectedOptions = this.querystringOptions;
});
window.addEventListener('popstate', this.processQuerystringIds);
},
methods: {
toggleOption(option) {
......@@ -108,6 +108,12 @@ export default {
isSelected(option) {
return this.selectedSet.has(option);
},
processQuerystringIds() {
this.selectedOptions = this.querystringOptions;
},
emitFilterChanged(data) {
this.$emit('filter-changed', data);
},
},
};
</script>
......
query groupProjects($fullPath: ID!) {
query groupProjects($fullPath: ID!, $ids: [ID!], $search: String, $pageSize: Int) {
group(fullPath: $fullPath) {
projects(includeSubgroups: true) {
projects(includeSubgroups: true, ids: $ids, search: $search, first: $pageSize) {
nodes {
id
name
......
query instanceProjects {
query instanceProjects($search: String) {
instanceSecurityDashboard {
projects {
projects(search: $search) {
nodes {
id
name
......
......@@ -3,6 +3,7 @@ import { REPORT_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/const
import { BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/constants';
import convertReportType from 'ee/vue_shared/security_reports/store/utils/convert_report_type';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
import { DEFAULT_SCANNER } from './constants';
......@@ -10,8 +11,8 @@ import { DEFAULT_SCANNER } from './constants';
const parseOptions = (obj) =>
Object.entries(obj).map(([id, name]) => ({ id: id.toUpperCase(), name }));
export const mapProjects = (projects) =>
projects.map((p) => ({ id: p.id.split('/').pop(), name: p.name }));
export const mapProjects = (projects = []) =>
projects.map((p) => ({ id: getIdFromGraphQLId(p.id).toString(), name: p.name }));
const stateOptions = parseOptions(VULNERABILITY_STATES);
const defaultStateOptions = stateOptions.filter((x) => ['DETECTED', 'CONFIRMED'].includes(x.id));
......
......@@ -7,6 +7,7 @@ module Groups
before_action do
push_frontend_feature_flag(:vulnerability_management_survey, type: :ops, default_enabled: :yaml)
push_frontend_feature_flag(:vuln_report_new_project_filter, current_user, default_enabled: :yaml)
end
feature_category :vulnerability_management
......
......@@ -6,6 +6,7 @@ module Security
before_action do
push_frontend_feature_flag(:vulnerability_management_survey, type: :ops, default_enabled: :yaml)
push_frontend_feature_flag(:vuln_report_new_project_filter, current_user, default_enabled: :yaml)
end
end
end
import { shallowMount } from '@vue/test-utils';
import ActivityFilter from 'ee/security_dashboard/components/shared/filters/activity_filter.vue';
import Filters from 'ee/security_dashboard/components/shared/filters/filters_layout.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 SimpleFilter from 'ee/security_dashboard/components/shared/filters/simple_filter.vue';
import { getProjectFilter, simpleScannerFilter } from 'ee/security_dashboard/helpers';
......@@ -20,6 +21,7 @@ describe('First class vulnerability filters component', () => {
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 } = {}) => {
return extendedWrapper(
......@@ -56,21 +58,52 @@ describe('First class vulnerability filters component', () => {
});
});
describe('when project filter is populated dynamically', () => {
it('should not render the project filter if there are no options', async () => {
wrapper = createComponent({ props: { projects: [] } });
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(false);
});
expect(findProjectFilter().exists()).toBe(isShown);
},
);
it('should render the project filter with the expected options', async () => {
wrapper = createComponent({ props: { projects } });
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', () => {
......
......@@ -29288,6 +29288,9 @@ msgstr ""
msgid "SecurityReports|Manage and track vulnerabilities identified in your selected projects. Vulnerabilities for selected projects with security testing configured are shown here."
msgstr ""
msgid "SecurityReports|Maximum selected projects limit reached"
msgstr ""
msgid "SecurityReports|Monitor vulnerabilities in all of your projects"
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