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 @@
list-style: none;
padding: 0 1px;
margin: 0;
.license-item {
line-height: $gl-padding-32;
.license-packages {
font-size: $label-font-size;
}
}
}
.report-block-list-icon {
......
......@@ -38,7 +38,7 @@ export default {
};
</script>
<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>
<button
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'
......@@ -14,6 +16,7 @@ export const LICENSE_APPROVAL_ACTION = {
DENY: 'deny',
};
/* eslint-disable @gitlab/require-i18n-strings */
export const KNOWN_LICENSES = [
'AGPL-1.0',
'AGPL-3.0',
......@@ -41,3 +44,22 @@ export const KNOWN_LICENSES = [
'WTFPL',
'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 @@
import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLink } from '@gitlab/ui';
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 { componentNames } from 'ee/reports/components/issue_body';
import Icon from '~/vue_shared/components/icon.vue';
import ReportSection from '~/reports/components/report_section.vue';
import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_compliance/store/constants';
import createStore from './store';
const store = createStore();
......@@ -19,8 +19,10 @@ export default {
store,
components: {
GlLink,
ReportItem,
ReportSection,
SetLicenseApprovalModal,
SmartVirtualList,
Icon,
},
mixins: [reportsMixin],
......@@ -64,6 +66,8 @@ export default {
default: '',
},
},
typicalReportItemHeight: 26,
maxShownReportItems: 20,
computed: {
...mapState(LICENSE_MANAGEMENT, ['loadLicenseReportError']),
...mapGetters(LICENSE_MANAGEMENT, [
......@@ -71,6 +75,7 @@ export default {
'isLoading',
'licenseSummaryText',
'reportContainsBlacklistedLicense',
'licenseReportGroups',
]),
hasLicenseReportIssues() {
const { licenseReport } = this;
......@@ -119,6 +124,38 @@ export default {
class="license-report-widget mr-report"
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>
<div class="pr-3">
{{ licenseSummaryText }}
......
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;
......@@ -11,6 +12,11 @@ export const hasPendingLicenses = state => state.pendingLicenses.length > 0;
export const licenseReport = state => state.newLicenses;
export const licenseReportGroups = state =>
REPORT_GROUPS.map(addLicensesMatchingReportGroupStatus(state.newLicenses)).filter(
reportGroupHasAtLeastOneLicense,
);
export const licenseSummaryText = (state, getters) => {
const hasReportItems = getters.licenseReport && getters.licenseReport.length;
const baseReportHasLicenses = state.existingLicenses.length;
......@@ -66,7 +72,7 @@ export const licenseSummaryText = (state, getters) => {
return s__('LicenseCompliance|License Compliance detected no new licenses');
};
export const reportContainsBlacklistedLicense = (_state, getters) =>
export const reportContainsBlacklistedLicense = (_, getters) =>
(getters.licenseReport || []).some(
license => license.approvalStatus === LICENSE_APPROVAL_STATUS.DENIED,
);
......
import { groupBy } from 'lodash';
import { LICENSE_APPROVAL_STATUS } from 'ee/vue_shared/license_compliance/constants';
import { s__, n__, sprintf } from '~/locale';
import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants';
......@@ -93,3 +94,30 @@ export const convertToOldReportFormat = license => {
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';
export const approvedLicense = {
......@@ -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 () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import LicenseManagement from 'ee/vue_shared/license_compliance/mr_widget_license_report.vue';
import ReportSection from '~/reports/components/report_section.vue';
import ReportItem from '~/reports/components/report_item.vue';
import { LOADING, ERROR, SUCCESS } from 'ee/vue_shared/security_reports/store/constants';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { TEST_HOST } from 'spec/test_constants';
import {
approvedLicense,
blacklistedLicense,
licenseReport as licenseReportMock,
generateReportGroup,
} from './mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('License Report MR Widget', () => {
const Component = Vue.extend(LicenseManagement);
const apiUrl = `${TEST_HOST}/license_management`;
const securityApprovalsHelpPagePath = `${TEST_HOST}/path/to/security/approvals/help`;
let vm;
let wrapper;
const defaultState = {
managedLicenses: [approvedLicense, blacklistedLicense],
......@@ -36,6 +39,9 @@ describe('License Report MR Widget', () => {
reportContainsBlacklistedLicense() {
return false;
},
licenseReportGroups() {
return [];
},
};
const defaultProps = {
......@@ -60,6 +66,7 @@ describe('License Report MR Widget', () => {
getters = defaultGetters,
state = defaultState,
actions = defaultActions,
stubs = {},
} = {}) => {
const store = new Vuex.Store({
modules: {
......@@ -71,15 +78,19 @@ describe('License Report MR Widget', () => {
},
},
});
return mountComponentWithStore(Component, { props, store });
wrapper = shallowMount(LicenseManagement, {
localVue,
propsData: props,
store,
stubs,
});
};
beforeEach(() => {
vm = mountComponent();
});
const findAllReportItems = () => wrapper.findAll(ReportItem);
afterEach(() => {
vm.$destroy();
wrapper.destroy();
wrapper = null;
});
describe('computed', () => {
......@@ -91,13 +102,15 @@ describe('License Report MR Widget', () => {
return [];
},
};
vm = mountComponent({ getters });
mountComponent({ getters });
expect(vm.hasLicenseReportIssues).toBe(false);
expect(wrapper.vm.hasLicenseReportIssues).toBe(false);
});
it('should be true, if the report is not empty', () => {
expect(vm.hasLicenseReportIssues).toBe(true);
mountComponent();
expect(wrapper.vm.hasLicenseReportIssues).toBe(true);
});
});
......@@ -109,20 +122,22 @@ describe('License Report MR Widget', () => {
return true;
},
};
vm = mountComponent({ getters });
mountComponent({ getters });
expect(vm.licenseReportStatus).toBe(LOADING);
expect(wrapper.vm.licenseReportStatus).toBe(LOADING);
});
it('should be `ERROR`, if the report is has an error', () => {
const state = { ...defaultState, loadLicenseReportError: new Error('test') };
vm = mountComponent({ state });
mountComponent({ state });
expect(vm.licenseReportStatus).toBe(ERROR);
expect(wrapper.vm.licenseReportStatus).toBe(ERROR);
});
it('should be `SUCCESS`, if the report is successful', () => {
expect(vm.licenseReportStatus).toBe(SUCCESS);
mountComponent();
expect(wrapper.vm.licenseReportStatus).toBe(SUCCESS);
});
});
......@@ -131,16 +146,16 @@ describe('License Report MR Widget', () => {
it('should be true if fullReportPath AND licenseManagementSettingsPath prop are provided', () => {
const props = { ...otherProps, fullReportPath, licenseManagementSettingsPath };
vm = mountComponent({ props });
mountComponent({ props });
expect(vm.showActionButtons).toBe(true);
expect(wrapper.vm.showActionButtons).toBe(true);
});
it('should be true if only licenseManagementSettingsPath is provided', () => {
const props = { ...otherProps, fullReportPath: null, licenseManagementSettingsPath };
vm = mountComponent({ props });
mountComponent({ props });
expect(vm.showActionButtons).toBe(true);
expect(wrapper.vm.showActionButtons).toBe(true);
});
it('should be true if only fullReportPath is provided', () => {
......@@ -149,9 +164,9 @@ describe('License Report MR Widget', () => {
fullReportPath,
licenseManagementSettingsPath: null,
};
vm = mountComponent({ props });
mountComponent({ props });
expect(vm.showActionButtons).toBe(true);
expect(wrapper.vm.showActionButtons).toBe(true);
});
it('should be false if fullReportPath and licenseManagementSettingsPath prop are not provided', () => {
......@@ -160,39 +175,138 @@ describe('License Report MR Widget', () => {
fullReportPath: null,
licenseManagementSettingsPath: null,
};
vm = mountComponent({ props });
mountComponent({ props });
expect(vm.showActionButtons).toBe(false);
expect(wrapper.vm.showActionButtons).toBe(false);
});
});
});
it('should render report section wrapper', () => {
expect(vm.$el.querySelector('.license-report-widget')).not.toBeNull();
describe('report section', () => {
it('should render correctly', () => {
const mockReportGroups = [generateReportGroup()];
mountComponent({
getters: {
...defaultGetters,
licenseReportGroups() {
return mockReportGroups;
},
},
});
expect(wrapper.find(ReportSection).element).toMatchSnapshot();
});
describe('report body', () => {
it('should render correctly', () => {
const mockReportGroups = [generateReportGroup()];
mountComponent({
getters: {
...defaultGetters,
licenseReportGroups() {
return mockReportGroups;
},
},
stubs: { ReportSection },
});
expect(wrapper.find({ ref: 'reportSectionBody' }).element).toMatchSnapshot();
});
it.each`
givenStatuses | expectedNumberOfReportHeadings
${[]} | ${0}
${['failed', 'neutral']} | ${2}
${['failed', 'neutral', 'success']} | ${3}
`(
'given reports for: $givenStatuses it has $expectedNumberOfReportHeadings report headings',
({ givenStatuses, expectedNumberOfReportHeadings }) => {
const mockReportGroups = givenStatuses.map(status => generateReportGroup({ status }));
mountComponent({
getters: {
...defaultGetters,
licenseReportGroups() {
return mockReportGroups;
},
},
stubs: { ReportSection },
});
expect(wrapper.findAll({ ref: 'reportHeading' }).length).toBe(
expectedNumberOfReportHeadings,
);
},
);
it.each([0, 1, 2])(
'should include %d report items when section has that many licenses',
numberOfLicenses => {
const mockReportGroups = [
generateReportGroup({
numberOfLicenses,
}),
];
mountComponent({
getters: {
...defaultGetters,
licenseReportGroups() {
return mockReportGroups;
},
},
stubs: { ReportSection },
});
expect(findAllReportItems().length).toBe(numberOfLicenses);
},
);
it('renders the report items in the correct order', () => {
const mockReportGroups = [
generateReportGroup({ status: 'failed', numberOfLicenses: 1 }),
generateReportGroup({ status: 'neutral', numberOfLicenses: 1 }),
generateReportGroup({ status: 'success', numberOfLicenses: 1 }),
];
mountComponent({
getters: {
...defaultGetters,
licenseReportGroups() {
return mockReportGroups;
},
},
stubs: { ReportSection },
});
it('should render report widget section', () => {
expect(vm.$el.querySelector('.report-block-container')).not.toBeNull();
const allReportItems = findAllReportItems();
mockReportGroups.forEach((group, index) => {
expect(allReportItems.at(index).props('status')).toBe(group.status);
});
});
});
});
describe('`View full report` button', () => {
const selector = '.js-full-report';
it('should be rendered when fullReportPath prop is provided', () => {
const linkEl = vm.$el.querySelector(selector);
mountComponent();
expect(linkEl).not.toBeNull();
expect(linkEl.getAttribute('href')).toEqual(defaultProps.fullReportPath);
expect(linkEl.textContent.trim()).toEqual('View full report');
const linkEl = wrapper.find(selector);
expect(linkEl.exists()).toBe(true);
expect(linkEl.attributes('href')).toEqual(defaultProps.fullReportPath);
expect(linkEl.text()).toBe('View full report');
});
it('should not be rendered when fullReportPath prop is not provided', () => {
const props = { ...defaultProps, fullReportPath: null };
vm = mountComponent({ props });
const linkEl = vm.$el.querySelector(selector);
mountComponent({ props });
expect(linkEl).toBeNull();
expect(wrapper.contains(selector)).toBe(false);
});
});
......@@ -200,25 +314,27 @@ describe('License Report MR Widget', () => {
const selector = '.js-manage-licenses';
it('should be rendered when licenseManagementSettingsPath prop is provided', () => {
const linkEl = vm.$el.querySelector(selector);
mountComponent();
const linkEl = wrapper.find(selector);
expect(linkEl).not.toBeNull();
expect(linkEl.getAttribute('href')).toEqual(defaultProps.licenseManagementSettingsPath);
expect(linkEl.textContent.trim()).toEqual('Manage licenses');
expect(linkEl.exists()).toBe(true);
expect(linkEl.attributes('href')).toEqual(defaultProps.licenseManagementSettingsPath);
expect(linkEl.text()).toBe('Manage licenses');
});
it('should not be rendered when licenseManagementSettingsPath prop is not provided', () => {
const props = { ...defaultProps, licenseManagementSettingsPath: null };
vm = mountComponent({ props });
mountComponent({ props });
const linkEl = vm.$el.querySelector(selector);
expect(linkEl).toBeNull();
expect(wrapper.contains(selector)).toBe(false);
});
});
it('should render set approval modal', () => {
expect(vm.$el.querySelector('#modal-set-license-approval')).not.toBeNull();
mountComponent();
expect(wrapper.find('#modal-set-license-approval')).not.toBeNull();
});
it('should init store after mount', () => {
......@@ -226,7 +342,7 @@ describe('License Report MR Widget', () => {
setAPISettings: jest.fn(() => {}),
fetchParsedLicenseReport: jest.fn(() => {}),
};
vm = mountComponent({ actions });
mountComponent({ actions });
expect(actions.setAPISettings).toHaveBeenCalledWith(
expect.any(Object),
......@@ -246,11 +362,14 @@ describe('License Report MR Widget', () => {
});
describe('approval status', () => {
const findSecurityApprovalHelpLink = () =>
vm.$el.querySelector('.js-security-approval-help-link');
const findSecurityApprovalHelpLink = () => wrapper.find('.js-security-approval-help-link');
it('does not show a link to security approval help page if report does not contain blacklisted licenses', () => {
expect(findSecurityApprovalHelpLink()).toBeNull();
mountComponent({
stubs: { ReportSection },
});
expect(findSecurityApprovalHelpLink().exists()).toBe(false);
});
it('shows a link to security approval help page if report contains blacklisted licenses', () => {
......@@ -260,11 +379,15 @@ describe('License Report MR Widget', () => {
return true;
},
};
vm = mountComponent({ getters });
mountComponent({
getters,
stubs: { ReportSection },
});
const securityApprovalHelpLink = findSecurityApprovalHelpLink();
expect(findSecurityApprovalHelpLink()).not.toBeNull();
expect(securityApprovalHelpLink.getAttribute('href')).toEqual(securityApprovalsHelpPagePath);
expect(findSecurityApprovalHelpLink().exists()).toBe(true);
expect(securityApprovalHelpLink.attributes('href')).toBe(securityApprovalsHelpPagePath);
});
});
});
......@@ -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('when licenses exist on both the HEAD and the BASE', () => {
beforeEach(() => {
......
......@@ -4,6 +4,8 @@ import {
getStatusTranslationsFromLicenseStatus,
getIssueStatusFromLicenseStatus,
convertToOldReportFormat,
addLicensesMatchingReportGroupStatus,
reportGroupHasAtLeastOneLicense,
} from 'ee/vue_shared/license_compliance/store/utils';
import { LICENSE_APPROVAL_STATUS } from 'ee/vue_shared/license_compliance/constants';
import { licenseReport } from '../mock_data';
......@@ -111,4 +113,55 @@ describe('utils', () => {
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 ""
msgid "Accept terms"
msgstr ""
msgid "Acceptable for use in this project"
msgstr ""
msgid "Accepted MR"
msgstr ""
......@@ -12000,6 +12003,15 @@ msgstr ""
msgid "LicenseCompliance|You are about to remove the license, %{name}, from this project."
msgstr ""
msgid "LicenseManagement|Allowed"
msgstr ""
msgid "LicenseManagement|Denied"
msgstr ""
msgid "LicenseManagement|Uncategorized"
msgstr ""
msgid "Licensed Features"
msgstr ""
......@@ -13556,6 +13568,9 @@ msgstr ""
msgid "No pods available"
msgstr ""
msgid "No policy matches this license"
msgstr ""
msgid "No preview for this file type"
msgstr ""
......@@ -14086,6 +14101,9 @@ msgstr ""
msgid "Other visibility settings have been disabled by the administrator."
msgstr ""
msgid "Out-of-compliance with this project's policies and should be removed"
msgstr ""
msgid "Outbound requests"
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