Skip to content
Snippets Groups Projects
Verified Commit 278339f1 authored by Paul Slaughter's avatar Paul Slaughter :two:
Browse files

FE components for MR approvals settings

parent 1ab0224a
No related branches found
No related tags found
No related merge requests found
Pipeline #42647192 canceled
Showing
with 1596 additions and 24 deletions
<script>
import ApproversListEmpty from './approvers_list_empty.vue';
import ApproversListItem from './approvers_list_item.vue';
export default {
components: {
ApproversListEmpty,
ApproversListItem,
},
props: {
value: {
type: Array,
required: true,
},
},
methods: {
removeApprover(idx) {
const newValue = [...this.value.slice(0, idx), ...this.value.slice(idx + 1)];
this.$emit('input', newValue);
},
},
};
</script>
<template>
<approvers-list-empty v-if="!value.length" />
<ul v-else class="content-list">
<approvers-list-item
v-for="(approver, index) in value"
:key="approver.type + approver.id"
:approver="approver"
@remove="removeApprover(index);"
/>
</ul>
</template>
<template>
<div class="d-flex justify-content-center align-items-center h-100 p-3">
<p class="text-center">
{{ __('You have not added any approvers. Start by adding users or groups.') }}
</p>
</div>
</template>
<script>
import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import Avatar from '~/vue_shared/components/project_avatar/default.vue';
import { TYPE_USER, TYPE_GROUP } from '../constants';
const types = [TYPE_USER, TYPE_GROUP];
export default {
components: {
GlButton,
Icon,
Avatar,
},
props: {
approver: {
type: Object,
required: true,
validator: ({ type }) => type && types.indexOf(type) >= 0,
},
},
computed: {
isGroup() {
return this.approver.type === TYPE_GROUP;
},
displayName() {
return this.isGroup ? this.approver.full_path : this.approver.name;
},
},
};
</script>
<template>
<transition name="fade">
<li class="settings-flex-row">
<div class="px-3 d-flex align-items-center">
<avatar :project="approver" :size="24" /><span>{{ displayName }}</span>
<gl-button variant="none" class="ml-auto" @click="$emit('remove', approver);">
<icon name="remove" :aria-label="__('Remove')" />
</gl-button>
</div>
</li>
</transition>
</template>
<script>
import $ from 'jquery';
import _ from 'underscore';
import { __ } from '~/locale';
import Api from 'ee/api';
import { TYPE_USER, TYPE_GROUP } from '../constants';
import { renderAvatar } from '~/helpers/avatar_helper';
function addType(type) {
return items => items.map(obj => Object.assign(obj, { type }));
}
function formatSelection(group) {
return _.escape(group.full_name || group.name);
}
function formatResultUser(result) {
const { name, username } = result;
const avatar = renderAvatar(result, { sizeClass: 's40' });
return `
<div class="user-result">
<div class="user-image">
${avatar}
</div>
<div class="user-info">
<div class="user-name">${_.escape(name)}</div>
<div class="user-username">@${_.escape(username)}</div>
</div>
</div>
`;
}
function formatResultGroup(result) {
const { full_name: fullName, full_path: fullPath } = result;
const avatar = renderAvatar(result, { sizeClass: 's40' });
return `
<div class="user-result group-result">
<div class="group-image">
${avatar}
</div>
<div class="group-info">
<div class="group-name">${_.escape(fullName)}</div>
<div class="group-path">${_.escape(fullPath)}</div>
</div>
</div>
`;
}
function formatResult(result) {
return result.type === TYPE_USER ? formatResultUser(result) : formatResultGroup(result);
}
export default {
props: {
value: {
type: Array,
required: false,
default: () => [],
},
projectId: {
type: String,
required: true,
},
skipUserIds: {
type: Array,
required: false,
default: () => [],
},
skipGroupIds: {
type: Array,
required: false,
default: () => [],
},
isInvalid: {
type: Boolean,
required: false,
default: false,
},
},
watch: {
value(val) {
if (val.length === 0) {
this.clear();
}
},
isInvalid(val) {
const $container = $(this.$refs.input).select2('container');
if (val) {
$container.addClass('is-invalid');
} else {
$container.removeClass('is-invalid');
}
},
},
mounted() {
$(this.$refs.input)
.select2({
placeholder: __('Search users or groups'),
minimumInputLength: 0,
multiple: true,
closeOnSelect: false,
formatResult,
formatSelection,
query: _.debounce(
({ term, callback }) => this.fetchGroupsAndUsers(term).then(callback),
250,
),
id: ({ type, id }) => `${type}${id}`,
})
.on('change', e => this.onChange(e));
},
beforeDestroy() {
$(this.$refs.input).select2('destroy');
},
methods: {
fetchGroupsAndUsers(term) {
const groupsAsync = this.fetchGroups(term).then(addType(TYPE_GROUP));
const usersAsync = this.fetchUsers(term).then(addType(TYPE_USER));
return Promise.all([groupsAsync, usersAsync])
.then(([groups, users]) => groups.concat(users))
.then(results => ({ results }));
},
fetchGroups(term) {
return Api.groups(term, {
skip_groups: this.skipGroupIds,
});
},
fetchUsers(term) {
return Api.approverUsers(term, {
skip_users: this.skipUserIds,
project_id: this.projectId,
});
},
onChange() {
// call data instead of val to get array of objects
const value = $(this.$refs.input).select2('data');
this.$emit('input', value);
},
clear() {
$(this.$refs.input).select2('data', []);
},
},
};
</script>
<template>
<input ref="input" name="members" type="hidden" />
</template>
<script>
import { mapState } from 'vuex';
import { __ } from '~/locale';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
import RuleForm from './rule_form.vue';
export default {
components: {
GlModalVuex,
RuleForm,
},
props: {
modalId: {
type: String,
required: true,
},
},
computed: {
...mapState('createModal', {
rule: 'data',
}),
title() {
return this.rule ? __('Update approvers') : __('Add approvers');
},
},
methods: {
submit() {
this.$refs.form.submit();
},
},
};
</script>
<template>
<gl-modal-vuex
modal-module="createModal"
:modal-id="modalId"
:title="title"
:ok-title="title"
ok-variant="success"
:cancel-title="__('Cancel')"
@ok.prevent="submit"
>
<rule-form ref="form" :init-rule="rule" />
</gl-modal-vuex>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import { sprintf, n__, s__ } from '~/locale';
import _ from 'underscore';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
export default {
components: {
GlModalVuex,
},
props: {
modalId: {
type: String,
required: true,
},
},
computed: {
...mapState('deleteModal', {
rule: 'data',
}),
message() {
if (!this.rule) {
return '';
}
const nMembers = n__(
'ApprovalRuleRemove|%d member',
'ApprovalRuleRemove|%d members',
this.rule.approvers.length,
);
const removeWarning = sprintf(
s__(
'ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}.',
),
{
name: `<strong>${_.escape(this.rule.name)}</strong>`,
nMembers: `<strong>${nMembers}</strong>`,
},
false,
);
const revokeWarning = n__(
'ApprovalRuleRemove|Approvals from this member are not revoked.',
'ApprovalRuleRemove|Approvals from these members are not revoked.',
this.rule.approvers.length,
);
return `${removeWarning} ${revokeWarning}`;
},
},
methods: {
...mapActions(['deleteRule']),
submit() {
this.deleteRule(this.rule.id);
},
},
};
</script>
<template>
<gl-modal-vuex
modal-module="deleteModal"
:modal-id="modalId"
:title="__('Remove approvers?')"
:ok-title="__('Remove approvers')"
ok-variant="remove"
:cancel-title="__('Cancel')"
@ok.prevent="submit"
>
<p v-html="message"></p>
</gl-modal-vuex>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import _ from 'underscore';
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import ApproversList from './approvers_list.vue';
import ApproversSelect from './approvers_select.vue';
import { TYPE_USER, TYPE_GROUP } from '../constants';
export default {
components: {
ApproversList,
ApproversSelect,
GlButton,
},
props: {
initRule: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
name: '',
approvalsRequired: 1,
approvers: [],
approversToAdd: [],
showValidation: false,
...this.getInitialData(),
};
},
computed: {
...mapState({ projectId: state => state.settings.projectId }),
approversByType() {
return _.groupBy(this.approvers, x => x.type);
},
userIds() {
return (this.approversByType[TYPE_USER] || []).map(x => x.id);
},
groupIds() {
return (this.approversByType[TYPE_GROUP] || []).map(x => x.id);
},
validation() {
if (!this.showValidation) {
return {};
}
return {
name: this.invalidName,
approvalsRequired: this.invalidApprovalsRequired,
approvers: this.invalidApprovers,
};
},
invalidName() {
return !this.name ? __('Please provide a name') : '';
},
invalidApprovalsRequired() {
return !_.isNumber(this.approvalsRequired) || this.approvalsRequired < 0
? __('Please enter a non-negative number')
: '';
},
invalidApprovers() {
return !this.approvers.length ? __('Please select and add a member') : '';
},
isValid() {
return Object.keys(this.validation).every(key => !this.validation[key]);
},
},
methods: {
...mapActions(['postRule', 'putRule']),
addSelection() {
if (!this.approversToAdd.length) {
return;
}
this.approvers = this.approversToAdd.concat(this.approvers);
this.approversToAdd = [];
},
submit() {
const id = this.initRule && this.initRule.id;
const data = {
name: this.name,
approvalsRequired: this.approvalsRequired,
users: this.userIds,
groups: this.groupIds,
};
this.showValidation = true;
if (!this.isValid) {
return Promise.resolve();
}
return id ? this.putRule({ id, ...data }) : this.postRule(data);
},
getInitialData() {
if (!this.initRule) {
return {};
}
const users = this.initRule.users.map(x => ({ ...x, type: TYPE_USER }));
const groups = this.initRule.groups.map(x => ({ ...x, type: TYPE_GROUP }));
return {
name: this.initRule.name,
approvalsRequired: this.initRule.approvalsRequired,
approvers: groups.concat(users),
};
},
},
};
</script>
<template>
<form novalidate @submit.prevent.stop="submit">
<div class="row">
<div class="form-group col-sm-6">
<label class="label-wrapper">
<span class="form-label label-bold">{{ s__('ApprovalRule|Name') }}</span>
<input
v-model="name"
:class="{ 'is-invalid': validation.name }"
class="form-control"
name="name"
type="text"
/>
<span class="invalid-feedback">{{ validation.name }}</span>
<span class="text-secondary">{{ s__('ApprovalRule|e.g. QA, Security, etc.') }}</span>
</label>
</div>
<div class="form-group col-sm-6">
<label class="label-wrapper">
<span class="form-label label-bold">{{
s__('ApprovalRuleForm|No. approvals required')
}}</span>
<input
v-model.number="approvalsRequired"
:class="{ 'is-invalid': validation.approvalsRequired }"
class="form-control mw-6em"
name="approvals_required"
type="number"
min="0"
/>
<span class="invalid-feedback">{{ validation.approvalsRequired }}</span>
</label>
</div>
</div>
<div class="form-group">
<label class="label-bold">{{ s__('ApprovalRule|Members') }}</label>
<div class="d-flex align-items-start">
<div class="w-100">
<approvers-select
v-model="approversToAdd"
:project-id="projectId"
:skip-user-ids="userIds"
:skip-group-ids="groupIds"
:is-invalid="!!validation.approvers"
/>
<div class="invalid-feedback">{{ validation.approvers }}</div>
</div>
<gl-button variant="success" class="btn-inverted prepend-left-8" @click="addSelection">
{{ __('Add') }}
</gl-button>
</div>
</div>
<div class="bordered-box overflow-auto h-13em"><approvers-list v-model="approvers" /></div>
</form>
</template>
<script>
import { GlButton } from '@gitlab/ui';
import { n__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
export default {
components: {
GlButton,
Icon,
UserAvatarList,
},
props: {
rules: {
type: Array,
required: true,
default: () => [],
},
},
methods: {
summaryText(rule) {
return sprintf(
n__(
'%d approval required from %{name}',
'%d approvals required from %{name}',
rule.approvalsRequired,
),
{ name: rule.name },
);
},
},
};
</script>
<template>
<table class="table">
<thead class="thead-white text-nowrap">
<tr class="d-none d-sm-table-row">
<th>{{ s__('ApprovalRule|Name') }}</th>
<th class="w-50">{{ s__('ApprovalRule|Members') }}</th>
<th>{{ s__('ApprovalRuleForm|No. approvals required') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="rule in rules" :key="rule.id">
<td>
<div class="d-none d-sm-block">{{ rule.name }}</div>
<div class="d-block d-sm-none">{{ summaryText(rule) }}</div>
</td>
<td class="d-none d-sm-table-cell">
<div v-if="!rule.approvers.length">{{ __('None') }}</div>
<user-avatar-list v-else :items="rule.approvers" :img-size="24" />
</td>
<td class="d-none d-sm-table-cell">
<icon name="approval" class="align-top text-tertiary" />
<span>{{ rule.approvalsRequired }}</span>
</td>
<td class="text-nowrap px-2 w-0">
<gl-button variant="none" @click="$emit('edit', rule);">
<icon name="pencil" :aria-label="__('Edit')" /> </gl-button
><gl-button
class="prepend-left-8 btn-inverted"
variant="remove"
@click="$emit('remove', rule);"
>
<icon name="remove" :aria-label="__('Remove')" />
</gl-button>
</td>
</tr>
</tbody>
</table>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import ApprovalRulesEmpty from './approval_rules_empty.vue';
import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import ModalRuleCreate from './modal_rule_create.vue';
import ModalRuleRemove from './modal_rule_remove.vue';
import RulesEmpty from './rules_empty.vue';
import Rules from './rules.vue';
const CREATE_MODAL_ID = 'approvals-settings-create-modal';
const REMOVE_MODAL_ID = 'approvals-settings-remove-modal';
export default {
components: {
ModalRuleCreate,
ModalRuleRemove,
Rules,
RulesEmpty,
GlButton,
GlLoadingIcon,
ApprovalRulesEmpty,
},
computed: {
...mapState(['isLoading', 'rules']),
......@@ -19,11 +29,37 @@ export default {
},
methods: {
...mapActions(['fetchRules']),
...mapActions({ openCreateModal: 'createModal/open' }),
...mapActions({ openDeleteModal: 'deleteModal/open' }),
},
CREATE_MODAL_ID,
REMOVE_MODAL_ID,
};
</script>
<template>
<gl-loading-icon v-if="isLoading" :size="2" />
<approval-rules-empty v-else-if="isEmpty" />
<div>
<template v-if="isEmpty">
<gl-loading-icon v-if="isLoading" :size="2" />
<rules-empty v-else @click="openCreateModal(null);" />
</template>
<template v-else>
<rules
class="m-0"
:rules="rules"
@edit="openCreateModal($event);"
@remove="openDeleteModal($event);"
/>
<div class="border-top border-bottom py-3 px-2">
<gl-loading-icon v-if="isLoading" />
<div class="d-flex">
<gl-button class="ml-auto btn-info btn-inverted" @click="openCreateModal(null);">{{
__('Add approvers')
}}</gl-button>
</div>
</div>
</template>
<modal-rule-create :modal-id="$options.CREATE_MODAL_ID" />
<modal-rule-remove :modal-id="$options.REMOVE_MODAL_ID" />
</div>
</template>
export const TYPE_USER = 'user';
export const TYPE_GROUP = 'group';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import Avatar from '~/vue_shared/components/project_avatar/default.vue';
import { TYPE_USER, TYPE_GROUP } from 'ee/approvals/constants';
import ApproversListItem from 'ee/approvals/components/approvers_list_item.vue';
const localVue = createLocalVue();
const TEST_USER = {
id: 1,
type: TYPE_USER,
name: 'Lorem Ipsum',
};
const TEST_GROUP = {
id: 1,
type: TYPE_GROUP,
name: 'Lorem Group',
full_path: 'dolar/sit/amit',
};
describe('Approvals ApproversListItem', () => {
let wrapper;
const factory = (options = {}) => {
wrapper = shallowMount(localVue.extend(ApproversListItem), {
...options,
localVue,
});
};
describe('when user', () => {
beforeEach(() => {
factory({
propsData: {
approver: TEST_USER,
},
});
});
it('renders avatar', () => {
const avatar = wrapper.find(Avatar);
expect(avatar.exists()).toBe(true);
expect(avatar.props('project')).toEqual(TEST_USER);
});
it('renders name', () => {
expect(wrapper.text()).toContain(TEST_USER.name);
});
it('when remove clicked, emits remove', () => {
const button = wrapper.find(GlButton);
button.vm.$emit('click');
expect(wrapper.emittedByOrder()).toEqual([{ name: 'remove', args: [TEST_USER] }]);
});
});
describe('when group', () => {
beforeEach(() => {
factory({
propsData: {
approver: TEST_GROUP,
},
});
});
it('renders full_path', () => {
expect(wrapper.text()).toContain(TEST_GROUP.full_path);
expect(wrapper.text()).not.toContain(TEST_GROUP.name);
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import ApproversListEmpty from 'ee/approvals/components/approvers_list_empty.vue';
import ApproversListItem from 'ee/approvals/components/approvers_list_item.vue';
import ApproversList from 'ee/approvals/components/approvers_list.vue';
import { TYPE_USER, TYPE_GROUP } from 'ee/approvals/constants';
const localVue = createLocalVue();
const TEST_APPROVERS = [
{ id: 1, type: TYPE_GROUP },
{ id: 1, type: TYPE_USER },
{ id: 2, type: TYPE_USER },
];
describe('ApproversList', () => {
let propsData;
let wrapper;
const factory = (options = {}) => {
wrapper = shallowMount(localVue.extend(ApproversList), {
...options,
localVue,
propsData,
});
};
beforeEach(() => {
propsData = {};
});
afterEach(() => {
wrapper.destroy();
});
describe('when empty', () => {
beforeEach(() => {
propsData.value = [];
});
it('renders empty', () => {
factory();
expect(wrapper.find(ApproversListEmpty).exists()).toBe(true);
expect(wrapper.find('ul').exists()).toBe(false);
});
});
describe('when not empty', () => {
beforeEach(() => {
propsData.value = TEST_APPROVERS;
});
it('renders items', () => {
factory();
const items = wrapper.findAll(ApproversListItem).wrappers.map(item => item.props('approver'));
expect(items).toEqual(TEST_APPROVERS);
});
TEST_APPROVERS.forEach((approver, idx) => {
it(`when remove (${idx}), emits new input`, () => {
factory();
const item = wrapper.findAll(ApproversListItem).at(idx);
item.vm.$emit('remove', approver);
const expected = TEST_APPROVERS.filter((x, i) => i !== idx);
expect(wrapper.emittedByOrder()).toEqual([{ name: 'input', args: [expected] }]);
});
});
});
});
import { createLocalVue, mount } from '@vue/test-utils';
import $ from 'jquery';
import Api from 'ee/api';
import ApproversSelect from 'ee/approvals/components/approvers_select.vue';
import { TYPE_USER, TYPE_GROUP } from 'ee/approvals/constants';
import { TEST_HOST } from 'spec/test_constants';
const DEBOUNCE_TIME = 250;
const TEST_PROJECT_ID = '17';
const TEST_GROUP_AVATAR = `${TEST_HOST}/group-avatar.png`;
const TEST_USER_AVATAR = `${TEST_HOST}/user-avatar.png`;
const TEST_GROUPS = [
{ id: 1, full_name: 'GitLab Org', full_path: 'gitlab/org', avatar_url: null },
{
id: 2,
full_name: 'Lorem Ipsum',
full_path: 'lorem-ipsum',
avatar_url: TEST_GROUP_AVATAR,
},
];
const TEST_USERS = [
{ id: 1, name: 'Dolar', username: 'dolar', avatar_url: TEST_USER_AVATAR },
{ id: 3, name: 'Sit', username: 'sit', avatar_url: TEST_USER_AVATAR },
];
const localVue = createLocalVue();
const waitForEvent = ($input, event) => new Promise(resolve => $input.one(event, resolve));
const parseAvatar = element => (element.classList.contains('identicon') ? null : element.src);
const select2Container = () => document.querySelector('.select2-container');
const select2DropdownOptions = () => document.querySelectorAll('#select2-drop .user-result');
const select2DropdownItems = () =>
Array.prototype.map.call(select2DropdownOptions(), element => {
const isGroup = element.classList.contains('group-result');
const avatar = parseAvatar(element.querySelector('.avatar'));
return isGroup
? {
avatar_url: avatar,
full_name: element.querySelector('.group-name').textContent,
full_path: element.querySelector('.group-path').textContent,
}
: {
avatar_url: avatar,
name: element.querySelector('.user-name').textContent,
username: element.querySelector('.user-username').textContent,
};
});
describe('Approvals ApproversSelect', () => {
let wrapper;
let $input;
const factory = (options = {}) => {
const propsData = {
projectId: TEST_PROJECT_ID,
...options.propsData,
};
wrapper = mount(localVue.extend(ApproversSelect), {
...options,
propsData,
localVue,
attachToDocument: true,
});
$input = $(wrapper.vm.$refs.input);
};
const search = (term = '') => {
$input.select2('search', term);
jasmine.clock().tick(DEBOUNCE_TIME);
};
beforeEach(() => {
jasmine.clock().install();
spyOn(Api, 'groups').and.returnValue(Promise.resolve(TEST_GROUPS));
spyOn(Api, 'approverUsers').and.returnValue(Promise.resolve(TEST_USERS));
});
afterEach(() => {
wrapper.destroy();
jasmine.clock().uninstall();
});
it('renders select2 input', () => {
expect(select2Container()).toBe(null);
factory();
expect(select2Container()).not.toBe(null);
});
it('queries and displays groups and users', done => {
factory();
const expected = TEST_GROUPS.concat(TEST_USERS)
.map(({ id, ...obj }) => obj)
.map(({ username, ...obj }) => (!username ? obj : { ...obj, username: `@${username}` }));
waitForEvent($input, 'select2-loaded')
.then(() => {
const items = select2DropdownItems();
expect(items).toEqual(expected);
})
.then(done)
.catch(done.fail);
search();
});
it('searches with text and skips given ids', done => {
factory();
const term = 'lorem';
waitForEvent($input, 'select2-loaded')
.then(() => {
expect(Api.groups).toHaveBeenCalledWith(term, { skip_groups: [] });
expect(Api.approverUsers).toHaveBeenCalledWith(term, {
skip_users: [],
project_id: TEST_PROJECT_ID,
});
})
.then(done)
.catch(done.fail);
search(term);
});
it('searches and skips given groups and users', done => {
const skipGroupIds = [7, 8];
const skipUserIds = [9, 10];
factory({
propsData: {
skipGroupIds,
skipUserIds,
},
});
waitForEvent($input, 'select2-loaded')
.then(() => {
expect(Api.groups).toHaveBeenCalledWith('', { skip_groups: skipGroupIds });
expect(Api.approverUsers).toHaveBeenCalledWith('', {
skip_users: skipUserIds,
project_id: TEST_PROJECT_ID,
});
})
.then(done)
.catch(done.fail);
search();
});
it('emits input when data changes', done => {
factory();
const expectedFinal = [
{ ...TEST_USERS[0], type: TYPE_USER },
{ ...TEST_GROUPS[0], type: TYPE_GROUP },
];
const expected = expectedFinal.map((x, idx) => ({
name: 'input',
args: [expectedFinal.slice(0, idx + 1)],
}));
waitForEvent($input, 'select2-loaded')
.then(() => {
const options = select2DropdownOptions();
$(options[TEST_GROUPS.length]).trigger('mouseup');
$(options[0]).trigger('mouseup');
})
.then(done)
.catch(done.fail);
waitForEvent($input, 'change')
.then(() => {
expect(wrapper.emittedByOrder()).toEqual(expected);
})
.then(done)
.catch(done.fail);
search();
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
import RuleForm from 'ee/approvals/components/rule_form.vue';
import ModalRuleCreate from 'ee/approvals/components/modal_rule_create.vue';
const TEST_MODAL_ID = 'test-modal-create-id';
const TEST_RULE = { id: 7 };
const MODAL_MODULE = 'createModal';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Approvals ModalRuleCreate', () => {
let createModalState;
let wrapper;
const factory = (options = {}) => {
const store = new Vuex.Store({
modules: {
[MODAL_MODULE]: {
namespaced: true,
state: createModalState,
},
},
});
const propsData = {
modalId: TEST_MODAL_ID,
...options.propsData,
};
wrapper = shallowMount(localVue.extend(ModalRuleCreate), {
...options,
localVue,
store,
propsData,
});
};
beforeEach(() => {
createModalState = {};
});
afterEach(() => {
wrapper.destroy();
});
describe('without data', () => {
beforeEach(() => {
createModalState.data = null;
});
it('renders modal', () => {
factory();
const modal = wrapper.find(GlModalVuex);
expect(modal.exists()).toBe(true);
expect(modal.props('modalModule')).toEqual(MODAL_MODULE);
expect(modal.props('modalId')).toEqual(TEST_MODAL_ID);
expect(modal.attributes('title')).toEqual('Add approvers');
expect(modal.attributes('ok-title')).toEqual('Add approvers');
});
it('renders form', () => {
factory();
const modal = wrapper.find(GlModalVuex);
const form = modal.find(RuleForm);
expect(form.exists()).toBe(true);
expect(form.props('initRule')).toEqual(null);
});
it('when modal emits ok, submits form', () => {
factory();
const form = wrapper.find(RuleForm);
form.vm.submit = jasmine.createSpy('submit');
const modal = wrapper.find(GlModalVuex);
modal.vm.$emit('ok', new Event('ok'));
expect(form.vm.submit).toHaveBeenCalled();
});
});
describe('with data', () => {
beforeEach(() => {
createModalState.data = TEST_RULE;
});
it('renders modal', () => {
factory();
const modal = wrapper.find(GlModalVuex);
expect(modal.exists()).toBe(true);
expect(modal.attributes('title')).toEqual('Update approvers');
expect(modal.attributes('ok-title')).toEqual('Update approvers');
});
it('renders form', () => {
factory();
const modal = wrapper.find(GlModalVuex);
const form = modal.find(RuleForm);
expect(form.exists()).toBe(true);
expect(form.props('initRule')).toEqual(TEST_RULE);
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import ModalRuleRemove from 'ee/approvals/components/modal_rule_remove.vue';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
const MODAL_MODULE = 'deleteModal';
const TEST_MODAL_ID = 'test-delete-modal-id';
const TEST_RULE = {
id: 7,
name: 'Lorem',
approvers: Array(5)
.fill(1)
.map((x, id) => ({ id })),
};
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Approvals ModalRuleRemove', () => {
let wrapper;
let actions;
let deleteModalState;
const factory = (options = {}) => {
const store = new Vuex.Store({
actions,
modules: {
[MODAL_MODULE]: {
namespaced: true,
state: deleteModalState,
},
},
});
const propsData = {
modalId: TEST_MODAL_ID,
...options.propsData,
};
wrapper = shallowMount(localVue.extend(ModalRuleRemove), {
...options,
localVue,
store,
propsData,
});
};
beforeEach(() => {
deleteModalState = {
data: TEST_RULE,
};
actions = {
deleteRule: jasmine.createSpy('deleteRule'),
};
});
it('renders modal', () => {
factory();
const modal = wrapper.find(GlModalVuex);
expect(modal.exists()).toBe(true);
expect(modal.props()).toEqual(
jasmine.objectContaining({
modalModule: MODAL_MODULE,
modalId: TEST_MODAL_ID,
}),
);
});
it('shows message', () => {
factory();
const modal = wrapper.find(GlModalVuex);
expect(modal.text()).toContain(TEST_RULE.name);
expect(modal.text()).toContain(`${TEST_RULE.approvers.length} members`);
});
it('shows singular message', () => {
deleteModalState.data = {
...TEST_RULE,
approvers: [{ id: 1 }],
};
factory();
const modal = wrapper.find(GlModalVuex);
expect(modal.text()).toContain('1 member');
});
it('deletes rule when modal is submitted', () => {
factory();
expect(actions.deleteRule).not.toHaveBeenCalled();
const modal = wrapper.find(GlModalVuex);
modal.vm.$emit('ok', new Event('submit'));
expect(actions.deleteRule).toHaveBeenCalledWith(jasmine.anything(), TEST_RULE.id, undefined);
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlButton } from '@gitlab/ui';
import ApproversSelect from 'ee/approvals/components/approvers_select.vue';
import RuleForm from 'ee/approvals/components/rule_form.vue';
import { TYPE_USER, TYPE_GROUP } from 'ee/approvals/constants';
const TEST_PROJECT_ID = '7';
const TEST_RULE = {
id: 10,
name: 'QA',
approvalsRequired: 2,
users: [{ id: 1 }, { id: 2 }, { id: 3 }],
groups: [{ id: 1 }, { id: 2 }],
};
const localVue = createLocalVue();
localVue.use(Vuex);
const wrapInput = node => ({
node,
feedback: () => node.element.nextElementSibling.textContent,
isValid: () => !node.classes('is-invalid'),
});
const findInput = (form, selector) => wrapInput(form.find(selector));
const findNameInput = form => findInput(form, 'input[name=name]');
const findApprovalsRequiredInput = form => findInput(form, 'input[name=approvals_required]');
const findApproversSelect = form => {
const input = findInput(form, ApproversSelect);
return {
...input,
isValid() {
return !input.node.props('isInvalid');
},
};
};
describe('Approvals RuleForm', () => {
let state;
let actions;
let wrapper;
const factory = (options = {}) => {
const store = new Vuex.Store({
state,
actions,
});
wrapper = shallowMount(localVue.extend(RuleForm), {
...options,
localVue,
store,
});
};
beforeEach(() => {
state = {
settings: { projectId: TEST_PROJECT_ID },
};
actions = {
postRule: jasmine.createSpy('postRule'),
putRule: jasmine.createSpy('putRule'),
};
});
afterEach(() => {
wrapper.destroy();
});
describe('without initRule', () => {
beforeEach(() => {
factory();
});
it('at first, shows no validation', () => {
const inputs = [
findNameInput(wrapper),
findApprovalsRequiredInput(wrapper),
findApproversSelect(wrapper),
];
const invalidInputs = inputs.filter(x => !x.isValid());
const feedbacks = inputs.map(x => x.feedback());
expect(invalidInputs.length).toBe(0);
expect(feedbacks.every(str => !str.length)).toBe(true);
});
it('on submit, does not dispatch action', () => {
wrapper.vm.submit();
expect(actions.postRule).not.toHaveBeenCalled();
});
it('on submit, shows name validation', () => {
const { node, isValid, feedback } = findNameInput(wrapper);
node.setValue('');
wrapper.vm.submit();
expect(isValid()).toBe(false);
expect(feedback()).toEqual('Please provide a name');
});
it('on submit, shows approvalsRequired validation', () => {
const { node, isValid, feedback } = findApprovalsRequiredInput(wrapper);
node.setValue(-1);
wrapper.vm.submit();
expect(isValid()).toBe(false);
expect(feedback()).toEqual('Please enter a non-negative number');
});
it('on submit, shows approvers validation', () => {
const { isValid, feedback } = findApproversSelect(wrapper);
wrapper.vm.approvers = [];
wrapper.vm.submit();
expect(isValid()).toBe(false);
expect(feedback()).toEqual('Please select and add a member');
});
it('on submit with data, posts rule', () => {
const expected = {
name: 'Lorem',
approvalsRequired: 2,
users: [1, 2],
groups: [2, 3],
};
const name = findNameInput(wrapper);
const approvalsRequired = findApprovalsRequiredInput(wrapper);
const groups = expected.groups.map(id => ({ id, type: TYPE_GROUP }));
const users = expected.users.map(id => ({ id, type: TYPE_USER }));
name.node.setValue(expected.name);
approvalsRequired.node.setValue(expected.approvalsRequired);
wrapper.vm.approvers = groups.concat(users);
wrapper.vm.submit();
expect(actions.postRule).toHaveBeenCalledWith(jasmine.anything(), expected, undefined);
});
it('adds selected approvers on button click', () => {
const { node } = findApproversSelect(wrapper);
const selected = [
{ id: 1, type: TYPE_USER },
{ id: 2, type: TYPE_USER },
{ id: 1, type: TYPE_GROUP },
];
const orig = [{ id: 7, type: TYPE_GROUP }];
const expected = selected.concat(orig);
wrapper.vm.approvers = orig;
node.vm.$emit('input', selected);
wrapper.find(GlButton).vm.$emit('click');
expect(wrapper.vm.approvers).toEqual(expected);
});
});
describe('with initRule', () => {
beforeEach(() => {
factory({
propsData: {
initRule: TEST_RULE,
},
});
});
it('on submit, puts rule', () => {
const expected = {
...TEST_RULE,
users: TEST_RULE.users.map(x => x.id),
groups: TEST_RULE.groups.map(x => x.id),
};
wrapper.vm.submit();
expect(actions.putRule).toHaveBeenCalledWith(jasmine.anything(), expected, undefined);
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import ApprovalRulesEmpty from 'ee/approvals/components/approval_rules_empty.vue';
import RulesEmpty from 'ee/approvals/components/rules_empty.vue';
const localVue = createLocalVue();
......@@ -8,7 +8,7 @@ describe('EE ApprovalsSettingsEmpty', () => {
let wrapper;
const factory = options => {
wrapper = shallowMount(localVue.extend(ApprovalRulesEmpty), {
wrapper = shallowMount(localVue.extend(RulesEmpty), {
localVue,
...options,
});
......@@ -21,7 +21,7 @@ describe('EE ApprovalsSettingsEmpty', () => {
it('shows message', () => {
factory();
expect(wrapper.text()).toContain(ApprovalRulesEmpty.message);
expect(wrapper.text()).toContain(RulesEmpty.message);
});
it('shows button', () => {
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import Rules from 'ee/approvals/components/rules.vue';
const TEST_RULES = [
{ id: 1, name: 'Lorem', approvalsRequired: 2, approvers: [{ id: 7 }, { id: 8 }] },
{ id: 2, name: 'Ipsum', approvalsRequired: 0, approvers: [{ id: 9 }] },
{ id: 3, name: 'Dolarsit', approvalsRequired: 3, approvers: [] },
];
const localVue = createLocalVue();
const getRowData = tr => {
const td = tr.findAll('td');
const avatarList = td.at(1).find(UserAvatarList);
return {
name: td
.at(0)
.find('.d-sm-block.d-none')
.text(),
summary: td
.at(0)
.find('.d-sm-none.d-block')
.text(),
approvers: avatarList.exists() ? avatarList.props('items') : td.at(1).text(),
approvalsRequired: Number(td.at(2).text()),
};
};
const findButton = (tr, icon) => {
const buttons = tr.findAll(GlButton);
return buttons.filter(x => x.find(Icon).props('name') === icon).at(0);
};
describe('Approvals Rules', () => {
let wrapper;
const factory = (options = {}) => {
const propsData = {
rules: TEST_RULES,
...options.propsData,
};
wrapper = shallowMount(localVue.extend(Rules), {
...options,
localVue,
propsData,
});
};
afterEach(() => {
wrapper.destroy();
});
it('renders row for each rule', () => {
factory();
const rows = wrapper.findAll('tbody tr');
const data = rows.wrappers.map(getRowData);
expect(data).toEqual(
TEST_RULES.map(rule => ({
name: rule.name,
summary: jasmine.stringMatching(`${rule.approvalsRequired} approval.*from ${rule.name}`),
approvalsRequired: rule.approvalsRequired,
approvers: rule.approvers.length ? rule.approvers : 'None',
})),
);
});
it('when edit is clicked, emits edit', () => {
const idx = 2;
const rule = TEST_RULES[idx];
factory();
const tr = wrapper.findAll('tbody tr').at(idx);
const editButton = findButton(tr, 'pencil');
editButton.vm.$emit('click');
expect(wrapper.emittedByOrder()).toEqual([{ name: 'edit', args: [rule] }]);
});
it('when remove is clicked, emits remove', () => {
const idx = 1;
const rule = TEST_RULES[idx];
factory();
const tr = wrapper.findAll('tbody tr').at(idx);
const removeButton = findButton(tr, 'remove');
removeButton.vm.$emit('click');
expect(wrapper.emittedByOrder()).toEqual([{ name: 'remove', args: [rule] }]);
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import ApprovalRulesEmpty from 'ee/approvals/components/approval_rules_empty.vue';
import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import ModalRuleCreate from 'ee/approvals/components/modal_rule_create.vue';
import ModalRuleRemove from 'ee/approvals/components/modal_rule_remove.vue';
import Rules from 'ee/approvals/components/rules.vue';
import RulesEmpty from 'ee/approvals/components/rules_empty.vue';
import Settings from 'ee/approvals/components/settings.vue';
const localVue = createLocalVue();
......@@ -30,6 +33,8 @@ describe('EE ApprovalsSettingsForm', () => {
actions = {
fetchRules: jasmine.createSpy('fetchRules'),
'createModal/open': jasmine.createSpy('createModal/open'),
'deleteModal/open': jasmine.createSpy('deleteModal/open'),
};
});
......@@ -41,31 +46,129 @@ describe('EE ApprovalsSettingsForm', () => {
expect(actions.fetchRules).toHaveBeenCalledTimes(1);
});
it('shows loading icon if loading', () => {
state.isLoading = true;
it('renders create modal', () => {
factory();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
const modal = wrapper.find(ModalRuleCreate);
expect(modal.exists()).toBe(true);
expect(modal.props('modalId')).toBe(wrapper.vm.$options.CREATE_MODAL_ID);
});
it('does not show loading icon if not loading', () => {
state.isLoading = false;
it('renders delete modal', () => {
factory();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
const modal = wrapper.find(ModalRuleRemove);
expect(modal.exists()).toBe(true);
expect(modal.props('modalId')).toBe(wrapper.vm.$options.REMOVE_MODAL_ID);
});
it('shows ApprovalsSettingsEmpty if empty', () => {
state.rules = [];
factory();
describe('if empty', () => {
beforeEach(() => {
state.rules = [];
});
it('shows RulesEmpty', () => {
factory();
expect(wrapper.find(RulesEmpty).exists()).toBe(true);
});
it('does not show Rules', () => {
factory();
expect(wrapper.find(Rules).exists()).toBe(false);
});
it('opens create modal if clicked', () => {
factory();
const empty = wrapper.find(RulesEmpty);
empty.vm.$emit('click');
expect(wrapper.find(ApprovalRulesEmpty).exists()).toBe(true);
expect(actions['createModal/open']).toHaveBeenCalledWith(jasmine.anything(), null, undefined);
});
it('shows loading icon if loading', () => {
state.isLoading = true;
factory();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('does not show loading icon if not loading', () => {
state.isLoading = false;
factory();
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
});
it('does not show ApprovalsSettingsEmpty is not empty', () => {
state.rules = [{ id: 1 }];
factory();
describe('if not empty', () => {
beforeEach(() => {
state.rules = [{ id: 1 }];
});
it('does not show RulesEmpty', () => {
factory();
expect(wrapper.find(RulesEmpty).exists()).toBe(false);
});
it('shows rules', () => {
factory();
const rules = wrapper.find(Rules);
expect(rules.exists()).toBe(true);
expect(rules.props('rules')).toEqual(state.rules);
});
it('opens create modal when edit is clicked', () => {
factory();
expect(wrapper.find(ApprovalRulesEmpty).exists()).toBe(false);
const rule = state.rules[0];
const rules = wrapper.find(Rules);
rules.vm.$emit('edit', rule);
expect(actions['createModal/open']).toHaveBeenCalledWith(jasmine.anything(), rule, undefined);
});
it('opens delete modal when remove is clicked', () => {
factory();
const { id } = state.rules[0];
const rules = wrapper.find(Rules);
rules.vm.$emit('remove', id);
expect(actions['deleteModal/open']).toHaveBeenCalledWith(jasmine.anything(), id, undefined);
});
it('renders add button', () => {
factory();
const button = wrapper.find(GlButton);
expect(button.exists()).toBe(true);
expect(button.text()).toBe('Add approvers');
});
it('opens create modal when add button is clicked', () => {
factory();
const button = wrapper.find(GlButton);
button.vm.$emit('click');
expect(actions['createModal/open']).toHaveBeenCalledWith(jasmine.anything(), null, undefined);
});
it('shows loading icon and rules if loading', () => {
state.isLoading = true;
factory();
expect(wrapper.find(Rules).exists()).toBe(true);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
});
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment