Commit 23457cfb authored by Phil Hughes's avatar Phil Hughes Committed by Igor Drozdov

Move approvals components to FOSS

This moves some approvals rules into FOSS
which will be a simpler version of our approval rules
and pnly require a single approve button to be shown

https://gitlab.com/gitlab-org/gitlab/-/issues/27426
parent 08963142
<script>
import { GlButton } from '@gitlab/ui';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import eventHub from '../../event_hub';
import approvalsMixin from '../../mixins/approvals';
import MrWidgetContainer from '../mr_widget_container.vue';
import MrWidgetIcon from '../mr_widget_icon.vue';
import ApprovalsSummary from './approvals_summary.vue';
import ApprovalsSummaryOptional from './approvals_summary_optional.vue';
import { FETCH_LOADING, FETCH_ERROR, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages';
export default {
name: 'MRWidgetApprovals',
components: {
MrWidgetContainer,
MrWidgetIcon,
ApprovalsSummary,
ApprovalsSummaryOptional,
GlButton,
},
mixins: [approvalsMixin],
props: {
mr: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
isOptionalDefault: {
type: Boolean,
required: false,
default: null,
},
approveDefault: {
type: Function,
required: false,
default: null,
},
modalId: {
type: String,
required: false,
default: null,
},
requirePasswordToApprove: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
fetchingApprovals: true,
hasApprovalAuthError: false,
isApproving: false,
};
},
computed: {
isBasic() {
return this.mr.approvalsWidgetType === 'base';
},
isApproved() {
return Boolean(this.approvals.approved);
},
isOptional() {
return this.isOptionalDefault !== null ? this.isOptionalDefault : !this.approvedBy.length;
},
hasAction() {
return Boolean(this.action);
},
approvals() {
return this.mr.approvals || {};
},
approvedBy() {
return this.approvals.approved_by ? this.approvals.approved_by.map(x => x.user) : [];
},
userHasApproved() {
return Boolean(this.approvals.user_has_approved);
},
userCanApprove() {
return Boolean(this.approvals.user_can_approve);
},
showApprove() {
return !this.userHasApproved && this.userCanApprove && this.mr.isOpen;
},
showUnapprove() {
return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged';
},
approvalText() {
return this.isApproved && this.approvedBy.length > 0
? s__('mrWidget|Approve additionally')
: s__('mrWidget|Approve');
},
action() {
// Use the default approve action, only if we aren't using the auth component for it
if (this.showApprove) {
return {
text: this.approvalText,
category: this.isApproved ? 'secondary' : 'primary',
variant: 'info',
action: () => this.approve(),
};
} else if (this.showUnapprove) {
return {
text: s__('mrWidget|Revoke approval'),
variant: 'warning',
category: 'secondary',
action: () => this.unapprove(),
};
}
return null;
},
},
created() {
this.refreshApprovals()
.then(() => {
this.fetchingApprovals = false;
})
.catch(() => createFlash(FETCH_ERROR));
},
methods: {
approve() {
if (this.requirePasswordToApprove) {
this.$root.$emit('bv::show::modal', this.modalId);
return;
}
this.updateApproval(
() => this.service.approveMergeRequest(),
() => createFlash(APPROVE_ERROR),
);
},
approveWithAuth(data) {
this.updateApproval(
() => this.service.approveMergeRequestWithAuth(data),
error => {
if (error && error.response && error.response.status === 401) {
this.hasApprovalAuthError = true;
return;
}
createFlash(APPROVE_ERROR);
},
);
},
unapprove() {
this.updateApproval(
() => this.service.unapproveMergeRequest(),
() => createFlash(UNAPPROVE_ERROR),
);
},
updateApproval(serviceFn, errFn) {
this.isApproving = true;
this.clearError();
return serviceFn()
.then(data => {
this.mr.setApprovals(data);
eventHub.$emit('MRWidgetUpdateRequested');
this.$emit('updated');
})
.catch(errFn)
.then(() => {
this.isApproving = false;
});
},
},
FETCH_LOADING,
};
</script>
<template>
<mr-widget-container>
<div class="js-mr-approvals d-flex align-items-start align-items-md-center">
<mr-widget-icon name="approval" />
<div v-if="fetchingApprovals">{{ $options.FETCH_LOADING }}</div>
<template v-else>
<gl-button
v-if="action"
:variant="action.variant"
:category="action.category"
:loading="isApproving"
class="mr-3"
data-qa-selector="approve_button"
@click="action.action"
>
{{ action.text }}
</gl-button>
<approvals-summary-optional
v-if="isOptional"
:can-approve="hasAction"
:help-path="mr.approvalsHelpPath"
/>
<approvals-summary
v-else
:approved="isApproved"
:approvals-left="approvals.approvals_left || 0"
:rules-left="approvals.approvalRuleNamesLeft"
:approvers="approvedBy"
/>
<slot
:is-approving="isApproving"
:approve-with-auth="approveWithAuth"
:hasApproval-auth-error="hasApprovalAuthError"
></slot>
</template>
</div>
<template #footer>
<slot name="footer"></slot>
</template>
</mr-widget-container>
</template>
......@@ -2,7 +2,7 @@
import { n__, sprintf } from '~/locale';
import { toNounSeriesText } from '~/lib/utils/grammar';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import { APPROVED_MESSAGE } from './messages';
import { APPROVED_MESSAGE } from '~/vue_merge_request_widget/components/approvals/messages';
export default {
components: {
......
<script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { OPTIONAL, OPTIONAL_CAN_APPROVE } from './messages';
import {
OPTIONAL,
OPTIONAL_CAN_APPROVE,
} from '~/vue_merge_request_widget/components/approvals/messages';
export default {
components: {
......
import { hideFlash } from '~/flash';
export default {
methods: {
clearError() {
this.$emit('clearError');
this.hasApprovalAuthError = false;
const flashEl = document.querySelector('.flash-alert');
if (flashEl) {
hideFlash(flashEl);
}
},
refreshApprovals() {
return this.service.fetchApprovals().then(data => {
this.mr.setApprovals(data);
});
},
},
};
......@@ -2,6 +2,7 @@
import { isEmpty } from 'lodash';
import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue';
import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
import { sprintf, s__, __ } from '~/locale';
import Project from '~/pages/projects/project';
......@@ -80,6 +81,7 @@ export default {
GroupedTestReportsApp,
TerraformPlan,
GroupedAccessibilityReportsApp,
MrWidgetApprovals,
},
props: {
mrData: {
......@@ -98,6 +100,9 @@ export default {
};
},
computed: {
shouldRenderApprovals() {
return this.mr.state !== 'nothingToMerge';
},
componentName() {
return stateMaps.stateToComponentMap[this.mr.state];
},
......@@ -221,6 +226,9 @@ export default {
mergeRequestCachedWidgetPath: store.mergeRequestCachedWidgetPath,
mergeActionsContentPath: store.mergeActionsContentPath,
rebasePath: store.rebasePath,
apiApprovalsPath: store.apiApprovalsPath,
apiApprovePath: store.apiApprovePath,
apiUnapprovePath: store.apiUnapprovePath,
};
},
createService(store) {
......@@ -384,6 +392,12 @@ export default {
class="mr-widget-workflow"
:mr="mr"
/>
<mr-widget-approvals
v-if="shouldRenderApprovals"
class="mr-widget-workflow"
:mr="mr"
:service="service"
/>
<div class="mr-section-container mr-widget-workflow">
<grouped-codequality-reports-app
v-if="shouldRenderCodeQuality"
......
......@@ -3,6 +3,10 @@ import axios from '../../lib/utils/axios_utils';
export default class MRWidgetService {
constructor(endpoints) {
this.endpoints = endpoints;
this.apiApprovalsPath = endpoints.apiApprovalsPath;
this.apiApprovePath = endpoints.apiApprovePath;
this.apiUnapprovePath = endpoints.apiUnapprovePath;
}
merge(data) {
......@@ -54,6 +58,18 @@ export default class MRWidgetService {
return axios.post(this.endpoints.rebasePath);
}
fetchApprovals() {
return axios.get(this.apiApprovalsPath).then(res => res.data);
}
approveMergeRequest() {
return axios.post(this.apiApprovePath).then(res => res.data);
}
unapproveMergeRequest() {
return axios.post(this.apiUnapprovePath).then(res => res.data);
}
static executeInlineAction(url) {
return axios.post(url);
}
......
......@@ -9,12 +9,19 @@ export default class MergeRequestStore {
this.sha = data.diff_head_sha;
this.gitlabLogo = data.gitlabLogo;
this.apiApprovalsPath = data.api_approvals_path;
this.apiApprovePath = data.api_approve_path;
this.apiUnapprovePath = data.api_unapprove_path;
this.hasApprovalsAvailable = data.has_approvals_available;
this.setPaths(data);
this.setData(data);
}
setData(data, isRebased) {
this.initApprovals();
if (isRebased) {
this.sha = data.diff_head_sha;
}
......@@ -52,6 +59,7 @@ export default class MergeRequestStore {
this.squashCommitMessage = data.default_squash_commit_message;
this.rebaseInProgress = data.rebase_in_progress;
this.mergeRequestDiffsPath = data.diffs_path;
this.approvalsWidgetType = data.approvals_widget_type;
if (data.issues_links) {
const links = data.issues_links;
......@@ -181,6 +189,7 @@ export default class MergeRequestStore {
this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path;
this.eligibleApproversDocsPath = data.eligible_approvers_docs_path;
this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path;
this.approvalsHelpPath = data.approvals_help_path;
this.mergeRequestAddCiConfigPath = data.merge_request_add_ci_config_path;
this.pipelinesEmptySvgPath = data.pipelines_empty_svg_path;
this.humanAccess = data.human_access;
......@@ -251,4 +260,14 @@ export default class MergeRequestStore {
return undefined;
}
initApprovals() {
this.isApproved = this.isApproved || false;
this.approvals = this.approvals || null;
}
setApprovals(data) {
this.approvals = data;
this.isApproved = data.approved || false;
}
}
......@@ -149,7 +149,10 @@ module IssuableCollections
when 'Issue'
common_attributes + [:project, project: :namespace]
when 'MergeRequest'
common_attributes + [:target_project, :latest_merge_request_diff, source_project: :route, head_pipeline: :project, target_project: :namespace]
common_attributes + [
:target_project, :latest_merge_request_diff, :approvals, :approved_by_users,
source_project: :route, head_pipeline: :project, target_project: :namespace
]
end
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
......
- merge_request = local_assigns.fetch(:merge_request)
- self_approved = merge_request.approved_by?(current_user)
- total = merge_request.approvals.size
- if total > 0
- final_text = n_("%d approver", "%d approvers", total) % total
- final_self_text = n_("%d approver (you've approved)", "%d approvers (you've approved)", total) % total
- approval_icon = sprite_icon((self_approved ? 'approval-solid' : 'approval'), size: 16, css_class: 'align-middle')
%li.d-none.d-sm-inline-block.has-tooltip.text-success{ title: self_approved ? final_self_text : final_text }
= approval_icon
= _("Approved")
......@@ -55,7 +55,7 @@
- if merge_request.assignees.any?
%li.d-flex
= render 'shared/issuable/assignees', project: merge_request.project, issuable: merge_request
= render_if_exists 'projects/merge_requests/approvals_count', merge_request: merge_request
= render 'projects/merge_requests/approvals_count', merge_request: merge_request
= render 'shared/issuable_meta_data', issuable: merge_request
......
......@@ -12,6 +12,7 @@
window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')}';
window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests-ultimate')}';
window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}';
window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/merge_request_approvals")}';
window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}';
window.gl.mrWidgetData.codequality_help_path = '#{help_page_path("user/project/merge_requests/code_quality", anchor: "code-quality-reports")}';
......
---
title: Show Approve button on merge requests in Core
merge_request: 36449
author:
type: added
<script>
import { GlButton } from '@gitlab/ui';
import createFlash, { hideFlash } from '~/flash';
import { s__ } from '~/locale';
import eventHub from '~/vue_merge_request_widget/event_hub';
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 ApprovalsSummary from './approvals_summary.vue';
import ApprovalsSummaryOptional from './approvals_summary_optional.vue';
import ApprovalsFooter from './approvals_footer.vue';
import createFlash from '~/flash';
import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue';
import approvalsMixin from '~/vue_merge_request_widget/mixins/approvals';
import ApprovalsAuth from './approvals_auth.vue';
import { FETCH_LOADING, FETCH_ERROR, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages';
import { FETCH_ERROR } from '~/vue_merge_request_widget/components/approvals/messages';
import ApprovalsFooter from './approvals_footer.vue';
export default {
name: 'MRWidgetMultipleRuleApprovals',
components: {
MrWidgetContainer,
MrWidgetIcon,
ApprovalsSummary,
ApprovalsSummaryOptional,
ApprovalsFooter,
Approvals,
ApprovalsAuth,
GlButton,
ApprovalsFooter,
},
mixins: [approvalsMixin],
props: {
mr: {
type: Object,
......@@ -34,75 +26,32 @@ export default {
},
data() {
return {
fetchingApprovals: true,
isApproving: false,
isExpanded: false,
isLoadingRules: false,
hasApprovalAuthError: false,
isExpanded: false,
modalId: 'approvals-auth',
};
},
computed: {
isBasic() {
return this.mr.approvalsWidgetType === 'base';
},
approvals() {
return this.mr.approvals || {};
},
hasFooter() {
return Boolean(this.mr.approvals);
},
approvedBy() {
return this.approvals.approved_by ? this.approvals.approved_by.map(x => x.user) : [];
},
isApproved() {
return Boolean(this.approvals.approved);
},
approvalsRequired() {
return this.approvals.approvals_required || 0;
return (!this.isBasic && this.approvals.approvals_required) || 0;
},
isOptional() {
return !this.approvedBy.length && !this.approvalsRequired;
},
userHasApproved() {
return Boolean(this.approvals.user_has_approved);
},
userCanApprove() {
return Boolean(this.approvals.user_can_approve);
},
showApprove() {
return !this.userHasApproved && this.userCanApprove && this.mr.isOpen;
},
showUnapprove() {
return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged';
hasFooter() {
return Boolean(this.mr.approvals);
},
requirePasswordToApprove() {
return this.mr.approvals.require_password_to_approve;
},
approvalText() {
return this.isApproved && this.approvedBy.length > 0
? s__('mrWidget|Approve additionally')
: s__('mrWidget|Approve');
},
action() {
// Use the default approve action, only if we aren't using the auth component for it
if (this.showApprove) {
return {
text: this.approvalText,
category: this.isApproved ? 'secondary' : 'primary',
variant: 'info',
action: () => this.approve(),
};
} else if (this.showUnapprove) {
return {
text: s__('mrWidget|Revoke approval'),
variant: 'warning',
category: 'secondary',
action: () => this.unapprove(),
};
}
return null;
},
hasAction() {
return Boolean(this.action);
return !this.isBasic && this.approvals.require_password_to_approve;
},
},
watch: {
......@@ -112,27 +61,19 @@ export default {
}
},
},
created() {
this.refreshApprovals()
.then(() => {
this.fetchingApprovals = false;
})
.catch(() => createFlash(FETCH_ERROR));
},
methods: {
clearError() {
this.hasApprovalAuthError = false;
const flashEl = document.querySelector('.flash-alert');
if (flashEl) {
hideFlash(flashEl);
}
},
refreshAll() {
if (this.isBasic) return Promise.resolve();
return Promise.all([this.refreshRules(), this.refreshApprovals()]).catch(() =>
createFlash(FETCH_ERROR),
);
},
refreshRules() {
if (this.isBasic) return Promise.resolve();
this.$root.$emit('bv::hide::modal', this.modalId);
this.isLoadingRules = true;
return this.service.fetchApprovalSettings().then(settings => {
......@@ -140,64 +81,19 @@ export default {
this.isLoadingRules = false;
});
},
refreshApprovals() {
return this.service.fetchApprovals().then(data => {
this.mr.setApprovals(data);
});
},
approve() {
if (this.requirePasswordToApprove) {
this.$root.$emit('bv::show::modal', this.modalId);
return;
}
this.updateApproval(
() => this.service.approveMergeRequest(),
() => createFlash(APPROVE_ERROR),
);
},
unapprove() {
this.updateApproval(
() => this.service.unapproveMergeRequest(),
() => createFlash(UNAPPROVE_ERROR),
);
},
approveWithAuth(data) {
this.updateApproval(
() => this.service.approveMergeRequestWithAuth(data),
error => {
if (error && error.response && error.response.status === 401) {
this.hasApprovalAuthError = true;
return;
}
createFlash(APPROVE_ERROR);
},
);
},
updateApproval(serviceFn, errFn) {
this.isApproving = true;
this.clearError();
return serviceFn()
.then(data => {
this.mr.setApprovals(data);
eventHub.$emit('MRWidgetUpdateRequested');
this.$root.$emit('bv::hide::modal', this.modalId);
})
.catch(errFn)
.then(() => {
this.isApproving = false;
this.refreshRules();
});
},
},
FETCH_LOADING,
};
</script>
<template>
<mr-widget-container>
<div class="js-mr-approvals d-flex align-items-start align-items-md-center">
<mr-widget-icon name="approval" />
<div v-if="fetchingApprovals">{{ $options.FETCH_LOADING }}</div>
<template v-else>
<approvals
:mr="mr"
:service="service"
:is-optional-default="isOptional"
:require-password-to-approve="requirePasswordToApprove"
:modal-id="modalId"
@updated="refreshRules"
>
<template v-if="!isBasic" #default="{ isApproving, approveWithAuth, hasApprovalAuthError }">
<approvals-auth
:is-approving="isApproving"
:has-error="hasApprovalAuthError"
......@@ -205,34 +101,10 @@ export default {
@approve="approveWithAuth"
@hide="clearError"
/>
<gl-button
v-if="action"
:variant="action.variant"
:category="action.category"
:loading="isApproving"
class="mr-3"
data-qa-selector="approve_button"
@click="action.action"
>
{{ action.text }}
</gl-button>
<approvals-summary-optional
v-if="isOptional"
:can-approve="hasAction"
:help-path="mr.approvalsHelpPath"
/>
<approvals-summary
v-else
:approved="isApproved"
:approvals-left="approvals.approvals_left"
:rules-left="approvals.approvalRuleNamesLeft"
:approvers="approvedBy"
/>
</template>
</div>
<template v-if="!isBasic" #footer>
<approvals-footer
v-if="hasFooter"
slot="footer"
v-model="isExpanded"
:suggested-approvers="approvals.suggested_approvers"
:approval-rules="mr.approvalRules"
......@@ -240,5 +112,6 @@ export default {
:security-approvals-help-page-path="mr.securityApprovalsHelpPagePath"
:eligible-approvers-docs-path="mr.eligibleApproversDocsPath"
/>
</mr-widget-container>
</template>
</approvals>
</template>
......@@ -10,7 +10,6 @@ import BlockingMergeRequestsReport from './components/blocking_merge_requests/bl
import { s__, __, sprintf } from '~/locale';
import CEWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
import MrWidgetApprovals from './components/approvals/approvals.vue';
import MrWidgetGeoSecondaryNode from './components/states/mr_widget_secondary_geo_node.vue';
import MrWidgetPolicyViolation from './components/states/mr_widget_policy_violation.vue';
import MergeTrainHelperText from './components/merge_train_helper_text.vue';
......@@ -20,7 +19,6 @@ export default {
components: {
MergeTrainHelperText,
MrWidgetLicenses,
MrWidgetApprovals,
MrWidgetGeoSecondaryNode,
MrWidgetPolicyViolation,
BlockingMergeRequestsReport,
......@@ -41,9 +39,6 @@ export default {
};
},
computed: {
shouldRenderApprovals() {
return this.mr.hasApprovalsAvailable && this.mr.state !== 'nothingToMerge';
},
shouldRenderLicenseReport() {
return this.mr.enabledReports?.licenseScanning;
},
......@@ -193,10 +188,7 @@ export default {
return {
...base,
apiApprovalsPath: store.apiApprovalsPath,
apiApprovalSettingsPath: store.apiApprovalSettingsPath,
apiApprovePath: store.apiApprovePath,
apiUnapprovePath: store.apiUnapprovePath,
};
},
......
......@@ -5,31 +5,17 @@ export default class MRWidgetService extends CEWidgetService {
constructor(mr) {
super(mr);
this.apiApprovalsPath = mr.apiApprovalsPath;
this.apiApprovalSettingsPath = mr.apiApprovalSettingsPath;
this.apiApprovePath = mr.apiApprovePath;
this.apiUnapprovePath = mr.apiUnapprovePath;
}
fetchApprovals() {
return axios.get(this.apiApprovalsPath).then(res => res.data);
}
fetchApprovalSettings() {
return axios.get(this.apiApprovalSettingsPath).then(res => res.data);
}
approveMergeRequest() {
return axios.post(this.apiApprovePath).then(res => res.data);
}
approveMergeRequestWithAuth(approvalPassword) {
return axios
.post(this.apiApprovePath, { approval_password: approvalPassword })
.then(res => res.data);
}
unapproveMergeRequest() {
return axios.post(this.apiUnapprovePath).then(res => res.data);
fetchApprovalSettings() {
return axios.get(this.apiApprovalSettingsPath).then(res => res.data);
}
// eslint-disable-next-line class-methods-use-this
......
......@@ -15,7 +15,6 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.vulnerabilityFeedbackPath = data.vulnerability_feedback_path;
this.canReadVulnerabilityFeedback = data.can_read_vulnerability_feedback;
this.vulnerabilityFeedbackHelpPath = data.vulnerability_feedback_help_path;
this.approvalsHelpPath = data.approvals_help_path;
this.securityReportsPipelineId = data.pipeline_id;
this.securityReportsPipelineIid = data.pipeline_iid;
this.createVulnerabilityFeedbackIssuePath = data.create_vulnerability_feedback_issue_path;
......@@ -35,16 +34,11 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.blockingMergeRequests = data.blocking_merge_requests;
this.hasApprovalsAvailable = data.has_approvals_available;
this.apiApprovalsPath = data.api_approvals_path;
this.apiApprovalSettingsPath = data.api_approval_settings_path;
this.apiApprovePath = data.api_approve_path;
this.apiUnapprovePath = data.api_unapprove_path;
}
setData(data, isRebased) {
this.initGeo(data);
this.initApprovals();
this.mergePipelinesEnabled = Boolean(data.merge_pipelines_enabled);
this.mergeTrainsCount = data.merge_trains_count || 0;
......@@ -59,15 +53,16 @@ export default class MergeRequestStore extends CEMergeRequestStore {
}
initApprovals() {
this.isApproved = this.isApproved || false;
this.approvals = this.approvals || null;
super.initApprovals();
this.approvalRules = this.approvalRules || [];
}
setApprovals(data) {
super.setApprovals(data);
this.approvals = mapApprovalsResponse(data);
this.approvalsLeft = Boolean(data.approvals_left);
this.isApproved = data.approved || false;
this.preventMerge = !this.isApproved;
}
......
......@@ -9,7 +9,7 @@ module EE
def preload_for_collection
@preload_for_collection ||= case collection_type
when 'MergeRequest'
super.push(:approvals, :approval_rules)
super.push(:approval_rules)
when 'Issue'
super.push(*issue_preloads)
else
......
......@@ -25,3 +25,5 @@
= _("Approved")
- else
= _("%{remaining_approvals} left") % { remaining_approvals: merge_request.approvals_left }
- else
= render_ce "projects/merge_requests/approvals_count", merge_request: merge_request
......@@ -13,7 +13,6 @@
window.gl.mrWidgetData.dependency_scanning_help_path = '#{help_page_path("user/application_security/dependency_scanning/index")}';
window.gl.mrWidgetData.secret_scanning_help_path = '#{help_page_path('user/application_security/sast/index', anchor: 'secret-detection')}';
window.gl.mrWidgetData.vulnerability_feedback_help_path = '#{help_page_path("user/application_security/index")}';
window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/merge_request_approvals")}';
window.gl.mrWidgetData.visual_review_app_available = '#{@project.feature_available?(:visual_review_app)}' === 'true';
window.gl.mrWidgetData.license_scanning_comparison_path = '#{license_scanning_reports_project_merge_request_path(@project, @merge_request) if @project.feature_available?(:license_scanning)}'
window.gl.mrWidgetData.container_scanning_comparison_path = '#{container_scanning_reports_project_merge_request_path(@project, @merge_request) if @project.feature_available?(:container_scanning)}'
......
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import MrWidgetContainer from '~/vue_merge_request_widget/components/mr_widget_container.vue';
import Approvals from 'ee/vue_merge_request_widget/components/approvals/approvals.vue';
import ApprovalsSummary from 'ee/vue_merge_request_widget/components/approvals/approvals_summary.vue';
import ApprovalsSummaryOptional from 'ee/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue';
import ApprovalsFoss from '~/vue_merge_request_widget/components/approvals/approvals.vue';
import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue';
import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue';
import ApprovalsFooter from 'ee/vue_merge_request_widget/components/approvals/approvals_footer.vue';
import ApprovalsAuth from 'ee/vue_merge_request_widget/components/approvals/approvals_auth.vue';
import createFlash from '~/flash';
......@@ -12,7 +14,7 @@ import {
FETCH_ERROR,
APPROVE_ERROR,
UNAPPROVE_ERROR,
} from 'ee/vue_merge_request_widget/components/approvals/messages';
} from '~/vue_merge_request_widget/components/approvals/messages';
import eventHub from '~/vue_merge_request_widget/event_hub';
const TEST_HELP_PATH = 'help/path';
......@@ -44,6 +46,10 @@ describe('EE MRWidget approvals', () => {
service,
...props,
},
stubs: {
approvals: ApprovalsFoss,
MrWidgetContainer,
},
});
};
......@@ -84,6 +90,7 @@ describe('EE MRWidget approvals', () => {
approvalRules: [],
isOpen: true,
state: 'open',
approvalsWidgetType: 'full',
};
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
......@@ -101,7 +108,7 @@ describe('EE MRWidget approvals', () => {
});
it('shows loading message', () => {
wrapper.setData({ fetchingApprovals: true });
wrapper.find(ApprovalsFoss).setData({ fetchingApprovals: true });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.text()).toContain(FETCH_LOADING);
......@@ -332,7 +339,7 @@ describe('EE MRWidget approvals', () => {
});
it('sets isApproving', () => {
wrapper.setData({ isApproving: true });
wrapper.find(ApprovalsFoss).setData({ isApproving: true });
return wrapper.vm.$nextTick().then(() => {
expect(findApprovalsAuth().props('isApproving')).toBe(true);
......@@ -340,7 +347,7 @@ describe('EE MRWidget approvals', () => {
});
it('sets hasError when auth fails', () => {
wrapper.setData({ hasApprovalAuthError: true });
wrapper.find(ApprovalsFoss).setData({ hasApprovalAuthError: true });
return wrapper.vm.$nextTick().then(() => {
expect(findApprovalsAuth().props('hasError')).toBe(true);
......
......@@ -852,18 +852,6 @@ describe('ee merge request widget options', () => {
describe('computed', () => {
describe('shouldRenderApprovals', () => {
it('should return false when no approvals', () => {
vm = mountComponent(Component, {
mrData: {
...mockData,
has_approvals_available: false,
},
});
vm.mr.state = 'readyToMerge';
expect(vm.shouldRenderApprovals).toBeFalsy();
});
it('should return false when in empty state', () => {
vm = mountComponent(Component, {
mrData: {
......
......@@ -36,11 +36,11 @@ module QA
element :expand_report_button
end
view 'ee/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue' do
view 'app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue' do
element :approve_button
end
view 'ee/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue' do
view 'app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue' do
element :approvals_summary_content
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Merge request > User approves', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) }
let(:merge_request) { create(:merge_request, source_project: project) }
before do
project.add_developer(user)
sign_in(user)
visit project_merge_request_path(project, merge_request)
end
it 'approves merge request' do
click_approval_button('Approve')
expect(page).to have_content('Merge request approved')
verify_approvals_count_on_index!
click_approval_button('Revoke approval')
expect(page).to have_content('No approval required; you can still approve')
end
def verify_approvals_count_on_index!
visit(project_merge_requests_path(project, state: :all))
expect(page.all('li').any? { |item| item["title"] == "1 approver (you've approved)"}).to be true
visit project_merge_request_path(project, merge_request)
end
def click_approval_button(action)
page.within('.mr-state-widget') do
click_button(action)
end
wait_for_requests
end
end
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue';
import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue';
import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue';
import createFlash from '~/flash';
import {
FETCH_LOADING,
FETCH_ERROR,
APPROVE_ERROR,
UNAPPROVE_ERROR,
} from '~/vue_merge_request_widget/components/approvals/messages';
import eventHub from '~/vue_merge_request_widget/event_hub';
jest.mock('~/flash');
const TEST_HELP_PATH = 'help/path';
const testApprovedBy = () => [1, 7, 10].map(id => ({ id }));
const testApprovals = () => ({
approved: false,
approved_by: testApprovedBy().map(user => ({ user })),
approval_rules_left: [],
approvals_left: 4,
suggested_approvers: [],
user_can_approve: true,
user_has_approved: true,
require_password_to_approve: false,
});
const testApprovalRulesResponse = () => ({ rules: [{ id: 2 }] });
// For some reason, the `Promise.resolve()` needs to be deferred
// or the timing doesn't work.
const tick = () => Promise.resolve();
const waitForTick = done =>
tick()
.then(done)
.catch(done.fail);
describe('MRWidget approvals', () => {
let wrapper;
let service;
let mr;
const createComponent = (props = {}) => {
wrapper = shallowMount(Approvals, {
propsData: {
mr,
service,
...props,
},
});
};
const findAction = () => wrapper.find(GlButton);
const findActionData = () => {
const action = findAction();
return !action.exists()
? null
: {
variant: action.props('variant'),
category: action.props('category'),
text: action.text(),
};
};
const findSummary = () => wrapper.find(ApprovalsSummary);
const findOptionalSummary = () => wrapper.find(ApprovalsSummaryOptional);
beforeEach(() => {
service = {
...{
fetchApprovals: jest.fn().mockReturnValue(Promise.resolve(testApprovals())),
fetchApprovalSettings: jest
.fn()
.mockReturnValue(Promise.resolve(testApprovalRulesResponse())),
approveMergeRequest: jest.fn().mockReturnValue(Promise.resolve(testApprovals())),
unapproveMergeRequest: jest.fn().mockReturnValue(Promise.resolve(testApprovals())),
approveMergeRequestWithAuth: jest.fn().mockReturnValue(Promise.resolve(testApprovals())),
},
};
mr = {
...{
setApprovals: jest.fn(),
setApprovalRules: jest.fn(),
},
approvalsHelpPath: TEST_HELP_PATH,
approvals: testApprovals(),
approvalRules: [],
isOpen: true,
state: 'open',
};
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when created', () => {
beforeEach(() => {
createComponent();
});
it('shows loading message', () => {
wrapper.setData({ fetchingApprovals: true });
return tick().then(() => {
expect(wrapper.text()).toContain(FETCH_LOADING);
});
});
it('fetches approvals', () => {
expect(service.fetchApprovals).toHaveBeenCalled();
});
});
describe('when fetch approvals error', () => {
beforeEach(done => {
jest.spyOn(service, 'fetchApprovals').mockReturnValue(Promise.reject());
createComponent();
waitForTick(done);
});
it('still shows loading message', () => {
expect(wrapper.text()).toContain(FETCH_LOADING);
});
it('flashes error', () => {
expect(createFlash).toHaveBeenCalledWith(FETCH_ERROR);
});
});
describe('action button', () => {
describe('when mr is closed', () => {
beforeEach(done => {
mr.isOpen = false;
mr.approvals.user_has_approved = false;
mr.approvals.user_can_approve = true;
createComponent();
waitForTick(done);
});
it('action is not rendered', () => {
expect(findActionData()).toBe(null);
});
});
describe('when user cannot approve', () => {
beforeEach(done => {
mr.approvals.user_has_approved = false;
mr.approvals.user_can_approve = false;
createComponent();
waitForTick(done);
});
it('action is not rendered', () => {
expect(findActionData()).toBe(null);
});
});
describe('when user can approve', () => {
beforeEach(() => {
mr.approvals.user_has_approved = false;
mr.approvals.user_can_approve = true;
});
describe('and MR is unapproved', () => {
beforeEach(done => {
createComponent();
waitForTick(done);
});
it('approve action is rendered', () => {
expect(findActionData()).toEqual({
variant: 'info',
text: 'Approve',
category: 'primary',
});
});
});
describe('and MR is approved', () => {
beforeEach(() => {
mr.approvals.approved = true;
});
describe('with no approvers', () => {
beforeEach(done => {
mr.approvals.approved_by = [];
createComponent();
waitForTick(done);
});
it('approve action (with inverted style) is rendered', () => {
expect(findActionData()).toEqual({
variant: 'info',
text: 'Approve',
category: 'secondary',
});
});
});
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: 'info',
text: 'Approve additionally',
category: 'secondary',
});
});
});
});
describe('when approve action is clicked', () => {
beforeEach(done => {
createComponent();
waitForTick(done);
});
it('shows loading icon', () => {
jest.spyOn(service, 'approveMergeRequest').mockReturnValue(new Promise(() => {}));
const action = findAction();
expect(action.props('loading')).toBe(false);
action.vm.$emit('click');
return tick().then(() => {
expect(action.props('loading')).toBe(true);
});
});
describe('and after loading', () => {
beforeEach(done => {
findAction().vm.$emit('click');
waitForTick(done);
});
it('calls service approve', () => {
expect(service.approveMergeRequest).toHaveBeenCalled();
});
it('emits to eventHub', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
});
it('calls store setApprovals', () => {
expect(mr.setApprovals).toHaveBeenCalledWith(testApprovals());
});
});
describe('and error', () => {
beforeEach(done => {
jest.spyOn(service, 'approveMergeRequest').mockReturnValue(Promise.reject());
findAction().vm.$emit('click');
waitForTick(done);
});
it('flashes error message', () => {
expect(createFlash).toHaveBeenCalledWith(APPROVE_ERROR);
});
});
});
});
describe('when user has approved', () => {
beforeEach(done => {
mr.approvals.user_has_approved = true;
mr.approvals.user_can_approve = false;
createComponent();
waitForTick(done);
});
it('revoke action is rendered', () => {
expect(findActionData()).toEqual({
variant: 'warning',
text: 'Revoke approval',
category: 'secondary',
});
});
describe('when revoke action is clicked', () => {
describe('and successful', () => {
beforeEach(done => {
findAction().vm.$emit('click');
waitForTick(done);
});
it('calls service unapprove', () => {
expect(service.unapproveMergeRequest).toHaveBeenCalled();
});
it('emits to eventHub', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
});
it('calls store setApprovals', () => {
expect(mr.setApprovals).toHaveBeenCalledWith(testApprovals());
});
});
describe('and error', () => {
beforeEach(done => {
jest.spyOn(service, 'unapproveMergeRequest').mockReturnValue(Promise.reject());
findAction().vm.$emit('click');
waitForTick(done);
});
it('flashes error message', () => {
expect(createFlash).toHaveBeenCalledWith(UNAPPROVE_ERROR);
});
});
});
});
});
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', () => {
beforeEach(done => {
createComponent();
waitForTick(done);
});
it('is rendered with props', () => {
const expected = testApprovals();
const summary = findSummary();
expect(findOptionalSummary().exists()).toBe(false);
expect(summary.exists()).toBe(true);
expect(summary.props()).toMatchObject({
approvalsLeft: expected.approvals_left,
rulesLeft: expected.approval_rules_left,
approvers: testApprovedBy(),
});
});
});
});
......@@ -3,12 +3,12 @@ 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/approvals_summary_optional.vue';
} from '~/vue_merge_request_widget/components/approvals/messages';
import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue';
const TEST_HELP_PATH = 'help/path';
describe('EE MRWidget approvals summary optional', () => {
describe('MRWidget approvals summary optional', () => {
let wrapper;
const createComponent = (props = {}) => {
......
import { shallowMount } from '@vue/test-utils';
import { APPROVED_MESSAGE } from 'ee/vue_merge_request_widget/components/approvals/messages';
import ApprovalsSummary from 'ee/vue_merge_request_widget/components/approvals/approvals_summary.vue';
import { APPROVED_MESSAGE } from '~/vue_merge_request_widget/components/approvals/messages';
import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue';
import { toNounSeriesText } from '~/lib/utils/grammar';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
......@@ -8,7 +8,7 @@ const testApprovers = () => Array.from({ length: 5 }, (_, i) => i).map(id => ({
const testRulesLeft = () => ['Lorem', 'Ipsum', 'dolar & sit'];
const TEST_APPROVALS_LEFT = 3;
describe('EE MRWidget approvals summary', () => {
describe('MRWidget approvals summary', () => {
let wrapper;
const createComponent = (props = {}) => {
......
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