Skip to content
Snippets Groups Projects
Verified Commit 5d4aac33 authored by Jay Montal's avatar Jay Montal Committed by GitLab
Browse files

Add filtering for the standards adherence report

- Adds filtering for the standards adherence report in the compliance
center
  - filter by projects
  - filter by checks
  - filter by standards

Changelog: changed
EE: true
parent 9f0a98a3
No related branches found
No related tags found
2 merge requests!140025Draft: Resolve "Draft: Decouple user's personal namespace in specs",!131594Adherence Report Filtering
Showing
with 488 additions and 3 deletions
......@@ -16,6 +16,7 @@ See report and manage standards adherence, violations, and compliance frameworks
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125444) standards adherence dashboard in GitLab 16.3 [with a flag](../../../administration/feature_flags.md) named `adherence_report_ui`. Disabled by default.
> - [Enabled](https://gitlab.com/gitlab-org/gitlab/-/issues/414495) in GitLab 16.5.
> - [Feature flag `compliance_adherence_report` and `adherence_report_ui`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/137398) removed in GitLab 16.7.
> - Standards adherence filtering [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/413734) in GitLab 16.7.
Standards adherence dashboard lists the adherence status of projects complying to GitLab standard.
......@@ -33,6 +34,12 @@ To view the standards adherence dashboard for a group:
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Secure > Compliance center**.
You can filter the standards adherence dashboard on:
- The project that the check was performed on.
- The type of check that was performed on a project.
- The standard that the check belongs to.
### GitLab standard
GitLab standard consists of three rules:
......
......@@ -5,8 +5,8 @@ import { GlButton, GlFilteredSearch, GlPopover } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { FRAMEWORKS_FILTER_TYPE_FRAMEWORK, FRAMEWORKS_FILTER_TYPE_PROJECT } from '../../constants';
import ComplianceFrameworkToken from './filter_tokens/compliance_framework_token.vue';
import ProjectSearchToken from './filter_tokens/project_search_token.vue';
import ComplianceFrameworkToken from './filter_tokens/compliance_framework_token.vue';
export default {
components: {
......
......@@ -85,3 +85,12 @@ const GITLAB = 'GITLAB';
export const STANDARDS_ADHERENCE_STANARD_LABELS = {
[GITLAB]: __('GitLab'),
};
export const ALLOWED_FILTER_TOKENS = {
checks: [
PREVENT_APPROVAL_BY_MERGE_REQUEST_AUTHOR,
PREVENT_APPROVAL_BY_MERGE_REQUEST_COMMITTERS,
AT_LEAST_TWO_APPROVALS,
],
standards: [GITLAB],
};
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
import { s__ } from '~/locale';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
computed: {
checks() {
return [
{
text: s__('ComplianceStandardsAdherence|At least two approvals'),
value: 'AT_LEAST_TWO_APPROVALS',
},
{
text: s__('ComplianceStandardsAdherence|Prevent authors as approvers'),
value: 'PREVENT_APPROVAL_BY_MERGE_REQUEST_AUTHOR',
},
{
text: s__('ComplianceStandardsAdherence|Prevent committers as approvers'),
value: 'PREVENT_APPROVAL_BY_MERGE_REQUEST_COMMITTERS',
},
];
},
findActiveCheck() {
return this.checks.find((check) => check.value === this.value.data);
},
},
};
</script>
<template>
<gl-filtered-search-token :config="config" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<template #view>
{{ findActiveCheck.text }}
</template>
<template #suggestions>
<gl-filtered-search-suggestion
v-for="(check, index) in checks"
:key="index"
:value="check.value"
>
{{ check.text }}
</gl-filtered-search-suggestion>
</template>
</gl-filtered-search-token>
</template>
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
computed: {
standards() {
return [
{
text: __('GitLab'),
value: 'GITLAB',
},
];
},
},
methods: {
findActiveStandard(inputValue) {
const activeStandard = this.standards.find((standard) => standard.value === this.value.data);
return activeStandard?.text || inputValue;
},
},
};
</script>
<template>
<gl-filtered-search-token :config="config" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<template #view="{ inputValue }">
{{ findActiveStandard(inputValue) }}
</template>
<template #suggestions>
<gl-filtered-search-suggestion
v-for="(standard, index) in standards"
:key="index"
:value="standard.value"
>
{{ standard.text }}
</gl-filtered-search-suggestion>
</template>
</gl-filtered-search-token>
</template>
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
export default {
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
methods: {
findActiveProject(inputValue) {
const activeProject = this.config.projects.find((project) => project.id === this.value.data);
return activeProject?.name || inputValue;
},
},
};
</script>
<template>
<gl-filtered-search-token :config="config" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<template #view="{ inputValue }">
{{ findActiveProject(inputValue) }}
</template>
<template #suggestions>
<gl-filtered-search-suggestion
v-for="(project, index) in config.projects"
:key="index"
:value="project.id"
>
{{ project.name }}
</gl-filtered-search-suggestion>
</template>
</gl-filtered-search-token>
</template>
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlFilteredSearch } from '@gitlab/ui';
import { __ } from '~/locale';
import ProjectsToken from './filter_tokens/projects_token.vue';
import ComplianceStandardNameToken from './filter_tokens/compliance_standard_name_token.vue';
import ComplianceCheckNameToken from './filter_tokens/compliance_check_name_token.vue';
export default {
components: {
GlFilteredSearch,
},
props: {
projects: {
type: Array,
required: true,
default: () => [],
},
groupPath: {
type: String,
required: true,
},
},
computed: {
filterTokens() {
return [
{
unique: true,
type: 'standard',
title: __('Standard'),
entityType: 'standard',
token: ComplianceStandardNameToken,
operators: [{ value: 'matches', description: 'matches' }],
},
{
unique: true,
type: 'project',
title: __('Project'),
entityType: 'project',
token: ProjectsToken,
operators: [{ value: 'matches', description: 'matches' }],
groupPath: this.groupPath,
projects: this.projects,
},
{
unique: true,
type: 'check',
title: __('Check'),
entityType: 'check',
token: ComplianceCheckNameToken,
operators: [{ value: 'matches', description: 'matches' }],
},
];
},
},
methods: {
onFilterSubmit(filters) {
this.$emit('submit', filters);
},
handleFilterClear() {
this.$emit('clear', []);
},
},
i18n: {
placeholder: __('Filter results'),
},
};
</script>
<template>
<div class="row-content-block gl-relative gl-border-0">
<gl-filtered-search
:placeholder="$options.i18n.placeholder"
:available-tokens="filterTokens"
@submit="onFilterSubmit"
@clear="handleFilterClear"
/>
</div>
</template>
......@@ -3,6 +3,8 @@ import { GlAlert, GlTable, GlIcon, GlLink, GlBadge, GlLoadingIcon } from '@gitla
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { formatDate } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale';
import { mapStandardsAdherenceQueryToFilters } from 'ee/compliance_dashboard/utils';
import getProjectsInComplianceStandardsAdherence from 'ee/compliance_dashboard/graphql/compliance_projects_in_standards_adherence.query.graphql';
import getProjectComplianceStandardsAdherence from '../../graphql/compliance_standards_adherence.query.graphql';
import Pagination from '../shared/pagination.vue';
import { GRAPHQL_PAGE_SIZE } from '../../constants';
......@@ -12,8 +14,10 @@ import {
STANDARDS_ADHERENCE_STANARD_LABELS,
NO_STANDARDS_ADHERENCES_FOUND,
STANDARDS_ADHERENCE_FETCH_ERROR,
ALLOWED_FILTER_TOKENS,
} from './constants';
import FixSuggestionsSidebar from './fix_suggestions_sidebar.vue';
import Filters from './filters.vue';
export default {
name: 'ComplianceStandardsAdherenceTable',
......@@ -26,6 +30,7 @@ export default {
GlLoadingIcon,
FixSuggestionsSidebar,
Pagination,
Filters,
},
props: {
groupPath: {
......@@ -36,12 +41,18 @@ export default {
data() {
return {
hasStandardsAdherenceFetchError: false,
hasFilterValueError: false,
hasRawTextError: false,
adherences: {
list: [],
pageInfo: {},
},
projects: {
list: [],
},
drawerId: null,
drawerAdherence: {},
filters: {},
};
},
apollo: {
......@@ -50,6 +61,7 @@ export default {
variables() {
return {
fullPath: this.groupPath,
filters: this.filters,
...this.paginationCursors,
};
},
......@@ -65,6 +77,20 @@ export default {
this.hasStandardsAdherenceFetchError = true;
},
},
projects: {
query: getProjectsInComplianceStandardsAdherence,
variables() {
return {
fullPath: this.groupPath,
};
},
update(data) {
const nodes = data?.group?.projects.nodes || [];
return {
list: nodes,
};
},
},
},
computed: {
isLoading() {
......@@ -198,6 +224,38 @@ export default {
},
});
},
onFiltersChanged(filters) {
this.hasStandardsAdherenceFetchError = false;
this.hasFilterValueError = false;
this.hasRawTextError = false;
const availableProjectIDs = this.projects.list.map((item) => item.id);
filters.forEach((filter) => {
if (
filter.type === 'standard' &&
!ALLOWED_FILTER_TOKENS.standards.includes(filter.value.data)
) {
this.hasFilterValueError = true;
}
if (filter.type === 'check' && !ALLOWED_FILTER_TOKENS.checks.includes(filter.value.data)) {
this.hasFilterValueError = true;
}
if (filter.type === 'project' && !availableProjectIDs.includes(filter.value.data)) {
this.hasFilterValueError = true;
}
if (!filter.type) {
this.hasRawTextError = true;
}
});
if (!this.hasFilterValueError) {
this.filters = mapStandardsAdherenceQueryToFilters(filters);
}
},
clearFilters() {
this.filters = {};
},
},
noStandardsAdherencesFound: NO_STANDARDS_ADHERENCES_FOUND,
standardsAdherenceFetchError: STANDARDS_ADHERENCE_FETCH_ERROR,
......@@ -212,6 +270,12 @@ export default {
lastScanned: s__('ComplianceStandardsAdherence|Last Scanned'),
moreInformation: s__('ComplianceStandardsAdherence|More Information'),
},
rawFiltersNotSupported: s__(
'ComplianceStandardsAdherence|Raw text search is not currently supported. Please use the available filters.',
),
invalidFilterValue: s__(
'ComplianceStandardsAdherence|Raw filter values is not currently supported. Please use available values.',
),
},
};
</script>
......@@ -226,6 +290,19 @@ export default {
>
{{ $options.standardsAdherenceFetchError }}
</gl-alert>
<gl-alert v-if="hasFilterValueError" variant="warning" class="gl-mt-3" :dismissible="false">
{{ $options.i18n.invalidFilterValue }}
</gl-alert>
<gl-alert v-if="hasRawTextError" variant="warning" class="gl-mt-3" :dismissible="false">
{{ $options.i18n.rawFiltersNotSupported }}
</gl-alert>
<filters
:projects="projects.list"
:group-path="groupPath"
:error="hasStandardsAdherenceFetchError"
@submit="onFiltersChanged"
@clear="clearFilters"
/>
<gl-table
:fields="fields"
:items="adherences.list"
......
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getProjectsInGroup($fullPath: ID!) {
group(fullPath: $fullPath) {
id
projects {
nodes {
id
name
}
pageInfo {
...PageInfo
}
}
}
}
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getProjectComplianceStandardsAdherence(
query projectComplianceStandardsAdherence(
$fullPath: ID!
$after: String
$before: String
$first: Int
$last: Int
$filters: ComplianceStandardsAdherenceInput
) {
group(fullPath: $fullPath) {
id
......@@ -14,6 +15,7 @@ query getProjectComplianceStandardsAdherence(
before: $before
first: $first
last: $last
filters: $filters
) {
nodes {
id
......
......@@ -74,3 +74,18 @@ export const checkFilterForChange = ({ currentFilters = {}, newFilters = {} }) =
return filterKeys.some((key) => currentFilters[key] !== newFilters[key]);
};
export function mapStandardsAdherenceQueryToFilters(filters) {
const filterParams = {};
const checkSearch = filters?.find((filter) => filter.type === 'check');
filterParams.checkName = checkSearch?.value?.data ?? undefined;
const standardSearch = filters?.find((filter) => filter.type === 'standard');
filterParams.standard = standardSearch?.value?.data ?? undefined;
const projectIdsSearch = filters?.find((filter) => filter.type === 'project');
filterParams.projectIds = projectIdsSearch?.value?.data ?? undefined;
return filterParams;
}
# frozen_string_literal: true
module Types
module ComplianceManagement
class ComplianceStandardsAdherenceInputType < BaseInputObject
graphql_name 'ComplianceStandardsAdherenceInput'
description 'Arguments for filtering compliance standards adherences'
argument :project_ids, [::Types::GlobalIDType[::Project]],
required: false,
description: 'Global ID of the project.',
prepare: ->(ids, _ctx) { ids.map(&:model_id) }
argument :check_name,
::GraphQL::Types::String,
required: false,
description: 'Name of the check for the compliance standard.'
argument :standard_name,
::GraphQL::Types::String,
required: false,
description: 'Name of the compliance standard.'
end
end
end
......@@ -11,6 +11,9 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import getProjectComplianceStandardsAdherence from 'ee/compliance_dashboard/graphql/compliance_standards_adherence.query.graphql';
import Pagination from 'ee/compliance_dashboard/components/shared/pagination.vue';
import { ROUTE_STANDARDS_ADHERENCE } from 'ee/compliance_dashboard/constants';
import Filters from 'ee/compliance_dashboard/components/standards_adherence_report/filters.vue';
import getProjectsInComplianceStandardsAdherence from 'ee/compliance_dashboard/graphql/compliance_projects_in_standards_adherence.query.graphql';
import { mapStandardsAdherenceQueryToFilters } from 'ee/compliance_dashboard/utils';
import { createComplianceAdherencesResponse } from '../../mock_data';
Vue.use(VueApollo);
......@@ -27,7 +30,10 @@ describe('ComplianceStandardsAdherenceTable component', () => {
const mockGraphQlLoading = jest.fn().mockResolvedValue(new Promise(() => {}));
const mockGraphQlError = jest.fn().mockRejectedValue(sentryError);
const createMockApolloProvider = (resolverMock) => {
return createMockApollo([[getProjectComplianceStandardsAdherence, resolverMock]]);
return createMockApollo([
[getProjectComplianceStandardsAdherence, resolverMock],
[getProjectsInComplianceStandardsAdherence, mockGraphQlLoading],
]);
};
const findErrorMessage = () => wrapper.findComponent(GlAlert);
......@@ -40,6 +46,7 @@ describe('ComplianceStandardsAdherenceTable component', () => {
const findFirstTableRowData = () => findNthTableRow(1).findAll('td');
const findViewDetails = () => wrapper.findComponent(GlLink);
const findPagination = () => wrapper.findComponent(Pagination);
const findFilters = () => wrapper.findComponent(Filters);
const openSidebar = async () => {
await findViewDetails().trigger('click');
......@@ -64,6 +71,13 @@ describe('ComplianceStandardsAdherenceTable component', () => {
wrapper = extendedWrapper(
mountFn(ComplianceStandardsAdherenceTable, {
apolloProvider,
data() {
return {
projects: {
list: [{ id: 'gid://gitlab/Project/1' }],
},
};
},
propsData: {
groupPath,
...props,
......@@ -278,6 +292,63 @@ describe('ComplianceStandardsAdherenceTable component', () => {
});
});
});
describe('filtering', () => {
describe('by standard', () => {
it('fetches the filtered adherences', async () => {
findFilters().vm.$emit('submit', [{ type: 'standard', value: { data: 'GITLAB' } }]);
await waitForPromises();
expect(mockGraphQlSuccess).toHaveBeenCalledTimes(2);
expect(mockGraphQlSuccess).toHaveBeenNthCalledWith(2, {
after: undefined,
fullPath: groupPath,
filters: mapStandardsAdherenceQueryToFilters([
{ type: 'standard', value: { data: 'GITLAB' } },
]),
first: 20,
});
});
});
describe('by project', () => {
it('fetches the filtered adherences', async () => {
findFilters().vm.$emit('submit', [
{ type: 'project', value: { data: 'gid://gitlab/Project/1' } },
]);
await waitForPromises();
expect(mockGraphQlSuccess).toHaveBeenCalledTimes(2);
expect(mockGraphQlSuccess).toHaveBeenNthCalledWith(2, {
after: undefined,
fullPath: groupPath,
filters: mapStandardsAdherenceQueryToFilters([
{ type: 'project', value: { data: 'gid://gitlab/Project/1' } },
]),
first: 20,
});
});
});
describe('by check name', () => {
it('fetches the filtered adherences', async () => {
findFilters().vm.$emit('submit', [
{ type: 'check', value: { data: 'AT_LEAST_TWO_APPROVALS' } },
]);
await waitForPromises();
expect(mockGraphQlSuccess).toHaveBeenCalledTimes(2);
expect(mockGraphQlSuccess).toHaveBeenNthCalledWith(2, {
after: undefined,
fullPath: groupPath,
filters: mapStandardsAdherenceQueryToFilters([
{ type: 'check', value: { data: 'AT_LEAST_TWO_APPROVALS' } },
]),
first: 20,
});
});
});
});
});
describe('when there are no standards adherence checks available', () => {
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ComplianceStandardsAdherenceInput'], feature_category: :compliance_management do
subject { described_class }
arguments = %w[
projectIds
checkName
standard
]
it { expect(described_class.graphql_name).to eq('ComplianceStandardsAdherenceInput') }
it { expect(described_class.arguments.keys).to match_array(arguments) }
end
......@@ -9951,6 +9951,9 @@ msgstr ""
msgid "ChatMessage|in %{project_link}"
msgstr ""
 
msgid "Check"
msgstr ""
msgid "Check again"
msgstr ""
 
......@@ -12813,6 +12816,12 @@ msgstr ""
msgid "ComplianceStandardsAdherence|Project"
msgstr ""
 
msgid "ComplianceStandardsAdherence|Raw filter values is not currently supported. Please use available values."
msgstr ""
msgid "ComplianceStandardsAdherence|Raw text search is not currently supported. Please use the available filters."
msgstr ""
msgid "ComplianceStandardsAdherence|Requirement"
msgstr ""
 
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment