Commit edaaf79c authored by David Pisek's avatar David Pisek Committed by Mark Florian

Add status groups to license-compliance MR widget

This commit groups licenses within the MR widget by their status
and adds a header and subscription to each group.

It also refactors related vue-specs to use vue-test-utils.

WIP - group licenses by status
parent 5b1879b1
...@@ -63,15 +63,6 @@ ...@@ -63,15 +63,6 @@
list-style: none; list-style: none;
padding: 0 1px; padding: 0 1px;
margin: 0; margin: 0;
.license-item {
line-height: $gl-padding-32;
.license-packages {
font-size: $label-font-size;
}
}
} }
.report-block-list-icon { .report-block-list-icon {
......
...@@ -38,7 +38,7 @@ export default { ...@@ -38,7 +38,7 @@ export default {
}; };
</script> </script>
<template> <template>
<div class="license-packages d-inline"> <div class="license-packages d-inline gl-font-size-12">
<div class="js-license-dependencies d-inline">{{ packageString }}</div> <div class="js-license-dependencies d-inline">{{ packageString }}</div>
<button <button
v-if="!showAllPackages && remainingPackages" v-if="!showAllPackages && remainingPackages"
......
/* eslint-disable @gitlab/require-i18n-strings */ import { __, s__ } from '~/locale';
import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants';
/* /*
* Endpoint still returns 'approved' & 'blacklisted' * Endpoint still returns 'approved' & 'blacklisted'
...@@ -14,6 +16,7 @@ export const LICENSE_APPROVAL_ACTION = { ...@@ -14,6 +16,7 @@ export const LICENSE_APPROVAL_ACTION = {
DENY: 'deny', DENY: 'deny',
}; };
/* eslint-disable @gitlab/require-i18n-strings */
export const KNOWN_LICENSES = [ export const KNOWN_LICENSES = [
'AGPL-1.0', 'AGPL-1.0',
'AGPL-3.0', 'AGPL-3.0',
...@@ -41,3 +44,22 @@ export const KNOWN_LICENSES = [ ...@@ -41,3 +44,22 @@ export const KNOWN_LICENSES = [
'WTFPL', 'WTFPL',
'Zlib', 'Zlib',
]; ];
/* eslint-enable @gitlab/require-i18n-strings */
export const REPORT_GROUPS = [
{
name: s__('LicenseManagement|Denied'),
description: __("Out-of-compliance with this project's policies and should be removed"),
status: STATUS_FAILED,
},
{
name: s__('LicenseManagement|Uncategorized'),
description: __('No policy matches this license'),
status: STATUS_NEUTRAL,
},
{
name: s__('LicenseManagement|Allowed'),
description: __('Acceptable for use in this project'),
status: STATUS_SUCCESS,
},
];
...@@ -2,13 +2,13 @@ ...@@ -2,13 +2,13 @@
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLink } from '@gitlab/ui'; import { GlLink } from '@gitlab/ui';
import reportsMixin from 'ee/vue_shared/security_reports/mixins/reports_mixin'; import reportsMixin from 'ee/vue_shared/security_reports/mixins/reports_mixin';
import ReportItem from '~/reports/components/report_item.vue';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
import SetLicenseApprovalModal from 'ee/vue_shared/license_compliance/components/set_approval_status_modal.vue'; import SetLicenseApprovalModal from 'ee/vue_shared/license_compliance/components/set_approval_status_modal.vue';
import { componentNames } from 'ee/reports/components/issue_body'; import { componentNames } from 'ee/reports/components/issue_body';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import ReportSection from '~/reports/components/report_section.vue'; import ReportSection from '~/reports/components/report_section.vue';
import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_compliance/store/constants'; import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_compliance/store/constants';
import createStore from './store'; import createStore from './store';
const store = createStore(); const store = createStore();
...@@ -19,8 +19,10 @@ export default { ...@@ -19,8 +19,10 @@ export default {
store, store,
components: { components: {
GlLink, GlLink,
ReportItem,
ReportSection, ReportSection,
SetLicenseApprovalModal, SetLicenseApprovalModal,
SmartVirtualList,
Icon, Icon,
}, },
mixins: [reportsMixin], mixins: [reportsMixin],
...@@ -64,6 +66,8 @@ export default { ...@@ -64,6 +66,8 @@ export default {
default: '', default: '',
}, },
}, },
typicalReportItemHeight: 26,
maxShownReportItems: 20,
computed: { computed: {
...mapState(LICENSE_MANAGEMENT, ['loadLicenseReportError']), ...mapState(LICENSE_MANAGEMENT, ['loadLicenseReportError']),
...mapGetters(LICENSE_MANAGEMENT, [ ...mapGetters(LICENSE_MANAGEMENT, [
...@@ -71,6 +75,7 @@ export default { ...@@ -71,6 +75,7 @@ export default {
'isLoading', 'isLoading',
'licenseSummaryText', 'licenseSummaryText',
'reportContainsBlacklistedLicense', 'reportContainsBlacklistedLicense',
'licenseReportGroups',
]), ]),
hasLicenseReportIssues() { hasLicenseReportIssues() {
const { licenseReport } = this; const { licenseReport } = this;
...@@ -119,6 +124,38 @@ export default { ...@@ -119,6 +124,38 @@ export default {
class="license-report-widget mr-report" class="license-report-widget mr-report"
data-qa-selector="license_report_widget" data-qa-selector="license_report_widget"
> >
<template #body>
<smart-virtual-list
ref="reportSectionBody"
:size="$options.typicalReportItemHeight"
:length="licenseReport.length"
:remain="$options.maxShownReportItems"
class="report-block-container"
wtag="ul"
wclass="report-block-list my-1"
>
<template v-for="(licenseReportGroup, index) in licenseReportGroups">
<li
ref="reportHeading"
:key="licenseReportGroup.name"
:class="{ 'mt-3': index > 0 }"
class="mx-1 mb-1"
>
<h2 class="h5 m-0">{{ licenseReportGroup.name }}</h2>
<p class="m-0">{{ licenseReportGroup.description }}</p>
</li>
<report-item
v-for="license in licenseReportGroup.licenses"
:key="license.name"
:issue="license"
:status="license.status"
:component="$options.componentNames.LicenseIssueBody"
:show-report-section-status-icon="true"
class="my-1"
/>
</template>
</smart-virtual-list>
</template>
<template #success> <template #success>
<div class="pr-3"> <div class="pr-3">
{{ licenseSummaryText }} {{ licenseSummaryText }}
......
import { n__, s__, sprintf } from '~/locale'; import { n__, s__, sprintf } from '~/locale';
import { LICENSE_APPROVAL_STATUS } from '../constants'; import { addLicensesMatchingReportGroupStatus, reportGroupHasAtLeastOneLicense } from './utils';
import { LICENSE_APPROVAL_STATUS, REPORT_GROUPS } from '../constants';
export const isLoading = state => state.isLoadingManagedLicenses || state.isLoadingLicenseReport; export const isLoading = state => state.isLoadingManagedLicenses || state.isLoadingLicenseReport;
...@@ -11,6 +12,11 @@ export const hasPendingLicenses = state => state.pendingLicenses.length > 0; ...@@ -11,6 +12,11 @@ export const hasPendingLicenses = state => state.pendingLicenses.length > 0;
export const licenseReport = state => state.newLicenses; export const licenseReport = state => state.newLicenses;
export const licenseReportGroups = state =>
REPORT_GROUPS.map(addLicensesMatchingReportGroupStatus(state.newLicenses)).filter(
reportGroupHasAtLeastOneLicense,
);
export const licenseSummaryText = (state, getters) => { export const licenseSummaryText = (state, getters) => {
const hasReportItems = getters.licenseReport && getters.licenseReport.length; const hasReportItems = getters.licenseReport && getters.licenseReport.length;
const baseReportHasLicenses = state.existingLicenses.length; const baseReportHasLicenses = state.existingLicenses.length;
...@@ -66,7 +72,7 @@ export const licenseSummaryText = (state, getters) => { ...@@ -66,7 +72,7 @@ export const licenseSummaryText = (state, getters) => {
return s__('LicenseCompliance|License Compliance detected no new licenses'); return s__('LicenseCompliance|License Compliance detected no new licenses');
}; };
export const reportContainsBlacklistedLicense = (_state, getters) => export const reportContainsBlacklistedLicense = (_, getters) =>
(getters.licenseReport || []).some( (getters.licenseReport || []).some(
license => license.approvalStatus === LICENSE_APPROVAL_STATUS.DENIED, license => license.approvalStatus === LICENSE_APPROVAL_STATUS.DENIED,
); );
......
import { groupBy } from 'lodash';
import { LICENSE_APPROVAL_STATUS } from 'ee/vue_shared/license_compliance/constants'; import { LICENSE_APPROVAL_STATUS } from 'ee/vue_shared/license_compliance/constants';
import { s__, n__, sprintf } from '~/locale'; import { s__, n__, sprintf } from '~/locale';
import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants'; import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants';
...@@ -93,3 +94,30 @@ export const convertToOldReportFormat = license => { ...@@ -93,3 +94,30 @@ export const convertToOldReportFormat = license => {
status: getIssueStatusFromLicenseStatus(approvalStatus), status: getIssueStatusFromLicenseStatus(approvalStatus),
}; };
}; };
/**
* Takes an array of licenses and returns a function that takes an report-group objects
*
* It returns a fresh object, containing all properties of the original report-group and added "license" property,
* containing an array of licenses, matching the report-group's status
*
* @param {Array} licenses
* @returns {function(*): {licenses: (*|*[])}}
*/
export const addLicensesMatchingReportGroupStatus = licenses => {
const licensesGroupedByStatus = groupBy(licenses, 'status');
return reportGroup => ({
...reportGroup,
licenses: licensesGroupedByStatus[reportGroup.status] || [],
});
};
/**
* Returns true of the given object has a "license" property, containing an array with at least licenses. Otherwise false.
*
*
* @param {Object}
* @returns {boolean}
*/
export const reportGroupHasAtLeastOneLicense = ({ licenses }) => licenses?.length > 0;
---
title: 'Clarify detected license results in merge request: Group licenses by status'
merge_request: 28631
author:
type: changed
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`License Report MR Widget report section report body should render correctly 1`] = `
<smart-virtual-list-stub
class="report-block-container"
length="1"
remain="20"
rtag="div"
size="26"
wclass="report-block-list my-1"
wtag="ul"
>
<li
class="mx-1 mb-1"
>
<h2
class="h5 m-0"
>
some-status group-name
</h2>
<p
class="m-0"
>
some-status group-description
</p>
</li>
</smart-virtual-list-stub>
`;
exports[`License Report MR Widget report section should render correctly 1`] = `
<report-section-stub
class="license-report-widget mr-report"
component="LicenseIssueBody"
data-qa-selector="license_report_widget"
errortext="FOO"
hasissues="true"
loadingtext="FOO"
neutralissues="[object Object]"
popoveroptions="[object Object]"
resolvedissues=""
showreportsectionstatusicon="true"
status="SUCCESS"
successtext=""
unresolvedissues=""
>
<div
class="append-right-default"
>
<a
class="btn btn-default btn-sm js-manage-licenses append-right-8"
href="http://test.host/lm_settings"
>
Manage licenses
</a>
<a
class="btn btn-default btn-sm js-full-report"
href="http://test.host/path/to/the/full/report"
target="_blank"
>
View full report
<icon-stub
name="external-link"
size="16"
/>
</a>
</div>
</report-section-stub>
`;
import { range } from 'lodash';
import { LICENSE_APPROVAL_STATUS } from 'ee/vue_shared/license_compliance/constants'; import { LICENSE_APPROVAL_STATUS } from 'ee/vue_shared/license_compliance/constants';
export const approvedLicense = { export const approvedLicense = {
...@@ -57,4 +58,14 @@ export const licenseReport = [ ...@@ -57,4 +58,14 @@ export const licenseReport = [
}, },
]; ];
export const generateReportGroup = ({ status = 'some-status', numberOfLicenses = 0 } = {}) => ({
status,
name: `${status} group-name`,
description: `${status} group-description`,
licenses: range(numberOfLicenses).map(i => ({
name: `${status} license-name-${i}`,
status,
})),
});
export default () => {}; export default () => {};
...@@ -91,6 +91,59 @@ describe('getters', () => { ...@@ -91,6 +91,59 @@ describe('getters', () => {
}); });
}); });
describe('licenseReportGroups', () => {
it('returns an array of objects containing information about the group and licenses', () => {
const licensesSuccess = [
{ status: 'success', value: 'foo' },
{ status: 'success', value: 'bar' },
];
const licensesNeutral = [
{ status: 'neutral', value: 'foo' },
{ status: 'neutral', value: 'bar' },
];
const licensesFailed = [
{ status: 'failed', value: 'foo' },
{ status: 'failed', value: 'bar' },
];
const newLicenses = [...licensesSuccess, ...licensesNeutral, ...licensesFailed];
expect(getters.licenseReportGroups({ newLicenses })).toEqual([
{
name: 'Denied',
description: `Out-of-compliance with this project's policies and should be removed`,
status: 'failed',
licenses: licensesFailed,
},
{
name: 'Uncategorized',
description: 'No policy matches this license',
status: 'neutral',
licenses: licensesNeutral,
},
{
name: 'Allowed',
description: 'Acceptable for use in this project',
status: 'success',
licenses: licensesSuccess,
},
]);
});
it.each(['failed', 'neutral', 'success'])(
`it filters report-groups that don't have the given status: %s`,
status => {
const newLicenses = [{ status }];
expect(getters.licenseReportGroups({ newLicenses })).toEqual([
expect.objectContaining({
status,
licenses: newLicenses,
}),
]);
},
);
});
describe('licenseSummaryText', () => { describe('licenseSummaryText', () => {
describe('when licenses exist on both the HEAD and the BASE', () => { describe('when licenses exist on both the HEAD and the BASE', () => {
beforeEach(() => { beforeEach(() => {
......
...@@ -4,6 +4,8 @@ import { ...@@ -4,6 +4,8 @@ import {
getStatusTranslationsFromLicenseStatus, getStatusTranslationsFromLicenseStatus,
getIssueStatusFromLicenseStatus, getIssueStatusFromLicenseStatus,
convertToOldReportFormat, convertToOldReportFormat,
addLicensesMatchingReportGroupStatus,
reportGroupHasAtLeastOneLicense,
} from 'ee/vue_shared/license_compliance/store/utils'; } from 'ee/vue_shared/license_compliance/store/utils';
import { LICENSE_APPROVAL_STATUS } from 'ee/vue_shared/license_compliance/constants'; import { LICENSE_APPROVAL_STATUS } from 'ee/vue_shared/license_compliance/constants';
import { licenseReport } from '../mock_data'; import { licenseReport } from '../mock_data';
...@@ -111,4 +113,55 @@ describe('utils', () => { ...@@ -111,4 +113,55 @@ describe('utils', () => {
expect(parsedLicense.name).toEqual(rawLicense.name); expect(parsedLicense.name).toEqual(rawLicense.name);
}); });
}); });
describe('addLicensesMatchingReportGroupStatus', () => {
describe('with matching licenses', () => {
it(`adds a "licenses" property containing an array of licenses matching the report's status to the report object`, () => {
const licenses = [
{ status: 'match' },
{ status: 'no-match' },
{ status: 'match' },
{ status: 'no-match' },
];
const reportGroup = { description: 'description', status: 'match' };
expect(addLicensesMatchingReportGroupStatus(licenses)(reportGroup)).toEqual({
...reportGroup,
licenses: [licenses[0], licenses[2]],
});
});
});
describe('without matching licenses', () => {
it('adds a "licenses" property containing an empty array to the report object', () => {
const licenses = [
{ status: 'no-match' },
{ status: 'no-match' },
{ status: 'no-match' },
{ status: 'no-match' },
];
const reportGroup = { description: 'description', status: 'match' };
expect(addLicensesMatchingReportGroupStatus(licenses)(reportGroup)).toEqual({
...reportGroup,
licenses: [],
});
});
});
});
describe('reportGroupHasAtLeastOneLicense', () => {
it.each`
givenReportGroup | expected
${{ licenses: [{ foo: 'foo ' }] }} | ${true}
${{ licenses: [] }} | ${false}
${{ licenses: null }} | ${false}
${{ licenses: undefined }} | ${false}
`(
'returns "$expected" if the given report-group contains $licenses.length licenses',
({ givenReportGroup, expected }) => {
expect(reportGroupHasAtLeastOneLicense(givenReportGroup)).toBe(expected);
},
);
});
}); });
...@@ -931,6 +931,9 @@ msgstr "" ...@@ -931,6 +931,9 @@ msgstr ""
msgid "Accept terms" msgid "Accept terms"
msgstr "" msgstr ""
msgid "Acceptable for use in this project"
msgstr ""
msgid "Accepted MR" msgid "Accepted MR"
msgstr "" msgstr ""
...@@ -12000,6 +12003,15 @@ msgstr "" ...@@ -12000,6 +12003,15 @@ msgstr ""
msgid "LicenseCompliance|You are about to remove the license, %{name}, from this project." msgid "LicenseCompliance|You are about to remove the license, %{name}, from this project."
msgstr "" msgstr ""
msgid "LicenseManagement|Allowed"
msgstr ""
msgid "LicenseManagement|Denied"
msgstr ""
msgid "LicenseManagement|Uncategorized"
msgstr ""
msgid "Licensed Features" msgid "Licensed Features"
msgstr "" msgstr ""
...@@ -13556,6 +13568,9 @@ msgstr "" ...@@ -13556,6 +13568,9 @@ msgstr ""
msgid "No pods available" msgid "No pods available"
msgstr "" msgstr ""
msgid "No policy matches this license"
msgstr ""
msgid "No preview for this file type" msgid "No preview for this file type"
msgstr "" msgstr ""
...@@ -14086,6 +14101,9 @@ msgstr "" ...@@ -14086,6 +14101,9 @@ msgstr ""
msgid "Other visibility settings have been disabled by the administrator." msgid "Other visibility settings have been disabled by the administrator."
msgstr "" msgstr ""
msgid "Out-of-compliance with this project's policies and should be removed"
msgstr ""
msgid "Outbound requests" msgid "Outbound requests"
msgstr "" 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