Commit f68a8a31 authored by Mark Florian's avatar Mark Florian

Merge branch '32149-add-license-compliance-approvals' into 'master'

Display 'License-Check' approval rule in the license compliance section

See merge request gitlab-org/gitlab!34588
parents da475f21 86b25f9f
...@@ -52,6 +52,14 @@ export default { ...@@ -52,6 +52,14 @@ export default {
// $root.$emit is a workaround because other b-modal approaches don't work yet with gl-modal // $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); this.$root.$emit('bv::hide::modal', this.modalId);
}, },
cancel() {
this.$emit('cancel');
this.syncHide();
},
ok() {
this.$emit('ok');
this.syncHide();
},
}, },
}; };
</script> </script>
...@@ -65,5 +73,6 @@ export default { ...@@ -65,5 +73,6 @@ export default {
@hidden="syncHide" @hidden="syncHide"
> >
<slot></slot> <slot></slot>
<slot slot="modal-footer" name="modal-footer" :ok="ok" :cancel="cancel"></slot>
</gl-modal> </gl-modal>
</template> </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 { ...@@ -128,6 +128,9 @@ export default {
isPersisted() { isPersisted() {
return this.initRule && this.initRule.id; return this.initRule && this.initRule.id;
}, },
isNameVisible() {
return !this.settings.lockedApprovalsRuleName;
},
isNameDisabled() { isNameDisabled() {
return this.isPersisted && READONLY_NAMES.includes(this.name); return this.isPersisted && READONLY_NAMES.includes(this.name);
}, },
...@@ -137,7 +140,7 @@ export default { ...@@ -137,7 +140,7 @@ export default {
submissionData() { submissionData() {
return { return {
id: this.initRule && this.initRule.id, id: this.initRule && this.initRule.id,
name: this.name || DEFAULT_NAME, name: this.settings.lockedApprovalsRuleName || this.name || DEFAULT_NAME,
approvalsRequired: this.approvalsRequired, approvalsRequired: this.approvalsRequired,
users: this.userIds, users: this.userIds,
groups: this.groupIds, groups: this.groupIds,
...@@ -266,7 +269,7 @@ export default { ...@@ -266,7 +269,7 @@ export default {
<template> <template>
<form novalidate @submit.prevent.stop="submit"> <form novalidate @submit.prevent.stop="submit">
<div class="row"> <div class="row">
<div class="form-group col-sm-6"> <div v-if="isNameVisible" class="form-group col-sm-6">
<label class="label-wrapper"> <label class="label-wrapper">
<span class="mb-2 bold inline">{{ s__('ApprovalRule|Rule name') }}</span> <span class="mb-2 bold inline">{{ s__('ApprovalRule|Rule name') }}</span>
<input <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 () => { ...@@ -11,8 +11,24 @@ export default () => {
documentationPath, documentationPath,
readLicensePoliciesEndpoint, readLicensePoliciesEndpoint,
writeLicensePoliciesEndpoint, writeLicensePoliciesEndpoint,
projectId,
projectPath,
rulesPath,
settingsPath,
approvalsDocumentationPath,
lockedApprovalsRuleName,
} = el.dataset; } = 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/setIsAdmin', Boolean(writeLicensePoliciesEndpoint));
store.dispatch('licenseManagement/setAPISettings', { store.dispatch('licenseManagement/setAPISettings', {
apiUrlManageLicenses: readLicensePoliciesEndpoint, apiUrlManageLicenses: readLicensePoliciesEndpoint,
......
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; 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 mediator from './plugins/mediator';
import listModule from './modules/list'; import listModule from './modules/list';
...@@ -10,11 +18,14 @@ import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_compliance/store/const ...@@ -10,11 +18,14 @@ import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_compliance/store/const
Vue.use(Vuex); Vue.use(Vuex);
export default () => export default (settings = {}) =>
new Vuex.Store({ new Vuex.Store({
state: createState(settings),
modules: { modules: {
[LICENSE_LIST]: listModule(), [LICENSE_LIST]: listModule(),
[LICENSE_MANAGEMENT]: licenseManagementModule(), [LICENSE_MANAGEMENT]: licenseManagementModule(),
[APPROVALS]: approvalsModule(),
[APPROVALS_MODAL]: modalModule(),
}, },
plugins: [mediator], plugins: [mediator],
}); });
const DEFAULT_SETTINGS = {
prefix: 'license-approvals',
};
export default (settings = {}) => ({
settings: {
...DEFAULT_SETTINGS,
...settings,
},
});
<script> <script>
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale'; 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 AddLicenseForm from './components/add_license_form.vue';
import AdminLicenseManagementRow from './components/admin_license_management_row.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 LicenseApprovals from '../../approvals/components/license_compliance/index.vue';
import { LICENSE_MANAGEMENT } from 'ee/vue_shared/license_compliance/store/constants';
export default { export default {
name: 'LicenseManagement', name: 'LicenseManagement',
...@@ -17,10 +18,12 @@ export default { ...@@ -17,10 +18,12 @@ export default {
DeleteConfirmationModal, DeleteConfirmationModal,
AdminLicenseManagementRow, AdminLicenseManagementRow,
LicenseManagementRow, LicenseManagementRow,
GlDeprecatedButton, GlButton,
GlLoadingIcon, GlLoadingIcon,
PaginatedList, PaginatedList,
LicenseApprovals,
}, },
mixins: [glFeatureFlagsMixin()],
data() { data() {
return { return {
formIsOpen: false, formIsOpen: false,
...@@ -37,6 +40,9 @@ export default { ...@@ -37,6 +40,9 @@ export default {
'hasPendingLicenses', 'hasPendingLicenses',
'isAddingNewLicense', 'isAddingNewLicense',
]), ]),
hasLicenseApprovals() {
return Boolean(this.glFeatures.licenseApprovals);
},
showLoadingSpinner() { showLoadingSpinner() {
return this.isLoadingManagedLicenses && !this.hasPendingLicenses; return this.isLoadingManagedLicenses && !this.hasPendingLicenses;
}, },
...@@ -82,16 +88,19 @@ export default { ...@@ -82,16 +88,19 @@ export default {
data-qa-selector="license_compliance_list" data-qa-selector="license_compliance_list"
> >
<template #header> <template #header>
<gl-deprecated-button <div v-if="isAdmin" class="order-1 gl-display-flex gl-align-items-center">
v-if="isAdmin" <gl-button
class="js-open-form order-1" class="js-open-form"
:disabled="formIsOpen" :disabled="formIsOpen"
variant="success" variant="success"
data-qa-selector="license_add_button" data-qa-selector="license_add_button"
@click="openAddLicenseForm" @click="openAddLicenseForm"
> >
{{ s__('LicenseCompliance|Add a license') }} {{ s__('LicenseCompliance|Add a license') }}
</gl-deprecated-button> </gl-button>
<license-approvals v-if="hasLicenseApprovals" class="gl-ml-3" />
</div>
<template v-else> <template v-else>
<div <div
......
...@@ -6,6 +6,7 @@ module Projects ...@@ -6,6 +6,7 @@ module Projects
before_action :authorize_admin_software_license_policy!, only: [:create, :update] before_action :authorize_admin_software_license_policy!, only: [:create, :update]
before_action do before_action do
push_frontend_feature_flag(:license_policy_list, default_enabled: true) push_frontend_feature_flag(:license_policy_list, default_enabled: true)
push_frontend_feature_flag(:license_approvals, default_enabled: false)
end end
def index def index
...@@ -101,7 +102,13 @@ module Projects ...@@ -101,7 +102,13 @@ module Projects
write_license_policies_endpoint: write_license_policies_endpoint, write_license_policies_endpoint: write_license_policies_endpoint,
documentation_path: help_page_path('user/compliance/license_compliance/index'), documentation_path: help_page_path('user/compliance/license_compliance/index'),
empty_state_svg_path: helpers.image_path('illustrations/Dependency-list-empty-state.svg'), 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
end end
......
...@@ -45,6 +45,12 @@ RSpec.describe Projects::LicensesController do ...@@ -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[: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[: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[: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
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 = { ...@@ -27,6 +27,7 @@ const TEST_FALLBACK_RULE = {
approvalsRequired: 1, approvalsRequired: 1,
isFallback: true, isFallback: true,
}; };
const TEST_LOCKED_RULE_NAME = 'LOCKED_RULE';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -511,30 +512,41 @@ describe('EE Approvals RuleForm', () => { ...@@ -511,30 +512,41 @@ describe('EE Approvals RuleForm', () => {
store.state.settings.allowMultiRule = false; store.state.settings.allowMultiRule = false;
}); });
it('hides name', () => { describe('with locked rule name', () => {
beforeEach(() => {
store.state.settings.lockedApprovalsRuleName = TEST_LOCKED_RULE_NAME;
createComponent(); 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(() => { beforeEach(() => {
store.state.settings.lockedApprovalsRuleName = lockedRuleName;
createComponent(); createComponent();
wrapper.vm.approvalsRequired = TEST_APPROVALS_REQUIRED; wrapper.vm.approvalsRequired = TEST_APPROVALS_REQUIRED;
}); });
describe('with approvers selected', () => { describe('with approvers selected', () => {
beforeEach(done => { beforeEach(() => {
wrapper.vm.approvers = TEST_APPROVERS; wrapper.vm.approvers = TEST_APPROVERS;
wrapper.vm.submit(); wrapper.vm.submit();
localVue.nextTick(done); return localVue.nextTick();
}); });
it('posts new rule', () => { it('posts new rule', () => {
expect(actions.postRule).toHaveBeenCalledWith( expect(actions.postRule).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
expect.objectContaining({ expect.objectContaining({
name: expectedNameSubmitted,
approvalsRequired: TEST_APPROVALS_REQUIRED, approvalsRequired: TEST_APPROVALS_REQUIRED,
users: TEST_APPROVERS.map(x => x.id), users: TEST_APPROVERS.map(x => x.id),
}), }),
...@@ -544,10 +556,10 @@ describe('EE Approvals RuleForm', () => { ...@@ -544,10 +556,10 @@ describe('EE Approvals RuleForm', () => {
}); });
describe('without approvers', () => { describe('without approvers', () => {
beforeEach(done => { beforeEach(() => {
wrapper.vm.submit(); wrapper.vm.submit();
localVue.nextTick(done); return localVue.nextTick();
}); });
it('puts fallback rule', () => { it('puts fallback rule', () => {
...@@ -560,8 +572,13 @@ describe('EE Approvals RuleForm', () => { ...@@ -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(() => { beforeEach(() => {
store.state.settings.lockedApprovalsRuleName = lockedRuleName;
createComponent({ createComponent({
initRule: TEST_RULE, initRule: TEST_RULE,
}); });
...@@ -569,12 +586,13 @@ describe('EE Approvals RuleForm', () => { ...@@ -569,12 +586,13 @@ describe('EE Approvals RuleForm', () => {
}); });
describe('with empty name and empty approvers', () => { describe('with empty name and empty approvers', () => {
beforeEach(done => { beforeEach(() => {
wrapper.vm.name = ''; wrapper.vm.name = '';
wrapper.vm.approvers = []; wrapper.vm.approvers = [];
wrapper.vm.submit(); wrapper.vm.submit();
localVue.nextTick(done); return localVue.nextTick();
}); });
it('deletes rule', () => { it('deletes rule', () => {
...@@ -596,7 +614,7 @@ describe('EE Approvals RuleForm', () => { ...@@ -596,7 +614,7 @@ describe('EE Approvals RuleForm', () => {
describe('with name and approvers', () => { describe('with name and approvers', () => {
beforeEach(done => { beforeEach(done => {
wrapper.vm.name = 'Bogus'; wrapper.vm.name = inputName;
wrapper.vm.approvers = TEST_APPROVERS; wrapper.vm.approvers = TEST_APPROVERS;
wrapper.vm.submit(); wrapper.vm.submit();
...@@ -608,7 +626,7 @@ describe('EE Approvals RuleForm', () => { ...@@ -608,7 +626,7 @@ describe('EE Approvals RuleForm', () => {
expect.anything(), expect.anything(),
expect.objectContaining({ expect.objectContaining({
id: TEST_RULE.id, id: TEST_RULE.id,
name: 'Bogus', name: expectedNameSubmitted,
approvalsRequired: TEST_APPROVALS_REQUIRED, approvalsRequired: TEST_APPROVALS_REQUIRED,
users: TEST_APPROVERS.map(x => x.id), 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 { shallowMount } from '@vue/test-utils';
import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import LicenseManagement from 'ee/vue_shared/license_compliance/license_management.vue'; import LicenseManagement from 'ee/vue_shared/license_compliance/license_management.vue';
...@@ -7,6 +7,7 @@ import AdminLicenseManagementRow from 'ee/vue_shared/license_compliance/componen ...@@ -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 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 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 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'; import { approvedLicense, blacklistedLicense } from './mock_data';
Vue.use(Vuex); Vue.use(Vuex);
...@@ -29,7 +30,7 @@ const PaginatedListMock = { ...@@ -29,7 +30,7 @@ const PaginatedListMock = {
const noop = () => {}; const noop = () => {};
const createComponent = ({ state, getters, props, actionMocks, isAdmin }) => { const createComponent = ({ state, getters, props, actionMocks, isAdmin, options }) => {
const fakeStore = new Vuex.Store({ const fakeStore = new Vuex.Store({
modules: { modules: {
licenseManagement: { licenseManagement: {
...@@ -63,6 +64,7 @@ const createComponent = ({ state, getters, props, actionMocks, isAdmin }) => { ...@@ -63,6 +64,7 @@ const createComponent = ({ state, getters, props, actionMocks, isAdmin }) => {
PaginatedList: PaginatedListMock, PaginatedList: PaginatedListMock,
}, },
store: fakeStore, store: fakeStore,
...options,
}); });
}; };
...@@ -124,14 +126,14 @@ describe('License Management', () => { ...@@ -124,14 +126,14 @@ describe('License Management', () => {
describe('permission based functionality', () => { describe('permission based functionality', () => {
describe('when admin', () => { 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(); const setLicenseApprovalMock = jest.fn();
createComponent({ createComponent({
state: { isLoadingManagedLicenses: false }, state: { isLoadingManagedLicenses: false },
actionMocks: { setLicenseApproval: setLicenseApprovalMock }, actionMocks: { setLicenseApproval: setLicenseApprovalMock },
isAdmin: true, isAdmin: true,
}); });
wrapper.find(GlDeprecatedButton).vm.$emit('click'); wrapper.find(GlButton).vm.$emit('click');
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
wrapper.find(AddLicenseForm).vm.$emit('addLicense'); wrapper.find(AddLicenseForm).vm.$emit('addLicense');
...@@ -139,23 +141,44 @@ describe('License Management', () => { ...@@ -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', () => { describe('when not loading', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ state: { isLoadingManagedLicenses: false }, isAdmin: true }); 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', () => {
wrapper.find(GlDeprecatedButton).vm.$emit('click'); wrapper.find(GlButton).vm.$emit('click');
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(AddLicenseForm).exists()).toBe(true); 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', () => { 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(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', () => { it('should render delete confirmation modal', () => {
...@@ -168,15 +191,16 @@ describe('License Management', () => { ...@@ -168,15 +191,16 @@ describe('License Management', () => {
}); });
}); });
}); });
describe('when developer', () => { 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(); const setLicenseApprovalMock = jest.fn();
createComponent({ createComponent({
state: { isLoadingManagedLicenses: false }, state: { isLoadingManagedLicenses: false },
actionMocks: { setLicenseApproval: setLicenseApprovalMock }, actionMocks: { setLicenseApproval: setLicenseApprovalMock },
isAdmin: false, isAdmin: false,
}); });
expect(wrapper.find(GlDeprecatedButton).exists()).toBe(false); expect(wrapper.find(GlButton).exists()).toBe(false);
expect(wrapper.find(AddLicenseForm).exists()).toBe(false); expect(wrapper.find(AddLicenseForm).exists()).toBe(false);
expect(setLicenseApprovalMock).not.toHaveBeenCalled(); expect(setLicenseApprovalMock).not.toHaveBeenCalled();
}); });
...@@ -186,9 +210,13 @@ describe('License Management', () => { ...@@ -186,9 +210,13 @@ describe('License Management', () => {
createComponent({ state: { isLoadingManagedLicenses: false, isAdmin: false } }); 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', () => { it('should not render the form', () => {
expect(wrapper.find(AddLicenseForm).exists()).toBe(false); 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', () => { it('should not render delete confirmation modal', () => {
......
...@@ -1391,6 +1391,9 @@ msgstr "" ...@@ -1391,6 +1391,9 @@ msgstr ""
msgid "Add approval rule" msgid "Add approval rule"
msgstr "" msgstr ""
msgid "Add approvers"
msgstr ""
msgid "Add bold text" msgid "Add bold text"
msgstr "" msgstr ""
...@@ -2281,6 +2284,9 @@ msgstr "" ...@@ -2281,6 +2284,9 @@ msgstr ""
msgid "An error occurred while acknowledging the notification. Refresh the page and try again." msgid "An error occurred while acknowledging the notification. Refresh the page and try again."
msgstr "" msgstr ""
msgid "An error occurred while adding approvers"
msgstr ""
msgid "An error occurred while adding formatted title for epic" msgid "An error occurred while adding formatted title for epic"
msgstr "" msgstr ""
...@@ -13264,6 +13270,12 @@ msgstr "" ...@@ -13264,6 +13270,12 @@ msgstr ""
msgid "License-Check" msgid "License-Check"
msgstr "" msgstr ""
msgid "LicenseCompliance|%{docLinkStart}License Approvals%{docLinkEnd} are active"
msgstr ""
msgid "LicenseCompliance|%{docLinkStart}License Approvals%{docLinkEnd} are inactive"
msgstr ""
msgid "LicenseCompliance|Add a license" msgid "LicenseCompliance|Add a license"
msgstr "" msgstr ""
...@@ -13285,9 +13297,15 @@ msgstr "" ...@@ -13285,9 +13297,15 @@ msgstr ""
msgid "LicenseCompliance|Deny" msgid "LicenseCompliance|Deny"
msgstr "" msgstr ""
msgid "LicenseCompliance|Learn more about %{linkStart}License Approvals%{linkEnd}"
msgstr ""
msgid "LicenseCompliance|License" msgid "LicenseCompliance|License"
msgstr "" msgstr ""
msgid "LicenseCompliance|License Approvals"
msgstr ""
msgid "LicenseCompliance|License Compliance detected %d license and policy violation for the source branch only; approval required" 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" msgid_plural "LicenseCompliance|License Compliance detected %d licenses and policy violations for the source branch only; approval required"
msgstr[0] "" msgstr[0] ""
...@@ -24576,6 +24594,9 @@ msgstr "" ...@@ -24576,6 +24594,9 @@ msgstr ""
msgid "Update approval rule" msgid "Update approval rule"
msgstr "" msgstr ""
msgid "Update approvers"
msgstr ""
msgid "Update failed" msgid "Update failed"
msgstr "" msgstr ""
...@@ -27189,6 +27210,9 @@ msgstr "" ...@@ -27189,6 +27210,9 @@ msgstr ""
msgid "load it anyway" msgid "load it anyway"
msgstr "" msgstr ""
msgid "loading"
msgstr ""
msgid "locked by %{path_lock_user_name} %{created_at}" msgid "locked by %{path_lock_user_name} %{created_at}"
msgstr "" msgstr ""
......
...@@ -38,6 +38,9 @@ describe('GlModalVuex', () => { ...@@ -38,6 +38,9 @@ describe('GlModalVuex', () => {
localVue, localVue,
store, store,
propsData, propsData,
stubs: {
GlModal,
},
}); });
}; };
...@@ -148,4 +151,29 @@ describe('GlModalVuex', () => { ...@@ -148,4 +151,29 @@ describe('GlModalVuex', () => {
.then(done) .then(done)
.catch(done.fail); .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