Commit 94c8d397 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '285458-group-merge-request-approval-vue-app' into 'master'

Add group merge request approval setting component

See merge request gitlab-org/gitlab!52297
parents 3a809280 eb82622a
......@@ -26,6 +26,7 @@
.settings-content
= render 'groups/settings/permissions'
= render_if_exists 'groups/merge_request_approval_settings', expanded: expanded, group: @group, user: current_user
= render_if_exists 'groups/insights', expanded: expanded
%section.settings.no-animate#js-badge-settings{ class: ('expanded' if expanded) }
......
<script>
import { mapActions, mapState } from 'vuex';
import { GlButton, GlForm, GlFormGroup, GlFormCheckbox, GlIcon, GlLink } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
export default {
components: {
GlButton,
GlForm,
GlFormGroup,
GlFormCheckbox,
GlIcon,
GlLink,
},
props: {
approvalSettingsPath: {
type: String,
required: true,
},
},
computed: {
...mapState({
preventAuthorApproval: (state) => state.approvals.preventAuthorApproval,
isLoading: (state) => state.approvals.isLoading,
}),
},
created() {
this.fetchSettings(this.approvalSettingsPath);
},
methods: {
...mapActions(['fetchSettings', 'updatePreventAuthorApproval']),
},
links: {
preventAuthorApprovalDocsPath: helpPagePath(
'user/project/merge_requests/merge_request_approvals',
{
anchor: 'allowing-merge-request-authors-to-approve-their-own-merge-requests',
},
),
},
i18n: {
authorApprovalLabel: __('Prevent MR approvals by the author.'),
saveChanges: __('Save changes'),
helpLabel: __('Help'),
},
};
</script>
<template>
<gl-form>
<gl-form-group>
<gl-form-checkbox
:checked="preventAuthorApproval"
data-testid="prevent-author-approval"
@input="updatePreventAuthorApproval"
>
{{ $options.i18n.authorApprovalLabel }}
<gl-link :href="$options.links.preventAuthorApprovalDocsPath" target="_blank">
<gl-icon name="question-o" :aria-label="$options.i18n.helpLabel" :size="16"
/></gl-link>
</gl-form-checkbox>
</gl-form-group>
<gl-button type="submit" variant="success" category="primary" :disabled="isLoading">
{{ $options.i18n.saveChanges }}
</gl-button>
</gl-form>
</template>
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import ApprovalSettings from '../approval_settings.vue';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
export default {
name: 'GroupApprovalSettingsApp',
components: {
ApprovalSettings,
GlSprintf,
GlLink,
SettingsBlock,
},
props: {
defaultExpanded: {
type: Boolean,
required: true,
},
approvalSettingsPath: {
type: String,
required: true,
},
},
links: {
groupSettingsDocsPath: helpPagePath('user/project/merge_requests/merge_request_approvals'),
},
i18n: {
groupSettingsHeader: __('Merge request approvals'),
groupSettingsDescription: __('Define approval settings. %{linkStart}Learn more.%{linkEnd}'),
},
};
</script>
<template>
<settings-block :default-expanded="defaultExpanded">
<template #title> {{ $options.i18n.groupSettingsHeader }}</template>
<template #description>
<gl-sprintf :message="$options.i18n.groupSettingsDescription">
<template #link="{ content }">
<gl-link :href="$options.links.groupSettingsDocsPath" target="_blank">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</template>
<template #default>
<approval-settings :approval-settings-path="approvalSettingsPath" />
</template>
</settings-block>
</template>
import Vue from 'vue';
import GroupSettingsApp from './components/group_settings/app.vue';
import createStore from './stores';
import groupSettingsModule from './stores/modules/group_settings';
import { parseBoolean } from '~/lib/utils/common_utils';
const mountGroupApprovalSettings = (el) => {
if (!el) {
return null;
}
const { defaultExpanded, approvalSettingsPath } = el.dataset;
const store = createStore(groupSettingsModule());
return new Vue({
el,
store,
render: (createElement) =>
createElement(GroupSettingsApp, {
props: {
defaultExpanded: parseBoolean(defaultExpanded),
approvalSettingsPath,
},
}),
});
};
export { mountGroupApprovalSettings };
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
import createFlash from '~/flash';
export const fetchSettings = ({ commit }, endpoint) => {
commit(types.REQUEST_SETTINGS);
return axios
.get(endpoint)
.then(({ data }) => {
commit(types.RECEIVE_SETTINGS_SUCCESS, data);
})
.catch(({ response }) => {
const error = response?.data?.message;
commit(types.RECEIVE_SETTINGS_ERROR, error);
createFlash({
message: __('There was an error loading merge request approval settings.'),
captureError: true,
error,
});
});
};
export const updatePreventAuthorApproval = ({ commit }, preventAuthorApproval) => {
commit(types.UPDATE_PREVENT_AUTHOR_APPROVAL, preventAuthorApproval);
};
import * as actions from './actions';
import createState from './state';
import mutations from './mutations';
export default () => ({
state: createState(),
actions,
mutations,
});
export * from '../base/mutation_types';
export const REQUEST_SETTINGS = 'REQUEST_SETTINGS';
export const RECEIVE_SETTINGS_SUCCESS = 'RECEIVE_SETTINGS_SUCCESS';
export const RECEIVE_SETTINGS_ERROR = 'RECEIVE_SETTINGS_ERROR';
export const UPDATE_PREVENT_AUTHOR_APPROVAL = 'UPDATE_PREVENT_AUTHOR_APPROVAL';
import * as types from './mutation_types';
export default {
[types.REQUEST_SETTINGS](state) {
state.isLoading = true;
},
[types.RECEIVE_SETTINGS_SUCCESS](state, data) {
state.preventAuthorApproval = !data.allow_author_approval;
state.isLoading = false;
},
[types.RECEIVE_SETTINGS_ERROR](state) {
state.isLoading = false;
},
[types.UPDATE_PREVENT_AUTHOR_APPROVAL](state, value) {
state.preventAuthorApproval = value;
},
};
export default () => ({
preventAuthorApproval: true,
isLoading: false,
});
......@@ -31,3 +31,22 @@ if (complianceFrameworksList) {
}
})();
}
const mergeRequestApprovalSetting = document.querySelector('#js-merge-request-approval-settings');
if (mergeRequestApprovalSetting) {
(async () => {
try {
const { mountGroupApprovalSettings } = await import(
/* webpackChunkName: 'mountGroupApprovalSettings' */ 'ee/approvals/mount_group_settings'
);
mountGroupApprovalSettings(mergeRequestApprovalSetting);
} catch (error) {
createFlash({
message: __('An error occurred while loading a section of this page.'),
captureError: true,
error: `Error mounting group approval settings component: #{error.message}`,
});
}
})();
}
# frozen_string_literal: true
module Groups
module MergeRequestApprovalSettingsHelper
def show_merge_request_approval_settings?(user, group)
Feature.enabled?(:group_merge_request_approval_settings_feature_flag, group) &&
user.can?(:admin_merge_request_approval_settings, group)
end
end
end
- expanded = local_assigns.fetch(:expanded)
- user = local_assigns.fetch(:user)
- group = local_assigns.fetch(:group)
- return unless show_merge_request_approval_settings?(user, group)
- group_merge_request_approval_setting_api_path = expose_path(api_v4_groups_merge_request_approval_setting_path(id: group.id))
%section#js-merge-request-approval-settings{ data: { default_expanded: expanded, approval_settings_path: group_merge_request_approval_setting_api_path } }
......@@ -273,4 +273,32 @@ RSpec.describe 'Edit group settings' do
end
end
end
describe 'merge request approval settings', :js do
context 'when feature flag is enabled and group is licensed' do
before do
stub_feature_flags(group_merge_request_approval_settings_feature_flag: true)
stub_licensed_features(group_merge_request_approval_settings: true)
end
it 'is visible' do
visit edit_group_path(group)
expect(page).to have_content('Merge request approvals')
end
end
context 'when feature flag is disabled and group is not licensed' do
before do
stub_feature_flags(group_merge_request_approval_settings_feature_flag: false)
stub_licensed_features(group_merge_request_approval_settings: false)
end
it 'is not visible' do
visit edit_group_path(group)
expect(page).not_to have_content('Merge request approvals')
end
end
end
end
import { GlButton } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { createStoreOptions } from 'ee/approvals/stores';
import groupSettingsModule from 'ee/approvals/stores/modules/group_settings';
import ApprovalSettings from 'ee/approvals/components/approval_settings.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ApprovalSettings', () => {
let wrapper;
let store;
let actions;
const approvalSettingsPath = 'groups/22/merge_request_approval_settings';
const createWrapper = () => {
wrapper = shallowMount(ApprovalSettings, {
localVue,
store: new Vuex.Store(store),
propsData: { approvalSettingsPath },
});
};
const findPreventAuthorApproval = () => wrapper.find('[data-testid="prevent-author-approval"]');
const findSaveButton = () => wrapper.findComponent(GlButton);
beforeEach(() => {
store = createStoreOptions(groupSettingsModule());
jest.spyOn(store.modules.approvals.actions, 'fetchSettings').mockImplementation();
({ actions } = store.modules.approvals);
});
afterEach(() => {
wrapper.destroy();
store = null;
});
it('fetches settings from API', () => {
createWrapper();
expect(actions.fetchSettings).toHaveBeenCalledWith(expect.any(Object), approvalSettingsPath);
});
describe('interact with checkboxes', () => {
it('renders checkbox with correct value', async () => {
createWrapper();
const input = findPreventAuthorApproval();
await input.vm.$emit('input', false);
expect(input.vm.$attrs.checked).toBe(false);
expect(store.modules.approvals.state.preventAuthorApproval).toBe(false);
});
});
describe('loading', () => {
it('renders enabled button when not loading', () => {
store.modules.approvals.state.isLoading = false;
createWrapper();
expect(findSaveButton().props('disabled')).toBe(false);
});
it('renders disabled button when loading', () => {
store.modules.approvals.state.isLoading = true;
createWrapper();
expect(findSaveButton().props('disabled')).toBe(true);
});
});
});
import { GlSprintf, GlLink } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import Vuex from 'vuex';
import GroupSettingsApp from 'ee/approvals/components/group_settings/app.vue';
import { createStoreOptions } from 'ee/approvals/stores';
import groupSettingsModule from 'ee/approvals/stores/modules/group_settings';
import ApprovalSettings from 'ee/approvals/components/approval_settings.vue';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('EE Approvals Group Settings App', () => {
let wrapper;
let store;
let axiosMock;
const defaultExpanded = true;
const approvalSettingsPath = 'groups/22/merge_request_approval_settings';
const createWrapper = () => {
wrapper = shallowMount(GroupSettingsApp, {
localVue,
store: new Vuex.Store(store),
propsData: {
defaultExpanded,
approvalSettingsPath,
},
stubs: {
ApprovalSettings,
GlLink,
GlSprintf,
SettingsBlock,
},
});
};
beforeEach(() => {
axiosMock = new MockAdapter(axios);
axiosMock.onGet('*');
store = createStoreOptions(groupSettingsModule());
});
afterEach(() => {
wrapper.destroy();
store = null;
});
const findSettingsBlock = () => wrapper.find(SettingsBlock);
const findLink = () => wrapper.find(GlLink);
const findApprovalSettings = () => wrapper.find(ApprovalSettings);
it('renders a settings block', () => {
createWrapper();
expect(findSettingsBlock().exists()).toBe(true);
expect(findSettingsBlock().props('defaultExpanded')).toBe(true);
});
it('has the correct link', () => {
createWrapper();
expect(findLink().attributes()).toMatchObject({
href: '/help/user/project/merge_requests/merge_request_approvals',
target: '_blank',
});
expect(findLink().text()).toBe('Learn more.');
});
it('renders an approval settings component', () => {
createWrapper();
expect(findApprovalSettings().exists()).toBe(true);
expect(findApprovalSettings().props('approvalSettingsPath')).toBe(approvalSettingsPath);
});
});
import MockAdapter from 'axios-mock-adapter';
import * as types from 'ee/approvals/stores/modules/group_settings/mutation_types';
import * as actions from 'ee/approvals/stores/modules/group_settings/actions';
import getInitialState from 'ee/approvals/stores/modules/group_settings/state';
import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash';
import httpStatus from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
jest.mock('~/flash');
describe('EE approvals group settings module actions', () => {
let state;
let mock;
const approvalSettingsPath = 'groups/22/merge_request_approval_setting';
beforeEach(() => {
state = getInitialState();
mock = new MockAdapter(axios);
});
afterEach(() => {
createFlash.mockClear();
mock.restore();
});
describe('fetchSettings', () => {
describe('on success', () => {
it('dispatches the request and updates payload', () => {
const data = { allow_author_approval: true };
mock.onGet(approvalSettingsPath).replyOnce(httpStatus.OK, data);
return testAction(
actions.fetchSettings,
approvalSettingsPath,
state,
[
{ type: types.REQUEST_SETTINGS },
{ type: types.RECEIVE_SETTINGS_SUCCESS, payload: data },
],
[],
);
});
});
describe('on error', () => {
it('dispatches the request, updates payload and sets error message', () => {
const data = { message: 'Internal Server Error' };
mock.onGet(approvalSettingsPath).replyOnce(httpStatus.INTERNAL_SERVER_ERROR, data);
return testAction(
actions.fetchSettings,
approvalSettingsPath,
state,
[
{ type: types.REQUEST_SETTINGS },
{ type: types.RECEIVE_SETTINGS_ERROR, payload: data.message },
],
[],
).then(() => {
expect(createFlash).toHaveBeenCalledWith({
message: 'There was an error loading merge request approval settings.',
captureError: true,
error: 'Internal Server Error',
});
});
});
});
});
describe('updatePreventAuthorApproval', () => {
it('updates payload', () => {
const value = false;
return testAction(
actions.updatePreventAuthorApproval,
value,
state,
[{ type: types.UPDATE_PREVENT_AUTHOR_APPROVAL, payload: value }],
[],
);
});
});
});
import mutations from 'ee/approvals/stores/modules/group_settings/mutations';
import getInitialState from 'ee/approvals/stores/modules/group_settings/state';
describe('Group settings store mutations', () => {
let state;
beforeEach(() => {
state = getInitialState();
});
describe('REQUEST_SETTINGS', () => {
it('sets loading state', () => {
mutations.REQUEST_SETTINGS(state);
expect(state.isLoading).toBe(true);
});
});
describe('RECEIVE_SETTINGS_SUCCESS', () => {
it('updates settings', () => {
mutations.RECEIVE_SETTINGS_SUCCESS(state, { allow_author_approval: true });
expect(state.preventAuthorApproval).toBe(false);
expect(state.isLoading).toBe(false);
});
});
describe('RECEIVE_SETTINGS_ERROR', () => {
it('sets loading state', () => {
mutations.RECEIVE_SETTINGS_ERROR(state);
expect(state.isLoading).toBe(false);
});
});
describe('UPDATE_PREVENT_AUTHOR_APPROVAL', () => {
it('updates setting', () => {
mutations.UPDATE_PREVENT_AUTHOR_APPROVAL(state, false);
expect(state.preventAuthorApproval).toBe(false);
});
});
});
......@@ -9318,6 +9318,9 @@ msgstr ""
msgid "Define approval settings."
msgstr ""
msgid "Define approval settings. %{linkStart}Learn more.%{linkEnd}"
msgstr ""
msgid "Define custom rules for what constitutes spam, independent of Akismet"
msgstr ""
......@@ -29292,6 +29295,9 @@ msgstr ""
msgid "There was an error importing the Jira project."
msgstr ""
msgid "There was an error loading merge request approval settings."
msgstr ""
msgid "There was an error loading users activity calendar."
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