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 @@ + + + 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 + + + + +
+
+

+ You are about to + + delete all branches + + that were merged into + + master + + . +

+ +

+ + This may include merged branches that are not visible on the current screen. + +

+ +

+ + A branch won't be deleted if it is protected or associated with an open merge request. + +

+ +

+ This bulk action is + + permanent and cannot be undone or recovered + + . +

+ +

+ Plese type the following to confirm: + + delete + + . + +

+ + + + +
+
+ + + + + + + + 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, +};