Commit 6927ff72 authored by Jiaan Louw's avatar Jiaan Louw Committed by Savas Vedova

Add sorting to compliance report violations table

- Add sorting to the compliance report violations table
- Sync sort param with apollo query
- Sync sort param with URL query
- Add utils to create and prase sort param
- Update violation field keys
- Update specs
parent 65710ad9
import { convertToSnakeCase, convertToCamelCase } from '~/lib/utils/text_utility';
import { DEFAULT_TH_CLASSES } from './constants'; import { DEFAULT_TH_CLASSES } from './constants';
/** /**
...@@ -7,3 +8,37 @@ import { DEFAULT_TH_CLASSES } from './constants'; ...@@ -7,3 +8,37 @@ import { DEFAULT_TH_CLASSES } from './constants';
* @returns {String} The classes to be used in GlTable fields object. * @returns {String} The classes to be used in GlTable fields object.
*/ */
export const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`; export const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`;
/**
* Converts a GlTable sort-changed event object into string format.
* This string can be used as a sort argument on GraphQL queries.
*
* @param {Object} - The table state context object.
* @returns {String} A string with the sort key and direction, for example 'NAME_DESC'.
*/
export const sortObjectToString = ({ sortBy, sortDesc }) => {
const sortingDirection = sortDesc ? 'DESC' : 'ASC';
const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
return `${sortingColumn}_${sortingDirection}`;
};
/**
* Converts a sort string into a sort state object that can be used to
* set the sort order on GlTable.
*
* @param {String} - The string with the sort key and direction, for example 'NAME_DESC'.
* @returns {Object} An object with the sortBy and sortDesc properties.
*/
export const sortStringToObject = (sortString) => {
let sortBy = null;
let sortDesc = null;
if (sortString && sortString.includes('_')) {
const [key, direction] = sortString.split(/_(ASC|DESC)$/);
sortBy = convertToCamelCase(key.toLowerCase());
sortDesc = direction === 'DESC';
}
return { sortBy, sortDesc };
};
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { GlAlert, GlLoadingIcon, GlTable, GlLink } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon, GlTable, GlLink } from '@gitlab/ui';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { thWidthClass } from '~/lib/utils/table_utility'; import { thWidthClass, sortObjectToString, sortStringToObject } from '~/lib/utils/table_utility';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import UrlSync from '~/vue_shared/components/url_sync.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue';
...@@ -10,6 +10,7 @@ import { helpPagePath } from '~/helpers/help_page_helper'; ...@@ -10,6 +10,7 @@ import { helpPagePath } from '~/helpers/help_page_helper';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue'; import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import complianceViolationsQuery from '../graphql/compliance_violations.query.graphql'; import complianceViolationsQuery from '../graphql/compliance_violations.query.graphql';
import { mapViolations } from '../graphql/mappers'; import { mapViolations } from '../graphql/mappers';
import { DEFAULT_SORT } from '../constants';
import { parseViolationsQueryFilter } from '../utils'; import { parseViolationsQueryFilter } from '../utils';
import MergeCommitsExportButton from './merge_requests/merge_commits_export_button.vue'; import MergeCommitsExportButton from './merge_requests/merge_commits_export_button.vue';
import MergeRequestDrawer from './drawer.vue'; import MergeRequestDrawer from './drawer.vue';
...@@ -47,6 +48,9 @@ export default { ...@@ -47,6 +48,9 @@ export default {
}, },
}, },
data() { data() {
const sortParam = this.defaultQuery.sort || DEFAULT_SORT;
const { sortBy, sortDesc } = sortStringToObject(sortParam);
return { return {
urlQuery: { ...this.defaultQuery }, urlQuery: { ...this.defaultQuery },
queryError: false, queryError: false,
...@@ -54,6 +58,9 @@ export default { ...@@ -54,6 +58,9 @@ export default {
showDrawer: false, showDrawer: false,
drawerMergeRequest: {}, drawerMergeRequest: {},
drawerProject: {}, drawerProject: {},
sortBy,
sortDesc,
sortParam,
}; };
}, },
apollo: { apollo: {
...@@ -63,6 +70,7 @@ export default { ...@@ -63,6 +70,7 @@ export default {
return { return {
fullPath: this.groupPath, fullPath: this.groupPath,
filter: parseViolationsQueryFilter(this.urlQuery), filter: parseViolationsQueryFilter(this.urlQuery),
sort: this.sortParam,
}; };
}, },
update(data) { update(data) {
...@@ -83,6 +91,10 @@ export default { ...@@ -83,6 +91,10 @@ export default {
}, },
}, },
methods: { methods: {
handleSortChanged(sortState) {
this.sortParam = sortObjectToString(sortState);
this.updateUrlQuery({ ...this.urlQuery, sort: this.sortParam });
},
toggleDrawer(rows) { toggleDrawer(rows) {
const { id, mergeRequest, project } = rows[0] || {}; const { id, mergeRequest, project } = rows[0] || {};
...@@ -108,7 +120,7 @@ export default { ...@@ -108,7 +120,7 @@ export default {
updateUrlQuery({ projectIds = [], ...rest }) { updateUrlQuery({ projectIds = [], ...rest }) {
this.urlQuery = { this.urlQuery = {
// Clear the URL param when the id array is empty // Clear the URL param when the id array is empty
projectIds: projectIds.length > 0 ? projectIds : null, projectIds: projectIds?.length > 0 ? projectIds : null,
...rest, ...rest,
}; };
}, },
...@@ -118,21 +130,25 @@ export default { ...@@ -118,21 +130,25 @@ export default {
key: 'severity', key: 'severity',
label: __('Severity'), label: __('Severity'),
thClass: thWidthClass(10), thClass: thWidthClass(10),
sortable: true,
}, },
{ {
key: 'reason', key: 'violationReason',
label: __('Violation'), label: __('Violation'),
thClass: thWidthClass(15), thClass: thWidthClass(15),
sortable: true,
}, },
{ {
key: 'mergeRequest', key: 'mergeRequestTitle',
label: __('Merge request'), label: __('Merge request'),
thClass: thWidthClass(40), thClass: thWidthClass(40),
sortable: true,
}, },
{ {
key: 'mergedAt', key: 'mergedAt',
label: __('Date merged'), label: __('Date merged'),
thClass: thWidthClass(20), thClass: thWidthClass(20),
sortable: true,
}, },
], ],
i18n: { i18n: {
...@@ -185,6 +201,10 @@ export default { ...@@ -185,6 +201,10 @@ export default {
:busy="isLoading" :busy="isLoading"
:empty-text="$options.i18n.noViolationsFound" :empty-text="$options.i18n.noViolationsFound"
:selectable="true" :selectable="true"
:sort-by="sortBy"
:sort-desc="sortDesc"
no-local-sorting
sort-icon-left
show-empty show-empty
head-variant="white" head-variant="white"
stacked="lg" stacked="lg"
...@@ -193,14 +213,15 @@ export default { ...@@ -193,14 +213,15 @@ export default {
selected-variant="primary" selected-variant="primary"
thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100" thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
@row-selected="toggleDrawer" @row-selected="toggleDrawer"
@sort-changed="handleSortChanged"
> >
<template #cell(severity)="{ item: { severity } }"> <template #cell(severity)="{ item: { severity } }">
<severity-badge class="gl-reset-text-align!" :severity="severity" /> <severity-badge class="gl-reset-text-align!" :severity="severity" />
</template> </template>
<template #cell(reason)="{ item: { reason, violatingUser } }"> <template #cell(violationReason)="{ item: { reason, violatingUser } }">
<violation-reason :reason="reason" :user="violatingUser" /> <violation-reason :reason="reason" :user="violatingUser" />
</template> </template>
<template #cell(mergeRequest)="{ item: { mergeRequest } }"> <template #cell(mergeRequestTitle)="{ item: { mergeRequest } }">
{{ mergeRequest.title }} {{ mergeRequest.title }}
</template> </template>
<template #cell(mergedAt)="{ item: { mergeRequest } }"> <template #cell(mergedAt)="{ item: { mergeRequest } }">
......
...@@ -36,3 +36,5 @@ export const MERGE_REQUEST_VIOLATION_SEVERITY_LEVELS = { ...@@ -36,3 +36,5 @@ export const MERGE_REQUEST_VIOLATION_SEVERITY_LEVELS = {
3: 'low', 3: 'low',
4: 'info', 4: 'info',
}; };
export const DEFAULT_SORT = 'SEVERITY_DESC';
# TODO: Add the correct filter type once it has been added in https://gitlab.com/gitlab-org/gitlab/-/issues/347325 # TODO: Add the correct filter type once it has been added in https://gitlab.com/gitlab-org/gitlab/-/issues/347325
query getComplianceViolations($fullPath: ID!, $filter: Object) { query getComplianceViolations($fullPath: ID!, $filter: Object, $sort: String) {
group(fullPath: $fullPath, filter: $filter) @client { group(fullPath: $fullPath, filter: $filter, sort: $sort) @client {
id id
mergeRequestViolations { mergeRequestViolations {
nodes { nodes {
......
...@@ -18,7 +18,9 @@ import createMockApollo from 'helpers/mock_apollo_helper'; ...@@ -18,7 +18,9 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import { sortObjectToString } from '~/lib/utils/table_utility';
import { parseViolationsQueryFilter } from 'ee/compliance_dashboard/utils'; import { parseViolationsQueryFilter } from 'ee/compliance_dashboard/utils';
import { DEFAULT_SORT } from 'ee/compliance_dashboard/constants';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -34,6 +36,7 @@ describe('ComplianceReport component', () => { ...@@ -34,6 +36,7 @@ describe('ComplianceReport component', () => {
projectIds: ['20'], projectIds: ['20'],
createdAfter, createdAfter,
createdBefore, createdBefore,
sort: DEFAULT_SORT,
}; };
const mockGraphQlError = new Error('GraphQL networkError'); const mockGraphQlError = new Error('GraphQL networkError');
...@@ -49,7 +52,7 @@ describe('ComplianceReport component', () => { ...@@ -49,7 +52,7 @@ describe('ComplianceReport component', () => {
const findViolationFilter = () => wrapper.findComponent(ViolationFilter); const findViolationFilter = () => wrapper.findComponent(ViolationFilter);
const findUrlSync = () => wrapper.findComponent(UrlSync); const findUrlSync = () => wrapper.findComponent(UrlSync);
const findTableHeaders = () => findViolationsTable().findAll('th'); const findTableHeaders = () => findViolationsTable().findAll('th div');
const findTablesFirstRowData = () => const findTablesFirstRowData = () =>
findViolationsTable().findAll('tbody > tr').at(0).findAll('td'); findViolationsTable().findAll('tbody > tr').at(0).findAll('td');
const findSelectedRows = () => findViolationsTable().findAll('tr.b-table-row-selected'); const findSelectedRows = () => findViolationsTable().findAll('tr.b-table-row-selected');
...@@ -131,12 +134,33 @@ describe('ComplianceReport component', () => { ...@@ -131,12 +134,33 @@ describe('ComplianceReport component', () => {
expect(findTableLoadingIcon().exists()).toBe(true); expect(findTableLoadingIcon().exists()).toBe(true);
}); });
it('fetches the list of merge request violations with the filter query', async () => { it('fetches the list of merge request violations with the default filter and sort params', async () => {
expect(mockResolver).toHaveBeenCalledTimes(1); expect(mockResolver).toHaveBeenCalledTimes(1);
expect(mockResolver).toHaveBeenCalledWith( expect(mockResolver).toHaveBeenCalledWith(
...expectApolloVariables({ ...expectApolloVariables({
fullPath: groupPath, fullPath: groupPath,
filter: parseViolationsQueryFilter(defaultQuery), filter: parseViolationsQueryFilter(defaultQuery),
sort: DEFAULT_SORT,
}),
);
});
});
describe('when the defaultQuery has a sort param', () => {
const sort = 'SEVERITY_ASC';
beforeEach(() => {
mockResolver = jest.fn();
wrapper = createComponent(mount, { defaultQuery: { ...defaultQuery, sort } });
});
it('fetches the list of merge request violations with sort params', async () => {
expect(mockResolver).toHaveBeenCalledTimes(1);
expect(mockResolver).toHaveBeenCalledWith(
...expectApolloVariables({
fullPath: groupPath,
filter: parseViolationsQueryFilter(defaultQuery),
sort,
}), }),
); );
}); });
...@@ -313,11 +337,46 @@ describe('ComplianceReport component', () => { ...@@ -313,11 +337,46 @@ describe('ComplianceReport component', () => {
...expectApolloVariables({ ...expectApolloVariables({
fullPath: groupPath, fullPath: groupPath,
filter: parseViolationsQueryFilter(query), filter: parseViolationsQueryFilter(query),
sort: DEFAULT_SORT,
}), }),
); );
}); });
}); });
}); });
describe('when the table sort changes', () => {
const sortState = { sortBy: 'mergedAt', sortDesc: true };
beforeEach(async () => {
mockResolver = jest.fn().mockReturnValue(resolvers.Query.group());
wrapper = createComponent(mount);
await waitForPromises();
await findViolationsTable().vm.$emit('sort-changed', sortState);
});
it('updates the URL query', () => {
expect(findUrlSync().props('query')).toMatchObject({
sort: sortObjectToString(sortState),
});
});
it('shows the table loading icon', () => {
expect(findTableLoadingIcon().exists()).toBe(true);
});
it('fetches the sorted violations', async () => {
expect(mockResolver).toHaveBeenCalledTimes(2);
expect(mockResolver).toHaveBeenNthCalledWith(
2,
...expectApolloVariables({
fullPath: groupPath,
filter: parseViolationsQueryFilter(defaultQuery),
sort: sortObjectToString(sortState),
}),
);
});
});
}); });
describe('when there are no violations', () => { describe('when there are no violations', () => {
......
...@@ -8,4 +8,33 @@ describe('table_utility', () => { ...@@ -8,4 +8,33 @@ describe('table_utility', () => {
expect(tableUtils.thWidthClass(width)).toBe(`gl-w-${width}p ${DEFAULT_TH_CLASSES}`); expect(tableUtils.thWidthClass(width)).toBe(`gl-w-${width}p ${DEFAULT_TH_CLASSES}`);
}); });
}); });
describe('sortObjectToString', () => {
it('returns the expected sorting string ending in "DESC" when sortDesc is true', () => {
expect(tableUtils.sortObjectToString({ sortBy: 'mergedAt', sortDesc: true })).toBe(
'MERGED_AT_DESC',
);
});
it('returns the expected sorting string ending in "ASC" when sortDesc is false', () => {
expect(tableUtils.sortObjectToString({ sortBy: 'mergedAt', sortDesc: false })).toBe(
'MERGED_AT_ASC',
);
});
});
describe('sortStringToObject', () => {
it.each`
sortBy | sortDesc | sortString
${'mergedAt'} | ${true} | ${'MERGED_AT_DESC'}
${'mergedAt'} | ${false} | ${'MERGED_AT_ASC'}
${'severity'} | ${true} | ${'SEVERITY_DESC'}
${'severity'} | ${false} | ${'SEVERITY_ASC'}
`(
'returns $sortString when sortBy = "$sortBy" and sortDesc = "sortDesc"',
({ sortBy, sortDesc, sortString }) => {
expect(tableUtils.sortStringToObject(sortString)).toStrictEqual({ sortBy, sortDesc });
},
);
});
}); });
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