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 = {
projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes',
projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
projectRunnersPath: '/api/:version/projects/:id/runners',
projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/groups/:namespace_path/-/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
......@@ -220,6 +221,22 @@ const Api = {
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 = {}) {
const url = Api.buildUrl(Api.mergeRequestsPath);
......
......@@ -63,7 +63,8 @@
display: block;
}
.select2-choices {
.select2-choices,
.select2-choice {
border-color: $red-500;
}
}
......
......@@ -11,6 +11,12 @@ export default {
GlButton,
GlLoadingIcon,
},
props: {
isMrEdit: {
type: Boolean,
default: true,
},
},
computed: {
...mapState({
settings: 'settings',
......@@ -53,7 +59,7 @@ export default {
</div>
<slot name="footer"></slot>
</template>
<modal-rule-create :modal-id="createModalId" />
<modal-rule-create :modal-id="createModalId" :is-mr-edit="isMrEdit" />
<modal-rule-remove :modal-id="removeModalId" />
</div>
</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 {
type: String,
required: true,
},
isMrEdit: {
type: Boolean,
default: true,
},
},
computed: {
...mapState('createModal', {
......@@ -41,6 +45,6 @@ export default {
:cancel-title="__('Cancel')"
@ok.prevent="submit"
>
<rule-form ref="form" :init-rule="rule" />
<rule-form ref="form" :init-rule="rule" :is-mr-edit="isMrEdit" />
</gl-modal-vuex>
</template>
<script>
import { mapActions } from 'vuex';
import { GlButton } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RuleInput from './rule_input.vue';
import EmptyRuleName from '../empty_rule_name.vue';
import RuleBranches from '../rule_branches.vue';
export default {
components: {
RuleInput,
EmptyRuleName,
RuleBranches,
GlButton,
},
mixins: [glFeatureFlagsMixin()],
props: {
rule: {
type: Object,
......@@ -33,6 +37,11 @@ export default {
required: true,
},
},
computed: {
showProtectedBranch() {
return this.glFeatures.scopedApprovalRules && !this.isMrEdit && this.allowMultiRule;
},
},
methods: {
...mapActions({ openCreateModal: 'createModal/open' }),
},
......@@ -44,6 +53,9 @@ export default {
<td colspan="2">
<empty-rule-name :eligible-approvers-docs-path="eligibleApproversDocsPath" />
</td>
<td v-if="showProtectedBranch">
<rule-branches :rule="rule" />
</td>
<td class="js-approvals-required">
<rule-input :rule="rule" :is-mr-edit="isMrEdit" />
</td>
......
......@@ -11,5 +11,5 @@ export default {
</script>
<template>
<app><project-rules slot="rules"/></app>
<app :is-mr-edit="false"><project-rules slot="rules"/></app>
</template>
......@@ -7,6 +7,7 @@ import Rules from '../rules.vue';
import RuleControls from '../rule_controls.vue';
import EmptyRule from '../mr_edit/empty_rule.vue';
import RuleInput from '../mr_edit/rule_input.vue';
import RuleBranches from '../rule_branches.vue';
export default {
components: {
......@@ -15,6 +16,7 @@ export default {
UserAvatarList,
EmptyRule,
RuleInput,
RuleBranches,
},
computed: {
...mapState(['settings']),
......@@ -91,17 +93,21 @@ export default {
<template>
<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">
<th class="w-25">{{ hasNamedRule ? name : members }}</th>
<th :class="settings.allowMultiRule ? 'w-50 d-none d-sm-table-cell' : 'w-75'">
<span v-if="hasNamedRule">{{ members }}</span>
</th>
<th v-if="glFeaturesScopedApprovalRules && settings.allowMultiRule">{{ branches }}</th>
<th>{{ approvalsRequired }}</th>
<th></th>
</tr>
</template>
<template slot="tbody" slot-scope="{ rules }">
<template slot="tbody" slot-scope="{ rules, glFeaturesScopedApprovalRules }">
<template v-for="(rule, index) in rules">
<empty-rule
v-if="rule.ruleType === 'any_approver'"
......@@ -119,6 +125,9 @@ export default {
<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="" />
</td>
<td v-if="glFeaturesScopedApprovalRules && settings.allowMultiRule" class="js-branches">
<rule-branches :rule="rule" />
</td>
<td class="js-approvals-required">
<rule-input :rule="rule" />
</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 @@
import { mapState, mapActions } from 'vuex';
import _ from 'underscore';
import { sprintf, __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ApproversList from './approvers_list.vue';
import ApproversSelect from './approvers_select.vue';
import BranchesSelect from './branches_select.vue';
import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from '../constants';
const DEFAULT_NAME = 'Default';
......@@ -14,13 +16,19 @@ export default {
components: {
ApproversList,
ApproversSelect,
BranchesSelect,
},
mixins: [glFeatureFlagsMixin()],
props: {
initRule: {
type: Object,
required: false,
default: null,
},
isMrEdit: {
type: Boolean,
default: true,
},
},
data() {
return {
......@@ -29,6 +37,8 @@ export default {
minApprovalsRequired: 0,
approvers: [],
approversToAdd: [],
branches: [],
branchesToAdd: [],
showValidation: false,
isFallback: false,
containsHiddenGroups: false,
......@@ -57,11 +67,17 @@ export default {
return {};
}
return {
const invalidObject = {
name: this.invalidName,
approvalsRequired: this.invalidApprovalsRequired,
approvers: this.invalidApprovers,
};
if (!this.isMrEdit) {
invalidObject.branches = this.invalidBranches;
}
return invalidObject;
},
invalidName() {
if (!this.isMultiSubmission) {
......@@ -92,6 +108,13 @@ export default {
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() {
return Object.keys(this.validation).every(key => !this.validation[key]);
},
......@@ -122,13 +145,20 @@ export default {
userRecords: this.users,
groupRecords: this.groups,
removeHiddenGroups: this.removeHiddenGroups,
protectedBranchIds: this.branches,
};
},
showProtectedBranch() {
return this.glFeatures.scopedApprovalRules && !this.isMrEdit && this.settings.allowMultiRule;
},
},
watch: {
approversToAdd(value) {
this.approvers.push(value[0]);
},
branchesToAdd(value) {
this.branches = value ? [value] : [];
},
},
methods: {
...mapActions(['putFallbackRule', 'postRule', 'putRule', 'deleteRule', 'postRegularRule']),
......@@ -215,6 +245,7 @@ export default {
const users = this.initRule.users.map(x => ({ ...x, type: TYPE_USER }));
const groups = this.initRule.groups.map(x => ({ ...x, type: TYPE_GROUP }));
const branches = this.initRule.protectedBranches?.map(x => x.id) || [];
return {
name: this.initRule.name || '',
......@@ -226,6 +257,7 @@ export default {
.concat(
containsHiddenGroups && !removeHiddenGroups ? [{ type: TYPE_HIDDEN_GROUPS }] : [],
),
branches,
};
},
},
......@@ -267,6 +299,23 @@ export default {
</label>
</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">
<label class="label-bold">{{ s__('ApprovalRule|Approvers') }}</label>
<div class="d-flex align-items-start">
......
<script>
import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const HEADERS = {
name: s__('ApprovalRule|Name'),
members: s__('ApprovalRule|Approvers'),
approvalsRequired: s__('ApprovalRule|No. approvals required'),
branches: s__('Target branch'),
};
export default {
mixins: [glFeatureFlagsMixin()],
props: {
rules: {
type: Array,
required: true,
},
},
computed: {
scopedApprovalRules() {
return this.glFeatures.scopedApprovalRules;
},
},
HEADERS,
};
</script>
......@@ -21,10 +29,18 @@ export default {
<template>
<table class="table m-0">
<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>
<tbody>
<slot name="tbody" :rules="rules"></slot>
<slot
name="tbody"
:rules="rules"
:gl-features-scoped-approval-rules="scopedApprovalRules"
></slot>
</tbody>
</table>
</template>
......@@ -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 => ({
......
---
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';
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();
......
......@@ -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 ""
......
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