diff --git a/ee/app/assets/javascripts/approvals/components/rules.vue b/ee/app/assets/javascripts/approvals/components/rules.vue
index 33fafe8b3a9d7ccfdf3fc71d290c003dbee79ce7..3eae0ee9751b50a0bee17aba4ae6ab71a57a4f96 100644
--- a/ee/app/assets/javascripts/approvals/components/rules.vue
+++ b/ee/app/assets/javascripts/approvals/components/rules.vue
@@ -1,19 +1,27 @@
@@ -21,10 +29,18 @@ export default {
diff --git a/ee/app/assets/javascripts/approvals/mappers.js b/ee/app/assets/javascripts/approvals/mappers.js
index 6255c2c7c3f1b34bf9e7c793a87bca0eccb37462..425dcf247a095037b98458db79240b69312b6a3a 100644
--- a/ee/app/assets/javascripts/approvals/mappers.js
+++ b/ee/app/assets/javascripts/approvals/mappers.js
@@ -18,6 +18,7 @@ function withDefaultEmptyRule(rules = []) {
users: [],
groups: [],
ruleType: RULE_TYPE_ANY_APPROVER,
+ protectedBranches: [],
},
];
}
@@ -28,6 +29,7 @@ export const mapApprovalRuleRequest = req => ({
users: req.users,
groups: req.groups,
remove_hidden_groups: req.removeHiddenGroups,
+ protected_branch_ids: req.protectedBranchIds,
});
export const mapApprovalFallbackRuleRequest = req => ({
@@ -45,6 +47,7 @@ export const mapApprovalRuleResponse = res => ({
users: res.users,
groups: res.groups,
ruleType: res.rule_type,
+ protectedBranches: res.protected_branches,
});
export const mapApprovalSettingsResponse = res => ({
diff --git a/ee/changelogs/unreleased/460-fe-protected-branches-api-search.yml b/ee/changelogs/unreleased/460-fe-protected-branches-api-search.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3decda4e1ca0527a75a4b17912c018ab31cae0a7
--- /dev/null
+++ b/ee/changelogs/unreleased/460-fe-protected-branches-api-search.yml
@@ -0,0 +1,5 @@
+---
+title: Scope merge request approval rules to protected branches using API search
+merge_request: 24344
+author:
+type: added
diff --git a/ee/spec/frontend/approvals/components/rule_branches_spec.js b/ee/spec/frontend/approvals/components/rule_branches_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..8b8f5aa2e81097e7a48f1e04a3dac277c6dc8a1e
--- /dev/null
+++ b/ee/spec/frontend/approvals/components/rule_branches_spec.js
@@ -0,0 +1,51 @@
+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');
+ });
+});
diff --git a/ee/spec/javascripts/approvals/components/branches_select_spec.js b/ee/spec/javascripts/approvals/components/branches_select_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..f8dc095223b4d62c747b4d11c749bc13f12f8521
--- /dev/null
+++ b/ee/spec/javascripts/approvals/components/branches_select_spec.js
@@ -0,0 +1,151 @@
+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();
+ });
+});
diff --git a/ee/spec/javascripts/approvals/components/rule_form_spec.js b/ee/spec/javascripts/approvals/components/rule_form_spec.js
index 29eb96185049f65952c5ed10867034f56452df9d..91ed1b69033c6ec25644ce3f4b7ea3c420a6ee12 100644
--- a/ee/spec/javascripts/approvals/components/rule_form_spec.js
+++ b/ee/spec/javascripts/approvals/components/rule_form_spec.js
@@ -4,6 +4,7 @@ import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
import ApproversSelect from 'ee/approvals/components/approvers_select.vue';
import 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 { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from 'ee/approvals/constants';
@@ -15,6 +16,11 @@ const TEST_RULE = {
users: [{ id: 1 }, { id: 2 }, { id: 3 }],
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_APPROVALS_REQUIRED = 3;
const TEST_FALLBACK_RULE = {
@@ -37,12 +43,16 @@ describe('EE Approvals RuleForm', () => {
propsData: props,
store: new Vuex.Store(store),
localVue,
+ provide: {
+ glFeatures: { scopedApprovalRules: true },
+ },
});
};
const findValidation = (node, hasProps = false) => ({
feedback: node.element.nextElementSibling.textContent,
isValid: hasProps ? !node.props('isInvalid') : !node.classes('is-invalid'),
});
+
const findNameInput = () => wrapper.find('input[name=name]');
const findNameValidation = () => findValidation(findNameInput(), false);
const findApprovalsRequiredInput = () => wrapper.find('input[name=approvals_required]');
@@ -50,12 +60,21 @@ describe('EE Approvals RuleForm', () => {
const findApproversSelect = () => wrapper.find(ApproversSelect);
const findApproversValidation = () => findValidation(findApproversSelect(), true);
const findApproversList = () => wrapper.find(ApproversList);
+ const findBranchesSelect = () => wrapper.find(BranchesSelect);
+ const findBranchesValidation = () => findValidation(findBranchesSelect(), true);
const findValidations = () => [
findNameValidation(),
findApprovalsRequiredValidation(),
findApproversValidation(),
];
+ const findValidationsWithBranch = () => [
+ findNameValidation(),
+ findApprovalsRequiredValidation(),
+ findApproversValidation(),
+ findBranchesValidation(),
+ ];
+
beforeEach(() => {
store = createStoreOptions(projectSettingsModule(), { projectId: TEST_PROJECT_ID });
@@ -75,6 +94,83 @@ describe('EE Approvals RuleForm', () => {
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', () => {
beforeEach(() => {
createComponent();
@@ -150,6 +246,7 @@ describe('EE Approvals RuleForm', () => {
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',
@@ -159,11 +256,13 @@ describe('EE Approvals RuleForm', () => {
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();
@@ -215,6 +314,7 @@ describe('EE Approvals RuleForm', () => {
userRecords,
groupRecords,
removeHiddenGroups: false,
+ protectedBranchIds: [],
};
wrapper.vm.submit();
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 8b695e1b12748fc759744344949310291f06fda8..1dfa5a393de9848a7efc056b41eb87256e8ae47d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1958,6 +1958,9 @@ msgstr ""
msgid "Any Milestone"
msgstr ""
+msgid "Any branch"
+msgstr ""
+
msgid "Any eligible user"
msgstr ""
@@ -2033,6 +2036,9 @@ msgstr ""
msgid "Apply template"
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."
msgstr ""
@@ -2086,6 +2092,9 @@ msgstr ""
msgid "ApprovalRule|Rule name"
msgstr ""
+msgid "ApprovalRule|Target branch"
+msgstr ""
+
msgid "ApprovalRule|e.g. QA, Security, etc."
msgstr ""
@@ -13999,6 +14008,9 @@ msgstr ""
msgid "Please select a group."
msgstr ""
+msgid "Please select a valid target branch"
+msgstr ""
+
msgid "Please select and add a member"
msgstr ""