Commit 9dedf055 authored by Phil Hughes's avatar Phil Hughes

Merge branch '9963-optional-approval-view' into 'master'

Resolve "Add "No approval required" state to approval rules MR component"

Closes #9963

See merge request gitlab-org/gitlab-ee!9899
parents eafa50ed d9ba6c77
...@@ -7,3 +7,5 @@ export const FETCH_ERROR = s__( ...@@ -7,3 +7,5 @@ export const FETCH_ERROR = s__(
export const APPROVE_ERROR = s__('mrWidget|An error occurred while submitting your approval.'); export const APPROVE_ERROR = s__('mrWidget|An error occurred while submitting your approval.');
export const UNAPPROVE_ERROR = s__('mrWidget|An error occurred while removing your approval.'); export const UNAPPROVE_ERROR = s__('mrWidget|An error occurred while removing your approval.');
export const APPROVED_MESSAGE = s__('mrWidget|Merge request approved.'); export const APPROVED_MESSAGE = s__('mrWidget|Merge request approved.');
export const OPTIONAL_CAN_APPROVE = s__('mrWidget|No approval required; you can still approve');
export const OPTIONAL = s__('mrWidget|No approval required');
...@@ -7,6 +7,7 @@ import eventHub from '~/vue_merge_request_widget/event_hub'; ...@@ -7,6 +7,7 @@ import eventHub from '~/vue_merge_request_widget/event_hub';
import MrWidgetContainer from '~/vue_merge_request_widget/components/mr_widget_container.vue'; import MrWidgetContainer from '~/vue_merge_request_widget/components/mr_widget_container.vue';
import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue'; import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue';
import ApprovalsSummary from './approvals_summary.vue'; import ApprovalsSummary from './approvals_summary.vue';
import ApprovalsSummaryOptional from './approvals_summary_optional.vue';
import ApprovalsFooter from './approvals_footer.vue'; import ApprovalsFooter from './approvals_footer.vue';
import { FETCH_LOADING, FETCH_ERROR, APPROVE_ERROR, UNAPPROVE_ERROR } from '../messages'; import { FETCH_LOADING, FETCH_ERROR, APPROVE_ERROR, UNAPPROVE_ERROR } from '../messages';
...@@ -17,6 +18,7 @@ export default { ...@@ -17,6 +18,7 @@ export default {
MrWidgetContainer, MrWidgetContainer,
MrWidgetIcon, MrWidgetIcon,
ApprovalsSummary, ApprovalsSummary,
ApprovalsSummaryOptional,
ApprovalsFooter, ApprovalsFooter,
GlButton, GlButton,
GlLoadingIcon, GlLoadingIcon,
...@@ -40,17 +42,29 @@ export default { ...@@ -40,17 +42,29 @@ export default {
}; };
}, },
computed: { computed: {
approvals() {
return this.mr.approvals || {};
},
hasFooter() { hasFooter() {
return this.mr.approvals && this.mr.approvals.has_approval_rules; return !!this.approvals.has_approval_rules;
}, },
approvedBy() { approvedBy() {
return this.mr.approvals.approved_by.map(x => x.user); return this.approvals.approved_by ? this.approvals.approved_by.map(x => x.user) : [];
},
isApproved() {
return !!this.approvals.approved;
},
approvalsRequired() {
return this.approvals.approvals_required || 0;
},
isOptional() {
return !this.approvedBy.length && !this.approvalsRequired;
}, },
userHasApproved() { userHasApproved() {
return this.mr.approvals.user_has_approved; return !!this.approvals.user_has_approved;
}, },
userCanApprove() { userCanApprove() {
return this.mr.approvals.user_can_approve; return !!this.approvals.user_can_approve;
}, },
showApprove() { showApprove() {
return !this.userHasApproved && this.userCanApprove && this.mr.isOpen; return !this.userHasApproved && this.userCanApprove && this.mr.isOpen;
...@@ -59,16 +73,16 @@ export default { ...@@ -59,16 +73,16 @@ export default {
return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged'; return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged';
}, },
action() { action() {
if (this.showApprove && this.mr.approvals.approved) { if (this.showApprove) {
return { const inverted = this.isApproved;
text: s__('mrWidget|Approve additionally'), const text =
variant: 'primary', this.isApproved && this.approvedBy.length > 0
inverted: true, ? s__('mrWidget|Approve additionally')
action: () => this.approve(), : s__('mrWidget|Approve');
};
} else if (this.showApprove) {
return { return {
text: s__('mrWidget|Approve'), text,
inverted,
variant: 'primary', variant: 'primary',
action: () => this.approve(), action: () => this.approve(),
}; };
...@@ -83,6 +97,9 @@ export default { ...@@ -83,6 +97,9 @@ export default {
return null; return null;
}, },
hasAction() {
return !!this.action;
},
}, },
watch: { watch: {
isExpanded(val) { isExpanded(val) {
...@@ -160,10 +177,16 @@ export default { ...@@ -160,10 +177,16 @@ export default {
<gl-loading-icon v-if="isApproving" inline /> <gl-loading-icon v-if="isApproving" inline />
{{ action.text }} {{ action.text }}
</gl-button> </gl-button>
<approvals-summary-optional
v-if="isOptional"
:can-approve="hasAction"
:help-path="mr.approvalsHelpPath"
/>
<approvals-summary <approvals-summary
:approved="mr.approvals.approved" v-else
:approvals-left="mr.approvals.approvals_left" :approved="isApproved"
:rules-left="mr.approvals.approvalRuleNamesLeft" :approvals-left="approvals.approvals_left"
:rules-left="approvals.approvalRuleNamesLeft"
:approvers="approvedBy" :approvers="approvedBy"
/> />
</template> </template>
...@@ -172,7 +195,7 @@ export default { ...@@ -172,7 +195,7 @@ export default {
v-if="hasFooter" v-if="hasFooter"
slot="footer" slot="footer"
v-model="isExpanded" v-model="isExpanded"
:suggested-approvers="mr.approvals.suggested_approvers" :suggested-approvers="approvals.suggested_approvers"
:approval-rules="mr.approvalRules" :approval-rules="mr.approvalRules"
:is-loading-rules="isLoadingRules" :is-loading-rules="isLoadingRules"
/> />
......
<script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { OPTIONAL, OPTIONAL_CAN_APPROVE } from '../messages';
export default {
components: {
GlLink,
Icon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
canApprove: {
type: Boolean,
required: true,
},
helpPath: {
type: String,
required: false,
default: '',
},
},
computed: {
message() {
return this.canApprove ? OPTIONAL_CAN_APPROVE : OPTIONAL;
},
},
};
</script>
<template>
<div class="d-flex align-items-center">
<span class="text-muted">{{ message }}</span>
<gl-link
v-if="canApprove && helpPath"
v-gl-tooltip
:href="helpPath"
:title="__('About this feature')"
target="_blank"
class="d-flex-center pl-1"
>
<icon name="question-o" />
</gl-link>
</div>
</template>
...@@ -5,7 +5,7 @@ import Icon from '~/vue_shared/components/icon.vue'; ...@@ -5,7 +5,7 @@ import Icon from '~/vue_shared/components/icon.vue';
import MrWidgetAuthor from '~/vue_merge_request_widget/components/mr_widget_author.vue'; import MrWidgetAuthor from '~/vue_merge_request_widget/components/mr_widget_author.vue';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '~/vue_merge_request_widget/event_hub'; import eventHub from '~/vue_merge_request_widget/event_hub';
import { APPROVE_ERROR } from '../messages'; import { APPROVE_ERROR, OPTIONAL_CAN_APPROVE, OPTIONAL } from '../messages';
export default { export default {
name: 'ApprovalsBody', name: 'ApprovalsBody',
...@@ -65,10 +65,10 @@ export default { ...@@ -65,10 +65,10 @@ export default {
approvalsRequiredStringified() { approvalsRequiredStringified() {
if (this.approvalsOptional) { if (this.approvalsOptional) {
if (this.userCanApprove) { if (this.userCanApprove) {
return s__('mrWidget|No Approval required; you can still approve'); return OPTIONAL_CAN_APPROVE;
} }
return s__('mrWidget|No Approval required'); return OPTIONAL;
} }
if (this.approvalsLeft === 0) { if (this.approvalsLeft === 0) {
......
---
title: Add 'No approvals required' view to approval rules (behind feature flag)
merge_request: 9899
author:
type: fixed
...@@ -308,7 +308,7 @@ describe('EE MRWidget approvals list', () => { ...@@ -308,7 +308,7 @@ describe('EE MRWidget approvals list', () => {
it('renders the name in a monospace font', () => { it('renders the name in a monospace font', () => {
const codeOwnerRow = findRowElement(row, 'name'); const codeOwnerRow = findRowElement(row, 'name');
expect(codeOwnerRow.hasClass('monospace')).toEqual(true); expect(codeOwnerRow.classes('monospace')).toEqual(true);
expect(codeOwnerRow.text()).toEqual(rule.name); expect(codeOwnerRow.text()).toEqual(rule.name);
}); });
}); });
......
...@@ -3,6 +3,7 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; ...@@ -3,6 +3,7 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import eventHub from '~/vue_merge_request_widget/event_hub'; import eventHub from '~/vue_merge_request_widget/event_hub';
import Approvals from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals.vue'; import Approvals from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals.vue';
import ApprovalsSummary from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals_summary.vue'; import ApprovalsSummary from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals_summary.vue';
import ApprovalsSummaryOptional from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals_summary_optional.vue';
import ApprovalsFooter from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals_footer.vue'; import ApprovalsFooter from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals_footer.vue';
import { import {
FETCH_LOADING, FETCH_LOADING,
...@@ -12,6 +13,7 @@ import { ...@@ -12,6 +13,7 @@ import {
} from 'ee/vue_merge_request_widget/components/approvals/messages'; } from 'ee/vue_merge_request_widget/components/approvals/messages';
const localVue = createLocalVue(); const localVue = createLocalVue();
const TEST_HELP_PATH = 'help/path';
const testApprovedBy = () => [1, 7, 10].map(id => ({ id })); const testApprovedBy = () => [1, 7, 10].map(id => ({ id }));
const testApprovals = () => ({ const testApprovals = () => ({
has_approval_rules: true, has_approval_rules: true,
...@@ -64,6 +66,7 @@ describe('EE MRWidget approvals', () => { ...@@ -64,6 +66,7 @@ describe('EE MRWidget approvals', () => {
}; };
}; };
const findSummary = () => wrapper.find(ApprovalsSummary); const findSummary = () => wrapper.find(ApprovalsSummary);
const findOptionalSummary = () => wrapper.find(ApprovalsSummaryOptional);
const findFooter = () => wrapper.find(ApprovalsFooter); const findFooter = () => wrapper.find(ApprovalsFooter);
beforeEach(() => { beforeEach(() => {
...@@ -75,6 +78,7 @@ describe('EE MRWidget approvals', () => { ...@@ -75,6 +78,7 @@ describe('EE MRWidget approvals', () => {
}); });
mr = { mr = {
...jasmine.createSpyObj('Store', ['setApprovals', 'setApprovalRules']), ...jasmine.createSpyObj('Store', ['setApprovals', 'setApprovalRules']),
approvalsHelpPath: TEST_HELP_PATH,
approvals: testApprovals(), approvals: testApprovals(),
approvalRules: [], approvalRules: [],
isOpen: true, isOpen: true,
...@@ -185,17 +189,39 @@ describe('EE MRWidget approvals', () => { ...@@ -185,17 +189,39 @@ describe('EE MRWidget approvals', () => {
}); });
describe('and MR is approved', () => { describe('and MR is approved', () => {
beforeEach(done => { beforeEach(() => {
mr.approvals.approved = true; mr.approvals.approved = true;
createComponent();
waitForTick(done);
}); });
it('approve additionally action is rendered', () => { describe('with no approvers', () => {
expect(findActionData()).toEqual({ beforeEach(done => {
variant: 'primary', mr.approvals.approved_by = [];
text: 'Approve additionally', createComponent();
inverted: true, waitForTick(done);
});
it('approve action (with inverted) is rendered', () => {
expect(findActionData()).toEqual({
variant: 'primary',
text: 'Approve',
inverted: true,
});
});
});
describe('with approvers', () => {
beforeEach(done => {
mr.approvals.approved_by = [{ user: { id: 7 } }];
createComponent();
waitForTick(done);
});
it('approve additionally action is rendered', () => {
expect(findActionData()).toEqual({
variant: 'primary',
text: 'Approve additionally',
inverted: true,
});
}); });
}); });
}); });
...@@ -315,6 +341,50 @@ describe('EE MRWidget approvals', () => { ...@@ -315,6 +341,50 @@ describe('EE MRWidget approvals', () => {
}); });
}); });
describe('approvals optional summary', () => {
describe('when no approvals required and no approvers', () => {
beforeEach(() => {
mr.approvals.approved_by = [];
mr.approvals.approvals_required = 0;
mr.approvals.user_has_approved = false;
});
describe('and can approve', () => {
beforeEach(done => {
mr.approvals.user_can_approve = true;
createComponent();
waitForTick(done);
});
it('is shown', () => {
expect(findSummary().exists()).toBe(false);
expect(findOptionalSummary().props()).toEqual({
canApprove: true,
helpPath: TEST_HELP_PATH,
});
});
});
describe('and cannot approve', () => {
beforeEach(done => {
mr.approvals.user_can_approve = false;
createComponent();
waitForTick(done);
});
it('is shown', () => {
expect(findSummary().exists()).toBe(false);
expect(findOptionalSummary().props()).toEqual({
canApprove: false,
helpPath: TEST_HELP_PATH,
});
});
});
});
});
describe('approvals summary', () => { describe('approvals summary', () => {
beforeEach(done => { beforeEach(done => {
createComponent(); createComponent();
...@@ -325,6 +395,7 @@ describe('EE MRWidget approvals', () => { ...@@ -325,6 +395,7 @@ describe('EE MRWidget approvals', () => {
const expected = testApprovals(); const expected = testApprovals();
const summary = findSummary(); const summary = findSummary();
expect(findOptionalSummary().exists()).toBe(false);
expect(summary.exists()).toBe(true); expect(summary.exists()).toBe(true);
expect(summary.props()).toEqual( expect(summary.props()).toEqual(
jasmine.objectContaining({ jasmine.objectContaining({
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui';
import {
OPTIONAL,
OPTIONAL_CAN_APPROVE,
} from 'ee/vue_merge_request_widget/components/approvals/messages';
import ApprovalsSummaryOptional from 'ee/vue_merge_request_widget/components/approvals/multiple_rule/approvals_summary_optional.vue';
const localVue = createLocalVue();
const TEST_HELP_PATH = 'help/path';
describe('EE MRWidget approvals summary optional', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(localVue.extend(ApprovalsSummaryOptional), {
propsData: props,
sync: false,
localVue,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findHelpLink = () => wrapper.find(GlLink);
describe('when can approve', () => {
beforeEach(() => {
createComponent({ canApprove: true, helpPath: TEST_HELP_PATH });
});
it('shows optional can approve message', () => {
expect(wrapper.text()).toEqual(OPTIONAL_CAN_APPROVE);
});
it('shows help link', () => {
const link = findHelpLink();
expect(link.exists()).toBe(true);
expect(link.attributes('href')).toBe(TEST_HELP_PATH);
});
});
describe('when cannot approve', () => {
beforeEach(() => {
createComponent({ canApprove: false, helpPath: TEST_HELP_PATH });
});
it('shows optional message', () => {
expect(wrapper.text()).toEqual(OPTIONAL);
});
it('does not show help link', () => {
expect(findHelpLink().exists()).toBe(false);
});
});
});
import Vue from 'vue'; import Vue from 'vue';
import ApprovalsBody from 'ee/vue_merge_request_widget/components/approvals/single_rule/approvals_body.vue'; import ApprovalsBody from 'ee/vue_merge_request_widget/components/approvals/single_rule/approvals_body.vue';
import {
OPTIONAL,
OPTIONAL_CAN_APPROVE,
} from 'ee/vue_merge_request_widget/components/approvals/messages';
describe('Approvals Body Component', () => { describe('Approvals Body Component', () => {
let vm; let vm;
...@@ -62,7 +66,7 @@ describe('Approvals Body Component', () => { ...@@ -62,7 +66,7 @@ describe('Approvals Body Component', () => {
}); });
it('should display the correct string for 0 approvals required', done => { it('should display the correct string for 0 approvals required', done => {
const correctText = 'No Approval required'; const correctText = OPTIONAL;
vm.approvalsOptional = true; vm.approvalsOptional = true;
...@@ -73,7 +77,7 @@ describe('Approvals Body Component', () => { ...@@ -73,7 +77,7 @@ describe('Approvals Body Component', () => {
}); });
it('should display the correct string for 0 approvals required and if the user is able to approve', done => { it('should display the correct string for 0 approvals required and if the user is able to approve', done => {
const correctText = 'No Approval required; you can still approve'; const correctText = OPTIONAL_CAN_APPROVE;
vm.approvalsOptional = true; vm.approvalsOptional = true;
vm.userCanApprove = true; vm.userCanApprove = true;
......
...@@ -12050,10 +12050,10 @@ msgstr "" ...@@ -12050,10 +12050,10 @@ msgstr ""
msgid "mrWidget|Merged by" msgid "mrWidget|Merged by"
msgstr "" msgstr ""
msgid "mrWidget|No Approval required" msgid "mrWidget|No approval required"
msgstr "" msgstr ""
msgid "mrWidget|No Approval required; you can still approve" msgid "mrWidget|No approval required; you can still approve"
msgstr "" msgstr ""
msgid "mrWidget|Open in Web IDE" msgid "mrWidget|Open in Web IDE"
......
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