Commit 86b25f9f authored by David Pisek's avatar David Pisek Committed by Mark Florian

Add License Compliance approvals UI

This adds a button and status indicator to the license-management page.
They should allow users to quickly add, editor or remove merge request
approval rules specific to license compliance.

* Adds new vuex-module that builds on existing state, actions and
  mutations for approval settings
* Adds a new vue component that contains the 'license approvals' button,
  the status text and a modal
* Adds to the rails controller to pass data via HAML to the
  store-settings
* Includes the newly added store and component within the
  policies-management app

This is implemented behind the `license_approvals` feature flag.
parent e4e7f73d
......@@ -52,6 +52,14 @@ export default {
// $root.$emit is a workaround because other b-modal approaches don't work yet with gl-modal
this.$root.$emit('bv::hide::modal', this.modalId);
},
cancel() {
this.$emit('cancel');
this.syncHide();
},
ok() {
this.$emit('ok');
this.syncHide();
},
},
};
</script>
......@@ -65,5 +73,6 @@ export default {
@hidden="syncHide"
>
<slot></slot>
<slot slot="modal-footer" name="modal-footer" :ok="ok" :cancel="cancel"></slot>
</gl-modal>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import { GlButton, GlIcon, GlLink, GlSkeletonLoading, GlSprintf } from '@gitlab/ui';
import { APPROVALS, APPROVALS_MODAL } from 'ee/approvals/stores/modules/license_compliance';
import ModalLicenseCompliance from './modal.vue';
import { s__ } from '~/locale';
export default {
components: {
GlButton,
GlIcon,
GlLink,
GlSkeletonLoading,
GlSprintf,
ModalLicenseCompliance,
},
computed: {
...mapState({
isLoading: state => state[APPROVALS].isLoading,
rules: state => state[APPROVALS].rules,
documentationPath: ({ settings }) => settings.approvalsDocumentationPath,
licenseCheckRuleName: ({ settings }) => settings.lockedApprovalsRuleName,
}),
licenseCheckRule() {
return this.rules?.find(({ name }) => name === this.licenseCheckRuleName);
},
hasLicenseCheckRule() {
return this.licenseCheckRule !== undefined;
},
licenseCheckStatusText() {
return this.hasLicenseCheckRule
? s__('LicenseCompliance|%{docLinkStart}License Approvals%{docLinkEnd} are active')
: s__('LicenseCompliance|%{docLinkStart}License Approvals%{docLinkEnd} are inactive');
},
},
created() {
this.fetchRules();
},
methods: {
...mapActions(['fetchRules']),
...mapActions({
openModal(dispatch, licenseCheckRule) {
dispatch(`${APPROVALS_MODAL}/open`, licenseCheckRule);
},
}),
},
};
</script>
<template>
<span class="gl-display-inline-flex gl-align-items-center">
<gl-button :loading="isLoading" @click="openModal(licenseCheckRule)"
>{{ s__('LicenseCompliance|License Approvals') }}
</gl-button>
<span data-testid="licenseCheckStatus" class="gl-ml-3">
<gl-skeleton-loading
v-if="isLoading"
:aria-label="__('loading')"
:lines="1"
class="gl-display-inline-flex gl-h-auto gl-align-items-center"
/>
<span v-else class="gl-m-0 gl-font-weight-normal">
<gl-icon name="information" :size="12" class="gl-text-blue-600" />
<gl-sprintf :message="licenseCheckStatusText">
<template #docLink="{ content }">
<gl-link :href="documentationPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</span>
</span>
<modal-license-compliance />
</span>
</template>
<script>
import { mapState } from 'vuex';
import { GlButton, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import { APPROVALS_MODAL } from 'ee/approvals/stores/modules/license_compliance';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
import RuleForm from '../rule_form.vue';
export default {
components: {
GlButton,
GlIcon,
GlLink,
GlSprintf,
GlModalVuex,
RuleForm,
},
computed: {
...mapState({
documentationPath: ({ settings }) => settings.approvalsDocumentationPath,
licenseApprovalRule(state) {
return state[APPROVALS_MODAL].data;
},
}),
title() {
return this.licenseApprovalRule ? __('Update approvers') : __('Add approvers');
},
},
methods: {
submit() {
this.$refs.form.submit();
},
},
modalModule: APPROVALS_MODAL,
};
</script>
<template>
<gl-modal-vuex
:modal-module="$options.modalModule"
modal-id="licenseComplianceApproval"
:title="title"
size="sm"
@ok="submit"
>
<rule-form ref="form" :init-rule="licenseApprovalRule" />
<template #modal-footer="{ ok, cancel }">
<section class="gl-display-flex gl-w-full">
<p>
<gl-icon name="question" :size="12" class="gl-text-blue-600" />
<gl-sprintf
:message="
s__('LicenseCompliance|Learn more about %{linkStart}License Approvals%{linkEnd}')
"
>
<template #link="{ content }">
<gl-link :href="documentationPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
<div class="gl-ml-auto">
<gl-button name="cancel" @click="cancel">{{ __('Cancel') }}</gl-button>
<gl-button name="ok" variant="success" @click="ok">{{ title }}</gl-button>
</div>
</section>
</template>
</gl-modal-vuex>
</template>
......@@ -128,6 +128,9 @@ export default {
isPersisted() {
return this.initRule && this.initRule.id;
},
isNameVisible() {
return !this.settings.lockedApprovalsRuleName;
},
isNameDisabled() {
return this.isPersisted && READONLY_NAMES.includes(this.name);
},
......@@ -137,7 +140,7 @@ export default {
submissionData() {
return {
id: this.initRule && this.initRule.id,
name: this.name || DEFAULT_NAME,
name: this.settings.lockedApprovalsRuleName || this.name || DEFAULT_NAME,
approvalsRequired: this.approvalsRequired,
users: this.userIds,
groups: this.groupIds,
......@@ -266,7 +269,7 @@ export default {
<template>
<form novalidate @submit.prevent.stop="submit">
<div class="row">
<div class="form-group col-sm-6">
<div v-if="isNameVisible" class="form-group col-sm-6">
<label class="label-wrapper">
<span class="mb-2 bold inline">{{ s__('ApprovalRule|Rule name') }}</span>
<input
......
import createFlash from '~/flash';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import * as types from '../base/mutation_types';
import {
mapApprovalRuleRequest,
mapApprovalSettingsResponse,
mapApprovalFallbackRuleRequest,
} from 'ee/approvals/mappers';
export const receiveRulesSuccess = ({ commit }, approvalSettings) => {
commit(types.SET_APPROVAL_SETTINGS, approvalSettings);
commit(types.SET_LOADING, false);
};
export const fetchRules = ({ rootState, dispatch, commit }) => {
const { settingsPath } = rootState.settings;
commit(types.SET_LOADING, true);
return axios
.get(settingsPath)
.then(response => dispatch('receiveRulesSuccess', mapApprovalSettingsResponse(response.data)))
.catch(() => createFlash(__('An error occurred fetching the approval rules.')));
};
export const postRule = ({ rootState, dispatch }, rule) => {
const { rulesPath } = rootState.settings;
return axios
.post(rulesPath, mapApprovalRuleRequest(rule))
.then(() => dispatch('fetchRules'))
.catch(() => createFlash(__('An error occurred while adding approvers')));
};
export const putRule = ({ rootState, dispatch }, { id, ...newRule }) => {
const { rulesPath } = rootState.settings;
return axios
.put(`${rulesPath}/${id}`, mapApprovalRuleRequest(newRule))
.then(() => dispatch('fetchRules'))
.catch(() => createFlash(__('An error occurred while updating approvers')));
};
export const deleteRule = ({ rootState, dispatch }, id) => {
const { rulesPath } = rootState.settings;
return axios
.delete(`${rulesPath}/${id}`)
.then(() => dispatch('fetchRules'))
.catch(() => createFlash(__('An error occurred while deleting the approvers group')));
};
export const putFallbackRule = ({ rootState, dispatch }, fallback) => {
const { projectPath } = rootState.settings;
return axios
.put(projectPath, mapApprovalFallbackRuleRequest(fallback))
.then(() => dispatch('fetchRules'))
.catch(() => createFlash(__('An error occurred while deleting the approvers group')));
};
export const APPROVALS = 'approvals';
export const APPROVALS_MODAL = 'approvalsModal';
import base from '../base';
import * as actions from './actions';
export { APPROVALS, APPROVALS_MODAL } from './constants';
export default () => ({
...base(),
actions,
});
......@@ -11,8 +11,24 @@ export default () => {
documentationPath,
readLicensePoliciesEndpoint,
writeLicensePoliciesEndpoint,
projectId,
projectPath,
rulesPath,
settingsPath,
approvalsDocumentationPath,
lockedApprovalsRuleName,
} = el.dataset;
const store = createStore();
const storeSettings = {
projectId,
projectPath,
rulesPath,
settingsPath,
approvalsDocumentationPath,
lockedApprovalsRuleName,
};
const store = createStore(storeSettings);
store.dispatch('licenseManagement/setIsAdmin', Boolean(writeLicensePoliciesEndpoint));
store.dispatch('licenseManagement/setAPISettings', {
apiUrlManageLicenses: readLicensePoliciesEndpoint,
......
import Vue from 'vue';
import Vuex from 'vuex';
import createState from './state';
import modalModule from '~/vuex_shared/modules/modal';
import approvalsModule, {
APPROVALS,
APPROVALS_MODAL,
} from 'ee/approvals/stores/modules/license_compliance';
import mediator from './plugins/mediator';
import listModule from './modules/list';
......@@ -10,11 +18,14 @@ import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_compliance/store/const
Vue.use(Vuex);
export default () =>
export default (settings = {}) =>
new Vuex.Store({
state: createState(settings),
modules: {
[LICENSE_LIST]: listModule(),
[LICENSE_MANAGEMENT]: licenseManagementModule(),
[APPROVALS]: approvalsModule(),
[APPROVALS_MODAL]: modalModule(),
},
plugins: [mediator],
});
const DEFAULT_SETTINGS = {
prefix: 'license-approvals',
};
export default (settings = {}) => ({
settings: {
...DEFAULT_SETTINGS,
...settings,
},
});
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_compliance/store/constants';
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 DeleteConfirmationModal from './components/delete_confirmation_modal.vue';
import PaginatedList from '~/vue_shared/components/paginated_list.vue';
import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_compliance/store/constants';
import LicenseApprovals from '../../approvals/components/license_compliance/index.vue';
export default {
name: 'LicenseManagement',
......@@ -17,10 +18,12 @@ export default {
DeleteConfirmationModal,
AdminLicenseManagementRow,
LicenseManagementRow,
GlDeprecatedButton,
GlButton,
GlLoadingIcon,
PaginatedList,
LicenseApprovals,
},
mixins: [glFeatureFlagsMixin()],
data() {
return {
formIsOpen: false,
......@@ -37,6 +40,9 @@ export default {
'hasPendingLicenses',
'isAddingNewLicense',
]),
hasLicenseApprovals() {
return Boolean(this.glFeatures.licenseApprovals);
},
showLoadingSpinner() {
return this.isLoadingManagedLicenses && !this.hasPendingLicenses;
},
......@@ -82,16 +88,19 @@ export default {
data-qa-selector="license_compliance_list"
>
<template #header>
<gl-deprecated-button
v-if="isAdmin"
class="js-open-form order-1"
:disabled="formIsOpen"
variant="success"
data-qa-selector="license_add_button"
@click="openAddLicenseForm"
>
{{ s__('LicenseCompliance|Add a license') }}
</gl-deprecated-button>
<div v-if="isAdmin" class="order-1 gl-display-flex gl-align-items-center">
<gl-button
class="js-open-form"
:disabled="formIsOpen"
variant="success"
data-qa-selector="license_add_button"
@click="openAddLicenseForm"
>
{{ s__('LicenseCompliance|Add a license') }}
</gl-button>
<license-approvals v-if="hasLicenseApprovals" class="gl-ml-3" />
</div>
<template v-else>
<div
......
......@@ -6,6 +6,7 @@ module Projects
before_action :authorize_admin_software_license_policy!, only: [:create, :update]
before_action do
push_frontend_feature_flag(:license_policy_list, default_enabled: true)
push_frontend_feature_flag(:license_approvals, default_enabled: false)
end
def index
......@@ -101,7 +102,13 @@ module Projects
write_license_policies_endpoint: write_license_policies_endpoint,
documentation_path: help_page_path('user/compliance/license_compliance/index'),
empty_state_svg_path: helpers.image_path('illustrations/Dependency-list-empty-state.svg'),
software_licenses: SoftwareLicense.unclassified_licenses_for(project).pluck_names
software_licenses: SoftwareLicense.unclassified_licenses_for(project).pluck_names,
project_id: @project.id,
project_path: expose_path(api_v4_projects_path(id: @project.id)),
rules_path: expose_path(api_v4_projects_approval_settings_rules_path(id: @project.id)),
settings_path: expose_path(api_v4_projects_approval_settings_path(id: @project.id)),
approvals_documentation_path: help_page_path('user/application_security/index', anchor: 'enabling-license-approvals-within-a-project'),
locked_approvals_rule_name: ApprovalRuleLike::DEFAULT_NAME_FOR_LICENSE_REPORT
}
end
end
......
......@@ -45,6 +45,12 @@ RSpec.describe Projects::LicensesController do
expect(licenses_app_data[:documentation_path]).to eql(help_page_path('user/compliance/license_compliance/index'))
expect(licenses_app_data[:empty_state_svg_path]).to eql(controller.helpers.image_path('illustrations/Dependency-list-empty-state.svg'))
expect(licenses_app_data[:software_licenses]).to eql([apache_license.name, mit_license.name])
expect(licenses_app_data[:project_id]).to eql(project.id)
expect(licenses_app_data[:project_path]).to eql(controller.helpers.api_v4_projects_path(id: project.id))
expect(licenses_app_data[:rules_path]).to eql(controller.helpers.api_v4_projects_approval_settings_rules_path(id: project.id))
expect(licenses_app_data[:settings_path]).to eql(controller.helpers.api_v4_projects_approval_settings_path(id: project.id))
expect(licenses_app_data[:approvals_documentation_path]).to eql(help_page_path('user/application_security/index', anchor: 'enabling-license-approvals-within-a-project'))
expect(licenses_app_data[:locked_approvals_rule_name]).to eql(ApprovalRuleLike::DEFAULT_NAME_FOR_LICENSE_REPORT)
end
end
......
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui';
import LicenseComplianceApprovals from 'ee/approvals/components/license_compliance/index.vue';
import modalModule from '~/vuex_shared/modules/modal';
import approvalsLicenceComplianceModule, {
APPROVALS,
APPROVALS_MODAL,
} from 'ee/approvals/stores/modules/license_compliance';
const localVue = createLocalVue();
localVue.use(Vuex);
const TEST_APPROVALS_DOCUMENTATION_PATH = 'http://foo.bar';
const TEST_LOCKED_APPROVALS_RULE_NAME = 'License-Check';
describe('EE Approvals LicenseCompliance', () => {
let wrapper;
let store;
const createStore = () => {
store = {
state: {
settings: {
approvalsDocumentationPath: TEST_APPROVALS_DOCUMENTATION_PATH,
lockedApprovalsRuleName: TEST_LOCKED_APPROVALS_RULE_NAME,
},
},
modules: {
[APPROVALS]: approvalsLicenceComplianceModule(),
[APPROVALS_MODAL]: modalModule(),
},
};
};
const createWrapper = () => {
wrapper = mount(LicenseComplianceApprovals, {
localVue,
store: new Vuex.Store(store),
stubs: {
'rule-form': true,
},
});
};
beforeEach(() => {
createStore();
jest.spyOn(store.modules.approvals.actions, 'fetchRules').mockImplementation();
jest.spyOn(store.modules.approvalsModal.actions, 'open');
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findByHrefAttribute = href => wrapper.find(`[href="${href}"]`);
const findOpenModalButton = () => wrapper.find('button');
const findLoadingIndicator = () => wrapper.find('[aria-label="loading"]');
const findInformationIcon = () => wrapper.find(GlIcon);
const findLicenseCheckStatus = () => wrapper.find('[data-testid="licenseCheckStatus"]');
describe('when created', () => {
it('fetches approval rules', () => {
expect(store.modules.approvals.actions.fetchRules).not.toHaveBeenCalled();
createWrapper();
expect(store.modules.approvals.actions.fetchRules).toHaveBeenCalledTimes(1);
});
});
describe('when loading', () => {
beforeEach(() => {
store.modules.approvals.state.isLoading = true;
createWrapper();
});
it('renders the open-modal button with an active loading state', () => {
expect(findOpenModalButton().props('loading')).toBe(true);
});
it('disables the open-modal button', () => {
expect(findOpenModalButton().attributes('disabled')).toBeTruthy();
});
it('renders a loading indicator', () => {
expect(findLoadingIndicator().exists()).toBe(true);
});
});
describe('when data has loaded', () => {
const mockLicenseCheckRule = { name: TEST_LOCKED_APPROVALS_RULE_NAME };
beforeEach(() => {
store.modules.approvals.state.rules = [mockLicenseCheckRule];
createWrapper();
});
it('renders the open-modal button without an active loading state', () => {
expect(findOpenModalButton().props('loading')).toBe(false);
});
it('does not render a loading indicator', () => {
expect(findLoadingIndicator().exists()).toBe(false);
});
it('renders an information icon', () => {
expect(findInformationIcon().props('name')).toBe('information');
});
it('opens the link to the documentation page in a new tab', () => {
expect(findByHrefAttribute(TEST_APPROVALS_DOCUMENTATION_PATH).attributes('target')).toBe(
'_blank',
);
});
it('opens a modal when the open-modal button is clicked', () => {
expect(store.modules.approvalsModal.actions.open).not.toHaveBeenCalled();
findOpenModalButton().trigger('click');
expect(store.modules.approvalsModal.actions.open).toHaveBeenCalledWith(
expect.any(Object),
mockLicenseCheckRule,
undefined,
);
});
});
describe.each`
givenApprovalRule | expectedStatus
${{}} | ${'inactive'}
${{ name: 'Foo' }} | ${'inactive'}
${{ name: TEST_LOCKED_APPROVALS_RULE_NAME }} | ${'active'}
`('when approval rule is "$givenApprovalRule.name"', ({ givenApprovalRule, expectedStatus }) => {
beforeEach(() => {
store.modules.approvals.state.rules = [givenApprovalRule];
createWrapper();
});
it(`renders the status as "${expectedStatus}"`, () => {
expect(findLicenseCheckStatus().text()).toBe(`License Approvals are ${expectedStatus}`);
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlSprintf } from '@gitlab/ui';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
import LicenseComplianceModal from 'ee/approvals/components/license_compliance/modal.vue';
import { APPROVALS_MODAL } from 'ee/approvals/stores/modules/license_compliance';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('EE Approvals LicenseCompliance Modal', () => {
let wrapper;
let store;
const mocks = {
actions: {
modalHide: jest.fn(),
},
RuleForm: {
template: '<div>mock-rule-form</div>',
props: ['initRule'],
methods: {
submit: jest.fn(),
},
},
approvalsDocumentationPath: 'http://foo.bar',
};
const createStore = () => {
const storeOptions = {
state: {
settings: {
approvalsDocumentationPath: mocks.approvalsDocumentationPath,
},
},
modules: {
[APPROVALS_MODAL]: {
namespaced: true,
actions: {
hide: mocks.actions.modalHide,
},
state: {
isVisible: false,
data: {},
},
},
},
};
store = new Vuex.Store(storeOptions);
};
const createWrapper = () => {
wrapper = shallowMount(LicenseComplianceModal, {
localVue,
store,
stubs: {
GlModalVuex,
GlSprintf,
RuleForm: mocks.RuleForm,
},
});
};
beforeEach(() => {
createStore();
createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findByHref = href => wrapper.find(`[href="${href}"`);
const findModal = () => wrapper.find(GlModalVuex);
const findRuleForm = () => wrapper.find(mocks.RuleForm);
const findInformationIcon = () => wrapper.find('[name="question"]');
const findOkButton = () => wrapper.find('[name="ok"]');
const findCancelButton = () => wrapper.find('[name="cancel"]');
describe('modal title', () => {
it.each`
givenStoreData | expectTitleStartsWith
${null} | ${'Add'}
${{ name: 'foo' }} | ${'Update'}
`('starts with $titleStartsWith', ({ givenStoreData, expectTitleStartsWith }) => {
store.state[APPROVALS_MODAL].data = givenStoreData;
createWrapper();
expect(
findModal()
.attributes('title')
.startsWith(expectTitleStartsWith),
).toBe(true);
});
});
describe('rule form', () => {
it(`receives the modal's states data so it can display and edit the containing rule`, () => {
expect(findRuleForm().props('initRule')).toBe(store.state.approvalsModal.data);
});
});
describe('footer information section', () => {
it('contains an information icon', () => {
expect(findInformationIcon().exists()).toBe(true);
});
it('opens a link to the relevant documentation page in a new tab', () => {
expect(findByHref(mocks.approvalsDocumentationPath).attributes('target')).toBe('_blank');
});
});
describe('action buttons', () => {
it('submits the form when "ok" button is clicked', () => {
expect(mocks.RuleForm.methods.submit).not.toHaveBeenCalled();
findOkButton().vm.$emit('click');
expect(mocks.RuleForm.methods.submit).toHaveBeenCalledTimes(1);
});
it('hides the modal when the "ok" button is clicked', () => {
expect(mocks.actions.modalHide).not.toHaveBeenCalled();
findOkButton().vm.$emit('click');
expect(mocks.actions.modalHide).toHaveBeenCalledTimes(1);
});
it('hides the form when the "cancel" button is clicked', () => {
expect(mocks.actions.modalHide).not.toHaveBeenCalled();
findCancelButton().vm.$emit('click');
expect(mocks.actions.modalHide).toHaveBeenCalledTimes(1);
});
});
});
......@@ -27,6 +27,7 @@ const TEST_FALLBACK_RULE = {
approvalsRequired: 1,
isFallback: true,
};
const TEST_LOCKED_RULE_NAME = 'LOCKED_RULE';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -511,30 +512,41 @@ describe('EE Approvals RuleForm', () => {
store.state.settings.allowMultiRule = false;
});
it('hides name', () => {
createComponent();
describe('with locked rule name', () => {
beforeEach(() => {
store.state.settings.lockedApprovalsRuleName = TEST_LOCKED_RULE_NAME;
createComponent();
});
expect(findNameInput().exists()).toBe(true);
it('does not render the approval-rule name input', () => {
expect(findNameInput().exists()).toBe(false);
});
});
describe('with no init rule', () => {
describe.each`
lockedRuleName | expectedNameSubmitted
${TEST_LOCKED_RULE_NAME} | ${TEST_LOCKED_RULE_NAME}
${null} | ${'Default'}
`('with no init rule', ({ lockedRuleName, expectedNameSubmitted }) => {
beforeEach(() => {
store.state.settings.lockedApprovalsRuleName = lockedRuleName;
createComponent();
wrapper.vm.approvalsRequired = TEST_APPROVALS_REQUIRED;
});
describe('with approvers selected', () => {
beforeEach(done => {
beforeEach(() => {
wrapper.vm.approvers = TEST_APPROVERS;
wrapper.vm.submit();
localVue.nextTick(done);
return localVue.nextTick();
});
it('posts new rule', () => {
expect(actions.postRule).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
name: expectedNameSubmitted,
approvalsRequired: TEST_APPROVALS_REQUIRED,
users: TEST_APPROVERS.map(x => x.id),
}),
......@@ -544,10 +556,10 @@ describe('EE Approvals RuleForm', () => {
});
describe('without approvers', () => {
beforeEach(done => {
beforeEach(() => {
wrapper.vm.submit();
localVue.nextTick(done);
return localVue.nextTick();
});
it('puts fallback rule', () => {
......@@ -560,8 +572,13 @@ describe('EE Approvals RuleForm', () => {
});
});
describe('with init rule', () => {
describe.each`
lockedRuleName | inputName | expectedNameSubmitted
${TEST_LOCKED_RULE_NAME} | ${'Foo'} | ${TEST_LOCKED_RULE_NAME}
${null} | ${'Foo'} | ${'Foo'}
`('with init rule', ({ lockedRuleName, inputName, expectedNameSubmitted }) => {
beforeEach(() => {
store.state.settings.lockedApprovalsRuleName = lockedRuleName;
createComponent({
initRule: TEST_RULE,
});
......@@ -569,12 +586,13 @@ describe('EE Approvals RuleForm', () => {
});
describe('with empty name and empty approvers', () => {
beforeEach(done => {
beforeEach(() => {
wrapper.vm.name = '';
wrapper.vm.approvers = [];
wrapper.vm.submit();
localVue.nextTick(done);
return localVue.nextTick();
});
it('deletes rule', () => {
......@@ -596,7 +614,7 @@ describe('EE Approvals RuleForm', () => {
describe('with name and approvers', () => {
beforeEach(done => {
wrapper.vm.name = 'Bogus';
wrapper.vm.name = inputName;
wrapper.vm.approvers = TEST_APPROVERS;
wrapper.vm.submit();
......@@ -608,7 +626,7 @@ describe('EE Approvals RuleForm', () => {
expect.anything(),
expect.objectContaining({
id: TEST_RULE.id,
name: 'Bogus',
name: expectedNameSubmitted,
approvalsRequired: TEST_APPROVALS_REQUIRED,
users: TEST_APPROVERS.map(x => x.id),
}),
......
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from 'ee/approvals/stores/modules/license_compliance/actions';
import * as baseMutationTypes from 'ee/approvals/stores/modules/base/mutation_types';
import { mapApprovalSettingsResponse } from 'ee/approvals/mappers';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
jest.mock('~/flash');
describe('EE approvals license-compliance actions', () => {
let state;
let axiosMock;
const mocks = {
state: {
settingsPath: 'projects/9/approval_settings',
rulesPath: 'projects/9/approval_settings/rules',
projectPath: 'projects/9',
},
};
beforeEach(() => {
state = {
settings: {
settingsPath: mocks.state.settingsPath,
rulesPath: mocks.state.rulesPath,
projectPath: mocks.state.projectPath,
},
};
axiosMock = new MockAdapter(axios);
});
describe('receiveRulesSuccess', () => {
it('sets rules to given payload and "loading" to false', () => {
const payload = {};
return testAction(actions.receiveRulesSuccess, payload, state, [
{
type: baseMutationTypes.SET_APPROVAL_SETTINGS,
payload,
},
{
type: baseMutationTypes.SET_LOADING,
payload: false,
},
]);
});
});
describe('fetchRules', () => {
it('sets "loading" to be true and dispatches "receiveRuleSuccess"', () => {
const responseData = { rules: [] };
axiosMock.onGet(mocks.state.settingsPath).replyOnce(200, responseData);
return testAction(
actions.fetchRules,
null,
state,
[
{
type: baseMutationTypes.SET_LOADING,
payload: true,
},
],
[
{
type: 'receiveRulesSuccess',
payload: mapApprovalSettingsResponse(responseData),
},
],
);
});
it('creates a flash error if the request is not successful', async () => {
axiosMock.onGet(mocks.state.settingsPath).replyOnce(500);
await actions.fetchRules({ rootState: state, dispatch: () => {}, commit: () => {} });
expect(createFlash).toHaveBeenNthCalledWith(1, expect.any(String));
});
});
describe('postRule', () => {
it('posts correct data and dispatches "fetchRules" when request is successful', () => {
const rule = {
name: 'Foo',
approvalsRequired: 1,
users: [8, 9],
groups: [7],
};
axiosMock.onPost(mocks.state.rulesPath).replyOnce(200);
return testAction(
actions.postRule,
rule,
state,
[],
[
{
type: 'fetchRules',
},
],
() => {
expect(axiosMock.history.post[0].data).toBe(
'{"name":"Foo","approvals_required":1,"users":[8,9],"groups":[7]}',
);
},
);
});
it('creates a flash error if the request is not successful', async () => {
axiosMock.onPost(mocks.state.settingsPath).replyOnce(500);
await actions.postRule({ rootState: state, dispatch: () => {}, commit: () => {} }, []);
expect(createFlash).toHaveBeenNthCalledWith(1, expect.any(String));
});
});
describe('putRule', () => {
const id = 4;
const putUrl = `${mocks.state.rulesPath}/${4}`;
it('puts correct data and dispatches "fetchRules" when request is successful', () => {
const payload = {
id,
name: 'Foo',
approvalsRequired: 1,
users: [8, 9],
groups: [7],
};
axiosMock.onPut(putUrl).replyOnce(200);
return testAction(
actions.putRule,
payload,
state,
[],
[
{
type: 'fetchRules',
},
],
() => {
expect(axiosMock.history.put[0].data).toBe(
'{"name":"Foo","approvals_required":1,"users":[8,9],"groups":[7]}',
);
},
);
});
it('creates a flash error if the request is not successful', async () => {
axiosMock.onPut(putUrl).replyOnce(500);
await actions.putRule({ rootState: state, dispatch: () => {} }, { id });
expect(createFlash).toHaveBeenNthCalledWith(1, expect.any(String));
});
});
describe('deleteRule', () => {
const id = 0;
const deleteUrl = `${mocks.state.rulesPath}/${id}`;
it('dispatches "fetchRules" when the deletion is successful', () => {
axiosMock.onDelete(deleteUrl).replyOnce(200);
return testAction(
actions.deleteRule,
id,
state,
[],
[
{
type: 'fetchRules',
},
],
);
});
it('creates a flash error if the request is not successful', async () => {
axiosMock.onDelete(deleteUrl).replyOnce(500);
await actions.deleteRule({ rootState: state, dispatch: () => {} }, deleteUrl);
expect(createFlash).toHaveBeenNthCalledWith(1, expect.any(String));
});
});
describe('putFallbackRule', () => {
it('puts correct fallback-data and dispatches "fetchRules" when request is successful', () => {
const payload = {
name: 'Foo',
approvalsRequired: 1,
users: [8, 9],
groups: [7],
};
axiosMock.onPut(mocks.state.projectPath).replyOnce(200);
return testAction(
actions.putFallbackRule,
payload,
state,
[],
[
{
type: 'fetchRules',
},
],
() => {
expect(axiosMock.history.put[0].data).toBe('{"fallback_approvals_required":1}');
},
);
});
it('creates a flash error if the request is not successful', async () => {
axiosMock.onPut(mocks.state.projectPath).replyOnce(500);
await actions.putFallbackRule({ rootState: state, dispatch: () => {} }, {});
expect(createFlash).toHaveBeenNthCalledWith(1, expect.any(String));
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import Vue from 'vue';
import Vuex from 'vuex';
import LicenseManagement from 'ee/vue_shared/license_compliance/license_management.vue';
......@@ -7,6 +7,7 @@ import AdminLicenseManagementRow from 'ee/vue_shared/license_compliance/componen
import LicenseManagementRow from 'ee/vue_shared/license_compliance/components/license_management_row.vue';
import AddLicenseForm from 'ee/vue_shared/license_compliance/components/add_license_form.vue';
import DeleteConfirmationModal from 'ee/vue_shared/license_compliance/components/delete_confirmation_modal.vue';
import LicenseComplianceApprovals from 'ee/approvals/components/license_compliance/index.vue';
import { approvedLicense, blacklistedLicense } from './mock_data';
Vue.use(Vuex);
......@@ -29,7 +30,7 @@ const PaginatedListMock = {
const noop = () => {};
const createComponent = ({ state, getters, props, actionMocks, isAdmin }) => {
const createComponent = ({ state, getters, props, actionMocks, isAdmin, options }) => {
const fakeStore = new Vuex.Store({
modules: {
licenseManagement: {
......@@ -63,6 +64,7 @@ const createComponent = ({ state, getters, props, actionMocks, isAdmin }) => {
PaginatedList: PaginatedListMock,
},
store: fakeStore,
...options,
});
};
......@@ -124,14 +126,14 @@ describe('License Management', () => {
describe('permission based functionality', () => {
describe('when admin', () => {
it('should invoke `setLicenseAprroval` action on `addLicense` event on form only', () => {
it('should invoke `setLicenseApproval` action on `addLicense` event on form only', () => {
const setLicenseApprovalMock = jest.fn();
createComponent({
state: { isLoadingManagedLicenses: false },
actionMocks: { setLicenseApproval: setLicenseApprovalMock },
isAdmin: true,
});
wrapper.find(GlDeprecatedButton).vm.$emit('click');
wrapper.find(GlButton).vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
wrapper.find(AddLicenseForm).vm.$emit('addLicense');
......@@ -139,23 +141,44 @@ describe('License Management', () => {
});
});
describe.each([true, false])(
'with licenseApprovals feature flag set to "%p"',
licenseApprovalsEnabled => {
beforeEach(() => {
createComponent({
state: { isLoadingManagedLicenses: false },
isAdmin: true,
options: {
provide: {
glFeatures: { licenseApprovals: licenseApprovalsEnabled },
},
},
});
});
it('should render the license-approvals section accordingly', () => {
expect(wrapper.find(LicenseComplianceApprovals).exists()).toBe(licenseApprovalsEnabled);
});
},
);
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', () => {
wrapper.find(GlDeprecatedButton).vm.$emit('click');
wrapper.find(GlButton).vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(AddLicenseForm).exists()).toBe(true);
expect(wrapper.find(GlDeprecatedButton).attributes('disabled')).toBe('true');
expect(wrapper.find(GlButton).attributes('disabled')).toBe('true');
});
});
it('should not render the form if the form is closed and have active button', () => {
expect(wrapper.find(AddLicenseForm).exists()).toBe(false);
expect(wrapper.find(GlDeprecatedButton).attributes('disabled')).not.toBe('true');
expect(wrapper.find(GlButton).attributes('disabled')).not.toBe('true');
});
it('should render delete confirmation modal', () => {
......@@ -168,15 +191,16 @@ describe('License Management', () => {
});
});
});
describe('when developer', () => {
it('should not invoke `setLicenseAprroval` action or `addLicense` event on form', () => {
it('should not invoke `setLicenseApproval` action or `addLicense` event on form', () => {
const setLicenseApprovalMock = jest.fn();
createComponent({
state: { isLoadingManagedLicenses: false },
actionMocks: { setLicenseApproval: setLicenseApprovalMock },
isAdmin: false,
});
expect(wrapper.find(GlDeprecatedButton).exists()).toBe(false);
expect(wrapper.find(GlButton).exists()).toBe(false);
expect(wrapper.find(AddLicenseForm).exists()).toBe(false);
expect(setLicenseApprovalMock).not.toHaveBeenCalled();
});
......@@ -186,9 +210,13 @@ describe('License Management', () => {
createComponent({ state: { isLoadingManagedLicenses: false, isAdmin: false } });
});
it('should not render the approval section', () => {
expect(wrapper.find(LicenseComplianceApprovals).exists()).toBe(false);
});
it('should not render the form', () => {
expect(wrapper.find(AddLicenseForm).exists()).toBe(false);
expect(wrapper.find(GlDeprecatedButton).exists()).toBe(false);
expect(wrapper.find(GlButton).exists()).toBe(false);
});
it('should not render delete confirmation modal', () => {
......
......@@ -1391,6 +1391,9 @@ msgstr ""
msgid "Add approval rule"
msgstr ""
msgid "Add approvers"
msgstr ""
msgid "Add bold text"
msgstr ""
......@@ -2281,6 +2284,9 @@ msgstr ""
msgid "An error occurred while acknowledging the notification. Refresh the page and try again."
msgstr ""
msgid "An error occurred while adding approvers"
msgstr ""
msgid "An error occurred while adding formatted title for epic"
msgstr ""
......@@ -13261,6 +13267,12 @@ msgstr ""
msgid "License-Check"
msgstr ""
msgid "LicenseCompliance|%{docLinkStart}License Approvals%{docLinkEnd} are active"
msgstr ""
msgid "LicenseCompliance|%{docLinkStart}License Approvals%{docLinkEnd} are inactive"
msgstr ""
msgid "LicenseCompliance|Add a license"
msgstr ""
......@@ -13282,9 +13294,15 @@ msgstr ""
msgid "LicenseCompliance|Deny"
msgstr ""
msgid "LicenseCompliance|Learn more about %{linkStart}License Approvals%{linkEnd}"
msgstr ""
msgid "LicenseCompliance|License"
msgstr ""
msgid "LicenseCompliance|License Approvals"
msgstr ""
msgid "LicenseCompliance|License Compliance detected %d license and policy violation for the source branch only; approval required"
msgid_plural "LicenseCompliance|License Compliance detected %d licenses and policy violations for the source branch only; approval required"
msgstr[0] ""
......@@ -24573,6 +24591,9 @@ msgstr ""
msgid "Update approval rule"
msgstr ""
msgid "Update approvers"
msgstr ""
msgid "Update failed"
msgstr ""
......@@ -27186,6 +27207,9 @@ msgstr ""
msgid "load it anyway"
msgstr ""
msgid "loading"
msgstr ""
msgid "locked by %{path_lock_user_name} %{created_at}"
msgstr ""
......
......@@ -38,6 +38,9 @@ describe('GlModalVuex', () => {
localVue,
store,
propsData,
stubs: {
GlModal,
},
});
};
......@@ -148,4 +151,29 @@ describe('GlModalVuex', () => {
.then(done)
.catch(done.fail);
});
it.each(['ok', 'cancel'])(
'passes an "%s" handler to the "modal-footer" slot scope',
handlerName => {
state.isVisible = true;
const modalFooterSlotContent = jest.fn();
factory({
scopedSlots: {
'modal-footer': modalFooterSlotContent,
},
});
const handler = modalFooterSlotContent.mock.calls[0][0][handlerName];
expect(wrapper.emitted(handlerName)).toBeFalsy();
expect(actions.hide).not.toHaveBeenCalled();
handler();
expect(actions.hide).toHaveBeenCalledTimes(1);
expect(wrapper.emitted(handlerName)).toBeTruthy();
},
);
});
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