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
- 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
- if Feature.enabled?(:approval_rules, @project)
= render 'shared/issuable/approvals_multiple_rule', issuable: issuable
- else
= 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);
});
});
});
This diff is collapsed.
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 { 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();
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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