Commit 4174366f authored by Phil Hughes's avatar Phil Hughes

Merge branch '1979-fe-multiple-approval-rules' into 'master'

FE for "Multiple blocking merge request approval rules"

See merge request gitlab-org/gitlab-ee!9001
parents 016c4c1b 5e2e1a54
......@@ -166,7 +166,8 @@
@include btn-outline($white-light, $green-600, $green-500, $green-500, $white-light, $green-600, $green-600, $green-700);
}
&.btn-remove {
&.btn-remove,
&.btn-danger {
@include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700);
}
......
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import ModalRuleCreate from './modal_rule_create.vue';
import ModalRuleRemove from './modal_rule_remove.vue';
import FallbackRules from './fallback_rules.vue';
export default {
components: {
ModalRuleCreate,
ModalRuleRemove,
GlButton,
GlLoadingIcon,
FallbackRules,
},
computed: {
...mapState({
settings: 'settings',
isLoading: state => state.approvals.isLoading,
hasLoaded: state => state.approvals.hasLoaded,
}),
...mapGetters(['isEmpty']),
createModalId() {
return `${this.settings.prefix}-approvals-create-modal`;
},
removeModalId() {
return `${this.settings.prefix}-approvals-remove-modal`;
},
},
created() {
this.fetchRules();
},
methods: {
...mapActions(['fetchRules']),
...mapActions({ openCreateModal: 'createModal/open' }),
},
};
</script>
<template>
<div>
<gl-loading-icon v-if="!hasLoaded" :size="2" />
<template v-else>
<div class="border-bottom">
<slot v-if="isEmpty" name="fallback"> <fallback-rules /> </slot>
<slot v-else name="rules"></slot>
</div>
<div v-if="settings.canEdit" class="border-bottom py-3 px-2">
<gl-loading-icon v-if="isLoading" />
<div v-if="settings.allowMultiRule" class="d-flex">
<gl-button class="ml-auto btn-info btn-inverted" @click="openCreateModal(null)">{{
__('Add approvers')
}}</gl-button>
</div>
</div>
<slot name="footer"></slot>
</template>
<modal-rule-create :modal-id="createModalId" />
<modal-rule-remove :modal-id="removeModalId" />
</div>
</template>
<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 'select2/select2';
import _ from 'underscore';
import { __ } from '~/locale';
import Api from '~/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 Icon from '~/vue_shared/components/icon.vue';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import Rules from './rules.vue';
import RuleControls from './rule_controls.vue';
export default {
components: {
Icon,
UserAvatarList,
Rules,
RuleControls,
},
props: {
hasControls: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
...mapState({
approvalsRequired: state => state.approvals.fallbackApprovalsRequired,
minApprovalsRequired: state => state.approvals.minFallbackApprovalsRequired || 0,
}),
rules() {
return [
{
isFallback: true,
approvalsRequired: this.approvalsRequired,
minApprovalsRequired: this.minApprovalsRequired,
},
];
},
},
};
</script>
<template>
<rules :rules="rules">
<template slot="thead" slot-scope="{ members, approvalsRequired }">
<tr class="d-none d-sm-table-row">
<th class="w-75 pl-0">{{ members }}</th>
<th>{{ approvalsRequired }}</th>
<th v-if="hasControls"></th>
</tr>
</template>
<template slot="tr" slot-scope="{ rule }">
<td class="pl-0">
{{ s__('ApprovalRule|All members with Developer role or higher and code owners (if any)') }}
</td>
<td class="text-nowrap">
<slot
name="approvals-required"
:approvals-required="rule.approvalsRequired"
:min-approvals-required="rule.minApprovalsRequired"
>
<icon name="approval" class="align-top text-tertiary" />
<span>{{ rule.approvalsRequired }}</span>
</slot>
</td>
<td v-if="hasControls" class="text-nowrap px-2 w-0">
<rule-controls :rule="rule" />
</td>
</template>
</rules>
</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 App from '../app.vue';
import MrRules from './mr_rules.vue';
import MrRulesHiddenInputs from './mr_rules_hidden_inputs.vue';
import MrFallbackRules from './mr_fallback_rules.vue';
export default {
components: {
App,
MrRules,
MrRulesHiddenInputs,
MrFallbackRules,
},
};
</script>
<template>
<app>
<mr-fallback-rules slot="fallback" />
<mr-rules slot="rules" />
<mr-rules-hidden-inputs slot="footer" />
</app>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import FallbackRules from '../fallback_rules.vue';
export default {
components: {
FallbackRules,
},
computed: {
...mapState(['settings']),
},
methods: {
...mapActions(['putFallbackRule']),
},
};
</script>
<template>
<fallback-rules :has-controls="settings.canEdit">
<input
slot="approvals-required"
slot-scope="{ approvalsRequired, minApprovalsRequired }"
:value="approvalsRequired"
:disabled="!settings.canEdit"
class="form-control mw-6em"
type="number"
:min="minApprovalsRequired"
@input="putFallbackRule({ approvalsRequired: Number($event.target.value) })"
/>
</fallback-rules>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import Rules from '../rules.vue';
import RuleControls from '../rule_controls.vue';
export default {
components: {
UserAvatarList,
Rules,
RuleControls,
},
computed: {
...mapState(['settings']),
...mapState({
rules: state => state.approvals.rules,
}),
},
methods: {
...mapActions(['putRule']),
canEdit(rule) {
const { canEdit, allowMultiRule } = this.settings;
return canEdit && (!allowMultiRule || !rule.hasSource);
},
},
};
</script>
<template>
<rules :rules="rules">
<template slot="thead" slot-scope="{ name, members, approvalsRequired }">
<tr>
<th v-if="settings.allowMultiRule">{{ name }}</th>
<th :class="settings.allowMultiRule ? 'w-50 d-none d-sm-table-cell' : 'w-75'">
{{ members }}
</th>
<th>{{ approvalsRequired }}</th>
<th></th>
</tr>
</template>
<template slot="tr" slot-scope="{ rule }">
<td v-if="settings.allowMultiRule" class="js-name">{{ rule.name }}</td>
<td class="js-members" :class="settings.allowMultiRule ? 'd-none d-sm-table-cell' : ''">
<user-avatar-list :items="rule.approvers" :img-size="24" />
</td>
<td class="js-approvals-required">
<input
:value="rule.approvalsRequired"
:disabled="!settings.canEdit"
class="form-control mw-6em"
type="number"
:min="rule.minApprovalsRequired"
@input="putRule({ id: rule.id, approvalsRequired: Number($event.target.value) })"
/>
</td>
<td class="text-nowrap px-2 w-0 js-controls">
<rule-controls v-if="canEdit(rule)" :rule="rule" />
</td>
</template>
</rules>
</template>
<script>
import { mapState } from 'vuex';
const INPUT_ID = 'merge_request[approval_rules_attributes][][id]';
const INPUT_SOURCE_ID = 'merge_request[approval_rules_attributes][][approval_project_rule_id]';
const INPUT_NAME = 'merge_request[approval_rules_attributes][][name]';
const INPUT_APPROVALS_REQUIRED = 'merge_request[approval_rules_attributes][][approvals_required]';
const INPUT_USER_IDS = 'merge_request[approval_rules_attributes][][user_ids][]';
const INPUT_GROUP_IDS = 'merge_request[approval_rules_attributes][][group_ids][]';
const INPUT_DELETE = 'merge_request[approval_rules_attributes][][_destroy]';
const INPUT_FALLBACK_APPROVALS_REQUIRED = 'merge_request[approvals_before_merge]';
export default {
computed: {
...mapState(['settings']),
...mapState({
rules: state => state.approvals.rules,
rulesToDelete: state => state.approvals.rulesToDelete,
fallbackApprovalsRequired: state => state.approvals.fallbackApprovalsRequired,
}),
},
INPUT_ID,
INPUT_SOURCE_ID,
INPUT_NAME,
INPUT_APPROVALS_REQUIRED,
INPUT_USER_IDS,
INPUT_GROUP_IDS,
INPUT_DELETE,
INPUT_FALLBACK_APPROVALS_REQUIRED,
};
</script>
<template>
<div v-if="settings.canEdit">
<div v-for="id in rulesToDelete" :key="id">
<input :value="id" :name="$options.INPUT_ID" type="hidden" />
<input :value="1" :name="$options.INPUT_DELETE" type="hidden" />
</div>
<input
v-if="!rules.length"
:value="fallbackApprovalsRequired"
:name="$options.INPUT_FALLBACK_APPROVALS_REQUIRED"
type="hidden"
/>
<div v-for="rule in rules" :key="rule.id">
<input v-if="!rule.isNew" :value="rule.id" :name="$options.INPUT_ID" type="hidden" />
<input
v-if="rule.isNew && rule.hasSource"
:value="rule.sourceId"
:name="$options.INPUT_SOURCE_ID"
type="hidden"
/>
<input
:value="rule.approvalsRequired"
:name="$options.INPUT_APPROVALS_REQUIRED"
type="hidden"
/>
<input :value="rule.name" :name="$options.INPUT_NAME" type="hidden" />
<input
v-if="!rule.users || rule.users.length === 0"
value=""
:name="$options.INPUT_USER_IDS"
type="hidden"
/>
<input
v-for="user in rule.users"
:key="user.id"
:value="user.id"
:name="$options.INPUT_USER_IDS"
type="hidden"
/>
<input
v-if="!rule.groups || rule.groups.length === 0"
value=""
:name="$options.INPUT_GROUP_IDS"
type="hidden"
/>
<input
v-for="group in rule.groups"
:key="group.id"
:value="group.id"
:name="$options.INPUT_GROUP_IDS"
type="hidden"
/>
</div>
</div>
</template>
<script>
import App from '../app.vue';
import ProjectRules from './project_rules.vue';
export default {
components: {
App,
ProjectRules,
},
};
</script>
<template>
<app><project-rules slot="rules"/></app>
</template>
<script>
import { mapState } from 'vuex';
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';
import Rules from '../rules.vue';
import RuleControls from '../rule_controls.vue';
export default {
components: {
Icon,
UserAvatarList,
Rules,
RuleControls,
},
computed: {
...mapState(['settings']),
...mapState({
rules: state => state.approvals.rules,
}),
},
methods: {
summaryText(rule) {
return this.settings.allowMultiRule
? this.summaryMultipleRulesText(rule)
: this.summarySingleRuleText(rule);
},
membersCountText(rule) {
return n__(
'ApprovalRuleSummary|%d member',
'ApprovalRuleSummary|%d members',
rule.approvers.length,
);
},
summarySingleRuleText(rule) {
const membersCount = this.membersCountText(rule);
return sprintf(
n__(
'ApprovalRuleSummary|%{count} approval required from %{membersCount}',
'ApprovalRuleSummary|%{count} approvals required from %{membersCount}',
rule.approvalsRequired,
),
{ membersCount, count: rule.approvalsRequired },
);
},
summaryMultipleRulesText(rule) {
return sprintf(
n__(
'%{count} approval required from %{name}',
'%{count} approvals required from %{name}',
rule.approvalsRequired,
),
{ name: rule.name, count: rule.approvalsRequired },
);
},
},
};
</script>
<template>
<rules :rules="rules">
<template slot="thead" slot-scope="{ name, members, approvalsRequired }">
<tr class="d-none d-sm-table-row">
<th v-if="settings.allowMultiRule">{{ name }}</th>
<th class="w-50">{{ members }}</th>
<th>{{ approvalsRequired }}</th>
<th></th>
</tr>
</template>
<template slot="tr" slot-scope="{ rule }">
<td class="d-table-cell d-sm-none js-summary">{{ summaryText(rule) }}</td>
<td v-if="settings.allowMultiRule" class="d-none d-sm-table-cell js-name">
{{ rule.name }}
</td>
<td class="d-none d-sm-table-cell js-members">
<user-avatar-list :items="rule.approvers" :img-size="24" />
</td>
<td class="d-none d-sm-table-cell js-approvals-required">
<icon name="approval" class="align-top text-tertiary" />
<span>{{ rule.approvalsRequired }}</span>
</td>
<td class="text-nowrap px-2 w-0 js-controls"><rule-controls :rule="rule" /></td>
</template>
</rules>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
GlButton,
Icon,
},
props: {
rule: {
type: Object,
required: true,
},
},
computed: {
...mapState(['settings']),
isRemoveable() {
return !this.rule.isFallback && this.settings.allowMultiRule;
},
},
methods: {
...mapActions(['requestEditRule', 'requestDeleteRule']),
},
};
</script>
<template>
<div>
<gl-button variant="none" @click="requestEditRule(rule)">
<span>{{ __('Edit') }}</span> </gl-button
><gl-button
v-if="isRemoveable"
class="prepend-left-8 btn btn-inverted"
variant="danger"
@click="requestDeleteRule(rule)"
>
<icon name="remove" :aria-label="__('Remove')" />
</gl-button>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import _ from 'underscore';
import { GlButton } from '@gitlab/ui';
import { sprintf, __ } from '~/locale';
import ApproversList from './approvers_list.vue';
import ApproversSelect from './approvers_select.vue';
import { TYPE_USER, TYPE_GROUP } from '../constants';
const DEFAULT_NAME = 'Default';
export default {
components: {
ApproversList,
ApproversSelect,
GlButton,
},
props: {
initRule: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
name: '',
approvalsRequired: 1,
minApprovalsRequired: 0,
approvers: [],
approversToAdd: [],
showValidation: false,
isFallback: false,
...this.getInitialData(),
};
},
computed: {
...mapState(['settings']),
approversByType() {
return _.groupBy(this.approvers, x => x.type);
},
users() {
return this.approversByType[TYPE_USER] || [];
},
groups() {
return this.approversByType[TYPE_GROUP] || [];
},
userIds() {
return this.users.map(x => x.id);
},
groupIds() {
return this.groups.map(x => x.id);
},
validation() {
if (!this.showValidation) {
return {};
}
return {
name: this.invalidName,
approvalsRequired: this.invalidApprovalsRequired,
approvers: this.invalidApprovers,
};
},
invalidName() {
if (!this.isMultiSubmission) {
return '';
}
return !this.name ? __('Please provide a name') : '';
},
invalidApprovalsRequired() {
if (!_.isNumber(this.approvalsRequired)) {
return __('Please enter a valid number');
}
if (this.approvalsRequired < 0) {
return __('Please enter a non-negative number');
}
return this.approvalsRequired < this.minApprovalsRequired
? sprintf(__('Please enter a number greater than %{number} (from the project settings)'), {
number: this.minApprovalsRequired,
})
: '';
},
invalidApprovers() {
if (!this.isMultiSubmission) {
return '';
}
return !this.approvers.length ? __('Please select and add a member') : '';
},
isValid() {
return Object.keys(this.validation).every(key => !this.validation[key]);
},
isMultiSubmission() {
return this.settings.allowMultiRule && !this.isFallbackSubmission;
},
isFallbackSubmission() {
return (
this.settings.allowMultiRule && this.isFallback && !this.name && !this.approvers.length
);
},
submissionData() {
return {
id: this.initRule && this.initRule.id,
name: this.name || DEFAULT_NAME,
approvalsRequired: this.approvalsRequired,
users: this.userIds,
groups: this.groupIds,
userRecords: this.users,
groupRecords: this.groups,
};
},
},
methods: {
...mapActions(['putFallbackRule', 'postRule', 'putRule', 'deleteRule']),
addSelection() {
if (!this.approversToAdd.length) {
return;
}
this.approvers = this.approversToAdd.concat(this.approvers);
this.approversToAdd = [];
},
/**
* Validate and submit the form based on what type it is.
* - Fallback rule?
* - Single rule?
* - Multi rule?
*/
submit() {
if (!this.validate()) {
return Promise.resolve();
} else if (this.isFallbackSubmission) {
return this.submitFallback();
} else if (!this.isMultiSubmission) {
return this.submitSingleRule();
}
return this.submitRule();
},
/**
* Submit the rule, by either put-ing or post-ing.
*/
submitRule() {
const data = this.submissionData;
return data.id ? this.putRule(data) : this.postRule(data);
},
/**
* Submit as a fallback rule.
*/
submitFallback() {
return this.putFallbackRule({ approvalsRequired: this.approvalsRequired });
},
/**
* Submit as a single rule. This is determined by the settings.
*/
submitSingleRule() {
if (!this.approvers.length) {
return this.submitEmptySingleRule();
}
return this.submitRule();
},
/**
* Submit as a single rule without approvers, so submit the fallback.
* Also delete the rule if necessary.
*/
submitEmptySingleRule() {
const id = this.initRule && this.initRule.id;
return Promise.all([this.submitFallback(), id ? this.deleteRule(id) : Promise.resolve()]);
},
validate() {
this.showValidation = true;
return this.isValid;
},
getInitialData() {
if (!this.initRule) {
return {};
}
if (this.initRule.isFallback) {
return {
approvalsRequired: this.initRule.approvalsRequired,
isFallback: this.initRule.isFallback,
};
}
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 || 0,
minApprovalsRequired: this.initRule.minApprovalsRequired || 0,
approvers: groups.concat(users),
};
},
},
};
</script>
<template>
<form novalidate @submit.prevent.stop="submit">
<div class="row">
<div v-if="settings.allowMultiRule" 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__('ApprovalRule|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="minApprovalsRequired"
/>
<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="settings.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-12em"><approvers-list v-model="approvers" /></div>
</form>
</template>
<script>
import { s__ } from '~/locale';
const HEADERS = {
name: s__('ApprovalRule|Name'),
members: s__('ApprovalRule|Members'),
approvalsRequired: s__('ApprovalRule|No. approvals required'),
};
export default {
props: {
rules: {
type: Array,
required: true,
},
},
HEADERS,
};
</script>
<template>
<table class="table m-0">
<thead class="thead-white text-nowrap">
<slot name="thead" v-bind="$options.HEADERS"></slot>
</thead>
<tbody>
<tr v-for="rule in rules" :key="rule.id">
<slot :rule="rule" name="tr"></slot>
</tr>
</tbody>
</table>
</template>
export const TYPE_USER = 'user';
export const TYPE_GROUP = 'group';
export const RULE_TYPE_FALLBACK = 'fallback';
export const RULE_TYPE_REGULAR = 'regular';
export const RULE_TYPE_CODE_OWNER = 'code_owner';
import _ from 'underscore';
import { RULE_TYPE_REGULAR, RULE_TYPE_FALLBACK } from './constants';
export const mapApprovalRuleRequest = req => ({
name: req.name,
approvals_required: req.approvalsRequired,
users: req.users,
groups: req.groups,
});
export const mapApprovalFallbackRuleRequest = req => ({
fallback_approvals_required: req.approvalsRequired,
});
export const mapApprovalRuleResponse = res => ({
id: res.id,
hasSource: !!res.source_rule,
name: res.name,
approvalsRequired: res.approvals_required,
minApprovalsRequired: res.source_rule ? res.source_rule.approvals_required : 0,
approvers: res.approvers,
users: res.users,
groups: res.groups,
});
export const mapApprovalSettingsResponse = res => ({
rules: res.rules.map(mapApprovalRuleResponse),
fallbackApprovalsRequired: res.fallback_approvals_required,
});
/**
* Map the sourced approval rule response for the MR view
*
* This rule is sourced from project settings, which implies:
* - Not a real MR rule, so no "id".
* - The approvals required are the minimum.
*/
export const mapMRSourceRule = ({ id, ...rule }) => ({
...rule,
hasSource: true,
sourceId: id,
minApprovalsRequired: rule.approvalsRequired || 0,
});
/**
* Map the approval settings response for the MR view
*
* - Only show regular rules.
* - If needed, extract the fallback approvals required
* from the fallback rule.
*/
export const mapMRApprovalSettingsResponse = res => {
const rulesByType = _.groupBy(res.rules, x => x.rule_type);
const regularRules = rulesByType[RULE_TYPE_REGULAR] || [];
const [fallback] = rulesByType[RULE_TYPE_FALLBACK] || [];
const fallbackApprovalsRequired = fallback
? fallback.approvals_required
: res.fallback_approvals_required || 0;
return {
rules: regularRules
.map(mapApprovalRuleResponse)
.map(res.approval_rules_overwritten ? x => x : mapMRSourceRule),
fallbackApprovalsRequired,
minFallbackApprovalsRequired: 0,
};
};
import Vue from 'vue';
import Vuex from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import createStore from './stores';
import mrEditModule from './stores/modules/mr_edit';
import MrEditApp from './components/mr_edit/app.vue';
Vue.use(Vuex);
export default function mountApprovalInput(el) {
if (!el) {
return null;
}
const store = createStore(mrEditModule(), {
...el.dataset,
prefix: 'mr-edit',
canEdit: parseBoolean(el.dataset.canEdit),
allowMultiRule: parseBoolean(el.dataset.allowMultiRule),
});
return new Vue({
el,
store,
render(h) {
return h(MrEditApp);
},
});
}
import Vue from 'vue';
import Vuex from 'vuex';
import createStore from './stores';
import projectSettingsModule from './stores/modules/project_settings';
import ProjectSettingsApp from './components/project_settings/app.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(Vuex);
export default function mountProjectSettingsApprovals(el) {
if (!el) {
return null;
}
const store = createStore(projectSettingsModule(), {
...el.dataset,
prefix: 'project-settings',
allowMultiRule: parseBoolean(el.dataset.allowMultiRule),
});
return new Vue({
el,
store,
render(h) {
return h(ProjectSettingsApp);
},
});
}
import Vuex from 'vuex';
import modalModule from '~/vuex_shared/modules/modal';
import state from './state';
export const createStoreOptions = (approvalsModule, settings) => ({
state: state(settings),
modules: {
...(approvalsModule ? { approvals: approvalsModule } : {}),
createModal: modalModule(),
deleteModal: modalModule(),
},
});
export default (approvalsModule, settings = {}) =>
new Vuex.Store(createStoreOptions(approvalsModule, settings));
export const isEmpty = state => !state.rules || !state.rules.length;
export default () => {};
import createState from './state';
import mutations from './mutations';
import * as getters from './getters';
export default () => ({
state: createState(),
mutations,
getters,
});
export const SET_LOADING = 'SET_LOADING';
export const SET_APPROVAL_SETTINGS = 'SET_APPROVAL_SETTINGS';
import * as types from './mutation_types';
export default {
[types.SET_LOADING](state, isLoading) {
state.isLoading = isLoading;
},
[types.SET_APPROVAL_SETTINGS](state, settings) {
state.hasLoaded = true;
state.rules = settings.rules;
state.fallbackApprovalsRequired = settings.fallbackApprovalsRequired;
state.minFallbackApprovalsRequired = settings.minFallbackApprovalsRequired;
},
};
export default () => ({
hasLoaded: false,
isLoading: false,
rules: [],
fallbackApprovalsRequired: 0,
minFallbackApprovalsRequired: 0,
});
import createFlash from '~/flash';
import _ from 'underscore';
import { __ } from '~/locale';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
import { mapMRApprovalSettingsResponse } from '../../../mappers';
const fetchGroupMembers = _.memoize(id => Api.groupMembers(id).then(response => response.data));
const fetchApprovers = ({ userRecords, groups }) => {
const groupUsersAsync = Promise.all(groups.map(fetchGroupMembers));
return groupUsersAsync
.then(_.flatten)
.then(groupUsers => groupUsers.concat(userRecords))
.then(users => _.uniq(users, false, x => x.id));
};
const seedApprovers = rule =>
rule.groups || rule.userRecords
? fetchApprovers(rule).then(approvers => ({
...rule,
approvers,
}))
: Promise.resolve(rule);
const seedUsers = ({ userRecords, ...rule }) =>
userRecords ? { ...rule, users: userRecords } : rule;
const seedGroups = ({ groupRecords, ...rule }) =>
groupRecords ? { ...rule, groups: groupRecords } : rule;
const seedLocalRule = rule =>
seedApprovers(rule)
.then(seedUsers)
.then(seedGroups);
const seedNewRule = rule => ({
...rule,
isNew: true,
id: _.uniqueId('new'),
});
export const requestRules = ({ commit }) => {
commit(types.SET_LOADING, true);
};
export const receiveRulesSuccess = ({ commit }, settings) => {
commit(types.SET_LOADING, false);
commit(types.SET_APPROVAL_SETTINGS, settings);
};
export const receiveRulesError = () => {
createFlash(__('An error occurred fetching the approval rules.'));
};
export const fetchRules = ({ rootState, dispatch }) => {
dispatch('requestRules');
const { mrSettingsPath, projectSettingsPath } = rootState.settings;
const path = mrSettingsPath || projectSettingsPath;
return axios
.get(path)
.then(response => mapMRApprovalSettingsResponse(response.data))
.then(settings => ({
...settings,
rules: settings.rules.map(x => (x.id ? x : seedNewRule(x))),
}))
.then(settings => dispatch('receiveRulesSuccess', settings))
.catch(() => dispatch('receiveRulesError'));
};
export const postRule = ({ commit, dispatch }, rule) =>
seedLocalRule(rule)
.then(seedNewRule)
.then(newRule => {
commit(types.POST_RULE, newRule);
dispatch('createModal/close');
})
.catch(e => {
createFlash(__('An error occurred fetching the approvers for the new rule.'));
throw e;
});
export const putRule = ({ commit, dispatch }, rule) =>
seedLocalRule(rule)
.then(newRule => {
commit(types.PUT_RULE, newRule);
dispatch('createModal/close');
})
.catch(e => {
createFlash(__('An error occurred fetching the approvers for the new rule.'));
throw e;
});
export const deleteRule = ({ commit, dispatch }, id) => {
commit(types.DELETE_RULE, id);
dispatch('deleteModal/close');
};
export const putFallbackRule = ({ commit, dispatch }, fallback) => {
commit(types.SET_FALLBACK_RULE, fallback);
dispatch('createModal/close');
};
export const requestEditRule = ({ dispatch }, rule) => {
dispatch('createModal/open', rule);
};
export const requestDeleteRule = ({ dispatch }, rule) => {
dispatch('deleteRule', rule.id);
};
export default () => {};
import base from '../base';
import * as actions from './actions';
import mutations from './mutations';
import createState from './state';
export default () => ({
...base(),
state: createState(),
actions,
mutations,
});
export * from '../base/mutation_types';
export const DELETE_RULE = 'DELETE_RULE';
export const PUT_RULE = 'PUT_RULE';
export const POST_RULE = 'POST_RULE';
export const SET_FALLBACK_RULE = 'SET_FALLBACK_RULE';
import _ from 'underscore';
import base from '../base/mutations';
import * as types from './mutation_types';
export default {
...base,
[types.DELETE_RULE](state, id) {
const idx = _.findIndex(state.rules, x => x.id === id);
if (idx < 0) {
return;
}
const rule = state.rules[idx];
// Keep track of rules we need to submit that are deleted
if (!rule.isNew) {
state.rulesToDelete.push(rule.id);
}
state.rules.splice(idx, 1);
},
[types.PUT_RULE](state, { id, ...newRule }) {
const idx = _.findIndex(state.rules, x => x.id === id);
if (idx < 0) {
return;
}
const rule = { ...state.rules[idx], ...newRule };
state.rules.splice(idx, 1, rule);
},
[types.POST_RULE](state, rule) {
state.rules.push(rule);
},
[types.SET_FALLBACK_RULE](state, fallback) {
state.fallbackApprovalsRequired = fallback.approvalsRequired;
},
};
import baseState from '../base/state';
export default () => ({
...baseState(),
rulesToDelete: [],
});
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 '../../../mappers';
export const requestRules = ({ commit }) => {
commit(types.SET_LOADING, true);
};
export const receiveRulesSuccess = ({ commit }, approvalSettings) => {
commit(types.SET_APPROVAL_SETTINGS, approvalSettings);
commit(types.SET_LOADING, false);
};
export const receiveRulesError = () => {
createFlash(__('An error occurred fetching the approval rules.'));
};
export const fetchRules = ({ rootState, dispatch }) => {
const { settingsPath } = rootState.settings;
dispatch('requestRules');
return axios
.get(settingsPath)
.then(response => dispatch('receiveRulesSuccess', mapApprovalSettingsResponse(response.data)))
.catch(() => dispatch('receiveRulesError'));
};
export const postRuleSuccess = ({ dispatch }) => {
dispatch('createModal/close');
dispatch('fetchRules');
};
export const postRuleError = () => {
createFlash(__('An error occurred while updating approvers'));
};
export const postRule = ({ rootState, dispatch }, rule) => {
const { rulesPath } = rootState.settings;
return axios
.post(rulesPath, mapApprovalRuleRequest(rule))
.then(() => dispatch('postRuleSuccess'))
.catch(() => dispatch('postRuleError'));
};
export const putRule = ({ rootState, dispatch }, { id, ...newRule }) => {
const { rulesPath } = rootState.settings;
return axios
.put(`${rulesPath}/${id}`, mapApprovalRuleRequest(newRule))
.then(() => dispatch('postRuleSuccess'))
.catch(() => dispatch('postRuleError'));
};
export const deleteRuleSuccess = ({ dispatch }) => {
dispatch('deleteModal/close');
dispatch('fetchRules');
};
export const deleteRuleError = () => {
createFlash(__('An error occurred while deleting the approvers group'));
};
export const deleteRule = ({ rootState, dispatch }, id) => {
const { rulesPath } = rootState.settings;
return axios
.delete(`${rulesPath}/${id}`)
.then(() => dispatch('deleteRuleSuccess'))
.catch(() => dispatch('deleteRuleError'));
};
export const putFallbackRuleSuccess = ({ dispatch }) => {
dispatch('createModal/close');
dispatch('fetchRules');
};
export const putFallbackRuleError = () => {
createFlash(__('An error occurred while saving the approval settings'));
};
export const putFallbackRule = ({ rootState, dispatch }, fallback) => {
const { projectPath } = rootState.settings;
return axios
.put(projectPath, mapApprovalFallbackRuleRequest(fallback))
.then(() => dispatch('putFallbackRuleSuccess'))
.catch(() => dispatch('putFallbackRuleError'));
};
export const requestEditRule = ({ dispatch }, rule) => {
dispatch('createModal/open', rule);
};
export const requestDeleteRule = ({ dispatch }, rule) => {
dispatch('deleteModal/open', rule);
};
export default () => {};
import base from '../base';
import * as actions from './actions';
export default () => ({
...base(),
actions,
});
export const DEFAULT_SETTINGS = {
canEdit: true,
allowMultiRule: false,
};
export default (settings = {}) => ({
settings: {
...DEFAULT_SETTINGS,
...settings,
},
});
......@@ -5,6 +5,7 @@ import UsersSelect from '~/users_select';
import UserCallout from '~/user_callout';
import groupsSelect from '~/groups_select';
import ApproversSelect from 'ee/approvers_select';
import mountApprovals from 'ee/approvals/mount_project_settings';
import initServiceDesk from 'ee/projects/settings_service_desk';
document.addEventListener('DOMContentLoaded', () => {
......@@ -15,4 +16,5 @@ document.addEventListener('DOMContentLoaded', () => {
new UserCallout({ className: 'js-mr-approval-callout' });
new ApproversSelect();
initServiceDesk();
mountApprovals(document.getElementById('js-mr-approvals-settings'));
});
import initApprovals from '../../../../approvals';
import initApprovals from 'ee/approvals/setup_single_rule_approvals';
import mountApprovals from 'ee/approvals/mount_mr_edit';
export default () => initApprovals();
export default () => {
initApprovals();
mountApprovals(document.getElementById('js-mr-approvals-input'));
};
import MultipleRuleApprovals from './multiple_rule/approvals.vue';
import SingleRuleApprovals from './single_rule/approvals.vue';
export default {
functional: true,
render(h, context) {
const component = gon.features.approvalRules ? MultipleRuleApprovals : SingleRuleApprovals;
return h(component, context.data, context.children);
},
};
import { __, s__ } from '~/locale';
export const FETCH_LOADING = __('Checking approval status');
export const FETCH_ERROR = s__(
'mrWidget|An error occurred while retrieving approval data for this merge request.',
);
export const APPROVE_ERROR = s__('mrWidget|An error occurred while submitting your approval.');
export const UNAPPROVE_ERROR = s__('mrWidget|An error occurred while removing your approval.');
export const APPROVED_MESSAGE = s__('mrWidget|Merge request approved.');
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
import MrWidgetContainer from '~/vue_merge_request_widget/components/mr_widget_container.vue';
import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue';
import ApprovalsSummary from './approvals_summary.vue';
import ApprovalsFooter from './approvals_footer.vue';
import { FETCH_LOADING, FETCH_ERROR, APPROVE_ERROR, UNAPPROVE_ERROR } from '../messages';
export default {
name: 'MRWidgetMultipleRuleApprovals',
components: {
UserAvatarList,
MrWidgetContainer,
MrWidgetIcon,
ApprovalsSummary,
ApprovalsFooter,
GlButton,
GlLoadingIcon,
},
props: {
mr: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
},
data() {
return {
fetchingApprovals: true,
isApproving: false,
isExpanded: false,
isLoadingRules: false,
};
},
computed: {
hasFooter() {
return this.mr.approvals && this.mr.approvals.has_approval_rules;
},
approvedBy() {
return this.mr.approvals.approved_by.map(x => x.user);
},
userHasApproved() {
return this.mr.approvals.user_has_approved;
},
userCanApprove() {
return this.mr.approvals.user_can_approve;
},
showApprove() {
return !this.userHasApproved && this.userCanApprove && this.mr.isOpen;
},
showUnapprove() {
return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged';
},
action() {
if (this.showApprove && this.mr.approvals.approved) {
return {
text: s__('mrWidget|Approve additionally'),
variant: 'primary',
inverted: true,
action: () => this.approve(),
};
} else if (this.showApprove) {
return {
text: s__('mrWidget|Approve'),
variant: 'primary',
action: () => this.approve(),
};
} else if (this.showUnapprove) {
return {
text: s__('mrWidget|Revoke approval'),
variant: 'warning',
inverted: true,
action: () => this.unapprove(),
};
}
return null;
},
},
watch: {
isExpanded(val) {
if (val) {
this.refreshAll();
}
},
},
created() {
this.refreshApprovals()
.then(() => {
this.fetchingApprovals = false;
})
.catch(() => createFlash(FETCH_ERROR));
},
methods: {
refreshAll() {
return Promise.all([this.refreshRules(), this.refreshApprovals()]).catch(() =>
createFlash(FETCH_ERROR),
);
},
refreshRules() {
this.isLoadingRules = true;
return this.service.fetchApprovalSettings().then(settings => {
this.mr.setApprovalRules(settings);
this.isLoadingRules = false;
});
},
refreshApprovals() {
return this.service.fetchApprovals().then(data => {
this.mr.setApprovals(data);
});
},
approve() {
this.updateApproval(() => this.service.approveMergeRequest(), APPROVE_ERROR);
},
unapprove() {
this.updateApproval(() => this.service.unapproveMergeRequest(), UNAPPROVE_ERROR);
},
updateApproval(serviceFn, error) {
this.isApproving = true;
return serviceFn()
.then(data => {
this.mr.setApprovals(data);
eventHub.$emit('MRWidgetUpdateRequested');
this.isApproving = false;
})
.catch(() => {
createFlash(error);
this.isApproving = false;
})
.then(() => this.refreshRules());
},
},
FETCH_LOADING,
};
</script>
<template>
<mr-widget-container>
<div class="js-mr-approvals d-flex align-items-start align-items-md-center">
<mr-widget-icon name="approval" />
<div v-if="fetchingApprovals">{{ $options.FETCH_LOADING }}</div>
<template v-else>
<gl-button
v-if="action"
:variant="action.variant"
:class="{ 'btn-inverted': action.inverted }"
size="sm"
class="mr-3"
@click="action.action"
>
<gl-loading-icon v-if="isApproving" inline />
{{ action.text }}
</gl-button>
<approvals-summary
:approvals-left="mr.approvals.approvals_left"
:rules-left="mr.approvals.approvalRuleNamesLeft"
:approvers="approvedBy"
/>
</template>
</div>
<approvals-footer
v-if="hasFooter"
slot="footer"
v-model="isExpanded"
:suggested-approvers="mr.approvals.suggested_approvers"
:approval-rules="mr.approvalRules"
:is-loading-rules="isLoadingRules"
/>
</mr-widget-container>
</template>
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import ApprovalsList from './approvals_list.vue';
export default {
components: {
Icon,
GlButton,
GlLoadingIcon,
UserAvatarList,
ApprovalsList,
},
props: {
suggestedApprovers: {
type: Array,
required: true,
},
approvalRules: {
type: Array,
required: true,
},
value: {
type: Boolean,
required: false,
default: true,
},
isLoadingRules: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isCollapsed() {
return !this.value;
},
ariaLabel() {
return this.isCollapsed ? __('Expand approvers') : __('Collapse approvers');
},
angleIcon() {
return this.isCollapsed ? 'chevron-right' : 'chevron-down';
},
suggestedApproversTrimmed() {
return this.suggestedApprovers.slice(0, Math.min(5, this.suggestedApprovers.length));
},
},
methods: {
toggle() {
this.$emit('input', !this.value);
},
},
};
</script>
<template>
<div>
<div class="mr-widget-extension d-flex align-items-center pl-3">
<button
class="btn btn-blank square s32 d-flex-center append-right-default"
type="button"
:aria-label="ariaLabel"
@click="toggle"
>
<gl-loading-icon v-if="!isCollapsed && isLoadingRules" />
<icon v-else :name="angleIcon" :size="16" />
</button>
<template v-if="isCollapsed">
<user-avatar-list :items="suggestedApproversTrimmed" :breakpoint="0" empty-text="" />
<gl-button variant="link" @click="toggle">{{ __('View eligible approvers') }}</gl-button>
</template>
<template v-else>
<gl-button variant="link" @click="toggle">{{ __('Collapse') }}</gl-button>
</template>
</div>
<div v-if="!isCollapsed && approvalRules.length" class="border-top">
<approvals-list :approval-rules="approvalRules" />
</div>
</div>
</template>
<script>
import { sprintf, __ } from '~/locale';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import ApprovedIcon from './approved_icon.vue';
export default {
components: {
UserAvatarList,
ApprovedIcon,
},
props: {
approvalRules: {
type: Array,
required: true,
},
},
methods: {
pendingApprovalsText(rule) {
if (!rule.approvals_required) {
return __('Optional');
}
return sprintf(__('%{count} of %{total}'), {
count: rule.approved_by.length,
total: rule.approvals_required,
});
},
isApproved(rule) {
return rule.approvals_required > 0 && rule.approvals_required <= rule.approved_by.length;
},
summaryText(rule) {
return rule.approvals_required === 0
? this.summaryOptionalText(rule)
: this.summaryRequiredText(rule);
},
summaryRequiredText(rule) {
return sprintf(__('%{count} of %{required} approvals from %{name}'), {
count: rule.approved_by.length,
required: rule.approvals_required,
name: rule.name,
});
},
summaryOptionalText(rule) {
return sprintf(__('%{count} approvals from %{name}'), {
count: rule.approved_by.length,
name: rule.name,
});
},
},
};
</script>
<template>
<table class="table m-0">
<thead class="thead-white text-nowrap">
<tr class="d-none d-sm-table-row">
<th class="w-0"></th>
<th>{{ s__('MRApprovals|Approvers') }}</th>
<th class="w-50"></th>
<th>{{ s__('MRApprovals|Pending approvals') }}</th>
<th>{{ s__('MRApprovals|Approved by') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="rule in approvalRules" :key="rule.id">
<td class="w-0"><approved-icon :is-approved="isApproved(rule)" /></td>
<td :colspan="rule.fallback ? 2 : 1">
<div class="d-none d-sm-block js-name">{{ rule.name }}</div>
<div class="d-flex d-sm-none flex-column js-summary">
<span>{{ summaryText(rule) }}</span>
<user-avatar-list
v-if="!rule.fallback"
class="mt-2"
:items="rule.approvers"
:img-size="24"
/>
<div v-if="rule.approved_by.length" class="mt-2">
<span>{{ s__('MRApprovals|Approved by') }}</span>
<user-avatar-list
class="d-inline-block align-middle"
:items="rule.approved_by"
:img-size="24"
/>
</div>
</div>
</td>
<td v-if="!rule.fallback" class="d-none d-sm-table-cell js-approvers">
<div><user-avatar-list :items="rule.approvers" :img-size="24" /></div>
</td>
<td class="w-0 d-none d-sm-table-cell text-nowrap js-pending">
{{ pendingApprovalsText(rule) }}
</td>
<td class="d-none d-sm-table-cell js-approved-by">
<user-avatar-list :items="rule.approved_by" :img-size="24" />
</td>
</tr>
</tbody>
</table>
</template>
<script>
import { n__, sprintf } from '~/locale';
import { toNounSeriesText } from '~/lib/utils/grammar';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import { APPROVED_MESSAGE } from '../messages';
export default {
components: {
UserAvatarList,
},
props: {
approvalsLeft: {
type: Number,
required: true,
},
rulesLeft: {
type: Array,
required: false,
default: () => [],
},
approvers: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
isApproved() {
return this.approvalsLeft <= 0;
},
message() {
if (this.isApproved) {
return APPROVED_MESSAGE;
}
if (!this.rulesLeft.length) {
return n__('Requires approval.', 'Requires %d more approvals.', this.approvalsLeft);
}
return sprintf(
n__(
'Requires approval from %{names}.',
'Requires %{count} more approvals from %{names}.',
this.approvalsLeft,
),
{
names: toNounSeriesText(this.rulesLeft),
count: this.approvalsLeft,
},
);
},
hasApprovers() {
return !!this.approvers.length;
},
},
APPROVED_MESSAGE,
};
</script>
<template>
<div>
<strong>{{ message }}</strong>
<template v-if="hasApprovers">
<span>{{ s__('mrWidget|Approved by') }}</span>
<user-avatar-list class="d-inline-block align-middle" :items="approvers" />
</template>
</div>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
props: {
isApproved: {
type: Boolean,
required: true,
},
},
};
</script>
<template>
<icon v-if="isApproved" name="mobile-issue-close" class="text-success" :size="16" />
<div v-else class="square s16"></div>
</template>
<script>
import Flash from '~/flash';
import createFlash from '~/flash';
import MrWidgetContainer from '~/vue_merge_request_widget/components/mr_widget_container.vue';
import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue';
import { s__ } from '~/locale';
import ApprovalsBody from './approvals_body.vue';
import ApprovalsFooter from './approvals_footer.vue';
import { FETCH_LOADING, FETCH_ERROR } from '../messages';
export default {
name: 'MRWidgetApprovals',
name: 'MRWidgetSingleRuleApprovals',
components: {
ApprovalsBody,
ApprovalsFooter,
......@@ -46,18 +46,15 @@ export default {
},
},
created() {
const flashErrorMessage = s__(
'mrWidget|An error occurred while retrieving approval data for this merge request.',
);
this.service
.fetchApprovals()
.then(data => {
this.mr.setApprovals(data);
this.fetchingApprovals = false;
})
.catch(() => new Flash(flashErrorMessage));
.catch(() => createFlash(FETCH_ERROR));
},
FETCH_LOADING,
};
</script>
<template>
......@@ -65,7 +62,7 @@ export default {
<div v-if="mr.approvalsRequired" class="media media-section js-mr-approvals align-items-center">
<mr-widget-icon name="approval" />
<div v-show="fetchingApprovals" class="mr-approvals-loading-state media-body">
<span class="approvals-loading-text"> {{ __('Checking approval status') }} </span>
<span class="approvals-loading-text"> {{ $options.FETCH_LOADING }} </span>
</div>
<div v-if="!fetchingApprovals" class="approvals-components media-body">
<approvals-body
......
......@@ -5,6 +5,7 @@ import Icon from '~/vue_shared/components/icon.vue';
import MrWidgetAuthor from '~/vue_merge_request_widget/components/mr_widget_author.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '~/vue_merge_request_widget/event_hub';
import { APPROVE_ERROR } from '../messages';
export default {
name: 'ApprovalsBody',
......@@ -128,7 +129,7 @@ export default {
})
.catch(() => {
this.approving = false;
Flash(s__('mrWidget|An error occurred while submitting your approval.'));
Flash(APPROVE_ERROR);
});
},
},
......
......@@ -5,6 +5,7 @@ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link
import { s__ } from '~/locale';
import eventHub from '~/vue_merge_request_widget/event_hub';
import { UNAPPROVE_ERROR } from '../messages';
export default {
name: 'ApprovalsFooter',
......@@ -76,7 +77,7 @@ export default {
})
.catch(() => {
this.unapproving = false;
Flash(s__('mrWidget|An error occurred while removing your approval.'));
Flash(UNAPPROVE_ERROR);
});
},
},
......
import { __ } from '~/locale';
import { RULE_TYPE_REGULAR, RULE_TYPE_FALLBACK } from 'ee/approvals/constants';
function mapApprovalRule(rule, settings) {
if (rule.rule_type === RULE_TYPE_FALLBACK) {
// Show a friendly name for the fallback rule
return {
...rule,
name: __('All Members'),
fallback: true,
};
} else if (rule.rule_type === RULE_TYPE_REGULAR && !settings.multiple_approval_rules_available) {
// Give a friendly name to the single rule
return {
...rule,
name: __('Merge Request'),
};
}
return rule;
}
/**
* Map the approval rules response for use by the MR widget
*/
export function mapApprovalRulesResponse(rules, settings) {
return rules.map(x => mapApprovalRule(x, settings));
}
/**
* Map the overall approvals response for use by the MR widget
*/
export function mapApprovalsResponse(data) {
return {
...data,
// Filter out empty names (fallback rule has no name) because
// the empties would look weird.
approvalRuleNamesLeft: data.multiple_approval_rules_available
? data.approval_rules_left.map(x => x.name).filter(x => x)
: [],
};
}
......@@ -7,7 +7,7 @@ import MrWidgetLicenses from 'ee/vue_shared/license_management/mr_widget_license
import { n__, s__, __, sprintf } from '~/locale';
import CEWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
import MrWidgetApprovals from './components/approvals/mr_widget_approvals.vue';
import MrWidgetApprovals from './components/approvals';
import MrWidgetGeoSecondaryNode from './components/states/mr_widget_secondary_geo_node.vue';
export default {
......@@ -144,6 +144,10 @@ export default {
return {
...base,
approvalsPath: store.approvalsPath,
apiApprovalsPath: store.apiApprovalsPath,
apiApprovalSettingsPath: store.apiApprovalSettingsPath,
apiApprovePath: store.apiApprovePath,
apiUnapprovePath: store.apiUnapprovePath,
};
},
fetchCodeQuality() {
......
import axios from '~/lib/utils/axios_utils';
import CEWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
export default class MRWidgetService extends CEWidgetService {
......@@ -7,6 +6,21 @@ export default class MRWidgetService extends CEWidgetService {
super(mr);
this.approvalsPath = mr.approvalsPath;
// This feature flag will be the default behavior when
// https://gitlab.com/gitlab-org/gitlab-ee/issues/1979 is closed
if (gon.features.approvalRules) {
this.apiApprovalsPath = mr.apiApprovalsPath;
this.apiApprovalSettingsPath = mr.apiApprovalSettingsPath;
this.apiApprovePath = mr.apiApprovePath;
this.apiUnapprovePath = mr.apiUnapprovePath;
this.fetchApprovals = () => axios.get(this.apiApprovalsPath).then(res => res.data);
this.fetchApprovalSettings = () =>
axios.get(this.apiApprovalSettingsPath).then(res => res.data);
this.approveMergeRequest = () => axios.post(this.apiApprovePath).then(res => res.data);
this.unapproveMergeRequest = () => axios.post(this.apiUnapprovePath).then(res => res.data);
}
}
fetchApprovals() {
......
import CEMergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store';
import { filterByKey } from 'ee/vue_shared/security_reports/store/utils';
import { mapApprovalsResponse, mapApprovalRulesResponse } from '../mappers';
export default class MergeRequestStore extends CEMergeRequestStore {
constructor(data) {
......@@ -42,17 +43,26 @@ export default class MergeRequestStore extends CEMergeRequestStore {
initApprovals(data) {
this.isApproved = this.isApproved || false;
this.approvals = this.approvals || null;
this.approvalRules = this.approvalRules || [];
this.approvalsPath = data.approvals_path || this.approvalsPath;
this.approvalsRequired = data.approvalsRequired || Boolean(this.approvalsPath);
this.apiApprovalsPath = data.api_approvals_path || this.apiApprovalsPath;
this.apiApprovalSettingsPath = data.api_approval_settings_path || this.apiApprovalSettingsPath;
this.apiApprovePath = data.api_approve_path || this.apiApprovePath;
this.apiUnapprovePath = data.api_unapprove_path || this.apiUnapprovePath;
}
setApprovals(data) {
this.approvals = data;
this.approvals = mapApprovalsResponse(data);
this.approvalsLeft = !!data.approvals_left;
this.isApproved = !this.approvalsLeft || false;
this.preventMerge = this.approvalsRequired && this.approvalsLeft;
}
setApprovalRules(data) {
this.approvalRules = mapApprovalRulesResponse(data.rules, this.approvals);
}
initCodeclimate(data) {
this.codeclimate = data.codeclimate;
this.codeclimateMetrics = {
......
......@@ -8,6 +8,10 @@ module EE
APPROVAL_RENDERING_ACTIONS = [:approve, :approvals, :unapprove].freeze
prepended do
before_action only: [:show] do
push_frontend_feature_flag(:approval_rules)
end
before_action :whitelist_query_limiting_ee_merge, only: [:merge]
before_action :whitelist_query_limiting_ee_show, only: [:show]
end
......
- can_override_approvers = project.can_override_approvers?
.form-group
= form.label :approver_ids, class: 'label-bold' do
= _("Approvers")
= hidden_field_tag "project[approver_ids]"
= hidden_field_tag "project[approver_group_ids]"
.input-group.input-btn-group
= hidden_field_tag :approver_user_and_group_ids, '', { class: 'js-select-user-and-group input-large', tabindex: 1, 'data-name': 'project', :style => "max-width: unset;" }
%button.btn.btn-success.js-add-approvers{ type: 'button', title: 'Add approvers(s)' }
= _("Add")
.form-text.text-muted
= _("Add users or groups who are allowed to approve every merge request")
.card.prepend-top-10.js-current-approvers
.load-wrapper.hidden
= icon('spinner spin', class: 'approver-list-loader')
.card-header
= _("Approvers")
%span.badge.badge-pill
- ids = []
- project.approvers.each do |user|
- ids << user.user_id
- project.approver_groups.each do |group|
- group.users.each do |user|
- unless ids.include?(user.id)
- ids << user.id
= ids.count
%ul.content-list.approver-list
- project.approvers.each do |approver|
%li.approver.settings-flex-row.js-approver{ data: { id: approver.user_id } }
= link_to approver.user.name, approver.user
.float-right
- confirm_message = _("Are you sure you want to remove approver %{name}?") % { name: approver.user.name }
%button{ href: project_approver_path(project, approver), data: { confirm: confirm_message }, class: "btn btn-remove js-approver-remove", title: _("Remove approver") }
= icon("trash")
- project.approver_groups.each do |approver_group|
%li.approver-group.settings-flex-row.js-approver-group{ data: { id: approver_group.group.id } }
%span
%span.light
= _("Group:")
= link_to approver_group.group.name, approver_group.group
%span.badge.badge-pill
= approver_group.group.members.count
.float-right
- confirm_message = _("Are you sure you want to remove group %{name}?") % { name: approver_group.group.name }
%button{ href: project_approver_group_path(project, approver_group), data: { confirm: confirm_message }, class: "btn btn-remove js-approver-remove", title: _("Remove group") }
= icon("trash")
- if project.approvers.empty? && project.approver_groups.empty?
%li= _("There are no approvers")
.form-group
= form.label :approvals_before_merge, class: 'label-bold' do
= _("Approvals required")
= form.number_field :approvals_before_merge, class: "form-control", min: 0
.form-text.text-muted
= _("Set number of approvers required before open merge requests can be merged")
- if Feature.enabled?(:approval_rules, project)
= render 'shared/merge_request_approvals_settings/multiple_rules_form', form: form, project: project
- else
= render 'shared/merge_request_approvals_settings/single_rule_form', form: form, project: project
.form-group
.form-check
......
......@@ -11,69 +11,10 @@
= form.label :approver_ids, class: 'col-form-label col-sm-2' do
Approvers
.col-sm-10
- if can_update_approvers
- skip_users = [*presenter.all_approvers_including_groups, *(issuable.authors unless presenter.authors_can_approve?)].compact
= users_select_tag("merge_request[approver_ids]",
multiple: true,
class: 'input-large',
email_user: true,
skip_users: skip_users,
project: issuable.target_project)
.form-text.text-muted
This merge request must be approved by these users.
You can override the project settings by setting your own list of approvers.
- skip_groups = presenter.overall_approver_groups.pluck(:group_id) # rubocop: disable CodeReuse/ActiveRecord
= groups_select_tag('merge_request[approver_group_ids]', multiple: true, data: { skip_groups: skip_groups, all_available: true, project: issuable.target_project }, class: 'input-large')
.form-text.text-muted
This merge request must be approved by members of these groups.
You can override the project settings by setting your own list of approvers.
.card.prepend-top-10
.card-header
Approvers
%ul.content-list.approver-list.qa-approver-list
- if presenter.all_approvers_including_groups.empty?
%li.no-approvers There are no approvers
- else
- unsaved_approvers = !presenter.approvers_overwritten?
- item_classes = unsaved_approvers ? ['unsaved-approvers'] : []
- presenter.overall_approvers.each do |approver|
%li{ id: dom_id(approver), class: item_classes + ['approver'] }
= link_to approver.name, approver, class: 'qa-approver'
- if can_update_approvers
.float-right
- if unsaved_approvers
= link_to "#", data: { confirm: "Are you sure you want to remove approver #{approver.name}"}, class: "btn-sm btn btn-remove", title: 'Remove approver' do
= icon("sign-out")
Remove
- if Feature.enabled?(:approval_rules, @project)
= render 'shared/issuable/approvals_multiple_rule', issuable: issuable
- else
= link_to project_merge_request_approver_via_user_id_path(@project, issuable, user_id: approver.id), data: { confirm: "Are you sure you want to remove approver #{approver.name}"}, method: :delete, class: "btn-sm btn btn-remove", title: 'Remove approver' do
= icon("sign-out")
Remove
- presenter.overall_approver_groups.each do |approver_group|
%li{ id: dom_id(approver_group.group), class: item_classes + ['approver-group'] }
Group:
= link_to approver_group.group.name, approver_group.group
- if can_update_approvers
.float-right
- if unsaved_approvers
= link_to "#", data: { confirm: "Are you sure you want to remove group #{approver_group.group.name}"}, class: "btn-sm btn btn-remove", title: 'Remove group' do
= icon("sign-out")
Remove
- else
= link_to project_merge_request_approver_group_path(@project, issuable, approver_group), data: { confirm: "Are you sure you want to remove group #{approver_group.group.name}"}, method: :delete, class: "btn-sm btn btn-remove", title: 'Remove group' do
= icon("sign-out")
Remove
.col-sm-12
.form-group.row
= form.label :approvals_before_merge, class: 'label-bold' do
Approvals required
= form.number_field :approvals_before_merge, class: 'form-control', value: issuable.approvals_required, readonly: !can_update_approvers
= render 'shared/issuable/approvals_single_rule', issuable: issuable, presenter: presenter, form: form
- if can_update_approvers
- approver_presenter = MergeRequestApproverPresenter.new(issuable, skip_user: current_user)
.form-text.text-muted.suggested-approvers
......
#js-mr-approvals-input{ data: { 'project_id': @project.id,
'can_edit': can?(current_user, :update_approvers, issuable).to_s,
'allow_multi_rule': @project.multiple_approval_rules_available?.to_s,
'mr_id': issuable.iid,
'mr_settings_path': issuable.iid && api_v4_projects_merge_requests_approval_settings_path(id: @project.id, merge_request_iid: issuable.iid),
'project_settings_path': api_v4_projects_approval_settings_path(id: @project.id) } }
= sprite_icon('spinner', size: 24, css_class: 'gl-spinner')
- issuable = local_assigns.fetch(:issuable)
- presenter = local_assigns.fetch(:presenter)
- form = local_assigns.fetch(:form)
- can_update_approvers = can?(current_user, :update_approvers, issuable)
- if can_update_approvers
- skip_users = [*presenter.all_approvers_including_groups, *(issuable.authors unless presenter.authors_can_approve?)].compact
= users_select_tag("merge_request[approver_ids]",
multiple: true,
class: 'input-large',
email_user: true,
skip_users: skip_users,
project: issuable.target_project)
.form-text.text-muted
= _('This merge request must be approved by these users. You can override the project settings by setting your own list of approvers.')
- skip_groups = presenter.overall_approver_groups.pluck(:group_id) # rubocop: disable CodeReuse/ActiveRecord
= groups_select_tag('merge_request[approver_group_ids]', multiple: true, data: { skip_groups: skip_groups, all_available: true, project: issuable.target_project }, class: 'input-large')
.form-text.text-muted
= _('This merge request must be approved by members of these groups. You can override the project settings by setting your own list of approvers.')
.card.prepend-top-10
.card-header
= _('Approvers')
%ul.content-list.approver-list.qa-approver-list
- if presenter.all_approvers_including_groups.empty?
%li.no-approvers= _('There are no approvers')
- else
- unsaved_approvers = !presenter.approvers_overwritten?
- item_classes = unsaved_approvers ? ['unsaved-approvers'] : []
- presenter.overall_approvers.each do |approver|
%li{ id: dom_id(approver), class: item_classes + ['approver'] }
= link_to approver.name, approver, class: 'qa-approver'
- if can_update_approvers
.float-right
- if unsaved_approvers
%button{ class: 'btn-sm btn btn-remove', title: _('Remove approver'), data: { confirm: _("Are you sure you want to remove approver %{name}") % { name: approver.name } } }
= icon("sign-out")
= _('Remove')
- else
= link_to project_merge_request_approver_via_user_id_path(@project, issuable, user_id: approver.id), data: { confirm: _("Are you sure you want to remove approver %{name}") % { name: approver.name } }, method: :delete, class: "btn-sm btn btn-remove", title: _('Remove approver') do
= icon("sign-out")
= _('Remove')
- presenter.overall_approver_groups.each do |approver_group|
%li{ id: dom_id(approver_group.group), class: item_classes + ['approver-group'] }
= _('Group:')
= link_to approver_group.group.name, approver_group.group
- if can_update_approvers
.float-right
- if unsaved_approvers
%button{ class: "btn-sm btn btn-remove", title: _('Remove group'), data: { confirm: _("Are you sure you want to remove group %{name}") % { name: approver_group.group.name } } }
= icon("sign-out")
= _('Remove')
- else
= link_to project_merge_request_approver_group_path(@project, issuable, approver_group), data: { confirm: _("Are you sure you want to remove group %{name}") % { name: approver_group.group.name } }, method: :delete, class: "btn-sm btn btn-remove", title: _('Remove group') do
= icon("sign-out")
= _('Remove')
.col-sm-12
.form-group.row
= form.label :approvals_before_merge, class: 'label-bold' do
= _('Approvals required')
= form.number_field :approvals_before_merge, class: 'form-control', value: issuable.approvals_required, readonly: !can_update_approvers
.form-group
= form.label :approver_ids, class: 'label-bold' do
= _("Approvers")
#js-mr-approvals-settings{ data: { 'project_id': @project.id,
'project_path': api_v4_projects_path(id: @project.id),
'settings_path': api_v4_projects_approval_settings_path(id: @project.id),
'rules_path': api_v4_projects_approval_settings_rules_path(id: @project.id),
'allow_multi_rule': @project.multiple_approval_rules_available?.to_s } }
.text-center.prepend-top-default
= sprite_icon('spinner', size: 24, css_class: 'gl-spinner')
.form-group
= form.label :approver_ids, class: 'label-bold' do
= _("Approvers")
= hidden_field_tag "project[approver_ids]"
= hidden_field_tag "project[approver_group_ids]"
.input-group.input-btn-group
= hidden_field_tag :approver_user_and_group_ids, '', { class: 'js-select-user-and-group input-large', tabindex: 1, 'data-name': 'project', :style => "max-width: unset;" }
%button.btn.btn-success.js-add-approvers{ type: 'button', title: _('Add approver(s)') }
= _("Add")
.form-text.text-muted
= _("Add users or groups who are allowed to approve every merge request")
.card.prepend-top-10.js-current-approvers
.load-wrapper.hidden
= icon('spinner spin', class: 'approver-list-loader')
.card-header
= _("Approvers")
%span.badge.badge-pill
- ids = []
- project.approvers.each do |user|
- ids << user.user_id
- project.approver_groups.each do |group|
- group.users.each do |user|
- unless ids.include?(user.id)
- ids << user.id
= ids.count
%ul.content-list.approver-list
- project.approvers.each do |approver|
%li.approver.settings-flex-row.js-approver{ data: { id: approver.user_id } }
= link_to approver.user.name, approver.user
.float-right
- confirm_message = _("Are you sure you want to remove approver %{name}?") % { name: approver.user.name }
%button{ href: project_approver_path(project, approver), data: { confirm: confirm_message }, class: "btn btn-remove js-approver-remove", title: _("Remove approver") }
= icon("trash")
- project.approver_groups.each do |approver_group|
%li.approver-group.settings-flex-row.js-approver-group{ data: { id: approver_group.group.id } }
%span
%span.light
= _("Group:")
= link_to approver_group.group.name, approver_group.group
%span.badge.badge-pill
= approver_group.group.members.count
.float-right
- confirm_message = _("Are you sure you want to remove group %{name}?") % { name: approver_group.group.name }
%button{ href: project_approver_group_path(project, approver_group), data: { confirm: confirm_message }, class: "btn btn-remove js-approver-remove", title: _("Remove group") }
= icon("trash")
- if project.approvers.empty? && project.approver_groups.empty?
%li= _("There are no approvers")
.form-group
= form.label :approvals_before_merge, class: 'label-bold' do
= _("Approvals required")
= form.number_field :approvals_before_merge, class: "form-control", min: 0
.form-text.text-muted
= _("Set number of approvers required before open merge requests can be merged")
---
title: Multiple blocking merge request approval rules (behind feature flag)
merge_request: 9001
author:
type: added
......@@ -5,6 +5,10 @@ describe 'Merge request > User approves', :js do
let(:project) { create(:project, :public, :repository, approvals_before_merge: 1) }
let(:merge_request) { create(:merge_request, source_project: project) }
before do
stub_feature_flags(approval_rules: false)
end
context 'Approving by approvers from groups' do
let(:other_user) { create(:user) }
let(:group) { create :group }
......
......@@ -5,6 +5,10 @@ describe 'Merge request > User sees approval widget', :js do
let(:user) { project.creator }
let(:merge_request) { create(:merge_request, source_project: project) }
before do
stub_feature_flags(approval_rules: false)
end
context 'when merge when discussions resolved is active' do
let(:project) do
create(:project, :repository,
......
......@@ -6,6 +6,10 @@ describe 'Merge request > User sets approvers', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository, approvals_before_merge: 1) }
before do
stub_feature_flags(approval_rules: false)
end
context 'when editing an MR with a different author' do
let(:author) { create(:user) }
let(:merge_request) { create(:merge_request, author: author, source_project: project) }
......
......@@ -10,6 +10,8 @@ describe 'Project settings > [EE] Merge Requests', :js do
let(:non_member) { create(:user) }
before do
stub_feature_flags(approval_rules: false)
sign_in(user)
project.add_maintainer(user)
group.add_developer(user)
......
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlLoadingIcon, GlButton } from '@gitlab/ui';
import App from 'ee/approvals/components/app.vue';
import ModalRuleCreate from 'ee/approvals/components/modal_rule_create.vue';
import ModalRuleRemove from 'ee/approvals/components/modal_rule_remove.vue';
import { createStoreOptions } from 'ee/approvals/stores';
import settingsModule from 'ee/approvals/stores/modules/project_settings';
const localVue = createLocalVue();
localVue.use(Vuex);
const TEST_RULES_CLASS = 'js-fake-rules';
const APP_PREFIX = 'lorem-ipsum';
describe('EE Approvals App', () => {
let store;
let wrapper;
let slots;
const factory = () => {
wrapper = shallowMount(localVue.extend(App), {
localVue,
slots,
store: new Vuex.Store(store),
sync: false,
});
};
const findAddButton = () => wrapper.find(GlButton);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findRules = () => wrapper.find(`.${TEST_RULES_CLASS}`);
beforeEach(() => {
slots = {
rules: `<div class="${TEST_RULES_CLASS}">These are the rules!</div>`,
};
store = createStoreOptions(settingsModule(), {
canEdit: true,
prefix: APP_PREFIX,
});
spyOn(store.modules.approvals.actions, 'fetchRules');
spyOn(store.modules.createModal.actions, 'open');
});
describe('when allow multi rule', () => {
beforeEach(() => {
store.state.settings.allowMultiRule = true;
});
it('dispatches fetchRules action on created', () => {
expect(store.modules.approvals.actions.fetchRules).not.toHaveBeenCalled();
factory();
expect(store.modules.approvals.actions.fetchRules).toHaveBeenCalledTimes(1);
});
it('renders create modal', () => {
factory();
const modal = wrapper.find(ModalRuleCreate);
expect(modal.exists()).toBe(true);
expect(modal.props('modalId')).toBe(`${APP_PREFIX}-approvals-create-modal`);
});
it('renders delete modal', () => {
factory();
const modal = wrapper.find(ModalRuleRemove);
expect(modal.exists()).toBe(true);
expect(modal.props('modalId')).toBe(`${APP_PREFIX}-approvals-remove-modal`);
});
describe('if not loaded', () => {
beforeEach(() => {
store.modules.approvals.state.hasLoaded = false;
});
it('shows loading icon', () => {
store.modules.approvals.state.isLoading = false;
factory();
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('if loaded and empty', () => {
beforeEach(() => {
store.modules.approvals.state = {
hasLoaded: true,
rules: [],
isLoading: false,
};
});
it('does not show Rules', () => {
factory();
expect(findRules().exists()).toBe(false);
});
it('shows loading icon if loading', () => {
store.modules.approvals.state.isLoading = true;
factory();
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not show loading icon if not loading', () => {
store.modules.approvals.state.isLoading = false;
factory();
expect(findLoadingIcon().exists()).toBe(false);
});
});
describe('if not empty', () => {
beforeEach(() => {
store.modules.approvals.state.hasLoaded = true;
store.modules.approvals.state.rules = [{ id: 1 }];
});
it('shows rules', () => {
factory();
expect(findRules().exists()).toBe(true);
});
it('renders add button', () => {
factory();
const button = findAddButton();
expect(button.exists()).toBe(true);
expect(button.text()).toBe('Add approvers');
});
it('opens create modal when add button is clicked', () => {
factory();
findAddButton().vm.$emit('click');
expect(store.modules.createModal.actions.open).toHaveBeenCalledWith(
jasmine.anything(),
null,
undefined,
);
});
it('shows loading icon and rules if loading', () => {
store.modules.approvals.state.isLoading = true;
factory();
expect(findRules().exists()).toBe(true);
expect(findLoadingIcon().exists()).toBe(true);
});
});
});
describe('when allow only single rule', () => {
beforeEach(() => {
store.state.settings.allowMultiRule = false;
});
it('does not render add button', () => {
factory();
expect(findAddButton().exists()).toBe(false);
});
});
});
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,
sync: false,
});
};
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 '~/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(() => {
jasmine.clock().uninstall();
wrapper.destroy();
});
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 { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { createStoreOptions } from 'ee/approvals/stores';
import MREditModule from 'ee/approvals/stores/modules/mr_edit';
import MREditApp from 'ee/approvals/components/mr_edit/app.vue';
import MRFallbackRules from 'ee/approvals/components/mr_edit/mr_fallback_rules.vue';
import MRRules from 'ee/approvals/components/mr_edit/mr_rules.vue';
import MRRulesHiddenInputs from 'ee/approvals/components/mr_edit/mr_rules_hidden_inputs.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('EE Approvlas MREditApp', () => {
let wrapper;
let store;
const factory = () => {
wrapper = mount(localVue.extend(MREditApp), {
localVue,
store: new Vuex.Store(store),
sync: false,
});
};
beforeEach(() => {
store = createStoreOptions(MREditModule());
store.modules.approvals.state.hasLoaded = true;
});
afterEach(() => {
wrapper.destroy();
});
describe('with empty rules', () => {
beforeEach(() => {
store.modules.approvals.state.rules = [];
factory();
});
it('renders MR fallback rules', () => {
expect(wrapper.find(MRFallbackRules).exists()).toBe(true);
});
it('does not render MR rules', () => {
expect(wrapper.find(MRRules).exists()).toBe(false);
});
it('renders hidden inputs', () => {
expect(wrapper.find(MRRulesHiddenInputs).exists()).toBe(true);
});
});
describe('with rules', () => {
beforeEach(() => {
store.modules.approvals.state.rules = [{ id: 7, approvers: [] }];
factory();
});
it('does not render MR fallback rules', () => {
expect(wrapper.find(MRFallbackRules).exists()).toBe(false);
});
it('renders MR rules', () => {
expect(wrapper.find(MRRules).exists()).toBe(true);
});
it('renders hidden inputs', () => {
expect(wrapper.find(MRRulesHiddenInputs).exists()).toBe(true);
});
});
});
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { createStoreOptions } from 'ee/approvals/stores';
import MREditModule from 'ee/approvals/stores/modules/mr_edit';
import MRFallbackRules from 'ee/approvals/components/mr_edit/mr_fallback_rules.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
const TEST_APPROVALS_REQUIRED = 3;
const TEST_MIN_APPROVALS_REQUIRED = 2;
describe('EE Approvals MRFallbackRules', () => {
let wrapper;
let store;
const factory = () => {
wrapper = mount(localVue.extend(MRFallbackRules), {
localVue,
store: new Vuex.Store(store),
sync: false,
});
};
beforeEach(() => {
store = createStoreOptions(MREditModule());
store.modules.approvals.state = {
hasLoaded: true,
rules: [],
minFallbackApprovalsRequired: TEST_MIN_APPROVALS_REQUIRED,
fallbackApprovalsRequired: TEST_APPROVALS_REQUIRED,
};
store.modules.approvals.actions.putFallbackRule = jasmine.createSpy('putFallbackRule');
});
afterEach(() => {
wrapper.destroy();
});
const findInput = () => wrapper.find('input');
describe('if can not edit', () => {
beforeEach(() => {
store.state.settings.canEdit = false;
factory();
});
it('input is disabled', () => {
expect(findInput().attributes('disabled')).toBe('disabled');
});
it('input has value', () => {
expect(Number(findInput().element.value)).toBe(TEST_APPROVALS_REQUIRED);
});
});
describe('if can edit', () => {
beforeEach(() => {
store.state.settings.canEdit = true;
factory();
});
it('input is not disabled', () => {
expect(findInput().attributes('disabled')).toBe(undefined);
});
it('input has value', () => {
expect(Number(findInput().element.value)).toBe(TEST_APPROVALS_REQUIRED);
});
it('input has min value', () => {
expect(Number(findInput().attributes('min'))).toBe(TEST_MIN_APPROVALS_REQUIRED);
});
it('input dispatches putFallbackRule on change', () => {
const action = store.modules.approvals.actions.putFallbackRule;
const nextValue = TEST_APPROVALS_REQUIRED + 1;
expect(action).not.toHaveBeenCalled();
findInput().setValue(nextValue);
expect(action).toHaveBeenCalledWith(
jasmine.anything(),
{
approvalsRequired: nextValue,
},
undefined,
);
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { createStoreOptions } from 'ee/approvals/stores';
import MREditModule from 'ee/approvals/stores/modules/mr_edit';
import MRRulesHiddenInputs from 'ee/approvals/components/mr_edit/mr_rules_hidden_inputs.vue';
import { createMRRule } from '../../mocks';
const localVue = createLocalVue();
localVue.use(Vuex);
const {
INPUT_ID,
INPUT_SOURCE_ID,
INPUT_NAME,
INPUT_APPROVALS_REQUIRED,
INPUT_USER_IDS,
INPUT_GROUP_IDS,
INPUT_DELETE,
INPUT_FALLBACK_APPROVALS_REQUIRED,
} = MRRulesHiddenInputs;
const TEST_USERS = [{ id: 1 }, { id: 10 }];
const TEST_GROUPS = [{ id: 2 }, { id: 4 }];
const TEST_FALLBACK_APPROVALS_REQUIRED = 3;
describe('EE Approvlas MRRulesHiddenInputs', () => {
let wrapper;
let store;
const factory = () => {
wrapper = shallowMount(localVue.extend(MRRulesHiddenInputs), {
localVue,
store: new Vuex.Store(store),
sync: false,
});
};
beforeEach(() => {
store = createStoreOptions(MREditModule());
store.modules.approvals.state = {
rules: [],
rulesToDelete: [],
fallbackApprovalsRequired: TEST_FALLBACK_APPROVALS_REQUIRED,
};
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findHiddenInputs = () =>
wrapper.findAll('input[type=hidden]').wrappers.map(x => ({
name: x.attributes('name'),
value: x.element.value,
}));
describe('cannot edit', () => {
beforeEach(() => {
store.state.settings = { canEdit: false };
});
it('is empty', () => {
factory();
expect(wrapper.html()).toBeUndefined();
});
});
describe('can edit', () => {
it('is not empty', () => {
factory();
expect(wrapper.html()).not.toBeUndefined();
});
describe('with no rules', () => {
it('renders fallback rules', () => {
factory();
expect(findHiddenInputs()).toEqual([
{
name: INPUT_FALLBACK_APPROVALS_REQUIRED,
value: TEST_FALLBACK_APPROVALS_REQUIRED.toString(),
},
]);
});
});
describe('with rules to delete', () => {
beforeEach(() => {
store.modules.approvals.state.rulesToDelete = [4, 7];
});
it('renders delete inputs', () => {
factory();
expect(findHiddenInputs()).toEqual(
jasmine.arrayContaining([
{ name: INPUT_ID, value: '4' },
{ name: INPUT_DELETE, value: '1' },
{ name: INPUT_ID, value: '7' },
{ name: INPUT_DELETE, value: '1' },
]),
);
});
});
describe('with rules', () => {
let rule;
beforeEach(() => {
rule = {
...createMRRule(),
users: TEST_USERS,
groups: TEST_GROUPS,
};
store.modules.approvals.state.rules = [rule];
});
it('renders hidden fields for each row', () => {
factory();
expect(findHiddenInputs()).toEqual([
{ name: INPUT_ID, value: rule.id.toString() },
{ name: INPUT_APPROVALS_REQUIRED, value: rule.approvalsRequired.toString() },
{ name: INPUT_NAME, value: rule.name },
...TEST_USERS.map(({ id }) => ({ name: INPUT_USER_IDS, value: id.toString() })),
...TEST_GROUPS.map(({ id }) => ({ name: INPUT_GROUP_IDS, value: id.toString() })),
]);
});
describe('with empty users', () => {
beforeEach(() => {
rule.users = [];
});
it('renders empty users input', () => {
factory();
expect(findHiddenInputs().filter(x => x.name === INPUT_USER_IDS)).toEqual([
{ name: INPUT_USER_IDS, value: '' },
]);
});
});
describe('with empty groups', () => {
beforeEach(() => {
rule.groups = [];
});
it('renders empty groups input', () => {
factory();
expect(findHiddenInputs().filter(x => x.name === INPUT_GROUP_IDS)).toEqual([
{ name: INPUT_GROUP_IDS, value: '' },
]);
});
});
describe('with new rule', () => {
beforeEach(() => {
rule.isNew = true;
});
it('does not render id input', () => {
factory();
expect(findHiddenInputs().map(x => x.name)).not.toContain(INPUT_ID);
});
describe('with source', () => {
beforeEach(() => {
rule.hasSource = true;
rule.sourceId = 22;
});
it('renders source id input', () => {
factory();
expect(findHiddenInputs()).toContain({
name: INPUT_SOURCE_ID,
value: rule.sourceId.toString(),
});
});
});
});
});
});
});
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { createStoreOptions } from 'ee/approvals/stores';
import MREditModule from 'ee/approvals/stores/modules/mr_edit';
import MRRules from 'ee/approvals/components/mr_edit/mr_rules.vue';
import Rules from 'ee/approvals/components/rules.vue';
import RuleControls from 'ee/approvals/components/rule_controls.vue';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import { createMRRule, createMRRuleWithSource } from '../../mocks';
const { HEADERS } = Rules;
const localVue = createLocalVue();
localVue.use(Vuex);
describe('EE Approvals MRRules', () => {
let wrapper;
let store;
const factory = () => {
wrapper = mount(localVue.extend(MRRules), {
localVue,
store: new Vuex.Store(store),
sync: false,
});
};
const findHeaders = () => wrapper.findAll('thead th').wrappers.map(x => x.text());
const findRuleName = () => wrapper.find('td.js-name');
const findRuleMembers = () =>
wrapper
.find('td.js-members')
.find(UserAvatarList)
.props('items');
const findRuleApprovalsRequired = () => wrapper.find('td.js-approvals-required input');
const findRuleControls = () => wrapper.find('td.js-controls').find(RuleControls);
beforeEach(() => {
store = createStoreOptions(MREditModule());
store.modules.approvals.state = {
hasLoaded: true,
rules: [],
};
store.modules.approvals.actions.putRule = jasmine.createSpy('putRule');
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when allow multiple rules', () => {
beforeEach(() => {
store.state.settings.allowMultiRule = true;
});
it('renders headers', () => {
factory();
expect(findHeaders()).toEqual([HEADERS.name, HEADERS.members, HEADERS.approvalsRequired, '']);
});
describe('with sourced MR rule', () => {
const expected = createMRRuleWithSource();
beforeEach(() => {
store.modules.approvals.state.rules = [createMRRuleWithSource()];
factory();
});
it('shows name', () => {
expect(findRuleName().text()).toEqual(expected.name);
});
it('shows members', () => {
expect(findRuleMembers()).toEqual(expected.approvers);
});
it('shows approvals required input', () => {
const approvalsRequired = findRuleApprovalsRequired();
expect(Number(approvalsRequired.element.value)).toEqual(expected.approvalsRequired);
expect(Number(approvalsRequired.attributes('min'))).toEqual(expected.minApprovalsRequired);
expect(approvalsRequired.attributes('disabled')).toBeUndefined();
});
it('does not show controls', () => {
const controls = findRuleControls();
expect(controls.exists()).toBe(false);
});
it('dispatches putRule on change of approvals required', () => {
const action = store.modules.approvals.actions.putRule;
const approvalsRequired = findRuleApprovalsRequired();
const newValue = expected.approvalsRequired + 1;
approvalsRequired.setValue(newValue);
expect(action).toHaveBeenCalledWith(
jasmine.anything(),
{ id: expected.id, approvalsRequired: newValue },
undefined,
);
});
});
describe('with custom MR rule', () => {
const expected = createMRRule();
beforeEach(() => {
store.modules.approvals.state.rules = [createMRRule()];
factory();
});
it('shows controls', () => {
const controls = findRuleControls();
expect(controls.exists()).toBe(true);
expect(controls.props('rule')).toEqual(expected);
});
describe('with settings cannot edit', () => {
beforeEach(() => {
store.state.settings.canEdit = false;
factory();
});
it('hides controls', () => {
const controls = findRuleControls();
expect(controls.exists()).toBe(false);
});
it('disables input', () => {
const approvalsRequired = findRuleApprovalsRequired();
expect(approvalsRequired.attributes('disabled')).toBe('disabled');
});
});
});
});
describe('when allow single rule', () => {
beforeEach(() => {
store.state.settings.allowMultiRule = false;
});
it('does not show name header', () => {
factory();
expect(findHeaders()).not.toContain(HEADERS.name);
});
describe('with source rule', () => {
beforeEach(() => {
store.modules.approvals.state.rules = [createMRRuleWithSource()];
factory();
});
it('does not show name', () => {
expect(findRuleName().exists()).toBe(false);
});
it('shows controls', () => {
expect(findRuleControls().exists()).toBe(true);
});
});
});
});
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
import ProjectRules from 'ee/approvals/components/project_settings/project_rules.vue';
import RuleControls from 'ee/approvals/components/rule_controls.vue';
import { createProjectRules } from '../../mocks';
const TEST_RULES = createProjectRules();
const localVue = createLocalVue();
localVue.use(Vuex);
const findCell = (tr, name) => tr.find(`td.js-${name}`);
const getRowData = tr => {
const summary = findCell(tr, 'summary');
const name = findCell(tr, 'name');
const members = findCell(tr, 'members');
const controls = findCell(tr, 'controls');
const approvalsRequired = findCell(tr, 'approvals-required');
return {
name: name.text(),
summary: summary.text(),
approvers: members.find(UserAvatarList).props('items'),
approvalsRequired: Number(approvalsRequired.text()),
ruleControl: controls.find(RuleControls).props('rule'),
};
};
describe('Approvals ProjectRules', () => {
let wrapper;
let store;
const factory = (props = {}) => {
wrapper = mount(localVue.extend(ProjectRules), {
propsData: props,
sync: false,
store: new Vuex.Store(store),
localVue,
});
};
beforeEach(() => {
store = createStoreOptions(projectSettingsModule());
store.modules.approvals.state.rules = TEST_RULES;
});
afterEach(() => {
wrapper.destroy();
});
describe('when allow multiple rules', () => {
beforeEach(() => {
store.state.settings.allowMultiRule = true;
});
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,
ruleControl: rule,
})),
);
});
});
describe('when only allow single rule', () => {
let rule;
let row;
beforeEach(() => {
[rule] = TEST_RULES;
store.modules.approvals.state.rules = [rule];
factory();
row = wrapper.find('tbody tr');
});
it('does not render name', () => {
expect(findCell(row, 'name').exists()).toBe(false);
});
it('renders single summary', () => {
expect(findCell(row, 'summary').text()).toEqual(
`${rule.approvalsRequired} approvals required from ${rule.approvers.length} members`,
);
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import MREditModule from 'ee/approvals/stores/modules/mr_edit';
import { createStoreOptions } from 'ee/approvals/stores';
import RuleControls from 'ee/approvals/components/rule_controls.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
const TEST_RULE = { id: 10 };
const findButtonLabel = button => {
const icon = button.find(Icon);
return icon.exists() ? icon.attributes('aria-label') : button.text();
};
const hasLabel = (button, label) => findButtonLabel(button) === label;
describe('EE Approvals RuleControls', () => {
let wrapper;
let store;
let actions;
const factory = () => {
wrapper = shallowMount(localVue.extend(RuleControls), {
propsData: {
rule: TEST_RULE,
},
localVue,
store: new Vuex.Store(store),
sync: false,
});
};
const findButtons = () => wrapper.findAll(GlButton);
const findButton = label => findButtons().filter(button => hasLabel(button, label)).wrappers[0];
const findEditButton = () => findButton('Edit');
const findRemoveButton = () => findButton('Remove');
beforeEach(() => {
store = createStoreOptions(MREditModule());
({ actions } = store.modules.approvals);
['requestEditRule', 'requestDeleteRule'].forEach(actionName => spyOn(actions, actionName));
});
afterEach(() => {
wrapper.destroy();
});
describe('when allow multi rule', () => {
beforeEach(() => {
store.state.settings.allowMultiRule = true;
});
describe('edit button', () => {
let button;
beforeEach(() => {
factory();
button = findEditButton();
});
it('exists', () => {
expect(button.exists()).toBe(true);
});
it('when click, opens create modal', () => {
expect(store.modules.approvals.actions.requestEditRule).not.toHaveBeenCalled();
button.vm.$emit('click');
expect(store.modules.approvals.actions.requestEditRule).toHaveBeenCalledWith(
jasmine.anything(),
TEST_RULE,
undefined,
);
});
});
describe('remove button', () => {
let button;
beforeEach(() => {
factory();
button = findRemoveButton();
});
it('exists', () => {
expect(button.exists()).toBe(true);
});
it('when click, opens delete modal', () => {
expect(store.modules.approvals.actions.requestDeleteRule).not.toHaveBeenCalled();
button.vm.$emit('click');
expect(store.modules.approvals.actions.requestDeleteRule).toHaveBeenCalledWith(
jasmine.anything(),
TEST_RULE,
undefined,
);
});
});
});
describe('when allow only single rule', () => {
beforeEach(() => {
factory();
});
it('renders edit button', () => {
expect(findEditButton().exists()).toBe(true);
});
it('does not render remove button', () => {
expect(findRemoveButton()).toBe(undefined);
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlButton } from '@gitlab/ui';
import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
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 TEST_APPROVERS = [{ id: 7, type: TYPE_USER }];
const TEST_APPROVALS_REQUIRED = 3;
const TEST_FALLBACK_RULE = {
approvalsRequired: 1,
isFallback: true,
};
const localVue = createLocalVue();
localVue.use(Vuex);
describe('EE Approvals RuleForm', () => {
let wrapper;
let store;
let actions;
const createComponent = (props = {}) => {
wrapper = shallowMount(localVue.extend(RuleForm), {
propsData: props,
store: new Vuex.Store(store),
localVue,
sync: false,
});
};
const findValidation = (node, hasProps = false) => ({
feedback: node.element.nextElementSibling.textContent,
isValid: hasProps ? !node.props('isInvalid') : !node.classes('is-invalid'),
});
const findNameInput = () => wrapper.find('input[name=name]');
const findNameValidation = () => findValidation(findNameInput(), false);
const findApprovalsRequiredInput = () => wrapper.find('input[name=approvals_required]');
const findApprovalsRequiredValidation = () => findValidation(findApprovalsRequiredInput(), false);
const findApproversSelect = () => wrapper.find(ApproversSelect);
const findApproversValidation = () => findValidation(findApproversSelect(), true);
const findValidations = () => [
findNameValidation(),
findApprovalsRequiredValidation(),
findApproversValidation(),
];
beforeEach(() => {
store = createStoreOptions(projectSettingsModule(), { projectId: TEST_PROJECT_ID });
['postRule', 'putRule', 'deleteRule', 'putFallbackRule'].forEach(actionName => {
spyOn(store.modules.approvals.actions, actionName);
});
({ actions } = store.modules.approvals);
});
afterEach(() => {
wrapper.destroy();
});
describe('when allow multiple rules', () => {
beforeEach(() => {
store.state.settings.allowMultiRule = true;
});
describe('without initRule', () => {
beforeEach(() => {
createComponent();
});
it('at first, shows no validation', () => {
const inputs = findValidations();
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', done => {
findNameInput().setValue('');
wrapper.vm.submit();
localVue
.nextTick()
.then(() => {
expect(findNameValidation()).toEqual({
isValid: false,
feedback: 'Please provide a name',
});
})
.then(done)
.catch(done.fail);
});
it('on submit, shows approvalsRequired validation', done => {
findApprovalsRequiredInput().setValue(-1);
wrapper.vm.submit();
localVue
.nextTick()
.then(() => {
expect(findApprovalsRequiredValidation()).toEqual({
isValid: false,
feedback: 'Please enter a non-negative number',
});
})
.then(done)
.catch(done.fail);
});
it('on submit, shows approvers validation', done => {
wrapper.vm.approvers = [];
wrapper.vm.submit();
localVue
.nextTick()
.then(() => {
expect(findApproversValidation()).toEqual({
isValid: false,
feedback: 'Please select and add a member',
});
})
.then(done)
.catch(done.fail);
});
it('on submit with data, posts rule', () => {
const users = [1, 2];
const groups = [2, 3];
const userRecords = users.map(id => ({ id, type: TYPE_USER }));
const groupRecords = groups.map(id => ({ id, type: TYPE_GROUP }));
const expected = {
id: null,
name: 'Lorem',
approvalsRequired: 2,
users,
groups,
userRecords,
groupRecords,
};
findNameInput().setValue(expected.name);
findApprovalsRequiredInput().setValue(expected.approvalsRequired);
wrapper.vm.approvers = groupRecords.concat(userRecords);
wrapper.vm.submit();
expect(actions.postRule).toHaveBeenCalledWith(jasmine.anything(), expected, undefined);
});
it('adds selected approvers on button click', () => {
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;
findApproversSelect().vm.$emit('input', selected);
wrapper.find(GlButton).vm.$emit('click');
expect(wrapper.vm.approvers).toEqual(expected);
});
});
describe('with initRule', () => {
beforeEach(() => {
createComponent({
initRule: TEST_RULE,
});
});
it('on submit, puts rule', () => {
const userRecords = TEST_RULE.users.map(x => ({ ...x, type: TYPE_USER }));
const groupRecords = TEST_RULE.groups.map(x => ({ ...x, type: TYPE_GROUP }));
const users = userRecords.map(x => x.id);
const groups = groupRecords.map(x => x.id);
const expected = {
...TEST_RULE,
users,
groups,
userRecords,
groupRecords,
};
wrapper.vm.submit();
expect(actions.putRule).toHaveBeenCalledWith(jasmine.anything(), expected, undefined);
});
});
describe('with init fallback rule', () => {
beforeEach(() => {
createComponent({
initRule: TEST_FALLBACK_RULE,
});
wrapper.vm.name = '';
wrapper.vm.approvers = [];
wrapper.vm.approvalsRequired = TEST_APPROVALS_REQUIRED;
});
describe('with empty name and empty approvers', () => {
beforeEach(done => {
wrapper.vm.submit();
localVue.nextTick(done);
});
it('does not post rule', () => {
expect(actions.postRule).not.toHaveBeenCalled();
});
it('puts fallback rule', () => {
expect(actions.putFallbackRule).toHaveBeenCalledWith(
jasmine.anything(),
{ approvalsRequired: TEST_APPROVALS_REQUIRED },
undefined,
);
});
it('does not show any validation errors', () => {
expect(findValidations().every(x => x.isValid)).toBe(true);
});
});
describe('with name and empty approvers', () => {
beforeEach(done => {
wrapper.vm.name = 'Lorem';
wrapper.vm.submit();
localVue.nextTick(done);
});
it('does not put fallback rule', () => {
expect(actions.putFallbackRule).not.toHaveBeenCalled();
});
it('shows approvers validation error', () => {
expect(findApproversValidation().isValid).toBe(false);
});
});
describe('with empty name and approvers', () => {
beforeEach(done => {
wrapper.vm.approvers = TEST_APPROVERS;
wrapper.vm.submit();
localVue.nextTick(done);
});
it('does not put fallback rule', () => {
expect(actions.putFallbackRule).not.toHaveBeenCalled();
});
it('shows name validation error', () => {
expect(findNameValidation().isValid).toBe(false);
});
});
describe('with name and approvers', () => {
beforeEach(done => {
wrapper.vm.approvers = [{ id: 7, type: TYPE_USER }];
wrapper.vm.name = 'Lorem';
wrapper.vm.submit();
localVue.nextTick(done);
});
it('does not put fallback rule', () => {
expect(actions.putFallbackRule).not.toHaveBeenCalled();
});
it('posts new rule', () => {
expect(actions.postRule).toHaveBeenCalled();
});
});
});
});
describe('when allow only single rule', () => {
beforeEach(() => {
store.state.settings.allowMultiRule = false;
});
it('hides name', () => {
createComponent();
expect(findNameInput().exists()).toBe(false);
});
describe('with no init rule', () => {
beforeEach(() => {
createComponent();
wrapper.vm.approvalsRequired = TEST_APPROVALS_REQUIRED;
});
describe('with approvers selected', () => {
beforeEach(done => {
wrapper.vm.approvers = TEST_APPROVERS;
wrapper.vm.submit();
localVue.nextTick(done);
});
it('posts new rule', () => {
expect(actions.postRule).toHaveBeenCalledWith(
jasmine.anything(),
jasmine.objectContaining({
approvalsRequired: TEST_APPROVALS_REQUIRED,
users: TEST_APPROVERS.map(x => x.id),
}),
undefined,
);
});
});
describe('without approvers', () => {
beforeEach(done => {
wrapper.vm.submit();
localVue.nextTick(done);
});
it('puts fallback rule', () => {
expect(actions.putFallbackRule).toHaveBeenCalledWith(
jasmine.anything(),
{ approvalsRequired: TEST_APPROVALS_REQUIRED },
undefined,
);
});
});
});
describe('with init rule', () => {
beforeEach(() => {
createComponent({
initRule: TEST_RULE,
});
wrapper.vm.approvalsRequired = TEST_APPROVALS_REQUIRED;
});
describe('with empty name and empty approvers', () => {
beforeEach(done => {
wrapper.vm.name = '';
wrapper.vm.approvers = [];
wrapper.vm.submit();
localVue.nextTick(done);
});
it('deletes rule', () => {
expect(actions.deleteRule).toHaveBeenCalledWith(
jasmine.anything(),
TEST_RULE.id,
undefined,
);
});
it('puts fallback rule', () => {
expect(actions.putFallbackRule).toHaveBeenCalledWith(
jasmine.anything(),
{ approvalsRequired: TEST_APPROVALS_REQUIRED },
undefined,
);
});
});
describe('with name and approvers', () => {
beforeEach(done => {
wrapper.vm.name = 'Bogus';
wrapper.vm.approvers = TEST_APPROVERS;
wrapper.vm.submit();
localVue.nextTick(done);
});
it('puts rule', () => {
expect(actions.putRule).toHaveBeenCalledWith(
jasmine.anything(),
jasmine.objectContaining({
id: TEST_RULE.id,
name: 'Bogus',
approvalsRequired: TEST_APPROVALS_REQUIRED,
users: TEST_APPROVERS.map(x => x.id),
}),
undefined,
);
});
});
});
});
});
export const createProjectRules = () => [
{ 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: [] },
];
export const createMRRule = () => ({
id: 7,
name: 'Amit',
approvers: [{ id: 1 }, { id: 2 }],
approvalsRequired: 2,
minApprovalsRequired: 0,
});
export const createMRRuleWithSource = () => ({
...createMRRule(),
minApprovalsRequired: 1,
hasSource: true,
sourceId: 3,
});
import $ from 'jquery';
import approvals from 'ee/approvals';
import setup from 'ee/approvals/setup_single_rule_approvals';
describe('Approvals', () => {
describe('EE setup_single_rule_approvals', () => {
preloadFixtures('merge_requests_ee/merge_request_edit.html.raw');
let $approversEl;
......@@ -11,7 +11,7 @@ describe('Approvals', () => {
loadFixtures('merge_requests_ee/merge_request_edit.html.raw');
$approversEl = $('ul.approver-list');
$suggestionEl = $('.suggested-approvers');
approvals();
setup();
});
describe('add suggested approver', () => {
......@@ -57,7 +57,7 @@ describe('Approvals', () => {
spyOn(window, 'confirm').and.returnValue(true);
$approversEl
.find('.unsaved-approvers.approver a.btn-remove')
.find('.unsaved-approvers.approver .btn-remove')
.first()
.click();
......@@ -68,7 +68,7 @@ describe('Approvals', () => {
spyOn(window, 'confirm').and.returnValue(false);
$approversEl
.find('.unsaved-approvers.approver a.btn-remove')
.find('.unsaved-approvers.approver .btn-remove')
.first()
.click();
......@@ -83,7 +83,7 @@ describe('Approvals', () => {
expect($approversEl.find('li.approver-group').length).toEqual(1);
$approversEl
.find('.unsaved-approvers.approver-group a.btn-remove')
.find('.unsaved-approvers.approver-group .btn-remove')
.first()
.click();
......@@ -96,7 +96,7 @@ describe('Approvals', () => {
expect($approversEl.find('li.approver-group').length).toEqual(1);
$approversEl
.find('.unsaved-approvers.approver-group a.btn-remove')
.find('.unsaved-approvers.approver-group .btn-remove')
.first()
.click();
......
import * as getters from 'ee/approvals/stores/modules/base/getters';
describe('EE store modules base getters', () => {
describe('isEmpty', () => {
it('when rules is falsey, is true', () => {
expect(getters.isEmpty({})).toBe(true);
});
it('when rules is empty, is true', () => {
expect(getters.isEmpty({ rules: [] })).toBe(true);
});
it('when rules has items, is false', () => {
expect(getters.isEmpty({ rules: [1] })).toBe(false);
});
});
});
import createState from 'ee/approvals/stores/state';
import * as types from 'ee/approvals/stores/modules/base/mutation_types';
import mutations from 'ee/approvals/stores/modules/base/mutations';
describe('EE approvals base module mutations', () => {
let state;
beforeEach(() => {
state = createState();
});
describe(types.SET_LOADING, () => {
it('sets isLoading', () => {
state.isLoading = false;
mutations[types.SET_LOADING](state, true);
expect(state.isLoading).toBe(true);
});
});
describe(types.SET_APPROVAL_SETTINGS, () => {
it('sets rules', () => {
const settings = {
rules: [{ id: 1 }, { id: 2 }],
fallbackApprovalsRequired: 7,
minFallbackApprovalsRequired: 1,
};
state.hasLoaded = false;
state.rules = [];
mutations[types.SET_APPROVAL_SETTINGS](state, settings);
expect(state).toEqual(jasmine.objectContaining(settings));
});
});
});
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import testAction from 'spec/helpers/vuex_action_helper';
import * as types from 'ee/approvals/stores/modules/base/mutation_types';
import actionsModule, * as actions from 'ee/approvals/stores/modules/project_settings/actions';
import { mapApprovalRuleRequest, mapApprovalSettingsResponse } from 'ee/approvals/mappers';
const TEST_PROJECT_ID = 9;
const TEST_RULE_ID = 7;
const TEST_RULE_REQUEST = {
name: 'Lorem',
approvalsRequired: 1,
groups: [7],
users: [8, 9],
};
const TEST_RULE_RESPONSE = {
id: 7,
name: 'Ipsum',
approvals_required: 2,
approvers: [{ id: 7 }, { id: 8 }, { id: 9 }],
groups: [{ id: 4 }],
users: [{ id: 7 }, { id: 8 }],
};
const TEST_SETTINGS_PATH = 'projects/9/approval_settings';
const TEST_RULES_PATH = 'projects/9/approval_settings/rules';
describe('EE approvals project settings module actions', () => {
let state;
let flashSpy;
let mock;
beforeEach(() => {
state = {
settings: {
projectId: TEST_PROJECT_ID,
settingsPath: TEST_SETTINGS_PATH,
rulesPath: TEST_RULES_PATH,
},
};
flashSpy = spyOnDependency(actionsModule, 'createFlash');
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('requestRules', () => {
it('sets loading', done => {
testAction(
actions.requestRules,
null,
{},
[{ type: types.SET_LOADING, payload: true }],
[],
done,
);
});
});
describe('receiveRulesSuccess', () => {
it('sets rules', done => {
const settings = { rules: [{ id: 1 }] };
testAction(
actions.receiveRulesSuccess,
settings,
{},
[
{ type: types.SET_APPROVAL_SETTINGS, payload: settings },
{ type: types.SET_LOADING, payload: false },
],
[],
done,
);
});
});
describe('receiveRulesError', () => {
it('creates a flash', () => {
expect(flashSpy).not.toHaveBeenCalled();
actions.receiveRulesError();
expect(flashSpy).toHaveBeenCalledTimes(1);
expect(flashSpy).toHaveBeenCalledWith(jasmine.stringMatching('error occurred'));
});
});
describe('fetchRules', () => {
it('dispatches request/receive', done => {
const data = { rules: [TEST_RULE_RESPONSE] };
mock.onGet(TEST_SETTINGS_PATH).replyOnce(200, data);
testAction(
actions.fetchRules,
null,
state,
[],
[
{ type: 'requestRules' },
{ type: 'receiveRulesSuccess', payload: mapApprovalSettingsResponse(data) },
],
() => {
expect(mock.history.get.map(x => x.url)).toEqual([TEST_SETTINGS_PATH]);
done();
},
);
});
it('dispatches request/receive on error', done => {
mock.onGet(TEST_SETTINGS_PATH).replyOnce(500);
testAction(
actions.fetchRules,
null,
state,
[],
[{ type: 'requestRules' }, { type: 'receiveRulesError' }],
done,
);
});
});
describe('postRuleSuccess', () => {
it('closes modal and fetches', done => {
testAction(
actions.postRuleSuccess,
null,
{},
[],
[{ type: 'createModal/close' }, { type: 'fetchRules' }],
done,
);
});
});
describe('postRuleError', () => {
it('creates a flash', () => {
expect(flashSpy).not.toHaveBeenCalled();
actions.postRuleError();
expect(flashSpy.calls.allArgs()).toEqual([[jasmine.stringMatching('error occurred')]]);
});
});
describe('postRule', () => {
it('dispatches success on success', done => {
mock.onPost(TEST_RULES_PATH).replyOnce(200);
testAction(
actions.postRule,
TEST_RULE_REQUEST,
state,
[],
[{ type: 'postRuleSuccess' }],
() => {
expect(mock.history.post).toEqual([
jasmine.objectContaining({
url: TEST_RULES_PATH,
data: JSON.stringify(mapApprovalRuleRequest(TEST_RULE_REQUEST)),
}),
]);
done();
},
);
});
it('dispatches error on error', done => {
mock.onPost(TEST_RULES_PATH).replyOnce(500);
testAction(actions.postRule, TEST_RULE_REQUEST, state, [], [{ type: 'postRuleError' }], done);
});
});
describe('putRule', () => {
it('dispatches success on success', done => {
mock.onPut(`${TEST_RULES_PATH}/${TEST_RULE_ID}`).replyOnce(200);
testAction(
actions.putRule,
{ id: TEST_RULE_ID, ...TEST_RULE_REQUEST },
state,
[],
[{ type: 'postRuleSuccess' }],
() => {
expect(mock.history.put).toEqual([
jasmine.objectContaining({
url: `${TEST_RULES_PATH}/${TEST_RULE_ID}`,
data: JSON.stringify(mapApprovalRuleRequest(TEST_RULE_REQUEST)),
}),
]);
done();
},
);
});
it('dispatches error on error', done => {
mock.onPut(`${TEST_RULES_PATH}/${TEST_RULE_ID}`).replyOnce(500);
testAction(
actions.putRule,
{ id: TEST_RULE_ID, ...TEST_RULE_REQUEST },
state,
[],
[{ type: 'postRuleError' }],
done,
);
});
});
describe('deleteRuleSuccess', () => {
it('closes modal and fetches', done => {
testAction(
actions.deleteRuleSuccess,
null,
{},
[],
[{ type: 'deleteModal/close' }, { type: 'fetchRules' }],
done,
);
});
});
describe('deleteRuleError', () => {
it('creates a flash', () => {
expect(flashSpy).not.toHaveBeenCalled();
actions.deleteRuleError();
expect(flashSpy.calls.allArgs()).toEqual([[jasmine.stringMatching('error occurred')]]);
});
});
describe('deleteRule', () => {
it('dispatches success on success', done => {
mock.onDelete(`${TEST_RULES_PATH}/${TEST_RULE_ID}`).replyOnce(200);
testAction(
actions.deleteRule,
TEST_RULE_ID,
state,
[],
[{ type: 'deleteRuleSuccess' }],
() => {
expect(mock.history.delete).toEqual([
jasmine.objectContaining({
url: `${TEST_RULES_PATH}/${TEST_RULE_ID}`,
}),
]);
done();
},
);
});
it('dispatches error on error', done => {
mock.onDelete(`${TEST_RULES_PATH}/${TEST_RULE_ID}`).replyOnce(500);
testAction(actions.deleteRule, TEST_RULE_ID, state, [], [{ type: 'deleteRuleError' }], done);
});
});
});
......@@ -22,6 +22,8 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
end
before do
stub_feature_flags(approval_rules: false)
# Ensure some approver suggestions are displayed
service = double(:service)
expect(::Gitlab::AuthorityAnalyzer).to receive(:new).and_return(service)
......
import _ from 'underscore';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import ApprovalsList from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals_list.vue';
import ApprovalsFooter from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals_footer.vue';
const localVue = createLocalVue();
const testSuggestedApprovers = () => _.range(1, 11).map(id => ({ id }));
const testApprovalRules = () => [{ name: 'Lorem' }, { name: 'Ipsum' }];
describe('EE MRWidget approvals footer', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(localVue.extend(ApprovalsFooter), {
propsData: {
suggestedApprovers: testSuggestedApprovers(),
approvalRules: testApprovalRules(),
...props,
},
localVue,
sync: false,
});
};
const findToggle = () => wrapper.find('button');
const findToggleIcon = () => findToggle().find(Icon);
const findToggleLoadingIcon = () => findToggle().find(GlLoadingIcon);
const findExpandButton = () => wrapper.find(GlButton);
const findCollapseButton = () => wrapper.find(GlButton);
const findList = () => wrapper.find(ApprovalsList);
const findAvatars = () => wrapper.find(UserAvatarList);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when expanded', () => {
describe('and has rules', () => {
beforeEach(() => {
createComponent();
});
it('renders approvals list', () => {
const list = findList();
expect(list.exists()).toBe(true);
expect(list.props()).toEqual(
jasmine.objectContaining({
approvalRules: testApprovalRules(),
}),
);
});
it('does not render user avatar list', () => {
expect(findAvatars().exists()).toBe(false);
});
describe('toggle button', () => {
it('renders', () => {
const button = findToggle();
expect(button.exists()).toBe(true);
expect(button.attributes('aria-label')).toEqual('Collapse approvers');
});
it('renders icon', () => {
const icon = findToggleIcon();
expect(icon.exists()).toBe(true);
expect(icon.props()).toEqual(
jasmine.objectContaining({
name: 'chevron-down',
}),
);
});
});
describe('collapse button', () => {
it('renders', () => {
const button = findCollapseButton();
expect(button.exists()).toBe(true);
expect(button.text()).toEqual('Collapse');
});
it('when clicked, collapses the view', () => {
findCollapseButton().trigger('click');
expect(wrapper.vm.isCollapsed).toEqual(false);
});
});
});
describe('and loading', () => {
beforeEach(() => {
createComponent({ isLoadingRules: true });
});
it('does not render icon in toggle button', () => {
expect(findToggleIcon().exists()).toBe(false);
});
it('renders loading in toggle button', () => {
expect(findToggleLoadingIcon().exists()).toBe(true);
});
});
describe('and rules empty', () => {
beforeEach(() => {
createComponent({ approvalRules: [] });
});
it('does not render approvals list', () => {
expect(findList().exists()).toBe(false);
});
});
});
describe('when collapsed', () => {
beforeEach(() => {
createComponent({ value: false });
});
describe('toggle button', () => {
it('renders', () => {
const button = findToggle();
expect(button.exists()).toBe(true);
expect(button.attributes('aria-label')).toEqual('Expand approvers');
});
it('renders icon', () => {
const icon = findToggleIcon();
expect(icon.exists()).toBe(true);
expect(icon.props('name')).toEqual('chevron-right');
});
it('expands when clicked', () => {
const button = findToggle();
button.trigger('click');
expect(wrapper.emittedByOrder()).toEqual([{ name: 'input', args: [true] }]);
});
});
it('renders avatar list', () => {
const avatars = findAvatars();
expect(avatars.exists()).toBe(true);
expect(avatars.props()).toEqual(
jasmine.objectContaining({
items: testSuggestedApprovers().filter((x, idx) => idx < 5),
breakpoint: 0,
emptyText: '',
}),
);
});
it('does not render collapsed text', () => {
expect(wrapper.text()).not.toContain('Collapse');
});
it('does not render approvals list', () => {
expect(findList().exists()).toBe(false);
});
describe('expand button', () => {
let button;
beforeEach(() => {
button = findExpandButton();
});
it('renders', () => {
expect(button.exists()).toBe(true);
expect(button.text()).toBe('View eligible approvers');
});
it('expands when clicked', done => {
expect(wrapper.props('value')).toBe(false);
button.vm.$emit('click');
localVue
.nextTick()
.then(() => {
expect(wrapper.emittedByOrder()).toEqual([{ name: 'input', args: [true] }]);
})
.then(done)
.catch(done.fail);
});
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import _ from 'underscore';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import ApprovedIcon from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approved_icon.vue';
import ApprovalsList from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals_list.vue';
const localVue = createLocalVue();
const testApprovers = () => _.range(1, 11).map(id => ({ id }));
const testRuleApproved = () => ({
id: 1,
name: 'Lorem',
approvals_required: 2,
approved_by: [{ id: 1 }, { id: 2 }, { id: 3 }],
approvers: testApprovers(),
});
const testRuleUnapproved = () => ({
id: 2,
name: 'Ipsum',
approvals_required: 1,
approved_by: [],
approvers: testApprovers(),
});
const testRuleOptional = () => ({
id: 3,
name: 'Dolar',
approvals_required: 0,
approved_by: [{ id: 1 }],
approvers: testApprovers(),
});
const testRuleFallback = () => ({
id: 'fallback',
name: '',
fallback: true,
approvals_required: 3,
approved_by: [{ id: 1 }, { id: 2 }],
approvers: [],
});
const testRules = () => [testRuleApproved(), testRuleUnapproved(), testRuleOptional()];
describe('EE MRWidget approvals list', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(localVue.extend(ApprovalsList), {
propsData: props,
localVue,
sync: false,
});
};
const findRows = () => wrapper.findAll('tbody tr');
const findRowElement = (row, name) => row.find(`.js-${name}`);
const findRowIcon = row => row.find(ApprovedIcon);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when multiple rules', () => {
beforeEach(() => {
createComponent({
approvalRules: testRules(),
});
});
it('renders a row for each rule', () => {
const expected = testRules();
const rows = findRows();
const names = rows.wrappers.map(row => findRowElement(row, 'name').text());
expect(rows.length).toEqual(expected.length);
expect(names).toEqual(expected.map(x => x.name));
});
});
describe('when approved rule', () => {
const rule = testRuleApproved();
let row;
beforeEach(() => {
createComponent({
approvalRules: [rule],
});
row = findRows().at(0);
});
it('renders approved icon', () => {
const icon = findRowIcon(row);
expect(icon.exists()).toBe(true);
expect(icon.props()).toEqual(
jasmine.objectContaining({
isApproved: true,
}),
);
});
it('renders name', () => {
expect(findRowElement(row, 'name').text()).toEqual(rule.name);
});
it('renders approvers', () => {
const approversCell = findRowElement(row, 'approvers');
const approvers = approversCell.find(UserAvatarList);
expect(approvers.exists()).toBe(true);
expect(approvers.props()).toEqual(
jasmine.objectContaining({
items: testApprovers(),
}),
);
});
it('renders pending text', () => {
const pendingText = findRowElement(row, 'pending').text();
expect(pendingText).toEqual(`${rule.approved_by.length} of ${rule.approvals_required}`);
});
it('renders approved_by user avatar list', () => {
const approvedBy = findRowElement(row, 'approved-by');
const approvers = approvedBy.find(UserAvatarList);
expect(approvers.exists()).toBe(true);
expect(approvers.props()).toEqual(
jasmine.objectContaining({
items: rule.approved_by,
}),
);
});
describe('summary text', () => {
let summary;
beforeEach(() => {
summary = findRowElement(row, 'summary');
});
it('renders text', () => {
const count = rule.approved_by.length;
const required = rule.approvals_required;
const { name } = rule;
expect(summary.text()).toContain(`${count} of ${required} approvals from ${name}`);
});
it('renders approvers list', () => {
const approvers = summary.findAll(UserAvatarList).at(0);
expect(approvers.exists()).toBe(true);
expect(approvers.props()).toEqual(
jasmine.objectContaining({
items: rule.approvers,
}),
);
});
it('renders approved by list', () => {
const approvedBy = summary.findAll(UserAvatarList).at(1);
expect(approvedBy.exists()).toBe(true);
expect(approvedBy.props()).toEqual(
jasmine.objectContaining({
items: rule.approved_by,
}),
);
});
});
});
describe('when unapproved rule', () => {
const rule = testRuleUnapproved();
let row;
beforeEach(() => {
createComponent({
approvalRules: [rule],
});
row = findRows().at(0);
});
it('renders unapproved icon', () => {
const icon = findRowIcon(row);
expect(icon.exists()).toBe(true);
expect(icon.props()).toEqual(
jasmine.objectContaining({
isApproved: false,
}),
);
});
});
describe('when optional rule', () => {
const rule = testRuleOptional();
let row;
beforeEach(() => {
createComponent({
approvalRules: [rule],
});
row = findRows().at(0);
});
it('renders unapproved icon', () => {
const icon = findRowIcon(row);
expect(icon.exists()).toBe(true);
expect(icon.props()).toEqual(
jasmine.objectContaining({
isApproved: false,
}),
);
});
it('renders optional pending text', () => {
const pending = findRowElement(row, 'pending');
expect(pending.text()).toEqual('Optional');
});
it('renders optional summary text', () => {
const summary = findRowElement(row, 'summary');
expect(summary.text()).toContain(`${rule.approved_by.length} approvals from ${rule.name}`);
});
});
describe('when fallback rule', () => {
const rule = testRuleFallback();
let row;
beforeEach(() => {
createComponent({
approvalRules: [rule],
});
row = findRows().at(0);
});
it('does not render approvers', () => {
expect(findRowElement(row, 'approvers').exists()).toBe(false);
});
it('does not render approvers in summary', () => {
const summary = findRowElement(row, 'summary');
const lists = summary.findAll(UserAvatarList);
expect(lists.length).toEqual(1);
expect(lists.at(0).props('items')).toEqual(rule.approved_by);
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import eventHub from '~/vue_merge_request_widget/event_hub';
import Approvals from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals.vue';
import ApprovalsSummary from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals_summary.vue';
import ApprovalsFooter from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals_footer.vue';
import {
FETCH_LOADING,
FETCH_ERROR,
APPROVE_ERROR,
UNAPPROVE_ERROR,
} from 'ee/vue_merge_request_widget/components/approvals/messages';
const localVue = createLocalVue();
const testApprovedBy = () => [1, 7, 10].map(id => ({ id }));
const testApprovals = () => ({
has_approval_rules: true,
approved_by: testApprovedBy().map(user => ({ user })),
approval_rules_left: [],
approvals_left: 4,
suggested_approvers: [],
user_can_approve: true,
user_has_approved: true,
});
const testApprovalRulesResponse = () => ({ rules: [{ id: 2 }] });
// For some reason, the `localVue.nextTick` needs to be deferred
// or the timing doesn't work.
const tick = () => Promise.resolve().then(localVue.nextTick);
const waitForTick = done =>
tick()
.then(done)
.catch(done.fail);
describe('EE MRWidget approvals', () => {
let wrapper;
let service;
let mr;
let createFlash;
const createComponent = (props = {}) => {
wrapper = shallowMount(localVue.extend(Approvals), {
propsData: {
mr,
service,
...props,
},
localVue,
sync: false,
});
};
const findAction = () => wrapper.find(GlButton);
const findActionData = () => {
const action = findAction();
return !action.exists()
? null
: {
variant: action.attributes('variant'),
inverted: action.classes('btn-inverted'),
text: action.text(),
};
};
const findSummary = () => wrapper.find(ApprovalsSummary);
const findFooter = () => wrapper.find(ApprovalsFooter);
beforeEach(() => {
service = jasmine.createSpyObj('MRWidgetService', {
fetchApprovals: Promise.resolve(testApprovals()),
fetchApprovalSettings: Promise.resolve(testApprovalRulesResponse()),
approveMergeRequest: Promise.resolve(testApprovals()),
unapproveMergeRequest: Promise.resolve(testApprovals()),
});
mr = {
...jasmine.createSpyObj('Store', ['setApprovals', 'setApprovalRules']),
approvals: testApprovals(),
approvalRules: [],
isOpen: true,
state: 'open',
};
createFlash = spyOnDependency(Approvals, 'createFlash');
spyOn(eventHub, '$emit');
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when created', () => {
beforeEach(() => {
createComponent();
});
it('shows loading message', () => {
expect(wrapper.text()).toContain(FETCH_LOADING);
});
it('fetches approvals', () => {
expect(service.fetchApprovals).toHaveBeenCalled();
});
});
describe('when fetch approvals success', () => {
beforeEach(done => {
service.fetchApprovals.and.returnValue(Promise.resolve());
createComponent();
waitForTick(done);
});
it('hides loading message', () => {
expect(createFlash).not.toHaveBeenCalled();
expect(wrapper.text()).not.toContain(FETCH_LOADING);
});
});
describe('when fetch approvals error', () => {
beforeEach(done => {
service.fetchApprovals.and.returnValue(Promise.reject());
createComponent();
waitForTick(done);
});
it('still shows loading message', () => {
expect(wrapper.text()).toContain(FETCH_LOADING);
});
it('flashes error', () => {
expect(createFlash).toHaveBeenCalledWith(FETCH_ERROR);
});
});
describe('action button', () => {
describe('when mr is closed', () => {
beforeEach(done => {
mr.isOpen = false;
mr.approvals.user_has_approved = false;
mr.approvals.user_can_approve = true;
createComponent();
waitForTick(done);
});
it('action is not rendered', () => {
expect(findActionData()).toBe(null);
});
});
describe('when user cannot approve', () => {
beforeEach(done => {
mr.approvals.user_has_approved = false;
mr.approvals.user_can_approve = false;
createComponent();
waitForTick(done);
});
it('action is not rendered', () => {
expect(findActionData()).toBe(null);
});
});
describe('when user can approve', () => {
beforeEach(() => {
mr.approvals.user_has_approved = false;
mr.approvals.user_can_approve = true;
});
describe('and MR is unapproved', () => {
beforeEach(done => {
createComponent();
waitForTick(done);
});
it('approve action is rendered', () => {
expect(findActionData()).toEqual({
variant: 'primary',
text: 'Approve',
inverted: false,
});
});
});
describe('and MR is approved', () => {
beforeEach(done => {
mr.approvals.approved = true;
createComponent();
waitForTick(done);
});
it('approve additionally action is rendered', () => {
expect(findActionData()).toEqual({
variant: 'primary',
text: 'Approve additionally',
inverted: true,
});
});
});
describe('when approve action is clicked', () => {
beforeEach(done => {
createComponent();
waitForTick(done);
});
it('shows loading icon', done => {
service.approveMergeRequest.and.callFake(() => new Promise(() => {}));
const action = findAction();
expect(action.find(GlLoadingIcon).exists()).toBe(false);
action.vm.$emit('click');
tick()
.then(() => {
expect(action.find(GlLoadingIcon).exists()).toBe(true);
})
.then(done)
.catch(done.fail);
});
describe('and after loading', () => {
beforeEach(done => {
findAction().vm.$emit('click');
waitForTick(done);
});
it('calls service approve', () => {
expect(service.approveMergeRequest).toHaveBeenCalled();
});
it('emits to eventHub', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
});
it('calls store setApprovals', () => {
expect(mr.setApprovals).toHaveBeenCalledWith(testApprovals());
});
it('refetches the rules', () => {
expect(service.fetchApprovalSettings).toHaveBeenCalled();
});
});
describe('and error', () => {
beforeEach(done => {
service.approveMergeRequest.and.returnValue(Promise.reject());
findAction().vm.$emit('click');
waitForTick(done);
});
it('flashes error message', () => {
expect(createFlash).toHaveBeenCalledWith(APPROVE_ERROR);
});
});
});
});
describe('when user has approved', () => {
beforeEach(done => {
mr.approvals.user_has_approved = true;
mr.approvals.user_can_approve = false;
createComponent();
waitForTick(done);
});
it('revoke action is rendered', () => {
expect(findActionData()).toEqual({
variant: 'warning',
text: 'Revoke approval',
inverted: true,
});
});
describe('when revoke action is clicked', () => {
describe('and successful', () => {
beforeEach(done => {
findAction().vm.$emit('click');
waitForTick(done);
});
it('calls service unapprove', () => {
expect(service.unapproveMergeRequest).toHaveBeenCalled();
});
it('emits to eventHub', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
});
it('calls store setApprovals', () => {
expect(mr.setApprovals).toHaveBeenCalledWith(testApprovals());
});
it('refetches the rules', () => {
expect(service.fetchApprovalSettings).toHaveBeenCalled();
});
});
describe('and error', () => {
beforeEach(done => {
service.unapproveMergeRequest.and.returnValue(Promise.reject());
findAction().vm.$emit('click');
waitForTick(done);
});
it('flashes error message', () => {
expect(createFlash).toHaveBeenCalledWith(UNAPPROVE_ERROR);
});
});
});
});
});
describe('approvals summary', () => {
beforeEach(done => {
createComponent();
waitForTick(done);
});
it('is rendered with props', () => {
const expected = testApprovals();
const summary = findSummary();
expect(summary.exists()).toBe(true);
expect(summary.props()).toEqual(
jasmine.objectContaining({
approvalsLeft: expected.approvals_left,
rulesLeft: expected.approval_rules_left,
approvers: testApprovedBy(),
}),
);
});
});
describe('footer', () => {
let footer;
beforeEach(done => {
createComponent();
waitForTick(done);
});
beforeEach(() => {
footer = findFooter();
});
it('is rendered with props', () => {
expect(footer.exists()).toBe(true);
expect(footer.props()).toEqual(
jasmine.objectContaining({
value: false,
suggestedApprovers: mr.approvals.suggested_approvers,
approvalRules: mr.approvalRules,
isLoadingRules: false,
}),
);
});
describe('when opened', () => {
describe('and loading', () => {
beforeEach(done => {
service.fetchApprovalSettings.and.callFake(() => new Promise(() => {}));
footer.vm.$emit('input', true);
waitForTick(done);
});
it('calls service fetch approval rules', () => {
expect(service.fetchApprovalSettings).toHaveBeenCalled();
});
it('is loading rules', () => {
expect(wrapper.vm.isLoadingRules).toBe(true);
expect(footer.props('isLoadingRules')).toBe(true);
});
});
describe('and finished loading', () => {
beforeEach(done => {
footer.vm.$emit('input', true);
waitForTick(done);
});
it('sets approval rules', () => {
expect(mr.setApprovalRules).toHaveBeenCalledWith(testApprovalRulesResponse());
});
it('shows footer', () => {
expect(footer.props('value')).toBe(true);
});
describe('and closed', () => {
beforeEach(done => {
service.fetchApprovalSettings.calls.reset();
footer.vm.$emit('input', false);
waitForTick(done);
});
it('does not call service fetch approval rules', () => {
expect(service.fetchApprovalSettings).not.toHaveBeenCalled();
});
it('hides approval rules', () => {
expect(footer.props('value')).toBe(false);
});
});
});
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import _ from 'underscore';
import { toNounSeriesText } from '~/lib/utils/grammar';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import { APPROVED_MESSAGE } from 'ee/vue_merge_request_widget/components/approvals/messages';
import ApprovalsSummary from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals_summary.vue';
const localVue = createLocalVue();
const testApprovers = () => _.range(1, 5).map(id => ({ id }));
const testRulesLeft = () => ['Lorem', 'Ipsum', 'dolar sit'];
const TEST_APPROVALS_LEFT = 3;
describe('EE MRWidget approvals summary', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(localVue.extend(ApprovalsSummary), {
propsData: {
approvers: testApprovers(),
approvalsLeft: TEST_APPROVALS_LEFT,
rulesLeft: testRulesLeft(),
...props,
},
localVue,
sync: false,
});
};
const findAvatars = () => wrapper.find(UserAvatarList);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when approved', () => {
beforeEach(() => {
createComponent({
approvalsLeft: 0,
});
});
it('shows approved message', () => {
expect(wrapper.text()).toContain(APPROVED_MESSAGE);
});
it('renders avatar list for approvers', () => {
const avatars = findAvatars();
expect(avatars.exists()).toBe(true);
expect(avatars.props()).toEqual(
jasmine.objectContaining({
items: testApprovers(),
}),
);
});
});
describe('when not approved', () => {
beforeEach(() => {
createComponent();
});
it('render message', () => {
const names = toNounSeriesText(testRulesLeft());
expect(wrapper.text()).toContain(
`Requires ${TEST_APPROVALS_LEFT} more approvals from ${names}.`,
);
});
});
describe('when no rulesLeft', () => {
beforeEach(() => {
createComponent({
rulesLeft: [],
});
});
it('renders message', () => {
expect(wrapper.text()).toContain(`Requires ${TEST_APPROVALS_LEFT} more approvals.`);
});
});
describe('when no approvers', () => {
beforeEach(() => {
createComponent({
approvers: [],
});
});
it('does not render avatar list', () => {
expect(wrapper.find(UserAvatarList).exists()).toBe(false);
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Icon from '~/vue_shared/components/icon.vue';
import ApprovedIcon from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approved_icon.vue';
const localVue = createLocalVue();
const EXPECTED_SIZE = 16;
describe('EE MRWidget approved icon', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(localVue.extend(ApprovedIcon), {
propsData: props,
localVue,
sync: false,
});
};
const findIcon = () => wrapper.find(Icon);
const findSquare = () => wrapper.find('.square');
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when approved', () => {
beforeEach(() => {
createComponent({ isApproved: true });
});
it('renders icon', () => {
const icon = findIcon();
expect(icon.exists()).toBe(true);
expect(icon.props()).toEqual(
jasmine.objectContaining({
size: EXPECTED_SIZE,
name: 'mobile-issue-close',
}),
);
});
it('does not render square', () => {
expect(findSquare().exists()).toBe(false);
});
});
describe('when unapproved', () => {
beforeEach(() => {
createComponent({ isApproved: false });
});
it('does not render icon', () => {
expect(findIcon().exists()).toBe(false);
});
it('renders square', () => {
const square = findSquare();
expect(square.exists()).toBe(true);
expect(square.classes(`s${EXPECTED_SIZE}`)).toBe(true);
});
});
});
import Vue from 'vue';
import ApprovalsBody from 'ee/vue_merge_request_widget/components/approvals/approvals_body.vue';
import ApprovalsBody from 'ee/vue_merge_request_widget/components/approvals/single_rule/approvals_body.vue';
describe('Approvals Body Component', () => {
let vm;
......
import Vue from 'vue';
import ApprovalsFooter from 'ee/vue_merge_request_widget/components/approvals/approvals_footer.vue';
import ApprovalsFooter from 'ee/vue_merge_request_widget/components/approvals/single_rule/approvals_footer.vue';
import { TEST_HOST } from 'spec/test_constants';
describe('Approvals Footer Component', () => {
......
......@@ -39,6 +39,7 @@ describe('ee merge request widget options', () => {
}
beforeEach(() => {
gon.features = { approvalRules: false };
delete mrWidgetOptions.extends.el; // Prevent component mounting
Component = Vue.extend(mrWidgetOptions);
......@@ -46,6 +47,7 @@ describe('ee merge request widget options', () => {
});
afterEach(() => {
gon.features = null;
vm.$destroy();
mock.restore();
......
......@@ -9,6 +9,8 @@ describe 'shared/issuable/_approvals.html.haml' do
let(:form) { double('form') }
before do
stub_feature_flags(approval_rules: false)
allow(view).to receive(:can?).and_return(true)
allow(view).to receive(:current_user).and_return(user)
allow(form).to receive(:label)
......
......@@ -127,12 +127,26 @@ msgstr ""
msgid "%{count} %{alerts}"
msgstr ""
msgid "%{count} approval required from %{name}"
msgid_plural "%{count} approvals required from %{name}"
msgstr[0] ""
msgstr[1] ""
msgid "%{count} approvals from %{name}"
msgstr ""
msgid "%{count} more"
msgstr ""
msgid "%{count} more assignees"
msgstr ""
msgid "%{count} of %{required} approvals from %{name}"
msgstr ""
msgid "%{count} of %{total}"
msgstr ""
msgid "%{count} participant"
msgid_plural "%{count} participants"
msgstr[0] ""
......@@ -464,6 +478,12 @@ msgstr ""
msgid "Add additional text to appear in all email communications. %{character_limit} character limit"
msgstr ""
msgid "Add approver(s)"
msgstr ""
msgid "Add approvers"
msgstr ""
msgid "Add comment now"
msgstr ""
......@@ -649,6 +669,9 @@ msgstr ""
msgid "All"
msgstr ""
msgid "All Members"
msgstr ""
msgid "All changes are committed"
msgstr ""
......@@ -730,6 +753,12 @@ msgstr ""
msgid "An error occurred creating the new branch."
msgstr ""
msgid "An error occurred fetching the approval rules."
msgstr ""
msgid "An error occurred fetching the approvers for the new rule."
msgstr ""
msgid "An error occurred previewing the blob"
msgstr ""
......@@ -742,6 +771,9 @@ msgstr ""
msgid "An error occurred while adding approver"
msgstr ""
msgid "An error occurred while deleting the approvers group"
msgstr ""
msgid "An error occurred while deleting the comment"
msgstr ""
......@@ -835,12 +867,18 @@ msgstr ""
msgid "An error occurred while saving assignees"
msgstr ""
msgid "An error occurred while saving the approval settings"
msgstr ""
msgid "An error occurred while subscribing to notifications."
msgstr ""
msgid "An error occurred while unsubscribing to notifications."
msgstr ""
msgid "An error occurred while updating approvers"
msgstr ""
msgid "An error occurred while updating the comment"
msgstr ""
......@@ -931,6 +969,44 @@ msgstr ""
msgid "Apply suggestion"
msgstr ""
msgid "ApprovalRuleRemove|%d member"
msgid_plural "ApprovalRuleRemove|%d members"
msgstr[0] ""
msgstr[1] ""
msgid "ApprovalRuleRemove|Approvals from this member are not revoked."
msgid_plural "ApprovalRuleRemove|Approvals from these members are not revoked."
msgstr[0] ""
msgstr[1] ""
msgid "ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}."
msgstr ""
msgid "ApprovalRuleSummary|%d member"
msgid_plural "ApprovalRuleSummary|%d members"
msgstr[0] ""
msgstr[1] ""
msgid "ApprovalRuleSummary|%{count} approval required from %{membersCount}"
msgid_plural "ApprovalRuleSummary|%{count} approvals required from %{membersCount}"
msgstr[0] ""
msgstr[1] ""
msgid "ApprovalRule|All members with Developer role or higher and code owners (if any)"
msgstr ""
msgid "ApprovalRule|Members"
msgstr ""
msgid "ApprovalRule|Name"
msgstr ""
msgid "ApprovalRule|No. approvals required"
msgstr ""
msgid "ApprovalRule|e.g. QA, Security, etc."
msgstr ""
msgid "Approvals"
msgstr ""
......@@ -973,9 +1049,15 @@ msgstr ""
msgid "Are you sure you want to remove %{group_name}?"
msgstr ""
msgid "Are you sure you want to remove approver %{name}"
msgstr ""
msgid "Are you sure you want to remove approver %{name}?"
msgstr ""
msgid "Are you sure you want to remove group %{name}"
msgstr ""
msgid "Are you sure you want to remove group %{name}?"
msgstr ""
......@@ -2425,6 +2507,9 @@ msgstr ""
msgid "Collapse"
msgstr ""
msgid "Collapse approvers"
msgstr ""
msgid "Collapse sidebar"
msgstr ""
......@@ -3812,6 +3897,9 @@ msgstr ""
msgid "Expand all"
msgstr ""
msgid "Expand approvers"
msgstr ""
msgid "Expand sidebar"
msgstr ""
......@@ -5742,6 +5830,15 @@ msgstr ""
msgid "Logs"
msgstr ""
msgid "MRApprovals|Approved by"
msgstr ""
msgid "MRApprovals|Approvers"
msgstr ""
msgid "MRApprovals|Pending approvals"
msgstr ""
msgid "Make everyone on your team more productive regardless of their location. GitLab Geo creates read-only mirrors of your GitLab instance so you can reduce the time it takes to clone and fetch large repos."
msgstr ""
......@@ -6688,6 +6785,9 @@ msgstr ""
msgid "OperationsDashboard|Unable to add %{invalidProjects}. The Operations Dashboard is available for public projects, and private projects in groups with a Gold plan."
msgstr ""
msgid "Optional"
msgstr ""
msgid "Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab."
msgstr ""
......@@ -7018,6 +7118,15 @@ msgstr ""
msgid "Please enable and migrate to hashed storage to avoid security issues and ensure data integrity. %{migrate_link}"
msgstr ""
msgid "Please enter a non-negative number"
msgstr ""
msgid "Please enter a number greater than %{number} (from the project settings)"
msgstr ""
msgid "Please enter a valid number"
msgstr ""
msgid "Please fill in a descriptive name for your group."
msgstr ""
......@@ -7027,6 +7136,12 @@ msgstr ""
msgid "Please note that this application is not provided by GitLab and you should verify its authenticity before allowing access."
msgstr ""
msgid "Please provide a name"
msgstr ""
msgid "Please select and add a member"
msgstr ""
msgid "Please select at least one filter to see results"
msgstr ""
......@@ -7854,6 +7969,12 @@ msgstr ""
msgid "Remove approver"
msgstr ""
msgid "Remove approvers"
msgstr ""
msgid "Remove approvers?"
msgstr ""
msgid "Remove avatar"
msgstr ""
......@@ -7989,6 +8110,16 @@ msgstr ""
msgid "Require all users to accept Terms of Service and Privacy Policy when they access GitLab."
msgstr ""
msgid "Requires approval from %{names}."
msgid_plural "Requires %{count} more approvals from %{names}."
msgstr[0] ""
msgstr[1] ""
msgid "Requires approval."
msgid_plural "Requires %d more approvals."
msgstr[0] ""
msgstr[1] ""
msgid "Resend invite"
msgstr ""
......@@ -8270,6 +8401,9 @@ msgstr ""
msgid "Search users"
msgstr ""
msgid "Search users or groups"
msgstr ""
msgid "Search your projects"
msgstr ""
......@@ -9652,6 +9786,12 @@ msgstr ""
msgid "This merge request is locked."
msgstr ""
msgid "This merge request must be approved by members of these groups. You can override the project settings by setting your own list of approvers."
msgstr ""
msgid "This merge request must be approved by these users. You can override the project settings by setting your own list of approvers."
msgstr ""
msgid "This option is disabled as you don't have write permissions for the current branch"
msgstr ""
......@@ -10211,6 +10351,9 @@ msgstr ""
msgid "Update"
msgstr ""
msgid "Update approvers"
msgstr ""
msgid "Update failed"
msgstr ""
......@@ -10448,6 +10591,9 @@ msgstr ""
msgid "View documentation"
msgstr ""
msgid "View eligible approvers"
msgstr ""
msgid "View epics list"
msgstr ""
......@@ -10865,6 +11011,9 @@ msgstr ""
msgid "You have no permissions"
msgstr ""
msgid "You have not added any approvers. Start by adding users or groups."
msgstr ""
msgid "You have reached your project limit"
msgstr ""
......@@ -11450,6 +11599,9 @@ msgstr ""
msgid "mrWidget|Approve"
msgstr ""
msgid "mrWidget|Approve additionally"
msgstr ""
msgid "mrWidget|Approved by"
msgstr ""
......@@ -11522,6 +11674,9 @@ msgstr ""
msgid "mrWidget|Merge request approved"
msgstr ""
msgid "mrWidget|Merge request approved."
msgstr ""
msgid "mrWidget|Merge request approved; you can approve additionally"
msgstr ""
......@@ -11583,6 +11738,9 @@ msgstr ""
msgid "mrWidget|Revert this merge request in a new merge request"
msgstr ""
msgid "mrWidget|Revoke approval"
msgstr ""
msgid "mrWidget|Set by"
msgstr ""
......
......@@ -32,7 +32,7 @@ module QA
element :review_preview_toggle
end
view 'ee/app/views/shared/issuable/_approvals.html.haml' do
view 'ee/app/views/shared/issuable/_approvals_single_rule.html.haml' do
element :approver_list
end
......
......@@ -16,6 +16,8 @@ describe "User creates a merge request", :js do
let(:user2) { create(:user) }
before do
stub_feature_flags(approval_rules: false)
project.add_maintainer(user)
project.add_maintainer(user2)
project.add_maintainer(approver)
......
......@@ -7,6 +7,7 @@ describe 'Projects > Settings > For a forked project', :js do
let(:forked_project) { fork_project(original_project, user) }
before do
stub_feature_flags(approval_rules: false)
original_project.add_maintainer(user)
forked_project.add_maintainer(user)
sign_in(user)
......
......@@ -4,6 +4,10 @@ describe 'Project' do
include ProjectForksHelper
include MobileHelpers
before do
stub_feature_flags(approval_rules: false)
end
describe 'creating from template' do
let(:user) { create(:user) }
let(:template) { Gitlab::ProjectTemplate.find(:rails) }
......
......@@ -21,6 +21,7 @@ describe('mrWidgetOptions', () => {
const COLLABORATION_MESSAGE = 'Allows commits from members who can merge to the target branch';
beforeEach(() => {
gon.features = { approvalRules: false };
// Prevent component mounting
delete mrWidgetOptions.el;
......@@ -31,6 +32,7 @@ describe('mrWidgetOptions', () => {
});
afterEach(() => {
gon.features = null;
vm.$destroy();
});
......
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