Commit 2ae6395b authored by Jiaan Louw's avatar Jiaan Louw Committed by Ezekiel Kigbo

Add filter to compliance report

- Adds project and date range filters to the compliance report.
- Updates VSA shared project dropdown to watch for default projects
  changes and adds a default projects loading state.
parent 26182f03
......@@ -31,7 +31,8 @@ export default {
props: {
groupId: {
type: Number,
required: true,
required: false,
default: null,
},
groupNamespace: {
type: String,
......@@ -57,6 +58,11 @@ export default {
required: false,
default: () => [],
},
loadingDefaultProjects: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -111,6 +117,9 @@ export default {
searchTerm() {
this.search();
},
defaultProjects(projects) {
this.selectedProjects = [...projects];
},
},
mounted() {
this.search();
......@@ -202,6 +211,7 @@ export default {
ref="projectsDropdown"
class="dropdown dropdown-projects"
toggle-class="gl-shadow-none"
:loading="loadingDefaultProjects"
:show-clear-all="hasSelectedProjects"
show-highlighted-items-title
highlighted-items-title-class="gl-p-3"
......@@ -209,6 +219,7 @@ export default {
@hide="onHide"
>
<template #button-content>
<gl-loading-icon v-if="loadingDefaultProjects" class="gl-mr-2" />
<div class="gl-display-flex gl-flex-grow-1">
<gl-avatar
v-if="isOnlyOneProjectSelected"
......
......@@ -304,3 +304,10 @@ $gl-line-height-42: px-to-rem(42px);
width: 25%;
}
}
// TODO: Move to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1671
.gl-md-pr-5 {
@include gl-media-breakpoint-up(md) {
padding-right: $gl-spacing-scale-5;
}
}
......@@ -2,14 +2,22 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import resolvers from './graphql/resolvers';
import ComplianceDashboard from './components/dashboard.vue';
import ComplianceReport from './components/report.vue';
import { parseViolationsQuery } from './utils';
export default () => {
const el = document.getElementById('js-compliance-report');
const { mergeRequests, emptyStateSvgPath, isLastPage, mergeCommitsCsvExportPath } = el.dataset;
const {
mergeRequests,
emptyStateSvgPath,
isLastPage,
mergeCommitsCsvExportPath,
groupPath,
} = el.dataset;
if (gon.features.complianceViolationsReport) {
Vue.use(VueApollo);
......@@ -18,6 +26,10 @@ export default () => {
defaultClient: createDefaultClient(resolvers),
});
const defaultQuery = parseViolationsQuery(
queryToObject(window.location.search, { gatherArrays: true }),
);
return new Vue({
el,
apolloProvider,
......@@ -26,6 +38,8 @@ export default () => {
props: {
emptyStateSvgPath,
mergeCommitsCsvExportPath,
groupPath,
defaultQuery,
},
}),
});
......
......@@ -5,12 +5,14 @@ import { __ } from '~/locale';
import { thWidthClass } from '~/lib/utils/table_utility';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import complianceViolationsQuery from '../graphql/compliance_violations.query.graphql';
import { mapViolations } from '../graphql/mappers';
import EmptyState from './empty_state.vue';
import MergeCommitsExportButton from './merge_requests/merge_commits_export_button.vue';
import MergeRequestDrawer from './drawer.vue';
import ViolationReason from './violations/reason.vue';
import ViolationFilter from './violations/filter.vue';
export default {
name: 'ComplianceReport',
......@@ -23,6 +25,8 @@ export default {
MergeRequestDrawer,
ViolationReason,
TimeAgoTooltip,
ViolationFilter,
UrlSync,
},
props: {
emptyStateSvgPath: {
......@@ -34,9 +38,18 @@ export default {
required: false,
default: '',
},
groupPath: {
type: String,
required: true,
},
defaultQuery: {
type: Object,
required: true,
},
},
data() {
return {
urlQuery: {},
queryError: false,
violations: [],
showDrawer: false,
......@@ -95,6 +108,13 @@ export default {
this.drawerMergeRequest = {};
this.drawerProject = {};
},
updateUrlQuery({ projectIds = [], ...rest }) {
this.urlQuery = {
// Clear the URL param when the id array is empty
projectIds: projectIds.length > 0 ? projectIds : null,
...rest,
};
},
},
fields: [
{
......@@ -150,30 +170,36 @@ export default {
:dismissible="false"
:title="$options.i18n.queryError"
/>
<gl-table
v-else-if="hasViolations"
ref="table"
:fields="$options.fields"
:items="violations"
head-variant="white"
stacked="lg"
select-mode="single"
selectable
hover
selected-variant="primary"
thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
@row-selected="toggleDrawer"
>
<template #cell(reason)="{ item: { reason, violatingUser } }">
<violation-reason :reason="reason" :user="violatingUser" />
</template>
<template #cell(mergeRequest)="{ item: { mergeRequest } }">
{{ mergeRequest.title }}
</template>
<template #cell(mergedAt)="{ item: { mergeRequest } }">
<time-ago-tooltip :time="mergeRequest.mergedAt" />
</template>
</gl-table>
<template v-else-if="hasViolations">
<violation-filter
:group-path="groupPath"
:default-query="defaultQuery"
@filters-changed="updateUrlQuery"
/>
<gl-table
ref="table"
:fields="$options.fields"
:items="violations"
head-variant="white"
stacked="lg"
select-mode="single"
selectable
hover
selected-variant="primary"
thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
@row-selected="toggleDrawer"
>
<template #cell(reason)="{ item: { reason, violatingUser } }">
<violation-reason :reason="reason" :user="violatingUser" />
</template>
<template #cell(mergeRequest)="{ item: { mergeRequest } }">
{{ mergeRequest.title }}
</template>
<template #cell(mergedAt)="{ item: { mergeRequest } }">
<time-ago-tooltip :time="mergeRequest.mergedAt" />
</template>
</gl-table>
</template>
<empty-state v-else :image-path="emptyStateSvgPath" />
<merge-request-drawer
:show-drawer="showDrawer"
......@@ -182,5 +208,6 @@ export default {
:z-index="$options.DRAWER_Z_INDEX"
@close="closeDrawer"
/>
<url-sync :query="urlQuery" />
</section>
</template>
<script>
import { GlDaterangePicker } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import { getDateInPast, pikadayToString, parsePikadayDate } from '~/lib/utils/datetime_utility';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import getGroupProjects from '../../graphql/violation_group_projects.query.graphql';
import { CURRENT_DATE } from '../../../audit_events/constants';
export default {
components: {
GlDaterangePicker,
ProjectsDropdownFilter,
},
props: {
groupPath: {
type: String,
required: true,
},
defaultQuery: {
type: Object,
required: true,
},
showProjectFilter: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
filterQuery: {},
defaultProjects: [],
loadingDefaultProjects: false,
};
},
computed: {
defaultStartDate() {
const startDate = this.defaultQuery.createdAfter;
return startDate ? parsePikadayDate(startDate) : getDateInPast(CURRENT_DATE, 30);
},
defaultEndDate() {
const endDate = this.defaultQuery.createdBefore;
return endDate ? parsePikadayDate(endDate) : CURRENT_DATE;
},
},
async created() {
if (this.showProjectFilter && this.defaultQuery.projectIds?.length > 0) {
this.defaultProjects = await this.fetchProjects(this.defaultQuery.projectIds);
}
},
methods: {
fetchProjects(projectIds) {
const { groupPath } = this;
this.loadingDefaultProjects = true;
return this.$apollo
.query({
query: getGroupProjects,
variables: { groupPath, projectIds },
})
.then((response) => response.data?.group?.projects?.nodes)
.catch((error) => Sentry.captureException(error))
.finally(() => {
this.loadingDefaultProjects = false;
});
},
projectsChanged(projects) {
const projectIds = projects.map(({ id }) => getIdFromGraphQLId(id));
this.updateFilter({ projectIds });
},
dateRangeChanged({ startDate = this.defaultStartDate, endDate = this.defaultEndDate }) {
this.updateFilter({
createdAfter: pikadayToString(startDate),
createdBefore: pikadayToString(endDate),
});
},
updateFilter(query) {
this.filterQuery = { ...this.filterQuery, ...query };
this.$emit('filters-changed', this.filterQuery);
},
},
projectFilterLabel: __('Projects'),
defaultMaxDate: CURRENT_DATE,
projectsFilterParams: {
first: 50,
includeSubgroups: true,
},
dateRangePickerClass: 'gl-display-flex gl-flex-direction-column gl-w-full gl-md-w-auto',
};
</script>
<template>
<div
class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row row-content-block gl-pb-0 gl-mb-5"
>
<div class="gl-display-flex gl-flex-direction-column gl-mb-5 gl-md-pr-5">
<label data-testid="dropdown-label" class="gl-line-height-normal">{{
$options.projectFilterLabel
}}</label>
<projects-dropdown-filter
v-if="showProjectFilter"
class="gl-mb-2 gl-lg-mb-0 compliance-filter-dropdown-input"
:group-namespace="groupPath"
:query-params="$options.projectsFilterParams"
:multi-select="true"
:default-projects="defaultProjects"
:loading-default-projects="loadingDefaultProjects"
@selected="projectsChanged"
/>
</div>
<gl-daterange-picker
class="gl-display-flex gl-w-full gl-mb-5"
:default-start-date="defaultStartDate"
:default-end-date="defaultEndDate"
:default-max-date="$options.defaultMaxDate"
:start-picker-class="`${$options.dateRangePickerClass} gl-mr-5`"
:end-picker-class="$options.dateRangePickerClass"
date-range-indicator-class="gl-m-0!"
:same-day-selection="false"
@input="dateRangeChanged"
/>
</div>
</template>
query violationsGroupProjects($groupPath: ID!, $projectIds: [ID!] = null) {
group(fullPath: $groupPath) {
id
projects(ids: $projectIds, includeSubgroups: true) {
nodes {
id
name
fullPath
}
}
}
}
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertToGraphQLIds } from '~/graphql_shared/utils';
import { TYPE_PROJECT } from '~/graphql_shared/constants';
export const mapDashboardToDrawerData = (mergeRequest) => ({
id: mergeRequest.id,
......@@ -13,3 +15,11 @@ export const mapDashboardToDrawerData = (mergeRequest) => ({
),
},
});
export const parseViolationsQuery = ({ projectIds = [], ...rest }) => ({
projectIds: convertToGraphQLIds(
TYPE_PROJECT,
projectIds.filter((id) => Boolean(id)),
),
...rest,
});
.compliance-filter-dropdown-input {
@include media-breakpoint-up(md) {
max-width: 11rem;
}
}
......@@ -4,4 +4,5 @@
#js-compliance-report{ data: { merge_requests: @merge_requests.to_json,
is_last_page: @last_page.to_json,
empty_state_svg_path: image_path('illustrations/merge_requests.svg'),
merge_commits_csv_export_path: group_security_merge_commit_reports_path(@group, format: :csv) } }
merge_commits_csv_export_path: group_security_merge_commit_reports_path(@group, format: :csv),
group_path: @group.full_path } }
......@@ -8,12 +8,15 @@ import EmptyState from 'ee/compliance_dashboard/components/empty_state.vue';
import MergeRequestDrawer from 'ee/compliance_dashboard/components/drawer.vue';
import MergeCommitsExportButton from 'ee/compliance_dashboard/components/merge_requests/merge_commits_export_button.vue';
import ViolationReason from 'ee/compliance_dashboard/components/violations/reason.vue';
import ViolationFilter from 'ee/compliance_dashboard/components/violations/filter.vue';
import resolvers from 'ee/compliance_dashboard/graphql/resolvers';
import { mapViolations } from 'ee/compliance_dashboard/graphql/mappers';
import { stripTypenames } from 'helpers/graphql_helpers';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import { stubComponent } from 'helpers/stub_component';
Vue.use(VueApollo);
......@@ -23,6 +26,12 @@ describe('ComplianceReport component', () => {
const mergeCommitsCsvExportPath = '/csv';
const emptyStateSvgPath = 'empty.svg';
const groupPath = 'group-path';
const defaultQuery = {
projectIds: ['gid://gitlab/Project/20'],
createdAfter: '2021-11-16',
createdBefore: '2021-12-15',
};
const mockGraphQlError = new Error('GraphQL networkError');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
......@@ -33,6 +42,8 @@ describe('ComplianceReport component', () => {
const findMergeCommitsExportButton = () => wrapper.findComponent(MergeCommitsExportButton);
const findViolationReason = () => wrapper.findComponent(ViolationReason);
const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
const findViolationFilter = () => wrapper.findComponent(ViolationFilter);
const findUrlSync = () => wrapper.findComponent(UrlSync);
const findTableHeaders = () => findViolationsTable().findAll('th');
const findTablesFirstRowData = () =>
......@@ -54,10 +65,13 @@ describe('ComplianceReport component', () => {
propsData: {
mergeCommitsCsvExportPath,
emptyStateSvgPath,
groupPath,
defaultQuery,
...props,
},
stubs: {
GlTable: false,
ViolationFilter: stubComponent(ViolationFilter),
},
});
};
......@@ -76,6 +90,7 @@ describe('ComplianceReport component', () => {
expect(findLoadingIcon().exists()).toBe(true);
expect(findErrorMessage().exists()).toBe(false);
expect(findViolationsTable().exists()).toBe(false);
expect(findViolationFilter().exists()).toBe(false);
});
});
......@@ -209,6 +224,31 @@ describe('ComplianceReport component', () => {
);
});
});
describe('violation filter', () => {
it('configures the filter', () => {
expect(findViolationFilter().props()).toMatchObject({
groupPath,
defaultQuery,
});
});
it('updates the URL query when the filters changed', async () => {
const query = { foo: 'bar', projectIds: [1, 2, 3] };
await findViolationFilter().vm.$emit('filters-changed', query);
expect(findUrlSync().props('query')).toMatchObject(query);
});
it('clears the project URL query param when the filters changed and the project array is empty', async () => {
const query = { foo: 'bar', projectIds: [] };
await findViolationFilter().vm.$emit('filters-changed', query);
expect(findUrlSync().props('query')).toMatchObject({ ...query, projectIds: null });
});
});
});
describe('when there are no violations', () => {
......
import * as utils from 'ee/compliance_dashboard/utils';
describe('compliance report utils', () => {
describe('parseViolationsQuery', () => {
it('returns the expected result', () => {
const query = {
projectIds: ['1', '2'],
createdAfter: '2021-12-06',
createdBefore: '2022-01-06',
};
expect(utils.parseViolationsQuery(query)).toStrictEqual({
projectIds: ['gid://gitlab/Project/1', 'gid://gitlab/Project/2'],
createdAfter: query.createdAfter,
createdBefore: query.createdBefore,
});
});
});
});
import VueApollo from 'vue-apollo';
import Vue from 'vue';
import { GlDaterangePicker } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ViolationFilter from 'ee/compliance_dashboard/components/violations/filter.vue';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import { getDateInPast, pikadayToString } from '~/lib/utils/datetime_utility';
import { CURRENT_DATE } from 'ee/audit_events/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import getGroupProjectsQuery from 'ee/compliance_dashboard/graphql/violation_group_projects.query.graphql';
import waitForPromises from 'helpers/wait_for_promises';
import { createDefaultProjects, createDefaultProjectsResponse } from '../../mock_data';
Vue.use(VueApollo);
describe('ViolationFilter component', () => {
let wrapper;
const groupPath = 'group-path';
const projectIds = ['1', '2'];
const startDate = getDateInPast(CURRENT_DATE, 20);
const endDate = getDateInPast(CURRENT_DATE, 4);
const dateRangeQuery = {
createdAfter: pikadayToString(startDate),
createdBefore: pikadayToString(endDate),
};
const defaultProjects = createDefaultProjects(2);
const projectsResponse = createDefaultProjectsResponse(defaultProjects);
const groupProjectsLoading = jest.fn().mockReturnValue(new Promise((resolve) => resolve));
const groupProjectsSuccess = jest.fn().mockResolvedValue(projectsResponse);
const findProjectsFilter = () => wrapper.findComponent(ProjectsDropdownFilter);
const findProjectsFilterLabel = () => wrapper.findByTestId('dropdown-label');
const findDatePicker = () => wrapper.findComponent(GlDaterangePicker);
const mockApollo = (mockResponse = groupProjectsSuccess) =>
createMockApollo([[getGroupProjectsQuery, mockResponse]]);
const createComponent = (propsData = {}, mockResponse) => {
wrapper = shallowMountExtended(ViolationFilter, {
apolloProvider: mockApollo(mockResponse),
propsData: {
groupPath,
defaultQuery: {},
...propsData,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('component behavior', () => {
beforeEach(() => {
createComponent();
});
it('renders the project input label', () => {
expect(findProjectsFilterLabel().text()).toBe('Projects');
});
it('configures the project filter', () => {
expect(findProjectsFilter().props()).toMatchObject({
groupNamespace: groupPath,
queryParams: { first: 50, includeSubgroups: true },
multiSelect: true,
defaultProjects: [],
loadingDefaultProjects: false,
});
});
it('configures the date picker', () => {
expect(findDatePicker().props()).toMatchObject({
defaultStartDate: getDateInPast(CURRENT_DATE, 30),
defaultEndDate: CURRENT_DATE,
defaultMaxDate: CURRENT_DATE,
maxDateRange: 0,
sameDaySelection: false,
});
});
it('passes the default query dates to the dates range picker', () => {
createComponent({ defaultQuery: { ...dateRangeQuery } });
expect(findDatePicker().props()).toMatchObject({
defaultStartDate: startDate,
defaultEndDate: endDate,
});
});
it('hides the project filter when showProjectFilter is false', () => {
createComponent({ showProjectFilter: false });
expect(findProjectsFilter().exists()).toBe(false);
});
});
describe('filters-changed event', () => {
beforeEach(() => {
createComponent();
});
it('emits a query with projectIds when projects have been selected', async () => {
const expectedIds = defaultProjects.map(({ id }) => id);
await findProjectsFilter().vm.$emit('selected', defaultProjects);
expect(wrapper.emitted('filters-changed')).toHaveLength(1);
expect(wrapper.emitted('filters-changed')[0]).toStrictEqual([{ projectIds: expectedIds }]);
});
it('emits a query with a start and end date when a date range has been inputted', async () => {
await findDatePicker().vm.$emit('input', { startDate, endDate });
expect(wrapper.emitted('filters-changed')).toHaveLength(1);
expect(wrapper.emitted('filters-changed')[0]).toStrictEqual([{ ...dateRangeQuery }]);
});
it('emits the existing filter query with mutations on each update', async () => {
await findProjectsFilter().vm.$emit('selected', []);
expect(wrapper.emitted('filters-changed')).toHaveLength(1);
expect(wrapper.emitted('filters-changed')[0]).toStrictEqual([{ projectIds: [] }]);
await findDatePicker().vm.$emit('input', { startDate, endDate });
expect(wrapper.emitted('filters-changed')).toHaveLength(2);
expect(wrapper.emitted('filters-changed')[1]).toStrictEqual([
{
projectIds: [],
...dateRangeQuery,
},
]);
});
});
describe('projects filter', () => {
it('fetches the project details when the default query contains projectIds', () => {
createComponent({ defaultQuery: { projectIds } });
expect(groupProjectsSuccess).toHaveBeenCalledWith({ groupPath, projectIds });
});
describe('when the defaultProjects are being fetched', () => {
beforeEach(async () => {
createComponent({ defaultQuery: { projectIds } }, groupProjectsLoading);
await waitForPromises();
});
it('sets the project filter to loading', async () => {
expect(findProjectsFilter().props()).toMatchObject({
defaultProjects: [],
loadingDefaultProjects: true,
});
});
});
describe('when the defaultProjects have been fetched', () => {
beforeEach(async () => {
createComponent({ defaultQuery: { projectIds } });
await waitForPromises();
});
it('sets the default projects on the project filter', async () => {
expect(findProjectsFilter().props()).toMatchObject({
defaultProjects,
loadingDefaultProjects: false,
});
});
});
});
});
......@@ -70,3 +70,26 @@ export const createMergeRequests = ({ count = 1, props = {} } = {}) => {
}),
);
};
export const createDefaultProjects = (count) => {
return Array(count)
.fill(null)
.map((_, id) => ({
id,
name: `project-${id}`,
fullPath: `group/project-${id}`,
}));
};
export const createDefaultProjectsResponse = (projects) => ({
data: {
group: {
id: '1',
projects: {
nodes: projects,
__typename: 'Project',
},
__typename: 'Group',
},
},
});
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