Commit 30b55047 authored by Phil Hughes's avatar Phil Hughes

Merge branch '460-fe-protected-branches-api-search' into 'master'

Scope merge request approval rules to protected branches using API search

Closes #460

See merge request gitlab-org/gitlab!24344
parents deffce3b 21236b14
...@@ -24,6 +24,7 @@ const Api = { ...@@ -24,6 +24,7 @@ const Api = {
projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes',
projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
projectRunnersPath: '/api/:version/projects/:id/runners', projectRunnersPath: '/api/:version/projects/:id/runners',
projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
mergeRequestsPath: '/api/:version/merge_requests', mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/groups/:namespace_path/-/labels', groupLabelsPath: '/groups/:namespace_path/-/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
...@@ -220,6 +221,22 @@ const Api = { ...@@ -220,6 +221,22 @@ const Api = {
return axios.get(url, config); return axios.get(url, config);
}, },
projectProtectedBranches(id, query = '') {
const url = Api.buildUrl(Api.projectProtectedBranchesPath).replace(
':id',
encodeURIComponent(id),
);
return axios
.get(url, {
params: {
search: query,
per_page: DEFAULT_PER_PAGE,
},
})
.then(({ data }) => data);
},
mergeRequests(params = {}) { mergeRequests(params = {}) {
const url = Api.buildUrl(Api.mergeRequestsPath); const url = Api.buildUrl(Api.mergeRequestsPath);
......
...@@ -63,7 +63,8 @@ ...@@ -63,7 +63,8 @@
display: block; display: block;
} }
.select2-choices { .select2-choices,
.select2-choice {
border-color: $red-500; border-color: $red-500;
} }
} }
......
...@@ -11,6 +11,12 @@ export default { ...@@ -11,6 +11,12 @@ export default {
GlButton, GlButton,
GlLoadingIcon, GlLoadingIcon,
}, },
props: {
isMrEdit: {
type: Boolean,
default: true,
},
},
computed: { computed: {
...mapState({ ...mapState({
settings: 'settings', settings: 'settings',
...@@ -53,7 +59,7 @@ export default { ...@@ -53,7 +59,7 @@ export default {
</div> </div>
<slot name="footer"></slot> <slot name="footer"></slot>
</template> </template>
<modal-rule-create :modal-id="createModalId" /> <modal-rule-create :modal-id="createModalId" :is-mr-edit="isMrEdit" />
<modal-rule-remove :modal-id="removeModalId" /> <modal-rule-remove :modal-id="removeModalId" />
</div> </div>
</template> </template>
<script>
import $ from 'jquery';
import 'select2/select2';
import _ from 'underscore';
import Api from 'ee/api';
import { __ } from '~/locale';
const anyBranch = {
id: null,
name: __('Any branch'),
};
function formatSelection(object) {
return `<span>${object.name}</span>`;
}
function formatResult(result) {
const isAnyBranch = result.id ? `monospace` : '';
return `
<span class="result-name ${isAnyBranch}">${result.name}</span>
`;
}
export default {
props: {
projectId: {
type: String,
required: true,
},
initRule: {
type: Object,
required: false,
default: null,
},
isInvalid: {
type: Boolean,
required: false,
default: false,
},
},
watch: {
value(val) {
if (val.length > 0) {
this.clear();
}
},
isInvalid(val) {
const $container = this.$input.select2('container');
$container.toggleClass('is-invalid', val);
},
},
mounted() {
const $modal = $('#project-settings-approvals-create-modal .modal-content');
this.$input = $(this.$refs.input);
this.$input
.select2({
minimumInputLength: 0,
multiple: false,
closeOnSelect: false,
formatResult,
formatSelection,
initSelection: (element, callback) => this.initialOption(element, callback),
query: _.debounce(({ term, callback }) => this.fetchBranches(term).then(callback), 250),
id: ({ type, id }) => `${type}${id}`,
})
.on('change', e => this.onChange(e))
.on('select2-open', () => {
// https://stackoverflow.com/questions/18487056/select2-doesnt-work-when-embedded-in-a-bootstrap-modal
// Ensure search feature works in modal
// (known issue with our current select2 version, solved in version 4 with "dropdownParent")
$modal.removeAttr('tabindex', '-1');
})
.on('select2-close', () => {
$modal.attr('tabindex', '-1');
});
},
beforeDestroy() {
this.$input.select2('destroy');
},
methods: {
fetchBranches(term) {
const excludeAnyBranch = term && !term.toLowerCase().includes('any');
return Api.projectProtectedBranches(this.projectId, term).then(results => ({
results: excludeAnyBranch ? results : [anyBranch, ...results],
}));
},
initialOption(element, callback) {
let currentBranch = anyBranch;
if (this.initRule?.protectedBranches.length) {
const { name, id } = this.initRule.protectedBranches[0];
if (id) {
currentBranch = { name, id };
this.selectedId = id;
}
}
return callback(currentBranch);
},
onChange() {
const value = this.$input.select2('data');
this.$emit('input', value.id);
},
clear() {
this.$input.select2('data', []);
},
},
};
</script>
<template>
<input ref="input" name="protected_branch_ids" type="hidden" />
</template>
...@@ -14,6 +14,10 @@ export default { ...@@ -14,6 +14,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
isMrEdit: {
type: Boolean,
default: true,
},
}, },
computed: { computed: {
...mapState('createModal', { ...mapState('createModal', {
...@@ -41,6 +45,6 @@ export default { ...@@ -41,6 +45,6 @@ export default {
:cancel-title="__('Cancel')" :cancel-title="__('Cancel')"
@ok.prevent="submit" @ok.prevent="submit"
> >
<rule-form ref="form" :init-rule="rule" /> <rule-form ref="form" :init-rule="rule" :is-mr-edit="isMrEdit" />
</gl-modal-vuex> </gl-modal-vuex>
</template> </template>
<script> <script>
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RuleInput from './rule_input.vue'; import RuleInput from './rule_input.vue';
import EmptyRuleName from '../empty_rule_name.vue'; import EmptyRuleName from '../empty_rule_name.vue';
import RuleBranches from '../rule_branches.vue';
export default { export default {
components: { components: {
RuleInput, RuleInput,
EmptyRuleName, EmptyRuleName,
RuleBranches,
GlButton, GlButton,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
rule: { rule: {
type: Object, type: Object,
...@@ -33,6 +37,11 @@ export default { ...@@ -33,6 +37,11 @@ export default {
required: true, required: true,
}, },
}, },
computed: {
showProtectedBranch() {
return this.glFeatures.scopedApprovalRules && !this.isMrEdit && this.allowMultiRule;
},
},
methods: { methods: {
...mapActions({ openCreateModal: 'createModal/open' }), ...mapActions({ openCreateModal: 'createModal/open' }),
}, },
...@@ -44,6 +53,9 @@ export default { ...@@ -44,6 +53,9 @@ export default {
<td colspan="2"> <td colspan="2">
<empty-rule-name :eligible-approvers-docs-path="eligibleApproversDocsPath" /> <empty-rule-name :eligible-approvers-docs-path="eligibleApproversDocsPath" />
</td> </td>
<td v-if="showProtectedBranch">
<rule-branches :rule="rule" />
</td>
<td class="js-approvals-required"> <td class="js-approvals-required">
<rule-input :rule="rule" :is-mr-edit="isMrEdit" /> <rule-input :rule="rule" :is-mr-edit="isMrEdit" />
</td> </td>
......
...@@ -11,5 +11,5 @@ export default { ...@@ -11,5 +11,5 @@ export default {
</script> </script>
<template> <template>
<app><project-rules slot="rules"/></app> <app :is-mr-edit="false"><project-rules slot="rules"/></app>
</template> </template>
...@@ -7,6 +7,7 @@ import Rules from '../rules.vue'; ...@@ -7,6 +7,7 @@ import Rules from '../rules.vue';
import RuleControls from '../rule_controls.vue'; import RuleControls from '../rule_controls.vue';
import EmptyRule from '../mr_edit/empty_rule.vue'; import EmptyRule from '../mr_edit/empty_rule.vue';
import RuleInput from '../mr_edit/rule_input.vue'; import RuleInput from '../mr_edit/rule_input.vue';
import RuleBranches from '../rule_branches.vue';
export default { export default {
components: { components: {
...@@ -15,6 +16,7 @@ export default { ...@@ -15,6 +16,7 @@ export default {
UserAvatarList, UserAvatarList,
EmptyRule, EmptyRule,
RuleInput, RuleInput,
RuleBranches,
}, },
computed: { computed: {
...mapState(['settings']), ...mapState(['settings']),
...@@ -91,17 +93,21 @@ export default { ...@@ -91,17 +93,21 @@ export default {
<template> <template>
<rules :rules="rules"> <rules :rules="rules">
<template slot="thead" slot-scope="{ name, members, approvalsRequired }"> <template
slot="thead"
slot-scope="{ name, members, approvalsRequired, branches, glFeaturesScopedApprovalRules }"
>
<tr class="d-none d-sm-table-row"> <tr class="d-none d-sm-table-row">
<th class="w-25">{{ hasNamedRule ? name : members }}</th> <th class="w-25">{{ hasNamedRule ? name : members }}</th>
<th :class="settings.allowMultiRule ? 'w-50 d-none d-sm-table-cell' : 'w-75'"> <th :class="settings.allowMultiRule ? 'w-50 d-none d-sm-table-cell' : 'w-75'">
<span v-if="hasNamedRule">{{ members }}</span> <span v-if="hasNamedRule">{{ members }}</span>
</th> </th>
<th v-if="glFeaturesScopedApprovalRules && settings.allowMultiRule">{{ branches }}</th>
<th>{{ approvalsRequired }}</th> <th>{{ approvalsRequired }}</th>
<th></th> <th></th>
</tr> </tr>
</template> </template>
<template slot="tbody" slot-scope="{ rules }"> <template slot="tbody" slot-scope="{ rules, glFeaturesScopedApprovalRules }">
<template v-for="(rule, index) in rules"> <template v-for="(rule, index) in rules">
<empty-rule <empty-rule
v-if="rule.ruleType === 'any_approver'" v-if="rule.ruleType === 'any_approver'"
...@@ -119,6 +125,9 @@ export default { ...@@ -119,6 +125,9 @@ export default {
<td class="js-members" :class="settings.allowMultiRule ? 'd-none d-sm-table-cell' : null"> <td class="js-members" :class="settings.allowMultiRule ? 'd-none d-sm-table-cell' : null">
<user-avatar-list :items="rule.approvers" :img-size="24" empty-text="" /> <user-avatar-list :items="rule.approvers" :img-size="24" empty-text="" />
</td> </td>
<td v-if="glFeaturesScopedApprovalRules && settings.allowMultiRule" class="js-branches">
<rule-branches :rule="rule" />
</td>
<td class="js-approvals-required"> <td class="js-approvals-required">
<rule-input :rule="rule" /> <rule-input :rule="rule" />
</td> </td>
......
<script>
import { __ } from '~/locale';
export default {
props: {
rule: {
type: Object,
required: true,
},
},
computed: {
branchName() {
const { protectedBranches } = this.rule;
const [protectedBranch] = protectedBranches || [];
return protectedBranch?.name || __('Any branch');
},
isAnyBranch() {
return this.rule.protectedBranches?.length;
},
},
};
</script>
<template>
<div :class="{ monospace: isAnyBranch }">{{ branchName }}</div>
</template>
...@@ -2,8 +2,10 @@ ...@@ -2,8 +2,10 @@
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import _ from 'underscore'; import _ from 'underscore';
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ApproversList from './approvers_list.vue'; import ApproversList from './approvers_list.vue';
import ApproversSelect from './approvers_select.vue'; import ApproversSelect from './approvers_select.vue';
import BranchesSelect from './branches_select.vue';
import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from '../constants'; import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from '../constants';
const DEFAULT_NAME = 'Default'; const DEFAULT_NAME = 'Default';
...@@ -14,13 +16,19 @@ export default { ...@@ -14,13 +16,19 @@ export default {
components: { components: {
ApproversList, ApproversList,
ApproversSelect, ApproversSelect,
BranchesSelect,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
initRule: { initRule: {
type: Object, type: Object,
required: false, required: false,
default: null, default: null,
}, },
isMrEdit: {
type: Boolean,
default: true,
},
}, },
data() { data() {
return { return {
...@@ -29,6 +37,8 @@ export default { ...@@ -29,6 +37,8 @@ export default {
minApprovalsRequired: 0, minApprovalsRequired: 0,
approvers: [], approvers: [],
approversToAdd: [], approversToAdd: [],
branches: [],
branchesToAdd: [],
showValidation: false, showValidation: false,
isFallback: false, isFallback: false,
containsHiddenGroups: false, containsHiddenGroups: false,
...@@ -57,11 +67,17 @@ export default { ...@@ -57,11 +67,17 @@ export default {
return {}; return {};
} }
return { const invalidObject = {
name: this.invalidName, name: this.invalidName,
approvalsRequired: this.invalidApprovalsRequired, approvalsRequired: this.invalidApprovalsRequired,
approvers: this.invalidApprovers, approvers: this.invalidApprovers,
}; };
if (!this.isMrEdit) {
invalidObject.branches = this.invalidBranches;
}
return invalidObject;
}, },
invalidName() { invalidName() {
if (!this.isMultiSubmission) { if (!this.isMultiSubmission) {
...@@ -92,6 +108,13 @@ export default { ...@@ -92,6 +108,13 @@ export default {
return !this.approvers.length ? __('Please select and add a member') : ''; return !this.approvers.length ? __('Please select and add a member') : '';
}, },
invalidBranches() {
if (this.isMrEdit) return '';
const invalidTypes = this.branches.filter(id => typeof id !== 'number');
return invalidTypes.length ? __('Please select a valid target branch') : '';
},
isValid() { isValid() {
return Object.keys(this.validation).every(key => !this.validation[key]); return Object.keys(this.validation).every(key => !this.validation[key]);
}, },
...@@ -122,13 +145,20 @@ export default { ...@@ -122,13 +145,20 @@ export default {
userRecords: this.users, userRecords: this.users,
groupRecords: this.groups, groupRecords: this.groups,
removeHiddenGroups: this.removeHiddenGroups, removeHiddenGroups: this.removeHiddenGroups,
protectedBranchIds: this.branches,
}; };
}, },
showProtectedBranch() {
return this.glFeatures.scopedApprovalRules && !this.isMrEdit && this.settings.allowMultiRule;
},
}, },
watch: { watch: {
approversToAdd(value) { approversToAdd(value) {
this.approvers.push(value[0]); this.approvers.push(value[0]);
}, },
branchesToAdd(value) {
this.branches = value ? [value] : [];
},
}, },
methods: { methods: {
...mapActions(['putFallbackRule', 'postRule', 'putRule', 'deleteRule', 'postRegularRule']), ...mapActions(['putFallbackRule', 'postRule', 'putRule', 'deleteRule', 'postRegularRule']),
...@@ -215,6 +245,7 @@ export default { ...@@ -215,6 +245,7 @@ export default {
const users = this.initRule.users.map(x => ({ ...x, type: TYPE_USER })); const users = this.initRule.users.map(x => ({ ...x, type: TYPE_USER }));
const groups = this.initRule.groups.map(x => ({ ...x, type: TYPE_GROUP })); const groups = this.initRule.groups.map(x => ({ ...x, type: TYPE_GROUP }));
const branches = this.initRule.protectedBranches?.map(x => x.id) || [];
return { return {
name: this.initRule.name || '', name: this.initRule.name || '',
...@@ -226,6 +257,7 @@ export default { ...@@ -226,6 +257,7 @@ export default {
.concat( .concat(
containsHiddenGroups && !removeHiddenGroups ? [{ type: TYPE_HIDDEN_GROUPS }] : [], containsHiddenGroups && !removeHiddenGroups ? [{ type: TYPE_HIDDEN_GROUPS }] : [],
), ),
branches,
}; };
}, },
}, },
...@@ -267,6 +299,23 @@ export default { ...@@ -267,6 +299,23 @@ export default {
</label> </label>
</div> </div>
</div> </div>
<div v-if="showProtectedBranch" class="form-group">
<label class="label-bold">{{ s__('ApprovalRule|Target branch') }}</label>
<div class="d-flex align-items-start">
<div class="w-100">
<branches-select
v-model="branchesToAdd"
:project-id="settings.projectId"
:is-invalid="!!validation.branches"
:init-rule="initRule"
/>
<div class="invalid-feedback">{{ validation.branches }}</div>
</div>
</div>
<p class="text-muted">
{{ __('Apply this approval rule to any branch or a specific protected branch.') }}
</p>
</div>
<div class="form-group"> <div class="form-group">
<label class="label-bold">{{ s__('ApprovalRule|Approvers') }}</label> <label class="label-bold">{{ s__('ApprovalRule|Approvers') }}</label>
<div class="d-flex align-items-start"> <div class="d-flex align-items-start">
......
<script> <script>
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const HEADERS = { const HEADERS = {
name: s__('ApprovalRule|Name'), name: s__('ApprovalRule|Name'),
members: s__('ApprovalRule|Approvers'), members: s__('ApprovalRule|Approvers'),
approvalsRequired: s__('ApprovalRule|No. approvals required'), approvalsRequired: s__('ApprovalRule|No. approvals required'),
branches: s__('Target branch'),
}; };
export default { export default {
mixins: [glFeatureFlagsMixin()],
props: { props: {
rules: { rules: {
type: Array, type: Array,
required: true, required: true,
}, },
}, },
computed: {
scopedApprovalRules() {
return this.glFeatures.scopedApprovalRules;
},
},
HEADERS, HEADERS,
}; };
</script> </script>
...@@ -21,10 +29,18 @@ export default { ...@@ -21,10 +29,18 @@ export default {
<template> <template>
<table class="table m-0"> <table class="table m-0">
<thead class="thead-white text-nowrap"> <thead class="thead-white text-nowrap">
<slot name="thead" v-bind="$options.HEADERS"></slot> <slot
name="thead"
v-bind="$options.HEADERS"
:gl-features-scoped-approval-rules="scopedApprovalRules"
></slot>
</thead> </thead>
<tbody> <tbody>
<slot name="tbody" :rules="rules"></slot> <slot
name="tbody"
:rules="rules"
:gl-features-scoped-approval-rules="scopedApprovalRules"
></slot>
</tbody> </tbody>
</table> </table>
</template> </template>
...@@ -18,6 +18,7 @@ function withDefaultEmptyRule(rules = []) { ...@@ -18,6 +18,7 @@ function withDefaultEmptyRule(rules = []) {
users: [], users: [],
groups: [], groups: [],
ruleType: RULE_TYPE_ANY_APPROVER, ruleType: RULE_TYPE_ANY_APPROVER,
protectedBranches: [],
}, },
]; ];
} }
...@@ -28,6 +29,7 @@ export const mapApprovalRuleRequest = req => ({ ...@@ -28,6 +29,7 @@ export const mapApprovalRuleRequest = req => ({
users: req.users, users: req.users,
groups: req.groups, groups: req.groups,
remove_hidden_groups: req.removeHiddenGroups, remove_hidden_groups: req.removeHiddenGroups,
protected_branch_ids: req.protectedBranchIds,
}); });
export const mapApprovalFallbackRuleRequest = req => ({ export const mapApprovalFallbackRuleRequest = req => ({
...@@ -45,6 +47,7 @@ export const mapApprovalRuleResponse = res => ({ ...@@ -45,6 +47,7 @@ export const mapApprovalRuleResponse = res => ({
users: res.users, users: res.users,
groups: res.groups, groups: res.groups,
ruleType: res.rule_type, ruleType: res.rule_type,
protectedBranches: res.protected_branches,
}); });
export const mapApprovalSettingsResponse = res => ({ export const mapApprovalSettingsResponse = res => ({
......
---
title: Scope merge request approval rules to protected branches using API search
merge_request: 24344
author:
type: added
import { shallowMount } from '@vue/test-utils';
import RuleBranches from 'ee/approvals/components/rule_branches.vue';
describe('Rule Branches', () => {
let wrapper;
const defaultProp = {
rule: {},
};
const createComponent = (prop = {}) => {
wrapper = shallowMount(RuleBranches, {
propsData: {
...defaultProp,
...prop,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('displays "Any branch" if there are no protected branches', () => {
createComponent();
expect(wrapper.text()).toContain('Any branch');
});
it('displays the branch name of the first protected branch', () => {
const rule = {
protectedBranches: [
{
id: 1,
name: 'master',
},
{
id: 2,
name: 'hello',
},
],
};
createComponent({
rule,
});
expect(wrapper.text()).toContain('master');
expect(wrapper.text()).not.toContain('hello');
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import $ from 'jquery';
import Api from 'ee/api';
import BranchesSelect from 'ee/approvals/components/branches_select.vue';
const TEST_DEFAULT_BRANCH = { name: 'Any branch' };
const TEST_PROJECT_ID = '1';
const TEST_PROTECTED_BRANCHES = [{ id: 1, name: 'master' }, { id: 2, name: 'development' }];
const TEST_BRANCHES_SELECTIONS = [TEST_DEFAULT_BRANCH, ...TEST_PROTECTED_BRANCHES];
const DEBOUNCE_TIME = 250;
const waitForEvent = ($input, event) => new Promise(resolve => $input.one(event, resolve));
const select2Container = () => document.querySelector('.select2-container');
const select2DropdownOptions = () => document.querySelectorAll('.result-name');
const branchNames = () => TEST_BRANCHES_SELECTIONS.map(branch => branch.name);
const protectedBranchNames = () => TEST_PROTECTED_BRANCHES.map(branch => branch.name);
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Branches Select', () => {
let wrapper;
let store;
let $input;
const createComponent = (props = {}) => {
wrapper = shallowMount(localVue.extend(BranchesSelect), {
propsData: {
projectId: '1',
...props,
},
localVue,
store: new Vuex.Store(store),
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, 'projectProtectedBranches').and.returnValue(
Promise.resolve(TEST_PROTECTED_BRANCHES),
);
});
afterEach(() => {
jasmine.clock().uninstall();
wrapper.destroy();
});
it('renders select2 input', () => {
expect(select2Container()).toBe(null);
createComponent();
expect(select2Container()).not.toBe(null);
});
it('displays all the protected branches and any branch', done => {
createComponent();
waitForEvent($input, 'select2-loaded')
.then(() => {
const nodeList = select2DropdownOptions();
const names = [...nodeList].map(el => el.textContent);
expect(names).toEqual(branchNames());
})
.then(done)
.catch(done.fail);
search();
});
describe('with search term', () => {
beforeEach(() => {
createComponent();
});
it('fetches protected branches with search term', done => {
const term = 'lorem';
waitForEvent($input, 'select2-loaded')
.then(done)
.catch(done.fail);
search(term);
expect(Api.projectProtectedBranches).toHaveBeenCalledWith(TEST_PROJECT_ID, term);
});
it('fetches protected branches with no any branch if there is search', done => {
waitForEvent($input, 'select2-loaded')
.then(() => {
const nodeList = select2DropdownOptions();
const names = [...nodeList].map(el => el.textContent);
expect(names).toEqual(protectedBranchNames());
})
.then(done)
.catch(done.fail);
search('master');
});
it('fetches protected branches with any branch if search contains term "any"', done => {
waitForEvent($input, 'select2-loaded')
.then(() => {
const nodeList = select2DropdownOptions();
const names = [...nodeList].map(el => el.textContent);
expect(names).toEqual(branchNames());
})
.then(done)
.catch(done.fail);
search('any');
});
});
it('emits input when data changes', done => {
createComponent();
const selectedIndex = 1;
const selectedId = TEST_BRANCHES_SELECTIONS[selectedIndex].id;
const expected = [
{
name: 'input',
args: [selectedId],
},
];
waitForEvent($input, 'select2-loaded')
.then(() => {
const options = select2DropdownOptions();
$(options[selectedIndex]).trigger('mouseup');
})
.then(done)
.catch(done.fail);
waitForEvent($input, 'change')
.then(() => {
expect(wrapper.emittedByOrder()).toEqual(expected);
})
.then(done)
.catch(done.fail);
search();
});
});
...@@ -4,6 +4,7 @@ import { createStoreOptions } from 'ee/approvals/stores'; ...@@ -4,6 +4,7 @@ import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings'; import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
import ApproversSelect from 'ee/approvals/components/approvers_select.vue'; import ApproversSelect from 'ee/approvals/components/approvers_select.vue';
import ApproversList from 'ee/approvals/components/approvers_list.vue'; import ApproversList from 'ee/approvals/components/approvers_list.vue';
import BranchesSelect from 'ee/approvals/components/branches_select.vue';
import RuleForm from 'ee/approvals/components/rule_form.vue'; import RuleForm from 'ee/approvals/components/rule_form.vue';
import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from 'ee/approvals/constants'; import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from 'ee/approvals/constants';
...@@ -15,6 +16,11 @@ const TEST_RULE = { ...@@ -15,6 +16,11 @@ const TEST_RULE = {
users: [{ id: 1 }, { id: 2 }, { id: 3 }], users: [{ id: 1 }, { id: 2 }, { id: 3 }],
groups: [{ id: 1 }, { id: 2 }], groups: [{ id: 1 }, { id: 2 }],
}; };
const TEST_PROTECTED_BRANCHES = [{ id: 2 }];
const TEST_RULE_WITH_PROTECTED_BRANCHES = {
...TEST_RULE,
protectedBranches: TEST_PROTECTED_BRANCHES,
};
const TEST_APPROVERS = [{ id: 7, type: TYPE_USER }]; const TEST_APPROVERS = [{ id: 7, type: TYPE_USER }];
const TEST_APPROVALS_REQUIRED = 3; const TEST_APPROVALS_REQUIRED = 3;
const TEST_FALLBACK_RULE = { const TEST_FALLBACK_RULE = {
...@@ -37,12 +43,16 @@ describe('EE Approvals RuleForm', () => { ...@@ -37,12 +43,16 @@ describe('EE Approvals RuleForm', () => {
propsData: props, propsData: props,
store: new Vuex.Store(store), store: new Vuex.Store(store),
localVue, localVue,
provide: {
glFeatures: { scopedApprovalRules: true },
},
}); });
}; };
const findValidation = (node, hasProps = false) => ({ const findValidation = (node, hasProps = false) => ({
feedback: node.element.nextElementSibling.textContent, feedback: node.element.nextElementSibling.textContent,
isValid: hasProps ? !node.props('isInvalid') : !node.classes('is-invalid'), isValid: hasProps ? !node.props('isInvalid') : !node.classes('is-invalid'),
}); });
const findNameInput = () => wrapper.find('input[name=name]'); const findNameInput = () => wrapper.find('input[name=name]');
const findNameValidation = () => findValidation(findNameInput(), false); const findNameValidation = () => findValidation(findNameInput(), false);
const findApprovalsRequiredInput = () => wrapper.find('input[name=approvals_required]'); const findApprovalsRequiredInput = () => wrapper.find('input[name=approvals_required]');
...@@ -50,12 +60,21 @@ describe('EE Approvals RuleForm', () => { ...@@ -50,12 +60,21 @@ describe('EE Approvals RuleForm', () => {
const findApproversSelect = () => wrapper.find(ApproversSelect); const findApproversSelect = () => wrapper.find(ApproversSelect);
const findApproversValidation = () => findValidation(findApproversSelect(), true); const findApproversValidation = () => findValidation(findApproversSelect(), true);
const findApproversList = () => wrapper.find(ApproversList); const findApproversList = () => wrapper.find(ApproversList);
const findBranchesSelect = () => wrapper.find(BranchesSelect);
const findBranchesValidation = () => findValidation(findBranchesSelect(), true);
const findValidations = () => [ const findValidations = () => [
findNameValidation(), findNameValidation(),
findApprovalsRequiredValidation(), findApprovalsRequiredValidation(),
findApproversValidation(), findApproversValidation(),
]; ];
const findValidationsWithBranch = () => [
findNameValidation(),
findApprovalsRequiredValidation(),
findApproversValidation(),
findBranchesValidation(),
];
beforeEach(() => { beforeEach(() => {
store = createStoreOptions(projectSettingsModule(), { projectId: TEST_PROJECT_ID }); store = createStoreOptions(projectSettingsModule(), { projectId: TEST_PROJECT_ID });
...@@ -75,6 +94,83 @@ describe('EE Approvals RuleForm', () => { ...@@ -75,6 +94,83 @@ describe('EE Approvals RuleForm', () => {
store.state.settings.allowMultiRule = true; store.state.settings.allowMultiRule = true;
}); });
describe('when has protected branch feature', () => {
describe('with initial rule', () => {
beforeEach(() => {
createComponent({
isMrEdit: false,
initRule: TEST_RULE_WITH_PROTECTED_BRANCHES,
});
});
it('on load, it populates initial protected branch ids', () => {
expect(wrapper.vm.branches).toEqual(TEST_PROTECTED_BRANCHES.map(x => x.id));
});
});
describe('without initRule', () => {
beforeEach(() => {
store.state.settings.protectedBranches = TEST_PROTECTED_BRANCHES;
createComponent({
isMrEdit: false,
});
});
it('at first, shows no validation', () => {
const inputs = findValidationsWithBranch();
const invalidInputs = inputs.filter(x => !x.isValid);
const feedbacks = inputs.map(x => x.feedback);
expect(invalidInputs.length).toBe(0);
expect(feedbacks.every(str => !str.length)).toBe(true);
});
it('on submit, shows branches validation', done => {
wrapper.vm.branches = ['3'];
wrapper.vm.submit();
localVue
.nextTick()
.then(() => {
expect(findBranchesValidation()).toEqual({
isValid: false,
feedback: 'Please select a valid target branch',
});
})
.then(done)
.catch(done.fail);
});
it('on submit with data, posts rule', () => {
const users = [1, 2];
const groups = [2, 3];
const userRecords = users.map(id => ({ id, type: TYPE_USER }));
const groupRecords = groups.map(id => ({ id, type: TYPE_GROUP }));
const branches = TEST_PROTECTED_BRANCHES.map(x => x.id);
const expected = {
id: null,
name: 'Lorem',
approvalsRequired: 2,
users,
groups,
userRecords,
groupRecords,
removeHiddenGroups: false,
protectedBranchIds: branches,
};
findNameInput().setValue(expected.name);
findApprovalsRequiredInput().setValue(expected.approvalsRequired);
wrapper.vm.approvers = groupRecords.concat(userRecords);
wrapper.vm.branches = expected.protectedBranchIds;
wrapper.vm.submit();
expect(actions.postRule).toHaveBeenCalledWith(jasmine.anything(), expected, undefined);
});
});
});
describe('without initRule', () => { describe('without initRule', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -150,6 +246,7 @@ describe('EE Approvals RuleForm', () => { ...@@ -150,6 +246,7 @@ describe('EE Approvals RuleForm', () => {
const groups = [2, 3]; const groups = [2, 3];
const userRecords = users.map(id => ({ id, type: TYPE_USER })); const userRecords = users.map(id => ({ id, type: TYPE_USER }));
const groupRecords = groups.map(id => ({ id, type: TYPE_GROUP })); const groupRecords = groups.map(id => ({ id, type: TYPE_GROUP }));
const branches = TEST_PROTECTED_BRANCHES.map(x => x.id);
const expected = { const expected = {
id: null, id: null,
name: 'Lorem', name: 'Lorem',
...@@ -159,11 +256,13 @@ describe('EE Approvals RuleForm', () => { ...@@ -159,11 +256,13 @@ describe('EE Approvals RuleForm', () => {
userRecords, userRecords,
groupRecords, groupRecords,
removeHiddenGroups: false, removeHiddenGroups: false,
protectedBranchIds: branches,
}; };
findNameInput().setValue(expected.name); findNameInput().setValue(expected.name);
findApprovalsRequiredInput().setValue(expected.approvalsRequired); findApprovalsRequiredInput().setValue(expected.approvalsRequired);
wrapper.vm.approvers = groupRecords.concat(userRecords); wrapper.vm.approvers = groupRecords.concat(userRecords);
wrapper.vm.branches = expected.protectedBranchIds;
wrapper.vm.submit(); wrapper.vm.submit();
...@@ -215,6 +314,7 @@ describe('EE Approvals RuleForm', () => { ...@@ -215,6 +314,7 @@ describe('EE Approvals RuleForm', () => {
userRecords, userRecords,
groupRecords, groupRecords,
removeHiddenGroups: false, removeHiddenGroups: false,
protectedBranchIds: [],
}; };
wrapper.vm.submit(); wrapper.vm.submit();
......
...@@ -1958,6 +1958,9 @@ msgstr "" ...@@ -1958,6 +1958,9 @@ msgstr ""
msgid "Any Milestone" msgid "Any Milestone"
msgstr "" msgstr ""
msgid "Any branch"
msgstr ""
msgid "Any eligible user" msgid "Any eligible user"
msgstr "" msgstr ""
...@@ -2033,6 +2036,9 @@ msgstr "" ...@@ -2033,6 +2036,9 @@ msgstr ""
msgid "Apply template" msgid "Apply template"
msgstr "" msgstr ""
msgid "Apply this approval rule to any branch or a specific protected branch."
msgstr ""
msgid "Applying a template will replace the existing issue description. Any changes you have made will be lost." msgid "Applying a template will replace the existing issue description. Any changes you have made will be lost."
msgstr "" msgstr ""
...@@ -2086,6 +2092,9 @@ msgstr "" ...@@ -2086,6 +2092,9 @@ msgstr ""
msgid "ApprovalRule|Rule name" msgid "ApprovalRule|Rule name"
msgstr "" msgstr ""
msgid "ApprovalRule|Target branch"
msgstr ""
msgid "ApprovalRule|e.g. QA, Security, etc." msgid "ApprovalRule|e.g. QA, Security, etc."
msgstr "" msgstr ""
...@@ -13999,6 +14008,9 @@ msgstr "" ...@@ -13999,6 +14008,9 @@ msgstr ""
msgid "Please select a group." msgid "Please select a group."
msgstr "" msgstr ""
msgid "Please select a valid target branch"
msgstr ""
msgid "Please select and add a member" msgid "Please select and add a member"
msgstr "" msgstr ""
......
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