Commit 908ea56d authored by Nathan Friend's avatar Nathan Friend

Add warning dialog for "merge immediately" option

This commit adds a warning dialog after the user clicks the "merge
immediately" merge train option asking them to confirm their choice.

This dialog contains a link to the documentation as well as some text
about why merging immediately may not be the best choice.
parent fc0bcf9b
...@@ -2,9 +2,9 @@ ...@@ -2,9 +2,9 @@
import _ from 'underscore'; import _ from 'underscore';
import successSvg from 'icons/_icon_status_success.svg'; import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg'; import warningSvg from 'icons/_icon_status_warning.svg';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import simplePoll from '~/lib/utils/simple_poll'; import simplePoll from '~/lib/utils/simple_poll';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import { GlIcon } from '@gitlab/ui'; import { GlIcon } from '@gitlab/ui';
import MergeRequest from '../../../merge_request'; import MergeRequest from '../../../merge_request';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
...@@ -26,6 +26,10 @@ export default { ...@@ -26,6 +26,10 @@ export default {
CommitEdit, CommitEdit,
CommitMessageDropdown, CommitMessageDropdown,
GlIcon, GlIcon,
MergeImmediatelyConfirmationDialog: () =>
import(
'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue'
),
}, },
mixins: [readyToMergeMixin], mixins: [readyToMergeMixin],
props: { props: {
...@@ -165,6 +169,16 @@ export default { ...@@ -165,6 +169,16 @@ export default {
new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line
}); });
}, },
handleMergeImmediatelyButtonClick() {
if (this.isMergeImmediatelyDangerous) {
this.$refs.confirmationDialog.show();
} else {
this.handleMergeButtonClick(false, true);
}
},
onMergeImmediatelyConfirmation() {
this.handleMergeButtonClick(false, true);
},
initiateMergePolling() { initiateMergePolling() {
simplePoll( simplePoll(
(continuePolling, stopPolling) => { (continuePolling, stopPolling) => {
...@@ -286,11 +300,16 @@ export default { ...@@ -286,11 +300,16 @@ export default {
</a> </a>
</li> </li>
<li> <li>
<merge-immediately-confirmation-dialog
ref="confirmationDialog"
:docs-url="mr.mergeImmediatelyDocsPath"
@mergeImmediately="onMergeImmediatelyConfirmation"
/>
<a <a
class="accept-merge-request" class="accept-merge-request js-merge-immediately-button"
data-qa-selector="merge_immediately_option" data-qa-selector="merge_immediately_option"
href="#" href="#"
@click.prevent="handleMergeButtonClick(false, true)" @click.prevent="handleMergeImmediatelyButtonClick"
> >
<span class="media"> <span class="media">
<span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span> <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span>
......
...@@ -23,5 +23,8 @@ export default { ...@@ -23,5 +23,8 @@ export default {
shouldShowMergeImmediatelyDropdown() { shouldShowMergeImmediatelyDropdown() {
return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds; return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds;
}, },
isMergeImmediatelyDangerous() {
return false;
},
}, },
}; };
...@@ -173,6 +173,7 @@ export default class MergeRequestStore { ...@@ -173,6 +173,7 @@ export default class MergeRequestStore {
this.ciEnvironmentsStatusPath = data.ci_environments_status_path; this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path; this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path;
this.eligibleApproversDocsPath = data.eligible_approvers_docs_path; this.eligibleApproversDocsPath = data.eligible_approvers_docs_path;
this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path;
} }
get isNothingToMergeState() { get isNothingToMergeState() {
......
---
title: Add warning dialog when users click the "Merge immediately" merge train option
merge_request: 20054
author:
type: added
<script>
import { GlModal, GlButton } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import _ from 'underscore';
export default {
name: 'MergeImmediatelyConfirmationDialog',
components: {
GlModal,
GlButton,
},
props: {
docsUrl: {
type: String,
required: true,
},
},
computed: {
bodyText() {
return sprintf(
__(
"Merging immediately isn't recommended as it may negatively impact the existing merge train. Read the %{docsLinkStart}documentation%{docsLinkEnd} for more information.",
),
{
docsLinkStart: `<a href="${_.escape(
this.docsUrl,
)}" target="_blank" rel="noopener noreferrer">`,
docsLinkEnd: '</a>',
},
false,
);
},
},
methods: {
show() {
this.$refs.modal.show();
},
cancel() {
this.hide();
},
mergeImmediately() {
this.$emit('mergeImmediately');
this.hide();
},
hide() {
this.$refs.modal.hide();
},
focusCancelButton() {
this.$refs.cancelButton.$el.focus();
},
},
};
</script>
<template>
<gl-modal
ref="modal"
modal-id="merge-immediately-confirmation-dialog"
:title="__('Merge immediately')"
@shown="focusCancelButton"
>
<p v-html="bodyText"></p>
<p>{{ __('Are you sure you want to merge immediately?') }}</p>
<template v-slot:modal-footer>
<gl-button ref="cancelButton" @click="cancel">{{ __('Cancel') }}</gl-button>
<gl-button variant="danger" @click="mergeImmediately">{{
__('Merge immediately')
}}</gl-button>
</template>
</gl-modal>
</template>
...@@ -49,5 +49,8 @@ export default { ...@@ -49,5 +49,8 @@ export default {
return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds; return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds;
}, },
isMergeImmediatelyDangerous() {
return this.mr.preferredAutoMergeStrategy === MT_MERGE_STRATEGY;
},
}, },
}; };
...@@ -45,6 +45,10 @@ module EE ...@@ -45,6 +45,10 @@ module EE
help_page_path('ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md', anchor: 'startadd-to-merge-train-when-pipeline-succeeds') help_page_path('ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md', anchor: 'startadd-to-merge-train-when-pipeline-succeeds')
end end
def merge_immediately_docs_path
help_page_path('ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md', anchor: 'immediately-merge-a-merge-request-with-a-merge-train')
end
def target_project def target_project
merge_request.target_project.present(current_user: current_user) merge_request.target_project.present(current_user: current_user)
end end
......
...@@ -150,6 +150,10 @@ module EE ...@@ -150,6 +150,10 @@ module EE
presenter(merge_request).merge_train_when_pipeline_succeeds_docs_path presenter(merge_request).merge_train_when_pipeline_succeeds_docs_path
end end
expose :merge_immediately_docs_path do |merge_request|
presenter(merge_request).merge_immediately_docs_path
end
expose :blocking_merge_requests, if: -> (mr, _) { mr&.target_project&.feature_available?(:blocking_merge_requests) } expose :blocking_merge_requests, if: -> (mr, _) { mr&.target_project&.feature_available?(:blocking_merge_requests) }
private private
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Merge requests > User merges immediately', :js do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
let_it_be(:merge_request) do
create(:merge_request, :with_merge_request_pipeline,
source_project: project, source_branch: 'feature',
target_project: project, target_branch: 'master')
end
let_it_be(:ci_yaml) do
{ test: { stage: 'test', script: 'echo', only: ['merge_requests'] } }
end
before(:all) do
project.add_maintainer(user)
project.update!(merge_pipelines_enabled: true)
merge_request.all_pipelines.first.succeed!
merge_request.update_head_pipeline
end
before do
stub_licensed_features(merge_pipelines: true, merge_trains: true)
stub_ci_pipeline_yaml_file(YAML.dump(ci_yaml))
sign_in(user)
visit project_merge_request_path(project, merge_request)
wait_for_requests
end
context 'when the merge request is on the merge train' do
def merge_button
find('.mr-widget-body .accept-merge-request.btn-info')
end
def open_warning_dialog
find('.mr-widget-body .dropdown-toggle').click
click_link 'Merge immediately'
expect(page).to have_selector('#merge-immediately-confirmation-dialog')
end
it 'shows a warning dialog and does nothing if the user selects "Cancel"' do
Sidekiq::Testing.fake! do
open_warning_dialog
find(':focus').send_keys :enter
expect(merge_button).to have_no_content('Merge in progress')
end
end
it 'shows a warning dialog and merges immediately after the user confirms' do
Sidekiq::Testing.fake! do
open_warning_dialog
click_button 'Merge immediately'
expect(merge_button).to have_content('Merge in progress')
end
end
end
end
import { shallowMount } from '@vue/test-utils';
import MergeImmediatelyConfirmationDialog from 'ee/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue';
import { trimText } from 'helpers/text_helper';
describe('MergeImmediatelyConfirmationDialog', () => {
const docsUrl = 'path/to/merge/immediately/docs';
let wrapper;
beforeEach(() => {
wrapper = shallowMount(MergeImmediatelyConfirmationDialog, {
propsData: { docsUrl },
sync: false,
});
return wrapper.vm.$nextTick();
});
it('should render informational text explaining why merging immediately can be dangerous', () => {
expect(trimText(wrapper.text())).toContain(
"Merging immediately isn't recommended as it may negatively impact the existing merge train. Read the documentation for more information. Are you sure you want to merge immediately?",
);
});
it('should render a link to the documentation', () => {
const docsLink = wrapper.find(`a[href="${docsUrl}"]`);
expect(docsLink.exists()).toBe(true);
expect(docsLink.attributes().rel).toBe('noopener noreferrer');
expect(trimText(docsLink.text())).toBe('documentation');
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { MERGE_DISABLED_TEXT_UNAPPROVED } from 'ee/vue_merge_request_widget/mixins/ready_to_merge'; import { MERGE_DISABLED_TEXT_UNAPPROVED } from 'ee/vue_merge_request_widget/mixins/ready_to_merge';
import MergeImmediatelyConfirmationDialog from 'ee/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue';
import ReadyToMerge from '~/vue_merge_request_widget/components/states/ready_to_merge.vue'; import ReadyToMerge from '~/vue_merge_request_widget/components/states/ready_to_merge.vue';
import { import {
MWPS_MERGE_STRATEGY, MWPS_MERGE_STRATEGY,
...@@ -38,6 +39,7 @@ describe('ReadyToMerge', () => { ...@@ -38,6 +39,7 @@ describe('ReadyToMerge', () => {
targetBranch: 'master', targetBranch: 'master',
preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY, preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY,
availableAutoMergeStrategies: [MWPS_MERGE_STRATEGY], availableAutoMergeStrategies: [MWPS_MERGE_STRATEGY],
mergeImmediatelyDocsPath: 'path/to/merge/immediately/docs',
}; };
const factory = (mrUpdates = {}) => { const factory = (mrUpdates = {}) => {
...@@ -48,6 +50,9 @@ describe('ReadyToMerge', () => { ...@@ -48,6 +50,9 @@ describe('ReadyToMerge', () => {
}, },
localVue, localVue,
sync: false, sync: false,
stubs: {
MergeImmediatelyConfirmationDialog,
},
}); });
({ vm } = wrapper); ({ vm } = wrapper);
...@@ -55,6 +60,8 @@ describe('ReadyToMerge', () => { ...@@ -55,6 +60,8 @@ describe('ReadyToMerge', () => {
const findResolveItemsMessage = () => wrapper.find('.js-resolve-mr-widget-items-message'); const findResolveItemsMessage = () => wrapper.find('.js-resolve-mr-widget-items-message');
const findMergeButton = () => wrapper.find('.qa-merge-button'); const findMergeButton = () => wrapper.find('.qa-merge-button');
const findMergeButtonDropdown = () => wrapper.find('.js-merge-moment');
const findMergeImmediatelyButton = () => wrapper.find('.js-merge-immediately-button');
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -145,6 +152,20 @@ describe('ReadyToMerge', () => { ...@@ -145,6 +152,20 @@ describe('ReadyToMerge', () => {
expect(vm.autoMergeText).toEqual('Add to merge train when pipeline succeeds'); expect(vm.autoMergeText).toEqual('Add to merge train when pipeline succeeds');
}); });
}); });
describe('isMergeImmediatelyDangerous', () => {
it('should return false if the preferred auto merge strategy is not merge trains', () => {
factory({ preferredAutoMergeStrategy: MWPS_MERGE_STRATEGY });
expect(vm.isMergeImmediatelyDangerous).toBe(false);
});
it('should return true if the preferred auto merge strategy is merge trains', () => {
factory({ preferredAutoMergeStrategy: MT_MERGE_STRATEGY });
expect(vm.isMergeImmediatelyDangerous).toBe(true);
});
});
}); });
describe('shouldShowMergeImmediatelyDropdown', () => { describe('shouldShowMergeImmediatelyDropdown', () => {
...@@ -186,6 +207,45 @@ describe('ReadyToMerge', () => { ...@@ -186,6 +207,45 @@ describe('ReadyToMerge', () => {
}); });
}); });
describe('merge immediately warning dialog', () => {
let dialog;
const clickMergeImmediately = () => {
dialog = wrapper.find(MergeImmediatelyConfirmationDialog);
expect(dialog.exists()).toBe(true);
dialog.vm.show = jest.fn();
vm.handleMergeButtonClick = jest.fn();
findMergeButtonDropdown().trigger('click');
findMergeImmediatelyButton().trigger('click');
};
it('should show a warning dialog asking for confirmation if the user is trying to skip the merge train', () => {
factory({ preferredAutoMergeStrategy: MT_MERGE_STRATEGY });
clickMergeImmediately();
expect(dialog.vm.show).toHaveBeenCalled();
expect(vm.handleMergeButtonClick).not.toHaveBeenCalled();
});
it('should perform the merge when the user confirms their intent to merge immediately', () => {
factory({ preferredAutoMergeStrategy: MT_MERGE_STRATEGY });
clickMergeImmediately();
dialog.vm.$emit('mergeImmediately');
expect(vm.handleMergeButtonClick).toHaveBeenCalled();
});
it('should not ask for confirmation in non-merge train scenarios', () => {
factory({ isPipelineActive: true, onlyAllowMergeIfPipelineSucceeds: false });
clickMergeImmediately();
expect(dialog.vm.show).not.toHaveBeenCalled();
expect(vm.handleMergeButtonClick).toHaveBeenCalled();
});
});
describe('cannot merge', () => { describe('cannot merge', () => {
describe('when isMergeAllowed=false', () => { describe('when isMergeAllowed=false', () => {
beforeEach(() => { beforeEach(() => {
......
...@@ -2041,6 +2041,9 @@ msgstr "" ...@@ -2041,6 +2041,9 @@ msgstr ""
msgid "Are you sure you want to lose your issue information?" msgid "Are you sure you want to lose your issue information?"
msgstr "" msgstr ""
msgid "Are you sure you want to merge immediately?"
msgstr ""
msgid "Are you sure you want to permanently delete this license?" msgid "Are you sure you want to permanently delete this license?"
msgstr "" msgstr ""
...@@ -11004,6 +11007,9 @@ msgstr "" ...@@ -11004,6 +11007,9 @@ msgstr ""
msgid "Merges this merge request when the pipeline succeeds." msgid "Merges this merge request when the pipeline succeeds."
msgstr "" msgstr ""
msgid "Merging immediately isn't recommended as it may negatively impact the existing merge train. Read the %{docsLinkStart}documentation%{docsLinkEnd} for more information."
msgstr ""
msgid "Messages" msgid "Messages"
msgstr "" msgstr ""
......
import Vue from 'vue'; import Vue from 'vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import ReadyToMerge from '~/vue_merge_request_widget/components/states/ready_to_merge.vue'; import ReadyToMerge from '~/vue_merge_request_widget/components/states/ready_to_merge.vue';
import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squash_before_merge.vue'; import SquashBeforeMerge from '~/vue_merge_request_widget/components/states/squash_before_merge.vue';
import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue'; import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue';
import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue'; import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue';
import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue'; import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue';
import eventHub from '~/vue_merge_request_widget/event_hub'; import eventHub from '~/vue_merge_request_widget/event_hub';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { MWPS_MERGE_STRATEGY, MTWPS_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants'; import { MWPS_MERGE_STRATEGY, MTWPS_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants';
const commitMessage = 'This is the commit message'; const commitMessage = 'This is the commit message';
...@@ -288,6 +288,12 @@ describe('ReadyToMerge', () => { ...@@ -288,6 +288,12 @@ describe('ReadyToMerge', () => {
expect(vm.isMergeButtonDisabled).toBe(true); expect(vm.isMergeButtonDisabled).toBe(true);
}); });
}); });
describe('isMergeImmediatelyDangerous', () => {
it('should always return false in CE', () => {
expect(vm.isMergeImmediatelyDangerous).toBe(false);
});
});
}); });
describe('methods', () => { describe('methods', () => {
......
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