Commit 6f2abe2f authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'license-compliance-policy-tab' into 'master'

Feature flag + License compliance policy tab/table

See merge request gitlab-org/gitlab!22465
parents e4972d73 a7948458
import Vue from 'vue'; import Vue from 'vue';
import Dashboard from 'ee/vue_shared/license_management/license_management.vue'; import LicenseManagement from 'ee/vue_shared/license_management/license_management.vue';
import createStore from 'ee/vue_shared/license_management/store/index';
import ProtectedEnvironmentCreate from 'ee/protected_environments/protected_environment_create'; import ProtectedEnvironmentCreate from 'ee/protected_environments/protected_environment_create';
import ProtectedEnvironmentEditList from 'ee/protected_environments/protected_environment_edit_list'; import ProtectedEnvironmentEditList from 'ee/protected_environments/protected_environment_edit_list';
import showToast from '~/vue_shared/plugins/global_toast'; import showToast from '~/vue_shared/plugins/global_toast';
...@@ -10,11 +11,14 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -10,11 +11,14 @@ document.addEventListener('DOMContentLoaded', () => {
const toasts = document.querySelectorAll('.js-toast-message'); const toasts = document.querySelectorAll('.js-toast-message');
if (el && el.dataset && el.dataset.apiUrl) { if (el && el.dataset && el.dataset.apiUrl) {
const store = createStore();
store.dispatch('licenseManagement/setIsAdmin', Boolean(el.dataset.apiUrl));
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el, el,
store,
render(createElement) { render(createElement) {
return createElement(Dashboard, { return createElement(LicenseManagement, {
props: { props: {
...el.dataset, ...el.dataset,
}, },
......
<script> <script>
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import { GlEmptyState, GlLoadingIcon, GlLink, GlIcon } from '@gitlab/ui'; import { GlEmptyState, GlLoadingIcon, GlLink, GlIcon, GlTab, GlTabs, GlBadge } from '@gitlab/ui';
import { LICENSE_LIST } from '../store/constants'; import { LICENSE_LIST } from '../store/constants';
import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_management/store/constants';
import PaginatedLicensesTable from './paginated_licenses_table.vue'; import PaginatedLicensesTable from './paginated_licenses_table.vue';
import PipelineInfo from './pipeline_info.vue'; import PipelineInfo from './pipeline_info.vue';
import LicenseManagement from 'ee/vue_shared/license_management/license_management.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default { export default {
name: 'ProjectLicensesApp', name: 'ProjectLicensesApp',
...@@ -14,7 +17,12 @@ export default { ...@@ -14,7 +17,12 @@ export default {
PaginatedLicensesTable, PaginatedLicensesTable,
PipelineInfo, PipelineInfo,
GlIcon, GlIcon,
GlTab,
GlTabs,
GlBadge,
LicenseManagement,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
emptyStateSvgPath: { emptyStateSvgPath: {
type: String, type: String,
...@@ -24,13 +32,35 @@ export default { ...@@ -24,13 +32,35 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
readLicensePoliciesEndpoint: {
type: String,
required: true,
},
},
data() {
return {
tabIndex: 0,
};
}, },
computed: { computed: {
...mapState(LICENSE_LIST, ['initialized', 'reportInfo']), ...mapState(LICENSE_LIST, ['initialized', 'licenses', 'reportInfo', 'listTypes']),
...mapState(LICENSE_MANAGEMENT, ['managedLicenses']),
...mapGetters(LICENSE_LIST, ['isJobSetUp', 'isJobFailed']), ...mapGetters(LICENSE_LIST, ['isJobSetUp', 'isJobFailed']),
hasEmptyState() { hasEmptyState() {
return Boolean(!this.isJobSetUp || this.isJobFailed); return Boolean(!this.isJobSetUp || this.isJobFailed);
}, },
hasLicensePolicyList() {
return Boolean(this.glFeatures.licensePolicyList);
},
licenseCount() {
return this.licenses.length;
},
policyCount() {
return this.managedLicenses.length;
},
isDetectedProjectTab() {
return this.tabIndex === 0;
},
}, },
created() { created() {
this.fetchLicenses(); this.fetchLicenses();
...@@ -65,7 +95,38 @@ export default { ...@@ -65,7 +95,38 @@ export default {
</gl-link> </gl-link>
</h2> </h2>
<pipeline-info :path="reportInfo.jobPath" :timestamp="reportInfo.generatedAt" /> <pipeline-info
v-if="isDetectedProjectTab"
:path="reportInfo.jobPath"
:timestamp="reportInfo.generatedAt"
/>
<template v-else>{{ s__('Licenses|Specified policies in this project') }}</template>
<!-- TODO: Remove feature flag -->
<template v-if="hasLicensePolicyList">
<gl-tabs v-model="tabIndex" content-class="pt-0">
<gl-tab>
<template #title>
{{ s__('Licenses|Detected in Project') }}
<gl-badge pill>{{ licenseCount }}</gl-badge>
</template>
<paginated-licenses-table />
</gl-tab>
<gl-tab>
<template #title>
{{ s__('Licenses|Policies') }}
<gl-badge pill>{{ policyCount }}</gl-badge>
</template>
<license-management :api-url="readLicensePoliciesEndpoint" />
</gl-tab>
</gl-tabs>
</template>
<template v-else>
<paginated-licenses-table class="mt-3" /> <paginated-licenses-table class="mt-3" />
</template>
</div> </div>
</template> </template>
...@@ -5,9 +5,16 @@ import { LICENSE_LIST } from './store/constants'; ...@@ -5,9 +5,16 @@ import { LICENSE_LIST } from './store/constants';
export default () => { export default () => {
const el = document.querySelector('#js-licenses-app'); const el = document.querySelector('#js-licenses-app');
const { endpoint, emptyStateSvgPath, documentationPath } = el.dataset; const {
projectLicensesEndpoint,
emptyStateSvgPath,
documentationPath,
readLicensePoliciesEndpoint,
writeLicensePoliciesEndpoint,
} = el.dataset;
const store = createStore(); const store = createStore();
store.dispatch(`${LICENSE_LIST}/setLicensesEndpoint`, endpoint); store.dispatch('licenseManagement/setIsAdmin', Boolean(writeLicensePoliciesEndpoint));
store.dispatch(`${LICENSE_LIST}/setLicensesEndpoint`, projectLicensesEndpoint);
return new Vue({ return new Vue({
el, el,
...@@ -20,6 +27,7 @@ export default () => { ...@@ -20,6 +27,7 @@ export default () => {
props: { props: {
emptyStateSvgPath, emptyStateSvgPath,
documentationPath, documentationPath,
readLicensePoliciesEndpoint,
}, },
}); });
}, },
......
...@@ -2,7 +2,9 @@ import Vue from 'vue'; ...@@ -2,7 +2,9 @@ import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import listModule from './modules/list'; import listModule from './modules/list';
import { licenseManagementModule } from 'ee/vue_shared/license_management/store/index';
import { LICENSE_LIST } from './constants'; import { LICENSE_LIST } from './constants';
import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_management/store/constants';
Vue.use(Vuex); Vue.use(Vuex);
...@@ -10,5 +12,6 @@ export default () => ...@@ -10,5 +12,6 @@ export default () =>
new Vuex.Store({ new Vuex.Store({
modules: { modules: {
[LICENSE_LIST]: listModule(), [LICENSE_LIST]: listModule(),
[LICENSE_MANAGEMENT]: licenseManagementModule(),
}, },
}); });
...@@ -12,8 +12,8 @@ export default { ...@@ -12,8 +12,8 @@ export default {
}, },
LICENSE_APPROVAL_STATUS, LICENSE_APPROVAL_STATUS,
approvalStatusOptions: [ approvalStatusOptions: [
{ value: LICENSE_APPROVAL_STATUS.APPROVED, label: s__('LicenseCompliance|Approve') }, { value: LICENSE_APPROVAL_STATUS.APPROVED, label: s__('LicenseCompliance|Allow') },
{ value: LICENSE_APPROVAL_STATUS.BLACKLISTED, label: s__('LicenseCompliance|Blacklist') }, { value: LICENSE_APPROVAL_STATUS.BLACKLISTED, label: s__('LicenseCompliance|Deny') },
], ],
props: { props: {
managedLicenses: { managedLicenses: {
......
<script>
import { mapActions } from 'vuex';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { getIssueStatusFromLicenseStatus } from 'ee/vue_shared/license_management/store/utils';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
import { LICENSE_APPROVAL_STATUS } from '../constants';
import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_management/store/constants';
const visibleClass = 'visible';
const invisibleClass = 'invisible';
export default {
name: 'AdminLicenseManagementRow',
components: {
GlDropdown,
GlDropdownItem,
Icon,
IssueStatusIcon,
},
props: {
license: {
type: Object,
required: true,
validator: license =>
Boolean(license.name) &&
Object.values(LICENSE_APPROVAL_STATUS).includes(license.approvalStatus),
},
},
LICENSE_APPROVAL_STATUS,
[LICENSE_APPROVAL_STATUS.APPROVED]: s__('LicenseCompliance|Allowed'),
[LICENSE_APPROVAL_STATUS.BLACKLISTED]: s__('LicenseCompliance|Denied'),
computed: {
approveIconClass() {
return this.license.approvalStatus === LICENSE_APPROVAL_STATUS.APPROVED
? visibleClass
: invisibleClass;
},
blacklistIconClass() {
return this.license.approvalStatus === LICENSE_APPROVAL_STATUS.BLACKLISTED
? visibleClass
: invisibleClass;
},
status() {
return getIssueStatusFromLicenseStatus(this.license.approvalStatus);
},
dropdownText() {
return this.$options[this.license.approvalStatus];
},
},
methods: {
...mapActions(LICENSE_MANAGEMENT, ['setLicenseInModal', 'approveLicense', 'blacklistLicense']),
},
};
</script>
<template>
<div data-qa-selector="admin_license_compliance_row">
<issue-status-icon :status="status" class="float-left append-right-default" />
<span class="js-license-name" data-qa-selector="license_name_content">{{ license.name }}</span>
<div class="float-right">
<div class="d-flex">
<gl-dropdown
:text="dropdownText"
toggle-class="d-flex justify-content-between align-items-center"
right
>
<gl-dropdown-item @click="approveLicense(license)">
<icon :class="approveIconClass" name="mobile-issue-close" />
{{ $options[$options.LICENSE_APPROVAL_STATUS.APPROVED] }}
</gl-dropdown-item>
<gl-dropdown-item @click="blacklistLicense(license)">
<icon :class="blacklistIconClass" name="mobile-issue-close" />
{{ $options[$options.LICENSE_APPROVAL_STATUS.BLACKLISTED] }}
</gl-dropdown-item>
</gl-dropdown>
<button
class="btn btn-blank js-remove-button"
type="button"
data-toggle="modal"
data-target="#modal-license-delete-confirmation"
@click="setLicenseInModal(license)"
>
<icon name="remove" />
</button>
</div>
</div>
</div>
</template>
...@@ -4,11 +4,13 @@ import { mapActions, mapState } from 'vuex'; ...@@ -4,11 +4,13 @@ import { mapActions, mapState } from 'vuex';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_management/store/constants';
export default { export default {
name: 'LicenseDeleteConfirmationModal', name: 'LicenseDeleteConfirmationModal',
components: { GlModal: DeprecatedModal2 }, components: { GlModal: DeprecatedModal2 },
computed: { computed: {
...mapState(['currentLicenseInModal']), ...mapState(LICENSE_MANAGEMENT, ['currentLicenseInModal']),
confirmationText() { confirmationText() {
const name = `<strong>${_.escape(this.currentLicenseInModal.name)}</strong>`; const name = `<strong>${_.escape(this.currentLicenseInModal.name)}</strong>`;
...@@ -20,7 +22,7 @@ export default { ...@@ -20,7 +22,7 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['resetLicenseInModal', 'deleteLicense']), ...mapActions(LICENSE_MANAGEMENT, ['resetLicenseInModal', 'deleteLicense']),
}, },
}; };
</script> </script>
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import LicensePackages from './license_packages.vue'; import LicensePackages from './license_packages.vue';
import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_management/store/constants';
export default { export default {
name: 'LicenseIssueBody', name: 'LicenseIssueBody',
...@@ -12,7 +13,7 @@ export default { ...@@ -12,7 +13,7 @@ export default {
required: true, required: true,
}, },
}, },
methods: { ...mapActions(['setLicenseInModal']) }, methods: { ...mapActions(LICENSE_MANAGEMENT, ['setLicenseInModal']) },
}; };
</script> </script>
......
<script> <script>
import { mapActions } from 'vuex'; import {
import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; getIssueStatusFromLicenseStatus,
import { getIssueStatusFromLicenseStatus } from 'ee/vue_shared/license_management/store/utils'; getStatusTranslationsFromLicenseStatus,
import { s__ } from '~/locale'; } from 'ee/vue_shared/license_management/store/utils';
import Icon from '~/vue_shared/components/icon.vue';
import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
import { LICENSE_APPROVAL_STATUS } from '../constants';
const visibleClass = 'visible';
const invisibleClass = 'invisible';
export default { export default {
name: 'LicenseManagementRow', name: 'LicenseManagementRow',
components: { components: {
GlDropdown,
GlDropdownItem,
Icon,
IssueStatusIcon, IssueStatusIcon,
}, },
props: { props: {
license: { license: {
type: Object, type: Object,
required: true, required: false,
validator: license => default: null,
Boolean(license.name) &&
Object.values(LICENSE_APPROVAL_STATUS).includes(license.approvalStatus),
}, },
}, },
LICENSE_APPROVAL_STATUS,
[LICENSE_APPROVAL_STATUS.APPROVED]: s__('LicenseCompliance|Approved'),
[LICENSE_APPROVAL_STATUS.BLACKLISTED]: s__('LicenseCompliance|Blacklisted'),
computed: { computed: {
approveIconClass() { iconStatus() {
return this.license.approvalStatus === LICENSE_APPROVAL_STATUS.APPROVED
? visibleClass
: invisibleClass;
},
blacklistIconClass() {
return this.license.approvalStatus === LICENSE_APPROVAL_STATUS.BLACKLISTED
? visibleClass
: invisibleClass;
},
status() {
return getIssueStatusFromLicenseStatus(this.license.approvalStatus); return getIssueStatusFromLicenseStatus(this.license.approvalStatus);
}, },
dropdownText() { textStatus() {
return this.$options[this.license.approvalStatus]; return getStatusTranslationsFromLicenseStatus(this.license.approvalStatus);
},
}, },
methods: {
...mapActions(['setLicenseInModal', 'approveLicense', 'blacklistLicense']),
}, },
}; };
</script> </script>
<template> <template>
<div data-qa-selector="license_compliance_row"> <div class="gl-responsive-table-row flex-md-column align-items-md-stretch p-0">
<issue-status-icon :status="status" class="float-left append-right-default" /> <div class="d-md-flex align-items-center js-license-row">
<span class="js-license-name" data-qa-selector="license_name_content">{{ license.name }}</span> <!-- Name-->
<div class="float-right"> <div class="table-section section-30 section-wrap pr-md-3">
<div class="d-flex"> <div class="table-mobile-header" role="rowheader">
<gl-dropdown {{ s__('Licenses|Name') }}
:text="dropdownText" </div>
toggle-class="d-flex justify-content-between align-items-center" <div class="table-mobile-content name">
right {{ license.name }}
> </div>
<gl-dropdown-item @click="approveLicense(license)"> </div>
<icon :class="approveIconClass" name="mobile-issue-close" />
{{ $options[$options.LICENSE_APPROVAL_STATUS.APPROVED] }} <!-- Policy -->
</gl-dropdown-item> <div class="table-section section-70 section-wrap pr-md-3">
<gl-dropdown-item @click="blacklistLicense(license)"> <div class="table-mobile-header" role="rowheader">{{ s__('Licenses|Policy') }}</div>
<icon :class="blacklistIconClass" name="mobile-issue-close" /> <div
{{ $options[$options.LICENSE_APPROVAL_STATUS.BLACKLISTED] }} class="table-mobile-content text-capitalize d-flex align-items-center justify-content-end justify-content-md-start status"
</gl-dropdown-item>
</gl-dropdown>
<button
class="btn btn-blank js-remove-button"
type="button"
data-toggle="modal"
data-target="#modal-license-delete-confirmation"
@click="setLicenseInModal(license)"
> >
<icon name="remove" /> <issue-status-icon :status="iconStatus" />
</button> {{ textStatus }}
</div>
</div> </div>
</div> </div>
</div> </div>
......
...@@ -5,20 +5,18 @@ import { s__ } from '~/locale'; ...@@ -5,20 +5,18 @@ import { s__ } from '~/locale';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import LicensePackages from './license_packages.vue'; import LicensePackages from './license_packages.vue';
import { LICENSE_APPROVAL_STATUS } from '../constants'; import { LICENSE_APPROVAL_STATUS } from '../constants';
import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_management/store/constants';
export default { export default {
name: 'LicenseSetApprovalStatusModal', name: 'LicenseSetApprovalStatusModal',
components: { SafeLink, LicensePackages, GlModal: DeprecatedModal2 }, components: { SafeLink, LicensePackages, GlModal: DeprecatedModal2 },
computed: { computed: {
...mapState(['currentLicenseInModal', 'canManageLicenses']), ...mapState(LICENSE_MANAGEMENT, ['currentLicenseInModal', 'canManageLicenses']),
headerTitleText() { headerTitleText() {
if (!this.canManageLicenses) { if (!this.canManageLicenses) {
return s__('LicenseCompliance|License details'); return s__('LicenseCompliance|License details');
} }
if (this.canApprove) { return s__('LicenseCompliance|License review');
return s__('LicenseCompliance|Approve license?');
}
return s__('LicenseCompliance|Blacklist license?');
}, },
canApprove() { canApprove() {
return ( return (
...@@ -36,7 +34,11 @@ export default { ...@@ -36,7 +34,11 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['resetLicenseInModal', 'approveLicense', 'blacklistLicense']), ...mapActions(LICENSE_MANAGEMENT, [
'resetLicenseInModal',
'approveLicense',
'blacklistLicense',
]),
}, },
}; };
</script> </script>
...@@ -97,7 +99,7 @@ export default { ...@@ -97,7 +99,7 @@ export default {
data-qa-selector="blacklist_license_button" data-qa-selector="blacklist_license_button"
@click="blacklistLicense(currentLicenseInModal)" @click="blacklistLicense(currentLicenseInModal)"
> >
{{ s__('LicenseCompliance|Blacklist license') }} {{ s__('LicenseCompliance|Deny') }}
</button> </button>
<button <button
v-if="canApprove" v-if="canApprove"
...@@ -107,7 +109,7 @@ export default { ...@@ -107,7 +109,7 @@ export default {
data-qa-selector="approve_license_button" data-qa-selector="approve_license_button"
@click="approveLicense(currentLicenseInModal)" @click="approveLicense(currentLicenseInModal)"
> >
{{ s__('LicenseCompliance|Approve license') }} {{ s__('LicenseCompliance|Allow') }}
</button> </button>
</template> </template>
</gl-modal> </gl-modal>
......
...@@ -3,18 +3,19 @@ import { mapState, mapActions } from 'vuex'; ...@@ -3,18 +3,19 @@ import { mapState, mapActions } from 'vuex';
import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import AddLicenseForm from './components/add_license_form.vue'; import AddLicenseForm from './components/add_license_form.vue';
import AdminLicenseManagementRow from './components/admin_license_management_row.vue';
import LicenseManagementRow from './components/license_management_row.vue'; import LicenseManagementRow from './components/license_management_row.vue';
import DeleteConfirmationModal from './components/delete_confirmation_modal.vue'; import DeleteConfirmationModal from './components/delete_confirmation_modal.vue';
import PaginatedList from '~/vue_shared/components/paginated_list.vue'; import PaginatedList from '~/vue_shared/components/paginated_list.vue';
import createStore from './store/index';
const store = createStore(); import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_management/store/constants';
export default { export default {
name: 'LicenseManagement', name: 'LicenseManagement',
components: { components: {
AddLicenseForm, AddLicenseForm,
DeleteConfirmationModal, DeleteConfirmationModal,
AdminLicenseManagementRow,
LicenseManagementRow, LicenseManagementRow,
GlButton, GlButton,
GlLoadingIcon, GlLoadingIcon,
...@@ -27,11 +28,16 @@ export default { ...@@ -27,11 +28,16 @@ export default {
}, },
}, },
data() { data() {
return { formIsOpen: false }; return {
formIsOpen: false,
tableHeaders: [
{ className: 'section-70', label: s__('Licenses|Policy') },
{ className: 'section-30', label: s__('Licenses|Name') },
],
};
}, },
store,
computed: { computed: {
...mapState(['managedLicenses', 'isLoadingManagedLicenses']), ...mapState(LICENSE_MANAGEMENT, ['managedLicenses', 'isLoadingManagedLicenses', 'isAdmin']),
}, },
mounted() { mounted() {
this.setAPISettings({ this.setAPISettings({
...@@ -40,7 +46,11 @@ export default { ...@@ -40,7 +46,11 @@ export default {
this.fetchManagedLicenses(); this.fetchManagedLicenses();
}, },
methods: { methods: {
...mapActions(['fetchManagedLicenses', 'setAPISettings', 'setLicenseApproval']), ...mapActions(LICENSE_MANAGEMENT, [
'fetchManagedLicenses',
'setAPISettings',
'setLicenseApproval',
]),
openAddLicenseForm() { openAddLicenseForm() {
this.formIsOpen = true; this.formIsOpen = true;
}, },
...@@ -59,17 +69,19 @@ export default { ...@@ -59,17 +69,19 @@ export default {
<template> <template>
<gl-loading-icon v-if="isLoadingManagedLicenses" /> <gl-loading-icon v-if="isLoadingManagedLicenses" />
<div v-else class="license-management"> <div v-else class="license-management">
<delete-confirmation-modal /> <delete-confirmation-modal v-if="isAdmin" />
<paginated-list <paginated-list
:list="managedLicenses" :list="managedLicenses"
:empty-search-message="$options.emptySearchMessage" :empty-search-message="$options.emptySearchMessage"
:empty-message="$options.emptyMessage" :empty-message="$options.emptyMessage"
:filterable="isAdmin"
filter="name" filter="name"
data-qa-selector="license_compliance_list" data-qa-selector="license_compliance_list"
> >
<template #header> <template #header>
<gl-button <gl-button
v-if="isAdmin"
class="js-open-form order-1" class="js-open-form order-1"
:disabled="formIsOpen" :disabled="formIsOpen"
variant="success" variant="success"
...@@ -78,9 +90,21 @@ export default { ...@@ -78,9 +90,21 @@ export default {
> >
{{ s__('LicenseCompliance|Add a license') }} {{ s__('LicenseCompliance|Add a license') }}
</gl-button> </gl-button>
<template v-else>
<div
v-for="header in tableHeaders"
:key="header.label"
class="table-section"
:class="header.className"
role="rowheader"
>
{{ header.label }}
</div>
</template>
</template> </template>
<template #subheader> <template v-if="isAdmin" #subheader>
<div v-if="formIsOpen" class="prepend-top-default append-bottom-default"> <div v-if="formIsOpen" class="prepend-top-default append-bottom-default">
<add-license-form <add-license-form
:managed-licenses="managedLicenses" :managed-licenses="managedLicenses"
...@@ -91,7 +115,8 @@ export default { ...@@ -91,7 +115,8 @@ export default {
</template> </template>
<template #default="{ listItem }"> <template #default="{ listItem }">
<license-management-row :license="listItem" /> <admin-license-management-row v-if="isAdmin" :license="listItem" />
<license-management-row v-else :license="listItem" />
</template> </template>
</paginated-list> </paginated-list>
</div> </div>
......
...@@ -7,6 +7,8 @@ import { componentNames } from 'ee/reports/components/issue_body'; ...@@ -7,6 +7,8 @@ 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_management/store/constants';
import createStore from './store'; import createStore from './store';
const store = createStore(); const store = createStore();
...@@ -63,8 +65,8 @@ export default { ...@@ -63,8 +65,8 @@ export default {
}, },
}, },
computed: { computed: {
...mapState(['loadLicenseReportError']), ...mapState(LICENSE_MANAGEMENT, ['loadLicenseReportError']),
...mapGetters([ ...mapGetters(LICENSE_MANAGEMENT, [
'licenseReport', 'licenseReport',
'isLoading', 'isLoading',
'licenseSummaryText', 'licenseSummaryText',
...@@ -98,7 +100,7 @@ export default { ...@@ -98,7 +100,7 @@ export default {
this.fetchParsedLicenseReport(); this.fetchParsedLicenseReport();
}, },
methods: { methods: {
...mapActions(['setAPISettings', 'fetchParsedLicenseReport']), ...mapActions(LICENSE_MANAGEMENT, ['setAPISettings', 'fetchParsedLicenseReport']),
}, },
}; };
</script> </script>
......
...@@ -105,6 +105,11 @@ export const receiveSetLicenseApproval = ({ commit, dispatch, state }) => { ...@@ -105,6 +105,11 @@ export const receiveSetLicenseApproval = ({ commit, dispatch, state }) => {
export const receiveSetLicenseApprovalError = ({ commit }, error) => { export const receiveSetLicenseApprovalError = ({ commit }, error) => {
commit(types.RECEIVE_SET_LICENSE_APPROVAL_ERROR, error); commit(types.RECEIVE_SET_LICENSE_APPROVAL_ERROR, error);
}; };
export const setIsAdmin = ({ commit }, payload) => {
commit(types.SET_IS_ADMIN, payload);
};
export const setLicenseApproval = ({ dispatch, state }, payload) => { export const setLicenseApproval = ({ dispatch, state }, payload) => {
const { apiUrlManageLicenses } = state; const { apiUrlManageLicenses } = state;
const { license, newStatus } = payload; const { license, newStatus } = payload;
......
/* eslint-disable import/prefer-default-export */
export const LICENSE_MANAGEMENT = 'licenseManagement';
...@@ -7,10 +7,17 @@ import mutations from './mutations'; ...@@ -7,10 +7,17 @@ import mutations from './mutations';
Vue.use(Vuex); Vue.use(Vuex);
export default () => export const licenseManagementModule = () => ({
new Vuex.Store({ namespaced: true,
state: createState(), state: createState(),
actions, actions,
getters, getters,
mutations, mutations,
});
export default () =>
new Vuex.Store({
modules: {
licenseManagement: licenseManagementModule(),
},
}); });
...@@ -13,6 +13,7 @@ export const REQUEST_SET_LICENSE_APPROVAL = 'REQUEST_SET_LICENSE_APPROVAL'; ...@@ -13,6 +13,7 @@ export const REQUEST_SET_LICENSE_APPROVAL = 'REQUEST_SET_LICENSE_APPROVAL';
export const RESET_LICENSE_IN_MODAL = 'RESET_LICENSE_IN_MODAL'; export const RESET_LICENSE_IN_MODAL = 'RESET_LICENSE_IN_MODAL';
export const SET_API_SETTINGS = 'SET_API_SETTINGS'; export const SET_API_SETTINGS = 'SET_API_SETTINGS';
export const SET_LICENSE_IN_MODAL = 'SET_LICENSE_IN_MODAL'; export const SET_LICENSE_IN_MODAL = 'SET_LICENSE_IN_MODAL';
export const SET_IS_ADMIN = 'SET_IS_ADMIN';
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -15,7 +15,11 @@ export default { ...@@ -15,7 +15,11 @@ export default {
[types.SET_API_SETTINGS](state, data) { [types.SET_API_SETTINGS](state, data) {
Object.assign(state, data); Object.assign(state, data);
}, },
[types.SET_IS_ADMIN](state, data) {
Object.assign(state, {
isAdmin: data,
});
},
[types.RECEIVE_MANAGED_LICENSES_SUCCESS](state, licenses = []) { [types.RECEIVE_MANAGED_LICENSES_SUCCESS](state, licenses = []) {
const managedLicenses = licenses.map(normalizeLicense).reverse(); const managedLicenses = licenses.map(normalizeLicense).reverse();
......
...@@ -3,6 +3,7 @@ export default () => ({ ...@@ -3,6 +3,7 @@ export default () => ({
licensesApiPath: null, licensesApiPath: null,
canManageLicenses: false, canManageLicenses: false,
currentLicenseInModal: null, currentLicenseInModal: null,
isAdmin: false,
isDeleting: false, isDeleting: false,
isLoadingLicenseReport: false, isLoadingLicenseReport: false,
isLoadingManagedLicenses: false, isLoadingManagedLicenses: false,
......
import { LICENSE_APPROVAL_STATUS } from 'ee/vue_shared/license_management/constants'; import { LICENSE_APPROVAL_STATUS } from 'ee/vue_shared/license_management/constants';
import { 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';
/** /**
...@@ -18,6 +18,15 @@ export const normalizeLicense = license => { ...@@ -18,6 +18,15 @@ export const normalizeLicense = license => {
}; };
}; };
export const getStatusTranslationsFromLicenseStatus = approvalStatus => {
if (approvalStatus === LICENSE_APPROVAL_STATUS.APPROVED) {
return s__('LicenseCompliance|Allowed');
} else if (approvalStatus === LICENSE_APPROVAL_STATUS.BLACKLISTED) {
return s__('LicenseCompliance|Denied');
}
return '';
};
export const getIssueStatusFromLicenseStatus = approvalStatus => { export const getIssueStatusFromLicenseStatus = approvalStatus => {
if (approvalStatus === LICENSE_APPROVAL_STATUS.APPROVED) { if (approvalStatus === LICENSE_APPROVAL_STATUS.APPROVED) {
return STATUS_SUCCESS; return STATUS_SUCCESS;
......
...@@ -4,6 +4,9 @@ module Projects ...@@ -4,6 +4,9 @@ module Projects
class LicensesController < Projects::ApplicationController class LicensesController < Projects::ApplicationController
before_action :authorize_read_licenses!, only: [:index] before_action :authorize_read_licenses!, only: [:index]
before_action :authorize_admin_software_license_policy!, only: [:create, :update] before_action :authorize_admin_software_license_policy!, only: [:create, :update]
before_action do
push_frontend_feature_flag(:license_policy_list)
end
def index def index
respond_to do |format| respond_to do |format|
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LicenseManagementRow allowed license renders the allowed status text with the status icon 1`] = `
<div
class="table-mobile-content text-capitalize d-flex align-items-center justify-content-end justify-content-md-start status"
>
<issue-status-icon-stub
status="success"
statusiconsize="24"
/>
Allowed
</div>
`;
exports[`LicenseManagementRow allowed license renders the license name 1`] = `
<div
class="table-mobile-content name"
>
MIT
</div>
`;
exports[`LicenseManagementRow denied license renders the denied status text with the status icon 1`] = `
<div
class="table-mobile-content text-capitalize d-flex align-items-center justify-content-end justify-content-md-start status"
>
<issue-status-icon-stub
status="failed"
statusiconsize="24"
/>
Denied
</div>
`;
exports[`LicenseManagementRow denied license renders the license name 1`] = `
<div
class="table-mobile-content name"
>
New BSD
</div>
`;
import { shallowMount } from '@vue/test-utils';
import LicenseManagementRow from 'ee/vue_shared/license_management/components/license_management_row.vue';
import { approvedLicense, blacklistedLicense } from 'ee_jest/license_management/mock_data';
let wrapper;
describe('LicenseManagementRow', () => {
afterEach(() => {
wrapper.destroy();
});
describe('allowed license', () => {
beforeEach(() => {
const props = { license: approvedLicense };
wrapper = shallowMount(LicenseManagementRow, {
propsData: {
...props,
},
});
});
it('renders the license name', () => {
expect(wrapper.find('.name').element).toMatchSnapshot();
});
it('renders the allowed status text with the status icon', () => {
expect(wrapper.find('.status').element).toMatchSnapshot();
});
});
describe('denied license', () => {
beforeEach(() => {
const props = { license: blacklistedLicense };
wrapper = shallowMount(LicenseManagementRow, {
propsData: {
...props,
},
});
});
it('renders the license name', () => {
expect(wrapper.find('.name').element).toMatchSnapshot();
});
it('renders the denied status text with the status icon', () => {
expect(wrapper.find('.status').element).toMatchSnapshot();
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import LicenseManagement from 'ee/vue_shared/license_management/license_management.vue'; import LicenseManagement from 'ee/vue_shared/license_management/license_management.vue';
import AdminLicenseManagementRow from 'ee/vue_shared/license_management/components/admin_license_management_row.vue';
import LicenseManagementRow from 'ee/vue_shared/license_management/components/license_management_row.vue';
import AddLicenseForm from 'ee/vue_shared/license_management/components/add_license_form.vue'; import AddLicenseForm from 'ee/vue_shared/license_management/components/add_license_form.vue';
import DeleteConfirmationModal from 'ee/vue_shared/license_management/components/delete_confirmation_modal.vue'; import DeleteConfirmationModal from 'ee/vue_shared/license_management/components/delete_confirmation_modal.vue';
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import { approvedLicense, blacklistedLicense } from './mock_data'; import { approvedLicense, blacklistedLicense } from './mock_data';
const localVue = createLocalVue(); Vue.use(Vuex);
localVue.use(Vuex);
let wrapper;
const apiUrl = `${TEST_HOST}/license_management`;
const managedLicenses = [approvedLicense, blacklistedLicense];
const PaginatedListMock = { const PaginatedListMock = {
name: 'PaginatedList', name: 'PaginatedList',
...@@ -24,16 +31,15 @@ const PaginatedListMock = { ...@@ -24,16 +31,15 @@ const PaginatedListMock = {
const noop = () => {}; const noop = () => {};
describe('LicenseManagement', () => { const createComponent = ({ state, props, actionMocks, isAdmin }) => {
const apiUrl = `${TEST_HOST}/license_management`;
const managedLicenses = [approvedLicense, blacklistedLicense];
let wrapper;
const createComponent = ({ state, props, actionMocks }) => {
const fakeStore = new Vuex.Store({ const fakeStore = new Vuex.Store({
modules: {
licenseManagement: {
namespaced: true,
state: { state: {
managedLicenses, managedLicenses,
isLoadingManagedLicenses: true, isLoadingManagedLicenses: true,
isAdmin,
...state, ...state,
}, },
actions: { actions: {
...@@ -42,6 +48,8 @@ describe('LicenseManagement', () => { ...@@ -42,6 +48,8 @@ describe('LicenseManagement', () => {
setLicenseApproval: noop, setLicenseApproval: noop,
...actionMocks, ...actionMocks,
}, },
},
},
}); });
wrapper = shallowMount(LicenseManagement, { wrapper = shallowMount(LicenseManagement, {
...@@ -50,26 +58,89 @@ describe('LicenseManagement', () => { ...@@ -50,26 +58,89 @@ describe('LicenseManagement', () => {
...props, ...props,
}, },
stubs: { stubs: {
LicenseManagementRow: true,
PaginatedList: PaginatedListMock, PaginatedList: PaginatedListMock,
}, },
store: fakeStore, store: fakeStore,
}); });
}; };
describe('License Management', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
}); });
describe('common functionality', () => {
describe.each`
desc | isAdmin
${'when admin'} | ${true}
${'when developer'} | ${false}
`('$desc', ({ isAdmin }) => {
it('when loading should render loading icon', () => { it('when loading should render loading icon', () => {
createComponent({ state: { isLoadingManagedLicenses: true } }); createComponent({ state: { isLoadingManagedLicenses: true }, isAdmin });
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
}); });
describe('when not loading', () => { describe('when not loading', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ state: { isLoadingManagedLicenses: false } }); createComponent({ state: { isLoadingManagedLicenses: false }, isAdmin });
});
it('should render list of managed licenses', () => {
expect(wrapper.find({ name: 'PaginatedList' }).props('list')).toBe(managedLicenses);
});
});
it('should set api settings after mount and init API calls', () => {
const setAPISettingsMock = jest.fn();
const fetchManagedLicensesMock = jest.fn();
createComponent({
state: { isLoadingManagedLicenses: false },
actionMocks: {
setAPISettings: setAPISettingsMock,
fetchManagedLicenses: fetchManagedLicensesMock,
},
isAdmin,
});
expect(setAPISettingsMock).toHaveBeenCalledWith(
expect.any(Object),
{
apiUrlManageLicenses: apiUrl,
},
undefined,
);
expect(fetchManagedLicensesMock).toHaveBeenCalledWith(
expect.any(Object),
undefined,
undefined,
);
});
});
});
describe('permission based functionality', () => {
describe('when admin', () => {
it('should invoke `setLicenseAprroval` action on `addLicense` event on form only', () => {
const setLicenseApprovalMock = jest.fn();
createComponent({
state: { isLoadingManagedLicenses: false },
actionMocks: { setLicenseApproval: setLicenseApprovalMock },
isAdmin: true,
});
wrapper.find(GlButton).vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
wrapper.find(AddLicenseForm).vm.$emit('addLicense');
expect(setLicenseApprovalMock).toHaveBeenCalled();
});
});
describe('when not loading', () => {
beforeEach(() => {
createComponent({ state: { isLoadingManagedLicenses: false }, isAdmin: true });
}); });
it('should render the form if the form is open and disable the form button', () => { it('should render the form if the form is open and disable the form button', () => {
...@@ -90,45 +161,44 @@ describe('LicenseManagement', () => { ...@@ -90,45 +161,44 @@ describe('LicenseManagement', () => {
expect(wrapper.find(DeleteConfirmationModal).exists()).toBe(true); expect(wrapper.find(DeleteConfirmationModal).exists()).toBe(true);
}); });
it('should render list of managed licenses', () => { it('renders the admin row', () => {
expect(wrapper.find({ name: 'PaginatedList' }).props('list')).toBe(managedLicenses); expect(wrapper.find(LicenseManagementRow).exists()).toBe(false);
expect(wrapper.find(AdminLicenseManagementRow).exists()).toBe(true);
}); });
}); });
});
it('should invoke `setLicenseAprroval` action on `addLicense` event on form', () => { describe('when developer', () => {
it('should not invoke `setLicenseAprroval` action or `addLicense` event on form', () => {
const setLicenseApprovalMock = jest.fn(); const setLicenseApprovalMock = jest.fn();
createComponent({ createComponent({
state: { isLoadingManagedLicenses: false }, state: { isLoadingManagedLicenses: false },
actionMocks: { setLicenseApproval: setLicenseApprovalMock }, actionMocks: { setLicenseApproval: setLicenseApprovalMock },
isAdmin: false,
}); });
wrapper.find(GlButton).vm.$emit('click'); expect(wrapper.find(GlButton).exists()).toBe(false);
expect(wrapper.find(AddLicenseForm).exists()).toBe(false);
return wrapper.vm.$nextTick().then(() => { expect(setLicenseApprovalMock).not.toHaveBeenCalled();
wrapper.find(AddLicenseForm).vm.$emit('addLicense');
expect(setLicenseApprovalMock).toHaveBeenCalled();
});
}); });
it('should set api settings after mount and init API calls', () => { describe('when not loading', () => {
const setAPISettingsMock = jest.fn(); beforeEach(() => {
const fetchManagedLicensesMock = jest.fn(); createComponent({ state: { isLoadingManagedLicenses: false, isAdmin: false } });
});
createComponent({ it('should not render the form', () => {
state: { isLoadingManagedLicenses: false }, expect(wrapper.find(AddLicenseForm).exists()).toBe(false);
actionMocks: { expect(wrapper.find(GlButton).exists()).toBe(false);
setAPISettings: setAPISettingsMock,
fetchManagedLicenses: fetchManagedLicensesMock,
},
}); });
expect(setAPISettingsMock).toHaveBeenCalledWith( it('should not render delete confirmation modal', () => {
expect.any(Object), expect(wrapper.find(DeleteConfirmationModal).exists()).toBe(false);
{ });
apiUrlManageLicenses: apiUrl,
},
undefined,
);
expect(fetchManagedLicensesMock).toHaveBeenCalledWith(expect.any(Object), undefined, undefined); it('renders the read only row', () => {
expect(wrapper.find(LicenseManagementRow).exists()).toBe(true);
expect(wrapper.find(AdminLicenseManagementRow).exists()).toBe(false);
});
});
});
}); });
}); });
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { GlEmptyState, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
import { REPORT_STATUS } from 'ee/project_licenses/store/modules/list/constants';
import ProjectLicensesApp from 'ee/project_licenses/components/app.vue';
import PaginatedLicensesTable from 'ee/project_licenses/components/paginated_licenses_table.vue';
import PipelineInfo from 'ee/project_licenses/components/pipeline_info.vue';
import LicenseManagement from 'ee/vue_shared/license_management/license_management.vue';
import * as getters from 'ee/project_licenses/store/modules/list/getters';
import { approvedLicense, blacklistedLicense } from 'ee_jest/license_management/mock_data';
Vue.use(Vuex);
let wrapper;
const readLicensePoliciesEndpoint = `${TEST_HOST}/license_management`;
const managedLicenses = [approvedLicense, blacklistedLicense];
const licenses = [{}, {}];
const emptyStateSvgPath = '/';
const documentationPath = '/';
const noop = () => {};
const createComponent = ({ state, props, options }) => {
const fakeStore = new Vuex.Store({
modules: {
licenseManagement: {
namespaced: true,
state: {
managedLicenses,
},
},
licenseList: {
namespaced: true,
state: {
licenses,
reportInfo: {
jobPath: '/',
generatedAt: '',
},
...state,
},
actions: {
fetchLicenses: noop,
},
getters,
},
},
});
wrapper = shallowMount(ProjectLicensesApp, {
propsData: {
emptyStateSvgPath,
documentationPath,
readLicensePoliciesEndpoint,
...props,
},
...options,
store: fakeStore,
});
};
describe('Project Licenses', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when loading', () => {
beforeEach(() => {
createComponent({
state: { initialized: false },
});
});
it('shows the loading component', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('does not show the empty state component', () => {
expect(wrapper.find(GlEmptyState).exists()).toBe(false);
});
it('does not show the list of detected in project licenses', () => {
expect(wrapper.find(PaginatedLicensesTable).exists()).toBe(false);
});
it('does not show the list of license policies', () => {
expect(wrapper.find(LicenseManagement).exists()).toBe(false);
});
it('does not render any tabs', () => {
expect(wrapper.find(GlTabs).exists()).toBe(false);
expect(wrapper.find(GlTab).exists()).toBe(false);
});
});
describe('when empty state', () => {
beforeEach(() => {
createComponent({
state: {
initialized: true,
reportInfo: {
jobPath: '/',
generatedAt: '',
status: REPORT_STATUS.jobNotSetUp,
},
},
});
});
it('shows the empty state component', () => {
expect(wrapper.find(GlEmptyState).exists()).toBe(true);
});
it('does not show the loading component', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
it('does not show the list of detected in project licenses', () => {
expect(wrapper.find(PaginatedLicensesTable).exists()).toBe(false);
});
it('does not show the list of license policies', () => {
expect(wrapper.find(LicenseManagement).exists()).toBe(false);
});
it('does not render any tabs', () => {
expect(wrapper.find(GlTabs).exists()).toBe(false);
expect(wrapper.find(GlTab).exists()).toBe(false);
});
});
describe('when licensePolicyList feature flag is enabled', () => {
beforeEach(() => {
createComponent({
state: {
initialized: true,
reportInfo: {
jobPath: '/',
generatedAt: '',
status: REPORT_STATUS.ok,
},
},
options: {
provide: {
glFeatures: { licensePolicyList: true },
},
},
});
});
it('renders a "Detected in project" tab and a "Policies" tab', () => {
expect(wrapper.find(GlTabs).exists()).toBe(true);
expect(wrapper.find(GlTab).exists()).toBe(true);
expect(wrapper.findAll(GlTab).length).toBe(2);
});
it('it renders the "Detected in project" table', () => {
expect(wrapper.find(PaginatedLicensesTable).exists()).toBe(true);
});
it('it renders the "Policies" table', () => {
expect(wrapper.find(LicenseManagement).exists()).toBe(true);
});
it('renders the pipeline info', () => {
expect(wrapper.find(PipelineInfo).exists()).toBe(true);
});
});
describe('when licensePolicyList feature flag is disabled', () => {
beforeEach(() => {
createComponent({
state: {
initialized: true,
reportInfo: {
jobPath: '/',
generatedAt: '',
status: REPORT_STATUS.ok,
},
},
options: {
provide: {
glFeatures: { licensePolicyList: false },
},
},
});
});
it('only renders the "Detected in project" table', () => {
expect(wrapper.find(PaginatedLicensesTable).exists()).toBe(true);
expect(wrapper.find(LicenseManagement).exists()).toBe(false);
});
it('renders no "Policies" table', () => {
expect(wrapper.find(GlTabs).exists()).toBe(false);
expect(wrapper.find(GlTab).exists()).toBe(false);
});
it('renders the pipeline info', () => {
expect(wrapper.find(PipelineInfo).exists()).toBe(true);
});
it('renders no tabs', () => {
expect(wrapper.find(GlTabs).exists()).toBe(false);
expect(wrapper.find(GlTab).exists()).toBe(false);
});
});
});
...@@ -98,9 +98,9 @@ describe('AddLicenseForm', () => { ...@@ -98,9 +98,9 @@ describe('AddLicenseForm', () => {
const radioButtonParents = vm.$el.querySelectorAll('.form-check'); const radioButtonParents = vm.$el.querySelectorAll('.form-check');
expect(radioButtonParents.length).toBe(2); expect(radioButtonParents.length).toBe(2);
expect(radioButtonParents[0].innerText.trim()).toBe('Approve'); expect(radioButtonParents[0].innerText.trim()).toBe('Allow');
expect(radioButtonParents[0].querySelector('.form-check-input')).not.toBeNull(); expect(radioButtonParents[0].querySelector('.form-check-input')).not.toBeNull();
expect(radioButtonParents[1].innerText.trim()).toBe('Blacklist'); expect(radioButtonParents[1].innerText.trim()).toBe('Deny');
expect(radioButtonParents[1].querySelector('.form-check-input')).not.toBeNull(); expect(radioButtonParents[1].querySelector('.form-check-input')).not.toBeNull();
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import LicenseManagementRow from 'ee/vue_shared/license_management/components/license_management_row.vue'; import AdminLicenseManagementRow from 'ee/vue_shared/license_management/components/admin_license_management_row.vue';
import { LICENSE_APPROVAL_STATUS } from 'ee/vue_shared/license_management/constants'; import { LICENSE_APPROVAL_STATUS } from 'ee/vue_shared/license_management/constants';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
...@@ -10,8 +10,8 @@ import { approvedLicense } from 'ee_spec/license_management/mock_data'; ...@@ -10,8 +10,8 @@ import { approvedLicense } from 'ee_spec/license_management/mock_data';
const visibleClass = 'visible'; const visibleClass = 'visible';
const invisibleClass = 'invisible'; const invisibleClass = 'invisible';
describe('LicenseManagementRow', () => { describe('AdminLicenseManagementRow', () => {
const Component = Vue.extend(LicenseManagementRow); const Component = Vue.extend(AdminLicenseManagementRow);
let vm; let vm;
let store; let store;
...@@ -28,8 +28,13 @@ describe('LicenseManagementRow', () => { ...@@ -28,8 +28,13 @@ describe('LicenseManagementRow', () => {
}; };
store = new Vuex.Store({ store = new Vuex.Store({
modules: {
licenseManagement: {
namespaced: true,
state: {}, state: {},
actions, actions,
},
},
}); });
const props = { license: approvedLicense }; const props = { license: approvedLicense };
...@@ -48,8 +53,8 @@ describe('LicenseManagementRow', () => { ...@@ -48,8 +53,8 @@ describe('LicenseManagementRow', () => {
}); });
describe('computed', () => { describe('computed', () => {
it('dropdownText returns `Approved`', () => { it('dropdownText returns `Allowed`', () => {
expect(vm.dropdownText).toBe('Approved'); expect(vm.dropdownText).toBe('Allowed');
}); });
it('isApproved returns `true`', () => { it('isApproved returns `true`', () => {
...@@ -83,8 +88,8 @@ describe('LicenseManagementRow', () => { ...@@ -83,8 +88,8 @@ describe('LicenseManagementRow', () => {
}); });
describe('computed', () => { describe('computed', () => {
it('dropdownText returns `Blacklisted`', () => { it('dropdownText returns `Denied`', () => {
expect(vm.dropdownText).toBe('Blacklisted'); expect(vm.dropdownText).toBe('Denied');
}); });
it('isApproved returns `false`', () => { it('isApproved returns `false`', () => {
...@@ -164,7 +169,7 @@ describe('LicenseManagementRow', () => { ...@@ -164,7 +169,7 @@ describe('LicenseManagementRow', () => {
expect(dropdownEl.innerText.trim()).toBe(vm.dropdownText); expect(dropdownEl.innerText.trim()).toBe(vm.dropdownText);
}); });
it('renders the dropdown with `Approved` and `Blacklisted` options', () => { it('renders the dropdown with `Allowed` and `Denied` options', () => {
const dropdownEl = vm.$el.querySelector('.dropdown'); const dropdownEl = vm.$el.querySelector('.dropdown');
expect(dropdownEl).not.toBeNull(); expect(dropdownEl).not.toBeNull();
...@@ -172,12 +177,12 @@ describe('LicenseManagementRow', () => { ...@@ -172,12 +177,12 @@ describe('LicenseManagementRow', () => {
const firstOption = findNthDropdown(0); const firstOption = findNthDropdown(0);
expect(firstOption).not.toBeNull(); expect(firstOption).not.toBeNull();
expect(firstOption.innerText.trim()).toBe('Approved'); expect(firstOption.innerText.trim()).toBe('Allowed');
const secondOption = findNthDropdown(1); const secondOption = findNthDropdown(1);
expect(secondOption).not.toBeNull(); expect(secondOption).not.toBeNull();
expect(secondOption.innerText.trim()).toBe('Blacklisted'); expect(secondOption.innerText.trim()).toBe('Denied');
}); });
}); });
}); });
...@@ -8,7 +8,6 @@ import { approvedLicense } from 'ee_spec/license_management/mock_data'; ...@@ -8,7 +8,6 @@ import { approvedLicense } from 'ee_spec/license_management/mock_data';
describe('DeleteConfirmationModal', () => { describe('DeleteConfirmationModal', () => {
const Component = Vue.extend(DeleteConfirmationModal); const Component = Vue.extend(DeleteConfirmationModal);
let vm; let vm;
let store; let store;
let actions; let actions;
...@@ -20,10 +19,15 @@ describe('DeleteConfirmationModal', () => { ...@@ -20,10 +19,15 @@ describe('DeleteConfirmationModal', () => {
}; };
store = new Vuex.Store({ store = new Vuex.Store({
modules: {
licenseManagement: {
namespaced: true,
state: { state: {
currentLicenseInModal: approvedLicense, currentLicenseInModal: approvedLicense,
}, },
actions, actions,
},
},
}); });
vm = mountComponentWithStore(Component, { store }); vm = mountComponentWithStore(Component, { store });
...@@ -47,10 +51,12 @@ describe('DeleteConfirmationModal', () => { ...@@ -47,10 +51,12 @@ describe('DeleteConfirmationModal', () => {
store.replaceState({ store.replaceState({
...store.state, ...store.state,
licenseManagement: {
currentLicenseInModal: { currentLicenseInModal: {
...approvedLicense, ...approvedLicense,
name, name,
}, },
},
}); });
Vue.nextTick() Vue.nextTick()
...@@ -89,7 +95,7 @@ describe('DeleteConfirmationModal', () => { ...@@ -89,7 +95,7 @@ describe('DeleteConfirmationModal', () => {
expect(actions.deleteLicense).toHaveBeenCalledWith( expect(actions.deleteLicense).toHaveBeenCalledWith(
jasmine.any(Object), jasmine.any(Object),
store.state.currentLicenseInModal, store.state.licenseManagement.currentLicenseInModal,
undefined, undefined,
); );
}); });
......
...@@ -25,11 +25,11 @@ describe('LicenseIssueBody', () => { ...@@ -25,11 +25,11 @@ describe('LicenseIssueBody', () => {
it('clicking the button triggers openModal with the current license', () => { it('clicking the button triggers openModal with the current license', () => {
const linkEl = vm.$el.querySelector('.license-item > .btn-link'); const linkEl = vm.$el.querySelector('.license-item > .btn-link');
expect(store.state.currentLicenseInModal).toBe(null); expect(store.state.licenseManagement.currentLicenseInModal).toBe(null);
linkEl.click(); linkEl.click();
expect(store.state.currentLicenseInModal).toBe(issue); expect(store.state.licenseManagement.currentLicenseInModal).toBe(issue);
}); });
}); });
......
...@@ -22,11 +22,16 @@ describe('SetApprovalModal', () => { ...@@ -22,11 +22,16 @@ describe('SetApprovalModal', () => {
}; };
store = new Vuex.Store({ store = new Vuex.Store({
modules: {
licenseManagement: {
namespaced: true,
state: { state: {
currentLicenseInModal: licenseReport[0], currentLicenseInModal: licenseReport[0],
canManageLicenses: true, canManageLicenses: true,
}, },
actions, actions,
},
},
}); });
vm = mountComponentWithStore(Component, { store }); vm = mountComponentWithStore(Component, { store });
...@@ -39,18 +44,20 @@ describe('SetApprovalModal', () => { ...@@ -39,18 +44,20 @@ describe('SetApprovalModal', () => {
describe('for approved license', () => { describe('for approved license', () => {
beforeEach(done => { beforeEach(done => {
store.replaceState({ store.replaceState({
licenseManagement: {
currentLicenseInModal: { currentLicenseInModal: {
...licenseReport[0], ...licenseReport[0],
approvalStatus: LICENSE_APPROVAL_STATUS.APPROVED, approvalStatus: LICENSE_APPROVAL_STATUS.APPROVED,
}, },
canManageLicenses: true, canManageLicenses: true,
},
}); });
Vue.nextTick(done); Vue.nextTick(done);
}); });
describe('computed', () => { describe('computed', () => {
it('headerTitleText returns `Blacklist license?`', () => { it('headerTitleText returns `License review', () => {
expect(vm.headerTitleText).toBe('Blacklist license?'); expect(vm.headerTitleText).toBe('License review');
}); });
it('canApprove is false', () => { it('canApprove is false', () => {
...@@ -67,20 +74,20 @@ describe('SetApprovalModal', () => { ...@@ -67,20 +74,20 @@ describe('SetApprovalModal', () => {
const headerEl = vm.$el.querySelector('.modal-title'); const headerEl = vm.$el.querySelector('.modal-title');
expect(headerEl).not.toBeNull(); expect(headerEl).not.toBeNull();
expect(headerEl.innerText.trim()).toBe('Blacklist license?'); expect(headerEl.innerText.trim()).toBe('License review');
}); });
it('renders no Approve button in modal footer', () => { it('renders no Allow button in modal footer', () => {
const footerButton = vm.$el.querySelector('.js-modal-primary-action'); const footerButton = vm.$el.querySelector('.js-modal-primary-action');
expect(footerButton).toBeNull(); expect(footerButton).toBeNull();
}); });
it('renders Blacklist button in modal footer', () => { it('renders Deny button in modal footer', () => {
const footerButton = vm.$el.querySelector('.js-modal-secondary-action'); const footerButton = vm.$el.querySelector('.js-modal-secondary-action');
expect(footerButton).not.toBeNull(); expect(footerButton).not.toBeNull();
expect(footerButton.innerText.trim()).toBe('Blacklist license'); expect(footerButton.innerText.trim()).toBe('Deny');
}); });
}); });
}); });
...@@ -88,18 +95,20 @@ describe('SetApprovalModal', () => { ...@@ -88,18 +95,20 @@ describe('SetApprovalModal', () => {
describe('for unapproved license', () => { describe('for unapproved license', () => {
beforeEach(done => { beforeEach(done => {
store.replaceState({ store.replaceState({
licenseManagement: {
currentLicenseInModal: { currentLicenseInModal: {
...licenseReport[0], ...licenseReport[0],
approvalStatus: undefined, approvalStatus: undefined,
}, },
canManageLicenses: true, canManageLicenses: true,
},
}); });
Vue.nextTick(done); Vue.nextTick(done);
}); });
describe('computed', () => { describe('computed', () => {
it('headerTitleText returns `Approve license?`', () => { it('headerTitleText returns `License review`', () => {
expect(vm.headerTitleText).toBe('Approve license?'); expect(vm.headerTitleText).toBe('License review');
}); });
it('canApprove is true', () => { it('canApprove is true', () => {
...@@ -116,21 +125,21 @@ describe('SetApprovalModal', () => { ...@@ -116,21 +125,21 @@ describe('SetApprovalModal', () => {
const headerEl = vm.$el.querySelector('.modal-title'); const headerEl = vm.$el.querySelector('.modal-title');
expect(headerEl).not.toBeNull(); expect(headerEl).not.toBeNull();
expect(headerEl.innerText.trim()).toBe('Approve license?'); expect(headerEl.innerText.trim()).toBe('License review');
}); });
it('renders Approve button in modal footer', () => { it('renders Allow button in modal footer', () => {
const footerButton = vm.$el.querySelector('.js-modal-primary-action'); const footerButton = vm.$el.querySelector('.js-modal-primary-action');
expect(footerButton).not.toBeNull(); expect(footerButton).not.toBeNull();
expect(footerButton.innerText.trim()).toBe('Approve license'); expect(footerButton.innerText.trim()).toBe('Allow');
}); });
it('renders Blacklist button in modal footer', () => { it('renders Deny button in modal footer', () => {
const footerButton = vm.$el.querySelector('.js-modal-secondary-action'); const footerButton = vm.$el.querySelector('.js-modal-secondary-action');
expect(footerButton).not.toBeNull(); expect(footerButton).not.toBeNull();
expect(footerButton.innerText.trim()).toBe('Blacklist license'); expect(footerButton.innerText.trim()).toBe('Deny');
}); });
}); });
}); });
...@@ -138,18 +147,20 @@ describe('SetApprovalModal', () => { ...@@ -138,18 +147,20 @@ describe('SetApprovalModal', () => {
describe('for blacklisted license', () => { describe('for blacklisted license', () => {
beforeEach(done => { beforeEach(done => {
store.replaceState({ store.replaceState({
licenseManagement: {
currentLicenseInModal: { currentLicenseInModal: {
...licenseReport[0], ...licenseReport[0],
approvalStatus: LICENSE_APPROVAL_STATUS.BLACKLISTED, approvalStatus: LICENSE_APPROVAL_STATUS.BLACKLISTED,
}, },
canManageLicenses: true, canManageLicenses: true,
},
}); });
Vue.nextTick(done); Vue.nextTick(done);
}); });
describe('computed', () => { describe('computed', () => {
it('headerTitleText returns `Approve license?`', () => { it('headerTitleText returns `License review`', () => {
expect(vm.headerTitleText).toBe('Approve license?'); expect(vm.headerTitleText).toBe('License review');
}); });
it('canApprove is true', () => { it('canApprove is true', () => {
...@@ -166,17 +177,17 @@ describe('SetApprovalModal', () => { ...@@ -166,17 +177,17 @@ describe('SetApprovalModal', () => {
const headerEl = vm.$el.querySelector('.modal-title'); const headerEl = vm.$el.querySelector('.modal-title');
expect(headerEl).not.toBeNull(); expect(headerEl).not.toBeNull();
expect(headerEl.innerText.trim()).toBe('Approve license?'); expect(headerEl.innerText.trim()).toBe('License review');
}); });
it('renders Approve button in modal footer', () => { it('renders Allow button in modal footer', () => {
const footerButton = vm.$el.querySelector('.js-modal-primary-action'); const footerButton = vm.$el.querySelector('.js-modal-primary-action');
expect(footerButton).not.toBeNull(); expect(footerButton).not.toBeNull();
expect(footerButton.innerText.trim()).toBe('Approve license'); expect(footerButton.innerText.trim()).toBe('Allow');
}); });
it('renders no Blacklist button in modal footer', () => { it('renders no Deny button in modal footer', () => {
const footerButton = vm.$el.querySelector('.js-modal-secondary-action'); const footerButton = vm.$el.querySelector('.js-modal-secondary-action');
expect(footerButton).toBeNull(); expect(footerButton).toBeNull();
...@@ -187,11 +198,13 @@ describe('SetApprovalModal', () => { ...@@ -187,11 +198,13 @@ describe('SetApprovalModal', () => {
describe('for user without the rights to manage licenses', () => { describe('for user without the rights to manage licenses', () => {
beforeEach(done => { beforeEach(done => {
store.replaceState({ store.replaceState({
licenseManagement: {
currentLicenseInModal: { currentLicenseInModal: {
...licenseReport[0], ...licenseReport[0],
approvalStatus: undefined, approvalStatus: undefined,
}, },
canManageLicenses: false, canManageLicenses: false,
},
}); });
Vue.nextTick(done); Vue.nextTick(done);
}); });
...@@ -284,7 +297,7 @@ describe('SetApprovalModal', () => { ...@@ -284,7 +297,7 @@ describe('SetApprovalModal', () => {
expect(actions.approveLicense).toHaveBeenCalledWith( expect(actions.approveLicense).toHaveBeenCalledWith(
jasmine.any(Object), jasmine.any(Object),
store.state.currentLicenseInModal, store.state.licenseManagement.currentLicenseInModal,
undefined, undefined,
); );
}); });
...@@ -297,7 +310,7 @@ describe('SetApprovalModal', () => { ...@@ -297,7 +310,7 @@ describe('SetApprovalModal', () => {
expect(actions.blacklistLicense).toHaveBeenCalledWith( expect(actions.blacklistLicense).toHaveBeenCalledWith(
jasmine.any(Object), jasmine.any(Object),
store.state.currentLicenseInModal, store.state.licenseManagement.currentLicenseInModal,
undefined, undefined,
); );
}); });
...@@ -309,11 +322,13 @@ describe('SetApprovalModal', () => { ...@@ -309,11 +322,13 @@ describe('SetApprovalModal', () => {
const badURL = 'javascript:alert("")'; const badURL = 'javascript:alert("")';
store.replaceState({ store.replaceState({
licenseManagement: {
currentLicenseInModal: { currentLicenseInModal: {
...licenseReport[0], ...licenseReport[0],
url: badURL, url: badURL,
approvalStatus: LICENSE_APPROVAL_STATUS.APPROVED, approvalStatus: LICENSE_APPROVAL_STATUS.APPROVED,
}, },
},
}); });
Vue.nextTick() Vue.nextTick()
.then(() => { .then(() => {
......
...@@ -62,9 +62,14 @@ describe('License Report MR Widget', () => { ...@@ -62,9 +62,14 @@ describe('License Report MR Widget', () => {
actions = defaultActions, actions = defaultActions,
} = {}) => { } = {}) => {
const store = new Vuex.Store({ const store = new Vuex.Store({
modules: {
licenseManagement: {
namespaced: true,
state, state,
getters, getters,
actions, actions,
},
},
}); });
return mountComponentWithStore(Component, { props, store }); return mountComponentWithStore(Component, { props, store });
}; };
......
...@@ -59,6 +59,20 @@ describe('License store actions', () => { ...@@ -59,6 +59,20 @@ describe('License store actions', () => {
}); });
}); });
describe('setIsAdmin', () => {
it('commits SET_IS_ADMIN', done => {
testAction(
actions.setIsAdmin,
false,
state,
[{ type: mutationTypes.SET_IS_ADMIN, payload: false }],
[],
)
.then(done)
.catch(done.fail);
});
});
describe('resetLicenseInModal', () => { describe('resetLicenseInModal', () => {
it('commits RESET_LICENSE_IN_MODAL', done => { it('commits RESET_LICENSE_IN_MODAL', done => {
testAction( testAction(
......
import { import {
normalizeLicense, normalizeLicense,
getPackagesString, getPackagesString,
getStatusTranslationsFromLicenseStatus,
getIssueStatusFromLicenseStatus, getIssueStatusFromLicenseStatus,
convertToOldReportFormat, convertToOldReportFormat,
} from 'ee/vue_shared/license_management/store/utils'; } from 'ee/vue_shared/license_management/store/utils';
...@@ -45,6 +46,24 @@ describe('utils', () => { ...@@ -45,6 +46,24 @@ describe('utils', () => {
}); });
}); });
describe('getStatusTranslationsFromLicenseStatus', () => {
it('returns "Allowed" for allowed license status', () => {
expect(getStatusTranslationsFromLicenseStatus(LICENSE_APPROVAL_STATUS.APPROVED)).toBe(
'Allowed',
);
});
it('returns "Denied" status for denied license status', () => {
expect(getStatusTranslationsFromLicenseStatus(LICENSE_APPROVAL_STATUS.BLACKLISTED)).toBe(
'Denied',
);
});
it('returns "" for any other status', () => {
expect(getStatusTranslationsFromLicenseStatus()).toBe('');
});
});
describe('getIssueStatusFromLicenseStatus', () => { describe('getIssueStatusFromLicenseStatus', () => {
it('returns SUCCESS status for approved license status', () => { it('returns SUCCESS status for approved license status', () => {
expect(getIssueStatusFromLicenseStatus(LICENSE_APPROVAL_STATUS.APPROVED)).toBe( expect(getIssueStatusFromLicenseStatus(LICENSE_APPROVAL_STATUS.APPROVED)).toBe(
......
...@@ -11244,31 +11244,19 @@ msgstr "" ...@@ -11244,31 +11244,19 @@ msgstr ""
msgid "LicenseCompliance|Add licenses manually to approve or blacklist" msgid "LicenseCompliance|Add licenses manually to approve or blacklist"
msgstr "" msgstr ""
msgid "LicenseCompliance|Approve" msgid "LicenseCompliance|Allow"
msgstr "" msgstr ""
msgid "LicenseCompliance|Approve license" msgid "LicenseCompliance|Allowed"
msgstr "" msgstr ""
msgid "LicenseCompliance|Approve license?" msgid "LicenseCompliance|Cancel"
msgstr ""
msgid "LicenseCompliance|Approved"
msgstr ""
msgid "LicenseCompliance|Blacklist"
msgstr ""
msgid "LicenseCompliance|Blacklist license"
msgstr ""
msgid "LicenseCompliance|Blacklist license?"
msgstr "" msgstr ""
msgid "LicenseCompliance|Blacklisted" msgid "LicenseCompliance|Denied"
msgstr "" msgstr ""
msgid "LicenseCompliance|Cancel" msgid "LicenseCompliance|Deny"
msgstr "" msgstr ""
msgid "LicenseCompliance|Here you can approve or blacklist licenses for this project. Using %{ci} or %{license} will allow you to see if there are any unmanaged licenses and approve or blacklist them in merge request." msgid "LicenseCompliance|Here you can approve or blacklist licenses for this project. Using %{ci} or %{license} will allow you to see if there are any unmanaged licenses and approve or blacklist them in merge request."
...@@ -11312,6 +11300,9 @@ msgstr "" ...@@ -11312,6 +11300,9 @@ msgstr ""
msgid "LicenseCompliance|License name" msgid "LicenseCompliance|License name"
msgstr "" msgstr ""
msgid "LicenseCompliance|License review"
msgstr ""
msgid "LicenseCompliance|Packages" msgid "LicenseCompliance|Packages"
msgstr "" msgstr ""
...@@ -11357,6 +11348,9 @@ msgstr "" ...@@ -11357,6 +11348,9 @@ msgstr ""
msgid "Licenses|Components" msgid "Licenses|Components"
msgstr "" msgstr ""
msgid "Licenses|Detected in Project"
msgstr ""
msgid "Licenses|Displays licenses detected in the project, based on the %{linkStart}latest pipeline%{linkEnd} scan" msgid "Licenses|Displays licenses detected in the project, based on the %{linkStart}latest pipeline%{linkEnd} scan"
msgstr "" msgstr ""
...@@ -11372,6 +11366,15 @@ msgstr "" ...@@ -11372,6 +11366,15 @@ msgstr ""
msgid "Licenses|Name" msgid "Licenses|Name"
msgstr "" msgstr ""
msgid "Licenses|Policies"
msgstr ""
msgid "Licenses|Policy"
msgstr ""
msgid "Licenses|Specified policies in this project"
msgstr ""
msgid "Licenses|The license list details information about the licenses used within your project." msgid "Licenses|The license list details information about the licenses used within your project."
msgstr "" msgstr ""
......
...@@ -20,8 +20,8 @@ module QA::EE ...@@ -20,8 +20,8 @@ module QA::EE
element :license_compliance_list element :license_compliance_list
end end
view 'ee/app/assets/javascripts/vue_shared/license_management/components/license_management_row.vue' do view 'ee/app/assets/javascripts/vue_shared/license_management/components/admin_license_management_row.vue' do
element :license_compliance_row element :admin_license_compliance_row
element :license_name_content element :license_name_content
end end
...@@ -30,13 +30,13 @@ module QA::EE ...@@ -30,13 +30,13 @@ module QA::EE
end end
def has_approved_license?(name) def has_approved_license?(name)
within_element(:license_compliance_row, text: name) do within_element(:admin_license_compliance_row, text: name) do
has_element?(:status_success_icon) has_element?(:status_success_icon)
end end
end end
def has_denied_license?(name) def has_denied_license?(name)
within_element(:license_compliance_row, text: name) do within_element(:admin_license_compliance_row, text: name) do
has_element?(:status_failed_icon) has_element?(:status_failed_icon)
end end
end end
......
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