diff --git a/app/assets/javascripts/branches/components/delete_merged_branches.vue b/app/assets/javascripts/branches/components/delete_merged_branches.vue
new file mode 100644
index 0000000000000000000000000000000000000000..70974f2e725c20915b499cc742588f4dc86b60f6
--- /dev/null
+++ b/app/assets/javascripts/branches/components/delete_merged_branches.vue
@@ -0,0 +1,171 @@
+
+
+
+
+
{{ $options.i18n.deleteButtonText }}
+
+
+
+
+
+
+
+ {{ $options.i18n.cancelButtonText }}
+
+ {{ $options.i18n.deleteButtonText }}
+
+
+
+
+
diff --git a/app/assets/javascripts/branches/init_delete_merged_branches.js b/app/assets/javascripts/branches/init_delete_merged_branches.js
new file mode 100644
index 0000000000000000000000000000000000000000..998db07d8de08ea05de746807c5b5b08e1a87122
--- /dev/null
+++ b/app/assets/javascripts/branches/init_delete_merged_branches.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import DeleteMergedBranches from '~/branches/components/delete_merged_branches.vue';
+
+export default function initDeleteMergedBranchesModal() {
+ const el = document.querySelector('.js-delete-merged-branches');
+ if (!el) {
+ return false;
+ }
+
+ const { formPath, defaultBranch } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createComponent) {
+ return createComponent(DeleteMergedBranches, {
+ props: {
+ formPath,
+ defaultBranch,
+ },
+ });
+ },
+ });
+}
diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js
index f3530b468450f6a9f8ec33ef894de533c97e62ff..ac5e0b28dd120ca3b77f764873ba7206060e451f 100644
--- a/app/assets/javascripts/pages/projects/branches/index/index.js
+++ b/app/assets/javascripts/pages/projects/branches/index/index.js
@@ -3,6 +3,7 @@ import BranchSortDropdown from '~/branches/branch_sort_dropdown';
import initDiverganceGraph from '~/branches/divergence_graph';
import initDeleteBranchButton from '~/branches/init_delete_branch_button';
import initDeleteBranchModal from '~/branches/init_delete_branch_modal';
+import initDeleteMergedBranches from '~/branches/init_delete_merged_branches';
const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
'.js-branch-list',
@@ -11,6 +12,7 @@ const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
initDiverganceGraph(divergingCountsEndpoint, defaultBranch);
BranchSortDropdown();
initDeprecatedRemoveRowBehavior();
+initDeleteMergedBranches();
document
.querySelectorAll('.js-delete-branch-button')
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index 82276938d45d47b2ad06347f4f4bdfe45d2f110a..475bc9e1c20de7cb538569eb09649e0363f719fa 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -13,16 +13,10 @@
#js-branches-sort-dropdown{ data: { project_branches_filtered_path: project_branches_path(@project, state: 'all'), sort_options: branches_sort_options_hash.to_json, mode: @mode } }
- if can? current_user, :push_code, @project
- = link_to project_merged_branches_path(@project),
- class: 'gl-button btn btn-danger btn-danger-secondary has-tooltip',
- title: s_("Branches|Delete all branches that are merged into '%{default_branch}'") % { default_branch: @project.repository.root_ref },
- method: :delete,
- aria: { label: s_('Branches|Delete merged branches') },
- data: { confirm: s_('Branches|Deleting the merged branches cannot be undone. Are you sure?'),
- confirm_btn_variant: 'danger',
- container: 'body',
- qa_selector: 'delete_merged_branches_link' } do
- = s_('Branches|Delete merged branches')
+ .js-delete-merged-branches{ data: {
+ default_branch: @project.repository.root_ref,
+ form_path: project_merged_branches_path(@project) }
+ }
= link_to new_project_branch_path(@project), class: 'gl-button btn btn-confirm' do
= s_('Branches|New branch')
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 0bf01382655a92728f8e4fa409a671413581767b..36aa64a904c8c91ff545b2805c70a75ca287e3cc 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -6996,6 +6996,9 @@ msgstr ""
msgid "Branches: %{source_branch} → %{target_branch}"
msgstr ""
+msgid "Branches|A branch won't be deleted if it is protected or associated with an open merge request."
+msgstr ""
+
msgid "Branches|Active"
msgstr ""
@@ -7017,7 +7020,10 @@ msgstr ""
msgid "Branches|Compare"
msgstr ""
-msgid "Branches|Delete all branches that are merged into '%{default_branch}'"
+msgid "Branches|Delete all branches that are merged into '%{defaultBranch}'"
+msgstr ""
+
+msgid "Branches|Delete all merged branches?"
msgstr ""
msgid "Branches|Delete branch"
@@ -7038,9 +7044,6 @@ msgstr ""
msgid "Branches|Deleting the %{strongStart}%{branchName}%{strongEnd} branch cannot be undone. Are you sure?"
msgstr ""
-msgid "Branches|Deleting the merged branches cannot be undone. Are you sure?"
-msgstr ""
-
msgid "Branches|Filter by branch name"
msgstr ""
@@ -7062,6 +7065,9 @@ msgstr ""
msgid "Branches|Please type the following to confirm:"
msgstr ""
+msgid "Branches|Plese type the following to confirm: %{codeStart}delete%{codeEnd}."
+msgstr ""
+
msgid "Branches|Show active branches"
msgstr ""
@@ -7095,6 +7101,12 @@ msgstr ""
msgid "Branches|This branch hasn't been merged into %{defaultBranchName}. To avoid data loss, consider merging this branch before deleting it."
msgstr ""
+msgid "Branches|This bulk action is %{strongStart}permanent and cannot be undone or recovered%{strongEnd}."
+msgstr ""
+
+msgid "Branches|This may include merged branches that are not visible on the current screen."
+msgstr ""
+
msgid "Branches|To discard the local changes and overwrite the branch with the upstream version, delete it here and choose 'Update Now' above."
msgstr ""
@@ -7107,6 +7119,9 @@ msgstr ""
msgid "Branches|Yes, delete protected branch"
msgstr ""
+msgid "Branches|You are about to %{strongStart}delete all branches%{strongEnd} that were merged into %{codeStart}%{defaultBranch}%{codeEnd}."
+msgstr ""
+
msgid "Branches|You're about to permanently delete the branch %{branchName}."
msgstr ""
diff --git a/qa/qa/page/project/branches/show.rb b/qa/qa/page/project/branches/show.rb
index 22b960b47ceeadd654729f48a741d22149edfd86..7163bc7464d120c832552e507b14f93cb62aa9da 100644
--- a/qa/qa/page/project/branches/show.rb
+++ b/qa/qa/page/project/branches/show.rb
@@ -5,8 +5,6 @@ module Page
module Project
module Branches
class Show < Page::Base
- include Page::Component::ConfirmModal
-
view 'app/assets/javascripts/branches/components/delete_branch_button.vue' do
element :delete_branch_button
end
@@ -25,8 +23,10 @@ class Show < Page::Base
element :all_branches_container
end
- view 'app/views/projects/branches/index.html.haml' do
- element :delete_merged_branches_link
+ view 'app/assets/javascripts/branches/components/delete_merged_branches.vue' do
+ element :delete_merged_branches_button
+ element :delete_merged_branches_input
+ element :delete_merged_branches_confirmation_button
end
def delete_branch(branch_name)
@@ -53,9 +53,11 @@ def has_branch_with_badge?(branch_name, badge)
end
end
- def delete_merged_branches
- click_element(:delete_merged_branches_link)
- click_confirmation_ok_button
+ def delete_merged_branches(branches_length)
+ click_element(:delete_merged_branches_button)
+ fill_element(:delete_merged_branches_input, branches_length)
+ click_element(:delete_merged_branches_confirmation_button)
+ finished_loading?
end
end
end
diff --git a/qa/qa/specs/features/browser_ui/3_create/repository/add_list_delete_branches_spec.rb b/qa/qa/specs/features/browser_ui/3_create/repository/add_list_delete_branches_spec.rb
index 849022f5a937f13590e10dd465047905a00856d5..866c6a146debd34624686c6b1ab1b8f1086f5263 100644
--- a/qa/qa/specs/features/browser_ui/3_create/repository/add_list_delete_branches_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/repository/add_list_delete_branches_spec.rb
@@ -76,7 +76,7 @@ module QA
expect(branches_page).to have_no_branch(third_branch)
- branches_page.delete_merged_branches
+ branches_page.delete_merged_branches('delete')
expect(branches_page).to have_content(
'Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes.'
diff --git a/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap
new file mode 100644
index 0000000000000000000000000000000000000000..6aab3b51806478c55e4873aebbbf46ee3fd5bb22
--- /dev/null
+++ b/spec/frontend/branches/components/__snapshots__/delete_merged_branches_spec.js.snap
@@ -0,0 +1,139 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Delete merged branches component Delete merged branches confirmation modal matches snapshot 1`] = `
+
+
+
+
+
+
+
+ Delete merged branches
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
+
+ Delete merged branches
+
+
+
+
+
+`;
diff --git a/spec/frontend/branches/components/delete_merged_branches_spec.js b/spec/frontend/branches/components/delete_merged_branches_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..4f1e772f4a4b9bb6f68a234eebbd55bc05a05988
--- /dev/null
+++ b/spec/frontend/branches/components/delete_merged_branches_spec.js
@@ -0,0 +1,143 @@
+import { GlButton, GlModal, GlFormInput, GlSprintf } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { stubComponent } from 'helpers/stub_component';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import DeleteMergedBranches, { i18n } from '~/branches/components/delete_merged_branches.vue';
+import { formPath, propsDataMock } from '../mock_data';
+
+jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
+
+let wrapper;
+
+const stubsData = {
+ GlModal: stubComponent(GlModal, {
+ template:
+ '
',
+ }),
+ GlButton,
+ GlFormInput,
+ GlSprintf,
+};
+
+const createComponent = (mountFn = shallowMountExtended, stubs = {}) => {
+ wrapper = mountFn(DeleteMergedBranches, {
+ propsData: {
+ ...propsDataMock,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ stubs,
+ });
+};
+
+const findDeleteButton = () => wrapper.findComponent(GlButton);
+const findModal = () => wrapper.findComponent(GlModal);
+const findConfirmationButton = () =>
+ wrapper.findByTestId('delete-merged-branches-confirmation-button');
+const findCancelButton = () => wrapper.findByTestId('delete-merged-branches-cancel-button');
+const findFormInput = () => wrapper.findComponent(GlFormInput);
+const findForm = () => wrapper.find('form');
+const submitFormSpy = () => jest.spyOn(wrapper.vm.$refs.form, 'submit');
+
+describe('Delete merged branches component', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe('Delete merged branches button', () => {
+ it('has correct attributes, text and tooltip', () => {
+ expect(findDeleteButton().attributes()).toMatchObject({
+ category: 'secondary',
+ variant: 'danger',
+ });
+
+ expect(findDeleteButton().text()).toBe(i18n.deleteButtonText);
+ });
+
+ it('displays a tooltip', () => {
+ const tooltip = getBinding(findDeleteButton().element, 'gl-tooltip');
+
+ expect(tooltip).toBeDefined();
+ expect(tooltip.value).toBe(wrapper.vm.buttonTooltipText);
+ });
+
+ it('opens modal when clicked', () => {
+ createComponent(mount);
+ jest.spyOn(wrapper.vm.$refs.modal, 'show');
+ findDeleteButton().trigger('click');
+
+ expect(wrapper.vm.$refs.modal.show).toHaveBeenCalled();
+ });
+ });
+
+ describe('Delete merged branches confirmation modal', () => {
+ beforeEach(() => {
+ createComponent(shallowMountExtended, stubsData);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders correct modal title and text', () => {
+ const modalText = findModal().text();
+ expect(findModal().props('title')).toBe(i18n.modalTitle);
+ expect(modalText).toContain(i18n.notVisibleBranchesWarning);
+ expect(modalText).toContain(i18n.protectedBranchWarning);
+ });
+
+ it('renders confirm and cancel buttons with correct text', () => {
+ expect(findConfirmationButton().text()).toContain(i18n.deleteButtonText);
+ expect(findCancelButton().text()).toContain(i18n.cancelButtonText);
+ });
+
+ it('renders form with correct attributes and hiden inputs', () => {
+ const form = findForm();
+ expect(form.attributes()).toEqual({
+ action: formPath,
+ method: 'post',
+ });
+ expect(form.find('input[name="_method"]').attributes('value')).toBe('delete');
+ expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe(
+ 'mock-csrf-token',
+ );
+ });
+
+ it('matches snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('has a disabled confirm button by default', () => {
+ expect(findConfirmationButton().props('disabled')).toBe(true);
+ });
+
+ it('keeps disabled state when wrong input is provided', async () => {
+ findFormInput().vm.$emit('input', 'hello');
+ await waitForPromises();
+ expect(findConfirmationButton().props('disabled')).toBe(true);
+ findConfirmationButton().trigger('click');
+
+ expect(submitFormSpy()).not.toHaveBeenCalled();
+ findFormInput().trigger('keyup.enter');
+
+ expect(submitFormSpy()).not.toHaveBeenCalled();
+ });
+
+ it('submits form when correct amount is provided and the confirm button is clicked', async () => {
+ findFormInput().vm.$emit('input', 'delete');
+ await waitForPromises();
+ expect(findDeleteButton().props('disabled')).not.toBe(true);
+ findConfirmationButton().trigger('click');
+ expect(submitFormSpy()).toHaveBeenCalled();
+ });
+
+ it('calls hide on the modal when cancel button is clicked', () => {
+ const closeModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide');
+ findCancelButton().trigger('click');
+ expect(closeModalSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/branches/mock_data.js b/spec/frontend/branches/mock_data.js
new file mode 100644
index 0000000000000000000000000000000000000000..9e8839d8ce9dbfd6d50ce9bddecdb82e4b9bb59b
--- /dev/null
+++ b/spec/frontend/branches/mock_data.js
@@ -0,0 +1,7 @@
+export const formPath = '/namespace/project/-/merged_branches';
+const defaultBranch = 'master';
+
+export const propsDataMock = {
+ formPath,
+ defaultBranch,
+};