From 1ab0224a38e75535435c109680d8dcb75f42e8d4 Mon Sep 17 00:00:00 2001 From: Paul Slaughter Date: Tue, 8 Jan 2019 12:04:30 -0600 Subject: [PATCH 1/2] Merge 1979-fe-settings-store --- ee/app/assets/javascripts/api.js | 41 ++++ .../assets/javascripts/approvals/mappers.js | 19 ++ .../services/approvals_service_stub.js | 20 -- .../javascripts/approvals/stores/actions.js | 54 ++++- .../javascripts/approvals/stores/index.js | 5 + ee/spec/javascripts/api_spec.js | 90 ++++++++ .../approvals/stores/actions_spec.js | 197 +++++++++++++++++- locale/gitlab.pot | 6 + 8 files changed, 398 insertions(+), 34 deletions(-) create mode 100644 ee/app/assets/javascripts/api.js create mode 100644 ee/app/assets/javascripts/approvals/mappers.js delete mode 100644 ee/app/assets/javascripts/approvals/services/approvals_service_stub.js create mode 100644 ee/spec/javascripts/api_spec.js diff --git a/ee/app/assets/javascripts/api.js b/ee/app/assets/javascripts/api.js new file mode 100644 index 00000000000000..aebea5a7f4bf09 --- /dev/null +++ b/ee/app/assets/javascripts/api.js @@ -0,0 +1,41 @@ +import CEApi from '~/api'; +import axios from '~/lib/utils/axios_utils'; + +export default { + ...CEApi, + projectApprovalRulesPath: '/api/:version/projects/:id/approval_rules', + projectApprovalRulePath: '/api/:version/projects/:id/approval_rules/:ruleid', + getProjectApprovalRules(projectId) { + const url = this.buildUrl(this.projectApprovalRulesPath).replace( + ':id', + encodeURIComponent(projectId), + ); + + return axios.get(url); + }, + + postProjectApprovalRule(projectId, rule) { + const url = this.buildUrl(this.projectApprovalRulesPath).replace( + ':id', + encodeURIComponent(projectId), + ); + + return axios.post(url, rule); + }, + + putProjectApprovalRule(projectId, ruleId, rule) { + const url = this.buildUrl(this.projectApprovalRulePath) + .replace(':id', encodeURIComponent(projectId)) + .replace(':ruleid', encodeURIComponent(ruleId)); + + return axios.put(url, rule); + }, + + deleteProjectApprovalRule(projectId, ruleId) { + const url = this.buildUrl(this.projectApprovalRulePath) + .replace(':id', encodeURIComponent(projectId)) + .replace(':ruleid', encodeURIComponent(ruleId)); + + return axios.delete(url); + }, +}; diff --git a/ee/app/assets/javascripts/approvals/mappers.js b/ee/app/assets/javascripts/approvals/mappers.js new file mode 100644 index 00000000000000..4d67822cf7bbc8 --- /dev/null +++ b/ee/app/assets/javascripts/approvals/mappers.js @@ -0,0 +1,19 @@ +export const mapApprovalRuleRequest = req => ({ + name: req.name, + approvals_required: req.approvalsRequired, + users: req.users, + groups: req.groups, +}); + +export const mapApprovalRuleResponse = res => ({ + id: res.id, + name: res.name, + approvalsRequired: res.approvals_required, + approvers: res.approvers, + users: res.users, + groups: res.groups, +}); + +export const mapApprovalRulesResponse = req => ({ + rules: req.rules.map(mapApprovalRuleResponse), +}); diff --git a/ee/app/assets/javascripts/approvals/services/approvals_service_stub.js b/ee/app/assets/javascripts/approvals/services/approvals_service_stub.js deleted file mode 100644 index 4288f874520664..00000000000000 --- a/ee/app/assets/javascripts/approvals/services/approvals_service_stub.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * This provides a stubbed API for approval rule requests. - * - * **PLEASE NOTE:** - * - This class will be removed when the BE is merged for https://gitlab.com/gitlab-org/gitlab-ee/issues/1979 - */ - -export function createApprovalsServiceStub() { - const projectApprovalRules = []; - - return { - getProjectApprovalRules() { - return Promise.resolve({ - data: { rules: projectApprovalRules }, - }); - }, - }; -} - -export default createApprovalsServiceStub(); diff --git a/ee/app/assets/javascripts/approvals/stores/actions.js b/ee/app/assets/javascripts/approvals/stores/actions.js index 1b121b508783b0..6a133a851c7bc9 100644 --- a/ee/app/assets/javascripts/approvals/stores/actions.js +++ b/ee/app/assets/javascripts/approvals/stores/actions.js @@ -1,7 +1,8 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; +import Api from 'ee/api'; import * as types from './mutation_types'; -import service from '../services/approvals_service_stub'; +import { mapApprovalRuleRequest, mapApprovalRulesResponse } from '../mappers'; export const setSettings = ({ commit }, settings) => { commit(types.SET_SETTINGS, settings); @@ -22,13 +23,56 @@ export const receiveRulesError = () => { export const fetchRules = ({ state, dispatch }) => { if (state.isLoading) { - return; + return Promise.resolve(); } + const { projectId } = state.settings; + dispatch('requestRules'); - service - .getProjectApprovalRules() - .then(response => dispatch('receiveRulesSuccess', response.data)) + return Api.getProjectApprovalRules(projectId) + .then(response => dispatch('receiveRulesSuccess', mapApprovalRulesResponse(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 = ({ state, dispatch }, rule) => { + const { projectId } = state.settings; + + return Api.postProjectApprovalRule(projectId, mapApprovalRuleRequest(rule)) + .then(() => dispatch('postRuleSuccess')) + .catch(() => dispatch('postRuleError')); +}; + +export const putRule = ({ state, dispatch }, { id, ...newRule }) => { + const { projectId } = state.settings; + + return Api.putProjectApprovalRule(projectId, 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 = ({ state, dispatch }, id) => { + const { projectId } = state.settings; + + return Api.deleteProjectApprovalRule(projectId, id) + .then(() => dispatch('deleteRuleSuccess')) + .catch(() => dispatch('deleteRuleError')); +}; diff --git a/ee/app/assets/javascripts/approvals/stores/index.js b/ee/app/assets/javascripts/approvals/stores/index.js index 64c78d2cb4ab7d..8d34f826efc3f7 100644 --- a/ee/app/assets/javascripts/approvals/stores/index.js +++ b/ee/app/assets/javascripts/approvals/stores/index.js @@ -1,4 +1,5 @@ import Vuex from 'vuex'; +import modalModule from '~/vuex_shared/modules/modal'; import state from './state'; import mutations from './mutations'; import * as actions from './actions'; @@ -8,4 +9,8 @@ export default () => state: state(), mutations, actions, + modules: { + createModal: modalModule(), + deleteModal: modalModule(), + }, }); diff --git a/ee/spec/javascripts/api_spec.js b/ee/spec/javascripts/api_spec.js new file mode 100644 index 00000000000000..a0674c2539157a --- /dev/null +++ b/ee/spec/javascripts/api_spec.js @@ -0,0 +1,90 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import Api from 'ee/api'; +import { TEST_HOST } from 'spec/test_constants'; + +const TEST_API_VERSION = 'v3000'; +const TEST_PROJECT_ID = 17; +const TEST_RULE_ID = 22; + +describe('EE Api', () => { + let originalGon; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + originalGon = window.gon; + window.gon = { + api_version: TEST_API_VERSION, + relative_url_root: TEST_HOST, + }; + }); + + afterEach(() => { + mock.restore(); + window.gon = originalGon; + }); + + describe('getProjectApprovalRules', () => { + it('gets with projectApprovalRulesPath', done => { + const expectedData = { rules: [] }; + const expectedUrl = `${TEST_HOST}${Api.projectApprovalRulesPath}` + .replace(':version', TEST_API_VERSION) + .replace(':id', TEST_PROJECT_ID); + + mock.onGet(expectedUrl).reply(200, expectedData); + Api.getProjectApprovalRules(TEST_PROJECT_ID) + .then(response => { + expect(response.data).toEqual(expectedData); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('postProjectApprovalRule', () => { + it('posts with projectApprovalRulesPath', done => { + const expectedUrl = `${TEST_HOST}${Api.projectApprovalRulesPath}` + .replace(':version', TEST_API_VERSION) + .replace(':id', TEST_PROJECT_ID); + + mock.onPost(expectedUrl).reply(200); + Api.postProjectApprovalRule(TEST_PROJECT_ID) + .then(done) + .catch(done.fail); + }); + }); + + describe('putProjectApprovalRule', () => { + it('puts with projectApprovalRulePath', done => { + const rule = { name: 'Lorem' }; + const expectedUrl = `${TEST_HOST}${Api.projectApprovalRulePath}` + .replace(':version', TEST_API_VERSION) + .replace(':id', TEST_PROJECT_ID) + .replace(':ruleid', TEST_RULE_ID); + + mock.onPut(expectedUrl).reply(200); + Api.putProjectApprovalRule(TEST_PROJECT_ID, TEST_RULE_ID, rule) + .then(() => { + expect(mock.history.put.length).toBe(1); + expect(mock.history.put[0].data).toBe(JSON.stringify(rule)); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('deleteProjectApprovalRule', () => { + it('deletes with projectApprovalRulePath', done => { + const expectedUrl = `${TEST_HOST}${Api.projectApprovalRulePath}` + .replace(':version', TEST_API_VERSION) + .replace(':id', TEST_PROJECT_ID) + .replace(':ruleid', TEST_RULE_ID); + + mock.onDelete(expectedUrl).reply(200); + Api.deleteProjectApprovalRule(TEST_PROJECT_ID, TEST_RULE_ID) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/ee/spec/javascripts/approvals/stores/actions_spec.js b/ee/spec/javascripts/approvals/stores/actions_spec.js index 05ee18d8d75794..5a493f2db3632d 100644 --- a/ee/spec/javascripts/approvals/stores/actions_spec.js +++ b/ee/spec/javascripts/approvals/stores/actions_spec.js @@ -1,14 +1,39 @@ +import Api from 'ee/api'; import testAction from 'spec/helpers/vuex_action_helper'; import * as types from 'ee/approvals/stores/mutation_types'; import actionsModule, * as actions from 'ee/approvals/stores/actions'; -import service from 'ee/approvals/services/approvals_service_stub'; +import { mapApprovalRuleRequest, mapApprovalRulesResponse } 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 }], +}; describe('EE approvals store actions', () => { + let state; let flashSpy; beforeEach(() => { + state = { + settings: { projectId: TEST_PROJECT_ID }, + }; flashSpy = spyOnDependency(actionsModule, 'createFlash'); - spyOn(service, 'getProjectApprovalRules'); + spyOn(Api, 'getProjectApprovalRules'); + spyOn(Api, 'postProjectApprovalRule'); + spyOn(Api, 'putProjectApprovalRule'); + spyOn(Api, 'deleteProjectApprovalRule'); }); describe('setSettings', () => { @@ -72,31 +97,185 @@ describe('EE approvals store actions', () => { it('dispatches request/receive', done => { const response = { - data: { rules: [] }, + data: { rules: [TEST_RULE_RESPONSE] }, }; - service.getProjectApprovalRules.and.returnValue(Promise.resolve(response)); + Api.getProjectApprovalRules.and.returnValue(Promise.resolve(response)); testAction( actions.fetchRules, null, - {}, + state, [], - [{ type: 'requestRules' }, { type: 'receiveRulesSuccess', payload: response.data }], - done, + [ + { type: 'requestRules' }, + { type: 'receiveRulesSuccess', payload: mapApprovalRulesResponse(response.data) }, + ], + () => { + expect(Api.getProjectApprovalRules).toHaveBeenCalledWith(TEST_PROJECT_ID); + + done(); + }, ); }); it('dispatches request/receive on error', done => { - service.getProjectApprovalRules.and.returnValue(Promise.reject()); + Api.getProjectApprovalRules.and.returnValue(Promise.reject()); 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 => { + Api.postProjectApprovalRule.and.returnValue(Promise.resolve()); + + testAction( + actions.postRule, + TEST_RULE_REQUEST, + state, + [], + [{ type: 'postRuleSuccess' }], + () => { + expect(Api.postProjectApprovalRule).toHaveBeenCalledWith( + TEST_PROJECT_ID, + mapApprovalRuleRequest(TEST_RULE_REQUEST), + ); + done(); + }, + ); + }); + + it('dispatches error on error', done => { + Api.postProjectApprovalRule.and.returnValue(Promise.reject()); + + testAction( + actions.postRule, + TEST_RULE_REQUEST, + state, + [], + [{ type: 'postRuleError' }], + () => { + expect(Api.postProjectApprovalRule).toHaveBeenCalledWith( + TEST_PROJECT_ID, + mapApprovalRuleRequest(TEST_RULE_REQUEST), + ); + + done(); + }, + ); + }); + }); + + describe('putRule', () => { + it('dispatches success on success', done => { + Api.putProjectApprovalRule.and.returnValue(Promise.resolve()); + + testAction( + actions.putRule, + { id: TEST_RULE_ID, ...TEST_RULE_REQUEST }, + state, + [], + [{ type: 'postRuleSuccess' }], + () => { + expect(Api.putProjectApprovalRule).toHaveBeenCalledWith( + TEST_PROJECT_ID, + TEST_RULE_ID, + mapApprovalRuleRequest(TEST_RULE_REQUEST), + ); + done(); + }, + ); + }); + + it('dispatches error on error', done => { + Api.putProjectApprovalRule.and.returnValue(Promise.reject()); + + 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 => { + Api.deleteProjectApprovalRule.and.returnValue(Promise.resolve()); + + testAction( + actions.deleteRule, + TEST_RULE_ID, + state, + [], + [{ type: 'deleteRuleSuccess' }], + () => { + expect(Api.deleteProjectApprovalRule).toHaveBeenCalledWith(TEST_PROJECT_ID, TEST_RULE_ID); + + done(); + }, + ); + }); + + it('dispatches error on error', done => { + Api.deleteProjectApprovalRule.and.returnValue(Promise.reject()); + + testAction(actions.deleteRule, TEST_RULE_ID, state, [], [{ type: 'deleteRuleError' }], done); + }); + }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7c97844e9024d5..7067f7a24a61fe 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -686,6 +686,9 @@ msgstr "" msgid "An error occurred while adding approver" msgstr "" +msgid "An error occurred while deleting the approvers group" +msgstr "" + msgid "An error occurred while deleting the comment" msgstr "" @@ -779,6 +782,9 @@ msgstr "" msgid "An error occurred while unsubscribing to notifications." msgstr "" +msgid "An error occurred while updating approvers" +msgstr "" + msgid "An error occurred while updating the comment" msgstr "" -- GitLab From fa8b476e0681fd086674520d7e93f23b87e674e9 Mon Sep 17 00:00:00 2001 From: Paul Slaughter Date: Sat, 5 Jan 2019 00:59:45 -0600 Subject: [PATCH 2/2] FE components for MR approvals settings See issue: https://gitlab.com/gitlab-org/gitlab-ee/issues/1979 --- .../approvals/components/approvers_list.vue | 35 ++++ .../components/approvers_list_empty.vue | 7 + .../components/approvers_list_item.vue | 44 ++++ .../approvals/components/approvers_select.vue | 153 ++++++++++++++ .../components/modal_rule_create.vue | 46 +++++ .../components/modal_rule_remove.vue | 71 +++++++ .../approvals/components/rule_form.vue | 168 ++++++++++++++++ .../approvals/components/rules.vue | 73 +++++++ ...proval_rules_empty.vue => rules_empty.vue} | 0 .../approvals/components/settings.vue | 46 ++++- .../assets/javascripts/approvals/constants.js | 2 + .../components/approvers_list_item_spec.js | 72 +++++++ .../components/approvers_list_spec.js | 73 +++++++ .../components/approvers_select_spec.js | 186 +++++++++++++++++ .../components/modal_rule_create_spec.js | 116 +++++++++++ .../components/modal_rule_remove_spec.js | 102 ++++++++++ .../approvals/components/rule_form_spec.js | 188 ++++++++++++++++++ ...ules_empty_spec.js => rules_empty_spec.js} | 6 +- .../approvals/components/rules_spec.js | 97 +++++++++ .../approvals/components/settings_spec.js | 135 +++++++++++-- locale/gitlab.pot | 54 +++++ 21 files changed, 1650 insertions(+), 24 deletions(-) create mode 100644 ee/app/assets/javascripts/approvals/components/approvers_list.vue create mode 100644 ee/app/assets/javascripts/approvals/components/approvers_list_empty.vue create mode 100644 ee/app/assets/javascripts/approvals/components/approvers_list_item.vue create mode 100644 ee/app/assets/javascripts/approvals/components/approvers_select.vue create mode 100644 ee/app/assets/javascripts/approvals/components/modal_rule_create.vue create mode 100644 ee/app/assets/javascripts/approvals/components/modal_rule_remove.vue create mode 100644 ee/app/assets/javascripts/approvals/components/rule_form.vue create mode 100644 ee/app/assets/javascripts/approvals/components/rules.vue rename ee/app/assets/javascripts/approvals/components/{approval_rules_empty.vue => rules_empty.vue} (100%) create mode 100644 ee/app/assets/javascripts/approvals/constants.js create mode 100644 ee/spec/javascripts/approvals/components/approvers_list_item_spec.js create mode 100644 ee/spec/javascripts/approvals/components/approvers_list_spec.js create mode 100644 ee/spec/javascripts/approvals/components/approvers_select_spec.js create mode 100644 ee/spec/javascripts/approvals/components/modal_rule_create_spec.js create mode 100644 ee/spec/javascripts/approvals/components/modal_rule_remove_spec.js create mode 100644 ee/spec/javascripts/approvals/components/rule_form_spec.js rename ee/spec/javascripts/approvals/components/{approval_rules_empty_spec.js => rules_empty_spec.js} (77%) create mode 100644 ee/spec/javascripts/approvals/components/rules_spec.js diff --git a/ee/app/assets/javascripts/approvals/components/approvers_list.vue b/ee/app/assets/javascripts/approvals/components/approvers_list.vue new file mode 100644 index 00000000000000..bf2d57f84bd4a0 --- /dev/null +++ b/ee/app/assets/javascripts/approvals/components/approvers_list.vue @@ -0,0 +1,35 @@ + + + diff --git a/ee/app/assets/javascripts/approvals/components/approvers_list_empty.vue b/ee/app/assets/javascripts/approvals/components/approvers_list_empty.vue new file mode 100644 index 00000000000000..d1ac017f5517aa --- /dev/null +++ b/ee/app/assets/javascripts/approvals/components/approvers_list_empty.vue @@ -0,0 +1,7 @@ + diff --git a/ee/app/assets/javascripts/approvals/components/approvers_list_item.vue b/ee/app/assets/javascripts/approvals/components/approvers_list_item.vue new file mode 100644 index 00000000000000..15cdbf86b0e9c0 --- /dev/null +++ b/ee/app/assets/javascripts/approvals/components/approvers_list_item.vue @@ -0,0 +1,44 @@ + + + diff --git a/ee/app/assets/javascripts/approvals/components/approvers_select.vue b/ee/app/assets/javascripts/approvals/components/approvers_select.vue new file mode 100644 index 00000000000000..07b5d3bc2a7982 --- /dev/null +++ b/ee/app/assets/javascripts/approvals/components/approvers_select.vue @@ -0,0 +1,153 @@ + + + diff --git a/ee/app/assets/javascripts/approvals/components/modal_rule_create.vue b/ee/app/assets/javascripts/approvals/components/modal_rule_create.vue new file mode 100644 index 00000000000000..ba4a90eb56e542 --- /dev/null +++ b/ee/app/assets/javascripts/approvals/components/modal_rule_create.vue @@ -0,0 +1,46 @@ + + + diff --git a/ee/app/assets/javascripts/approvals/components/modal_rule_remove.vue b/ee/app/assets/javascripts/approvals/components/modal_rule_remove.vue new file mode 100644 index 00000000000000..c45d8f07cf6baa --- /dev/null +++ b/ee/app/assets/javascripts/approvals/components/modal_rule_remove.vue @@ -0,0 +1,71 @@ + + + diff --git a/ee/app/assets/javascripts/approvals/components/rule_form.vue b/ee/app/assets/javascripts/approvals/components/rule_form.vue new file mode 100644 index 00000000000000..10e6d8e148f819 --- /dev/null +++ b/ee/app/assets/javascripts/approvals/components/rule_form.vue @@ -0,0 +1,168 @@ + + + diff --git a/ee/app/assets/javascripts/approvals/components/rules.vue b/ee/app/assets/javascripts/approvals/components/rules.vue new file mode 100644 index 00000000000000..2e0720e897d6d7 --- /dev/null +++ b/ee/app/assets/javascripts/approvals/components/rules.vue @@ -0,0 +1,73 @@ + + + diff --git a/ee/app/assets/javascripts/approvals/components/approval_rules_empty.vue b/ee/app/assets/javascripts/approvals/components/rules_empty.vue similarity index 100% rename from ee/app/assets/javascripts/approvals/components/approval_rules_empty.vue rename to ee/app/assets/javascripts/approvals/components/rules_empty.vue diff --git a/ee/app/assets/javascripts/approvals/components/settings.vue b/ee/app/assets/javascripts/approvals/components/settings.vue index e0d9f85f159d56..d9b931553630f5 100644 --- a/ee/app/assets/javascripts/approvals/components/settings.vue +++ b/ee/app/assets/javascripts/approvals/components/settings.vue @@ -1,12 +1,22 @@ diff --git a/ee/app/assets/javascripts/approvals/constants.js b/ee/app/assets/javascripts/approvals/constants.js new file mode 100644 index 00000000000000..23e2998dad21ca --- /dev/null +++ b/ee/app/assets/javascripts/approvals/constants.js @@ -0,0 +1,2 @@ +export const TYPE_USER = 'user'; +export const TYPE_GROUP = 'group'; diff --git a/ee/spec/javascripts/approvals/components/approvers_list_item_spec.js b/ee/spec/javascripts/approvals/components/approvers_list_item_spec.js new file mode 100644 index 00000000000000..ec1c3a3ddf96ac --- /dev/null +++ b/ee/spec/javascripts/approvals/components/approvers_list_item_spec.js @@ -0,0 +1,72 @@ +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, + }); + }; + + 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); + }); + }); +}); diff --git a/ee/spec/javascripts/approvals/components/approvers_list_spec.js b/ee/spec/javascripts/approvals/components/approvers_list_spec.js new file mode 100644 index 00000000000000..0ba9b4253fc0eb --- /dev/null +++ b/ee/spec/javascripts/approvals/components/approvers_list_spec.js @@ -0,0 +1,73 @@ +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] }]); + }); + }); + }); +}); diff --git a/ee/spec/javascripts/approvals/components/approvers_select_spec.js b/ee/spec/javascripts/approvals/components/approvers_select_spec.js new file mode 100644 index 00000000000000..d54c0260e5caff --- /dev/null +++ b/ee/spec/javascripts/approvals/components/approvers_select_spec.js @@ -0,0 +1,186 @@ +import { createLocalVue, mount } from '@vue/test-utils'; +import $ from 'jquery'; +import Api from 'ee/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(); + }); +}); diff --git a/ee/spec/javascripts/approvals/components/modal_rule_create_spec.js b/ee/spec/javascripts/approvals/components/modal_rule_create_spec.js new file mode 100644 index 00000000000000..ce614cd303b6dd --- /dev/null +++ b/ee/spec/javascripts/approvals/components/modal_rule_create_spec.js @@ -0,0 +1,116 @@ +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); + }); + }); +}); diff --git a/ee/spec/javascripts/approvals/components/modal_rule_remove_spec.js b/ee/spec/javascripts/approvals/components/modal_rule_remove_spec.js new file mode 100644 index 00000000000000..02674f60697409 --- /dev/null +++ b/ee/spec/javascripts/approvals/components/modal_rule_remove_spec.js @@ -0,0 +1,102 @@ +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); + }); +}); diff --git a/ee/spec/javascripts/approvals/components/rule_form_spec.js b/ee/spec/javascripts/approvals/components/rule_form_spec.js new file mode 100644 index 00000000000000..2d1b18d6798e84 --- /dev/null +++ b/ee/spec/javascripts/approvals/components/rule_form_spec.js @@ -0,0 +1,188 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import ApproversSelect from 'ee/approvals/components/approvers_select.vue'; +import RuleForm from 'ee/approvals/components/rule_form.vue'; +import { TYPE_USER, TYPE_GROUP } from 'ee/approvals/constants'; + +const TEST_PROJECT_ID = '7'; +const TEST_RULE = { + id: 10, + name: 'QA', + approvalsRequired: 2, + users: [{ id: 1 }, { id: 2 }, { id: 3 }], + groups: [{ id: 1 }, { id: 2 }], +}; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +const wrapInput = node => ({ + node, + feedback: () => node.element.nextElementSibling.textContent, + isValid: () => !node.classes('is-invalid'), +}); +const findInput = (form, selector) => wrapInput(form.find(selector)); +const findNameInput = form => findInput(form, 'input[name=name]'); +const findApprovalsRequiredInput = form => findInput(form, 'input[name=approvals_required]'); +const findApproversSelect = form => { + const input = findInput(form, ApproversSelect); + + return { + ...input, + isValid() { + return !input.node.props('isInvalid'); + }, + }; +}; + +describe('Approvals RuleForm', () => { + let state; + let actions; + let wrapper; + + const factory = (options = {}) => { + const store = new Vuex.Store({ + state, + actions, + }); + + wrapper = shallowMount(localVue.extend(RuleForm), { + ...options, + localVue, + store, + }); + }; + + beforeEach(() => { + state = { + settings: { projectId: TEST_PROJECT_ID }, + }; + + actions = { + postRule: jasmine.createSpy('postRule'), + putRule: jasmine.createSpy('putRule'), + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('without initRule', () => { + beforeEach(() => { + factory(); + }); + + it('at first, shows no validation', () => { + const inputs = [ + findNameInput(wrapper), + findApprovalsRequiredInput(wrapper), + findApproversSelect(wrapper), + ]; + const invalidInputs = inputs.filter(x => !x.isValid()); + const feedbacks = inputs.map(x => x.feedback()); + + expect(invalidInputs.length).toBe(0); + expect(feedbacks.every(str => !str.length)).toBe(true); + }); + + it('on submit, does not dispatch action', () => { + wrapper.vm.submit(); + + expect(actions.postRule).not.toHaveBeenCalled(); + }); + + it('on submit, shows name validation', () => { + const { node, isValid, feedback } = findNameInput(wrapper); + node.setValue(''); + + wrapper.vm.submit(); + + expect(isValid()).toBe(false); + expect(feedback()).toEqual('Please provide a name'); + }); + + it('on submit, shows approvalsRequired validation', () => { + const { node, isValid, feedback } = findApprovalsRequiredInput(wrapper); + node.setValue(-1); + + wrapper.vm.submit(); + + expect(isValid()).toBe(false); + expect(feedback()).toEqual('Please enter a non-negative number'); + }); + + it('on submit, shows approvers validation', () => { + const { isValid, feedback } = findApproversSelect(wrapper); + wrapper.vm.approvers = []; + + wrapper.vm.submit(); + + expect(isValid()).toBe(false); + expect(feedback()).toEqual('Please select and add a member'); + }); + + it('on submit with data, posts rule', () => { + const expected = { + name: 'Lorem', + approvalsRequired: 2, + users: [1, 2], + groups: [2, 3], + }; + const name = findNameInput(wrapper); + const approvalsRequired = findApprovalsRequiredInput(wrapper); + + const groups = expected.groups.map(id => ({ id, type: TYPE_GROUP })); + const users = expected.users.map(id => ({ id, type: TYPE_USER })); + + name.node.setValue(expected.name); + approvalsRequired.node.setValue(expected.approvalsRequired); + wrapper.vm.approvers = groups.concat(users); + + wrapper.vm.submit(); + + expect(actions.postRule).toHaveBeenCalledWith(jasmine.anything(), expected, undefined); + }); + + it('adds selected approvers on button click', () => { + const { node } = findApproversSelect(wrapper); + const selected = [ + { id: 1, type: TYPE_USER }, + { id: 2, type: TYPE_USER }, + { id: 1, type: TYPE_GROUP }, + ]; + const orig = [{ id: 7, type: TYPE_GROUP }]; + const expected = selected.concat(orig); + + wrapper.vm.approvers = orig; + + node.vm.$emit('input', selected); + wrapper.find(GlButton).vm.$emit('click'); + + expect(wrapper.vm.approvers).toEqual(expected); + }); + }); + + describe('with initRule', () => { + beforeEach(() => { + factory({ + propsData: { + initRule: TEST_RULE, + }, + }); + }); + + it('on submit, puts rule', () => { + const expected = { + ...TEST_RULE, + users: TEST_RULE.users.map(x => x.id), + groups: TEST_RULE.groups.map(x => x.id), + }; + + wrapper.vm.submit(); + + expect(actions.putRule).toHaveBeenCalledWith(jasmine.anything(), expected, undefined); + }); + }); +}); diff --git a/ee/spec/javascripts/approvals/components/approval_rules_empty_spec.js b/ee/spec/javascripts/approvals/components/rules_empty_spec.js similarity index 77% rename from ee/spec/javascripts/approvals/components/approval_rules_empty_spec.js rename to ee/spec/javascripts/approvals/components/rules_empty_spec.js index 47c65198080935..950cafd34083b3 100644 --- a/ee/spec/javascripts/approvals/components/approval_rules_empty_spec.js +++ b/ee/spec/javascripts/approvals/components/rules_empty_spec.js @@ -1,6 +1,6 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import { GlButton } from '@gitlab/ui'; -import ApprovalRulesEmpty from 'ee/approvals/components/approval_rules_empty.vue'; +import RulesEmpty from 'ee/approvals/components/rules_empty.vue'; const localVue = createLocalVue(); @@ -8,7 +8,7 @@ describe('EE ApprovalsSettingsEmpty', () => { let wrapper; const factory = options => { - wrapper = shallowMount(localVue.extend(ApprovalRulesEmpty), { + wrapper = shallowMount(localVue.extend(RulesEmpty), { localVue, ...options, }); @@ -21,7 +21,7 @@ describe('EE ApprovalsSettingsEmpty', () => { it('shows message', () => { factory(); - expect(wrapper.text()).toContain(ApprovalRulesEmpty.message); + expect(wrapper.text()).toContain(RulesEmpty.message); }); it('shows button', () => { diff --git a/ee/spec/javascripts/approvals/components/rules_spec.js b/ee/spec/javascripts/approvals/components/rules_spec.js new file mode 100644 index 00000000000000..b2c61d87878b15 --- /dev/null +++ b/ee/spec/javascripts/approvals/components/rules_spec.js @@ -0,0 +1,97 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; +import Rules from 'ee/approvals/components/rules.vue'; + +const TEST_RULES = [ + { 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: [] }, +]; + +const localVue = createLocalVue(); +const getRowData = tr => { + const td = tr.findAll('td'); + const avatarList = td.at(1).find(UserAvatarList); + return { + name: td + .at(0) + .find('.d-sm-block.d-none') + .text(), + summary: td + .at(0) + .find('.d-sm-none.d-block') + .text(), + approvers: avatarList.exists() ? avatarList.props('items') : td.at(1).text(), + approvalsRequired: Number(td.at(2).text()), + }; +}; +const findButton = (tr, icon) => { + const buttons = tr.findAll(GlButton); + + return buttons.filter(x => x.find(Icon).props('name') === icon).at(0); +}; + +describe('Approvals Rules', () => { + let wrapper; + + const factory = (options = {}) => { + const propsData = { + rules: TEST_RULES, + ...options.propsData, + }; + + wrapper = shallowMount(localVue.extend(Rules), { + ...options, + localVue, + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + 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.length ? rule.approvers : 'None', + })), + ); + }); + + it('when edit is clicked, emits edit', () => { + const idx = 2; + const rule = TEST_RULES[idx]; + + factory(); + + const tr = wrapper.findAll('tbody tr').at(idx); + const editButton = findButton(tr, 'pencil'); + editButton.vm.$emit('click'); + + expect(wrapper.emittedByOrder()).toEqual([{ name: 'edit', args: [rule] }]); + }); + + it('when remove is clicked, emits remove', () => { + const idx = 1; + const rule = TEST_RULES[idx]; + + factory(); + + const tr = wrapper.findAll('tbody tr').at(idx); + const removeButton = findButton(tr, 'remove'); + removeButton.vm.$emit('click'); + + expect(wrapper.emittedByOrder()).toEqual([{ name: 'remove', args: [rule] }]); + }); +}); diff --git a/ee/spec/javascripts/approvals/components/settings_spec.js b/ee/spec/javascripts/approvals/components/settings_spec.js index a01ae979bb1f0f..43f0ec0fbc9f5a 100644 --- a/ee/spec/javascripts/approvals/components/settings_spec.js +++ b/ee/spec/javascripts/approvals/components/settings_spec.js @@ -1,7 +1,10 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; -import ApprovalRulesEmpty from 'ee/approvals/components/approval_rules_empty.vue'; +import { GlLoadingIcon, GlButton } from '@gitlab/ui'; +import ModalRuleCreate from 'ee/approvals/components/modal_rule_create.vue'; +import ModalRuleRemove from 'ee/approvals/components/modal_rule_remove.vue'; +import Rules from 'ee/approvals/components/rules.vue'; +import RulesEmpty from 'ee/approvals/components/rules_empty.vue'; import Settings from 'ee/approvals/components/settings.vue'; const localVue = createLocalVue(); @@ -30,6 +33,8 @@ describe('EE ApprovalsSettingsForm', () => { actions = { fetchRules: jasmine.createSpy('fetchRules'), + 'createModal/open': jasmine.createSpy('createModal/open'), + 'deleteModal/open': jasmine.createSpy('deleteModal/open'), }; }); @@ -41,31 +46,129 @@ describe('EE ApprovalsSettingsForm', () => { expect(actions.fetchRules).toHaveBeenCalledTimes(1); }); - it('shows loading icon if loading', () => { - state.isLoading = true; + it('renders create modal', () => { factory(); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + const modal = wrapper.find(ModalRuleCreate); + + expect(modal.exists()).toBe(true); + expect(modal.props('modalId')).toBe(wrapper.vm.$options.CREATE_MODAL_ID); }); - it('does not show loading icon if not loading', () => { - state.isLoading = false; + it('renders delete modal', () => { factory(); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + const modal = wrapper.find(ModalRuleRemove); + + expect(modal.exists()).toBe(true); + expect(modal.props('modalId')).toBe(wrapper.vm.$options.REMOVE_MODAL_ID); }); - it('shows ApprovalsSettingsEmpty if empty', () => { - state.rules = []; - factory(); + describe('if empty', () => { + beforeEach(() => { + state.rules = []; + }); + + it('shows RulesEmpty', () => { + factory(); + + expect(wrapper.find(RulesEmpty).exists()).toBe(true); + }); + + it('does not show Rules', () => { + factory(); + + expect(wrapper.find(Rules).exists()).toBe(false); + }); + + it('opens create modal if clicked', () => { + factory(); + + const empty = wrapper.find(RulesEmpty); + empty.vm.$emit('click'); - expect(wrapper.find(ApprovalRulesEmpty).exists()).toBe(true); + expect(actions['createModal/open']).toHaveBeenCalledWith(jasmine.anything(), null, undefined); + }); + + it('shows loading icon if loading', () => { + state.isLoading = true; + factory(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('does not show loading icon if not loading', () => { + state.isLoading = false; + factory(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); }); - it('does not show ApprovalsSettingsEmpty is not empty', () => { - state.rules = [{ id: 1 }]; - factory(); + describe('if not empty', () => { + beforeEach(() => { + state.rules = [{ id: 1 }]; + }); + + it('does not show RulesEmpty', () => { + factory(); + + expect(wrapper.find(RulesEmpty).exists()).toBe(false); + }); + + it('shows rules', () => { + factory(); + + const rules = wrapper.find(Rules); + + expect(rules.exists()).toBe(true); + expect(rules.props('rules')).toEqual(state.rules); + }); + + it('opens create modal when edit is clicked', () => { + factory(); - expect(wrapper.find(ApprovalRulesEmpty).exists()).toBe(false); + const rule = state.rules[0]; + const rules = wrapper.find(Rules); + rules.vm.$emit('edit', rule); + + expect(actions['createModal/open']).toHaveBeenCalledWith(jasmine.anything(), rule, undefined); + }); + + it('opens delete modal when remove is clicked', () => { + factory(); + + const { id } = state.rules[0]; + const rules = wrapper.find(Rules); + rules.vm.$emit('remove', id); + + expect(actions['deleteModal/open']).toHaveBeenCalledWith(jasmine.anything(), id, undefined); + }); + + it('renders add button', () => { + factory(); + + const button = wrapper.find(GlButton); + + expect(button.exists()).toBe(true); + expect(button.text()).toBe('Add approvers'); + }); + + it('opens create modal when add button is clicked', () => { + factory(); + + const button = wrapper.find(GlButton); + button.vm.$emit('click'); + + expect(actions['createModal/open']).toHaveBeenCalledWith(jasmine.anything(), null, undefined); + }); + + it('shows loading icon and rules if loading', () => { + state.isLoading = true; + factory(); + + expect(wrapper.find(Rules).exists()).toBe(true); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7067f7a24a61fe..377ae8825b6aee 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43,6 +43,11 @@ msgid_plural "%d additions" msgstr[0] "" msgstr[1] "" +msgid "%d approval required from %{name}" +msgid_plural "%d approvals required from %{name}" +msgstr[0] "" +msgstr[1] "" + msgid "%d changed file" msgid_plural "%d changed files" msgstr[0] "" @@ -872,6 +877,31 @@ msgstr "" msgid "Apply suggestion" msgstr "" +msgid "ApprovalRuleForm|No. approvals required" +msgstr "" + +msgid "ApprovalRuleRemove|%d member" +msgid_plural "ApprovalRuleRemove|%d members" +msgstr[0] "" +msgstr[1] "" + +msgid "ApprovalRuleRemove|Approvals from this member are not revoked." +msgid_plural "ApprovalRuleRemove|Approvals from these members are not revoked." +msgstr[0] "" +msgstr[1] "" + +msgid "ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}." +msgstr "" + +msgid "ApprovalRule|Members" +msgstr "" + +msgid "ApprovalRule|Name" +msgstr "" + +msgid "ApprovalRule|e.g. QA, Security, etc." +msgstr "" + msgid "Approvals required" msgstr "" @@ -6480,12 +6510,21 @@ msgstr "" msgid "Please convert them to Git on Google Code, and go through the %{link_to_import_flow} again." msgstr "" +msgid "Please enter a non-negative number" +msgstr "" + msgid "Please fill in a descriptive name for your group." msgstr "" msgid "Please note that this application is not provided by GitLab and you should verify its authenticity before allowing access." msgstr "" +msgid "Please provide a name" +msgstr "" + +msgid "Please select and add a member" +msgstr "" + msgid "Please select at least one filter to see results" msgstr "" @@ -7268,6 +7307,12 @@ msgstr "" msgid "Remove approver" msgstr "" +msgid "Remove approvers" +msgstr "" + +msgid "Remove approvers?" +msgstr "" + msgid "Remove avatar" msgstr "" @@ -7651,6 +7696,9 @@ msgstr "" msgid "Search users" msgstr "" +msgid "Search users or groups" +msgstr "" + msgid "Search your projects" msgstr "" @@ -9460,6 +9508,9 @@ msgstr "" msgid "Update" msgstr "" +msgid "Update approvers" +msgstr "" + msgid "Update now" msgstr "" @@ -10021,6 +10072,9 @@ msgstr "" msgid "You have no permissions" msgstr "" +msgid "You have not added any approvers. Start by adding users or groups." +msgstr "" + msgid "You have reached your project limit" msgstr "" -- GitLab