diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 632c2ed371f1dfe653d8f79020cac28ea38a9d16..0daf77e03dc5057ea251ed51d00ba5399a226247 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -51,20 +51,11 @@ export default { required: true, type: Boolean, }, - canDestroy: { - required: true, - type: Boolean, - }, showInlineEditButton: { type: Boolean, required: false, default: true, }, - showDeleteButton: { - type: Boolean, - required: false, - default: true, - }, enableAutocomplete: { type: Boolean, required: false, @@ -494,14 +485,12 @@ export default { :endpoint="endpoint" :form-state="formState" :initial-description-text="initialDescriptionText" - :can-destroy="canDestroy" :issuable-templates="issuableTemplates" :markdown-docs-path="markdownDocsPath" :markdown-preview-path="markdownPreviewPath" :project-path="projectPath" :project-id="projectId" :project-namespace="projectNamespace" - :show-delete-button="showDeleteButton" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" :issuable-type="issuableType" diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue index 47b09bd6aa0c4000740b40e61acd899cbf65d18f..f86ee11e64b21f78c684b5f10c7ad3c4ab2acb9d 100644 --- a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue +++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue @@ -13,7 +13,8 @@ export default { props: { issuePath: { type: String, - required: true, + required: false, + default: '', }, issueType: { type: String, diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue index 358b53bd1317819bb0242799bf47ce03fc87e3c8..120034b8d67cb249c7d67f9fd265cc2be86d5c41 100644 --- a/app/assets/javascripts/issues/show/components/edit_actions.vue +++ b/app/assets/javascripts/issues/show/components/edit_actions.vue @@ -1,12 +1,10 @@ <script> -import { GlButton, GlModalDirective } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; -import { __, sprintf } from '~/locale'; +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; import Tracking from '~/tracking'; import eventHub from '../event_hub'; import updateMixin from '../mixins/update'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; -import DeleteIssueModal from './delete_issue_modal.vue'; const issuableTypes = { issue: __('Issue'), @@ -18,18 +16,10 @@ const trackingMixin = Tracking.mixin({ label: 'delete_issue' }); export default { components: { - DeleteIssueModal, GlButton, }, - directives: { - GlModal: GlModalDirective, - }, mixins: [trackingMixin, updateMixin], props: { - canDestroy: { - type: Boolean, - required: true, - }, endpoint: { required: true, type: String, @@ -38,11 +28,6 @@ export default { type: Object, required: true, }, - showDeleteButton: { - type: Boolean, - required: false, - default: true, - }, issuableType: { type: String, required: true, @@ -53,7 +38,6 @@ export default { deleteLoading: false, skipApollo: false, issueState: {}, - modalId: uniqueId('delete-issuable-modal-'), }; }, apollo: { @@ -68,17 +52,9 @@ export default { }, }, computed: { - deleteIssuableButtonText() { - return sprintf(__('Delete %{issuableType}'), { - issuableType: this.typeToShow.toLowerCase(), - }); - }, isSubmitEnabled() { return this.formState.title.trim() !== ''; }, - shouldShowDeleteButton() { - return this.canDestroy && this.showDeleteButton && this.typeToShow; - }, typeToShow() { const { issueState, issuableType } = this; const type = issueState.issueType ?? issuableType; @@ -89,52 +65,26 @@ export default { closeForm() { eventHub.$emit('close.form'); }, - deleteIssuable() { - this.deleteLoading = true; - eventHub.$emit('delete.issuable'); - }, }, }; </script> <template> - <div class="gl-mt-3 gl-mb-3 gl-display-flex gl-justify-content-space-between"> - <div> - <gl-button - :loading="formState.updateLoading" - :disabled="formState.updateLoading || !isSubmitEnabled" - category="primary" - variant="confirm" - class="gl-mr-3" - data-testid="issuable-save-button" - type="submit" - @click.prevent="updateIssuable" - > - {{ __('Save changes') }} - </gl-button> - <gl-button data-testid="issuable-cancel-button" @click="closeForm"> - {{ __('Cancel') }} - </gl-button> - </div> - <div v-if="shouldShowDeleteButton"> - <gl-button - v-gl-modal="modalId" - :loading="deleteLoading" - :disabled="deleteLoading" - category="secondary" - variant="danger" - data-testid="issuable-delete-button" - @click="track('click_button')" - > - {{ deleteIssuableButtonText }} - </gl-button> - <delete-issue-modal - :issue-path="endpoint" - :issue-type="typeToShow" - :modal-id="modalId" - :title="deleteIssuableButtonText" - @delete="deleteIssuable" - /> - </div> + <div class="gl-mt-3 gl-mb-3 gl-display-flex"> + <gl-button + :loading="formState.updateLoading" + :disabled="formState.updateLoading || !isSubmitEnabled" + category="primary" + variant="confirm" + class="gl-mr-3" + data-testid="issuable-save-button" + type="submit" + @click.prevent="updateIssuable" + > + {{ __('Save changes') }} + </gl-button> + <gl-button data-testid="issuable-cancel-button" @click="closeForm"> + {{ __('Cancel') }} + </gl-button> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index e2c12edf46d277a1d809d78d894a2b0859c689ea..f479c8ae78d224d10df937216efa582dc3a9825a 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -22,10 +22,6 @@ export default { LockedWarning, }, props: { - canDestroy: { - type: Boolean, - required: true, - }, endpoint: { type: String, required: true, @@ -63,11 +59,6 @@ export default { type: String, required: true, }, - showDeleteButton: { - type: Boolean, - required: false, - default: true, - }, canAttachFile: { type: Boolean, required: false, @@ -231,12 +222,6 @@ export default { :enable-autocomplete="enableAutocomplete" /> - <edit-actions - :endpoint="endpoint" - :form-state="formState" - :can-destroy="canDestroy" - :show-delete-button="showDeleteButton" - :issuable-type="issuableType" - /> + <edit-actions :endpoint="endpoint" :form-state="formState" :issuable-type="issuableType" /> </form> </template> diff --git a/ee/app/assets/javascripts/epic/components/epic_header.vue b/ee/app/assets/javascripts/epic/components/epic_header.vue index 83f31abd02d6d87d33b1b91ab40712bd75381d52..e3f4961081015518d6fa652c9f22d12a42af7432 100644 --- a/ee/app/assets/javascripts/epic/components/epic_header.vue +++ b/ee/app/assets/javascripts/epic/components/epic_header.vue @@ -1,5 +1,14 @@ <script> -import { GlButton, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { + GlButton, + GlBadge, + GlIcon, + GlTooltipDirective, + GlModalDirective, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, +} from '@gitlab/ui'; import { mapState, mapGetters, mapActions } from 'vuex'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; @@ -8,6 +17,7 @@ import { __ } from '~/locale'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; +import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; import { IssuableType, WorkspaceType } from '~/issues/constants'; import { statusType } from '../constants'; @@ -16,19 +26,30 @@ import epicUtils from '../utils/epic_utils'; export default { WorkspaceType, IssuableType, + deleteModalId: 'delete-modal-id', directives: { GlTooltipDirective, + GlModal: GlModalDirective, }, components: { + DeleteIssueModal, GlIcon, GlBadge, GlButton, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, UserAvatarLink, TimeagoTooltip, ConfidentialityBadge, GitlabTeamMemberBadge: () => import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'), }, + i18n: { + deleteButtonText: __('Delete epic'), + dropdownText: __('Epic actions'), + newEpicText: __('New epic'), + }, computed: { ...mapState([ 'sidebarCollapsed', @@ -38,6 +59,7 @@ export default { 'created', 'canCreate', 'canUpdate', + 'canDestroy', 'confidential', 'newEpicWebUrl', ]), @@ -129,29 +151,75 @@ export default { class="detail-page-header-actions gl-display-flex gl-flex-wrap gl-align-items-center gl-w-full gl-sm-w-auto!" data-testid="action-buttons" > + <gl-dropdown + v-if="canUpdate || canCreate || canDestroy" + class="gl-sm-display-none! gl-mt-3 w-100" + block + :text="$options.i18n.dropdownText" + data-testid="mobile-dropdown" + > + <gl-dropdown-item v-if="canCreate" :href="newEpicWebUrl"> + {{ $options.i18n.newEpicText }} + </gl-dropdown-item> + <gl-dropdown-item v-if="canUpdate" @click="toggleEpicStatus(isEpicOpen)"> + {{ actionButtonText }} + </gl-dropdown-item> + <template v-if="canDestroy"> + <gl-dropdown-divider /> + <gl-dropdown-item + v-gl-modal="$options.deleteModalId" + variant="danger" + data-testid="delete-epic-button" + > + {{ $options.i18n.deleteButtonText }} + </gl-dropdown-item> + </template> + </gl-dropdown> + <gl-button v-if="canUpdate" :loading="epicStatusChangeInProgress" :class="actionButtonClass" category="secondary" variant="default" - class="gl-mt-3 gl-sm-mt-0! gl-w-full gl-sm-w-auto!" + class="gl-display-none gl-sm-display-inline-flex! gl-mt-3 gl-sm-mt-0! gl-w-full gl-sm-w-auto!" data-qa-selector="close_reopen_epic_button" data-testid="toggle-status-button" @click="toggleEpicStatus(isEpicOpen)" > {{ actionButtonText }} </gl-button> - <gl-button - v-if="canCreate" - :href="newEpicWebUrl" - category="secondary" - variant="confirm" - class="gl-mt-3 gl-sm-mt-0! gl-sm-ml-3 gl-w-full gl-sm-w-auto!" - data-testid="new-epic-button" + + <gl-dropdown + v-if="canCreate || canDestroy" + class="gl-display-none gl-sm-display-inline-flex! gl-ml-3" + icon="ellipsis_v" + category="tertiary" + :text="$options.i18n.dropdownText" + :text-sr-only="true" + no-caret + right + data-testid="desktop-dropdown" > - {{ __('New epic') }} - </gl-button> + <gl-dropdown-item v-if="canCreate" :href="newEpicWebUrl" data-testid="new-epic-button"> + {{ $options.i18n.newEpicText }} + </gl-dropdown-item> + <template v-if="canDestroy"> + <gl-dropdown-divider /> + <gl-dropdown-item + v-gl-modal="$options.deleteModalId" + variant="danger" + data-testid="delete-epic-button" + > + {{ $options.i18n.deleteButtonText }} + </gl-dropdown-item> + </template> + </gl-dropdown> </div> + <delete-issue-modal + :issue-type="$options.IssuableType.Epic" + :modal-id="$options.deleteModalId" + :title="$options.i18n.deleteButtonText" + /> </div> </template> diff --git a/ee/spec/features/epics/delete_epic_spec.rb b/ee/spec/features/epics/delete_epic_spec.rb index 2145de1f4909ac7e8bc1a7de161fddeee8a632be..35b1d9323148c41f37f67b29bcc9132b369b90b9 100644 --- a/ee/spec/features/epics/delete_epic_spec.rb +++ b/ee/spec/features/epics/delete_epic_spec.rb @@ -18,7 +18,7 @@ it 'does not show the Delete button' do visit group_epic_path(group, epic) - expect(page).not_to have_selector('.detail-page-header button') + expect(page).not_to have_css('[data-testid="desktop-dropdown"]') end end @@ -27,11 +27,11 @@ group.add_owner(user) visit group_epic_path(group, epic) wait_for_requests - find('.js-issuable-edit').click + find('[data-testid="desktop-dropdown"]').click end it 'deletes the issue and redirect to epic list' do - click_button 'Delete epic' + click_on 'Delete epic' wait_for_requests find('.js-modal-action-primary').click diff --git a/ee/spec/features/epics/update_epic_spec.rb b/ee/spec/features/epics/update_epic_spec.rb index ac9721dc8c254f2d7f02ea6ded1c44ee15b39501..cd5cdbeedb3ad848ea24583bc605d7043b25f325 100644 --- a/ee/spec/features/epics/update_epic_spec.rb +++ b/ee/spec/features/epics/update_epic_spec.rb @@ -192,21 +192,4 @@ it_behaves_like 'updates epic' end - - context 'when user with owner access displays the epic' do - let(:group) { create(:group, :public) } - let(:epic) { create(:epic, group: group, description: markdown) } - - before do - group.add_owner(user) - visit group_epic_path(group, epic) - wait_for_requests - end - - it 'shows delete button inside the edit form' do - find('.btn-edit').click - - expect(page).to have_selector('.issuable-details .btn-danger') - end - end end diff --git a/ee/spec/frontend/epic/components/epic_body_spec.js b/ee/spec/frontend/epic/components/epic_body_spec.js index 20eb6f2378f202b847122bbbfd202a995e8296f8..6ede4be84609d64a1916a85b2621c6129a8f91ab 100644 --- a/ee/spec/frontend/epic/components/epic_body_spec.js +++ b/ee/spec/frontend/epic/components/epic_body_spec.js @@ -31,9 +31,7 @@ describe('EpicBodyComponent', () => { endpoint: 'http://test.host', updateEndpoint: '/groups/frontend-fixtures-group/-/epics/1.json', canUpdate: true, - canDestroy: true, showInlineEditButton: true, - showDeleteButton: true, enableAutocomplete: true, zoomMeetingUrl: '', publishedIncidentUrl: '', diff --git a/ee/spec/frontend/epic/components/epic_header_spec.js b/ee/spec/frontend/epic/components/epic_header_spec.js index 63e027d81242ca21461a2e764fd109f63ee25f89..f885d06ae0e99555718670e8f18858e1fa056175 100644 --- a/ee/spec/frontend/epic/components/epic_header_spec.js +++ b/ee/spec/frontend/epic/components/epic_header_spec.js @@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import EpicHeader from 'ee/epic/components/epic_header.vue'; +import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; import { statusType } from 'ee/epic/constants'; import createStore from 'ee/epic/store'; import waitForPromises from 'helpers/wait_for_promises'; @@ -30,6 +31,9 @@ describe('EpicHeaderComponent', () => { wrapper = null; }); + const modalId = 'delete-modal-id'; + + const findModal = () => wrapper.findComponent(DeleteIssueModal); const findStatusBox = () => wrapper.find('[data-testid="status-box"]'); const findStatusIcon = () => wrapper.find('[data-testid="status-icon"]'); const findStatusText = () => wrapper.find('[data-testid="status-text"]'); @@ -38,6 +42,7 @@ describe('EpicHeaderComponent', () => { const findActionButtons = () => wrapper.find('[data-testid="action-buttons"]'); const findToggleStatusButton = () => wrapper.find('[data-testid="toggle-status-button"]'); const findNewEpicButton = () => wrapper.find('[data-testid="new-epic-button"]'); + const findDeleteEpicButton = () => wrapper.find('[data-testid="delete-epic-button"]'); const findSidebarToggle = () => wrapper.find('[data-testid="sidebar-toggle"]'); describe('computed', () => { @@ -177,5 +182,30 @@ describe('EpicHeaderComponent', () => { await nextTick(); expect(findNewEpicButton().exists()).toBe(true); }); + + it('does not render delete epic button if user cannot create it', async () => { + store.state.canDestroy = false; + + await nextTick(); + expect(findDeleteEpicButton().exists()).toBe(false); + }); + + it('renders delete epic button if user can create it', async () => { + store.state.canDestroy = true; + + await nextTick(); + expect(findDeleteEpicButton().exists()).toBe(true); + }); + + describe('delete issue modal', () => { + it('renders', () => { + expect(findModal().props()).toEqual({ + issuePath: '', + issueType: 'epic', + modalId, + title: 'Delete epic', + }); + }); + }); }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 37843b8bb12e0eeb72d15a5fb55df3f873971f59..5816584e38fac48e0d1036bdb3aaf95a1ce98fe3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -12505,6 +12505,9 @@ msgstr "" msgid "Delete deploy key" msgstr "" +msgid "Delete epic" +msgstr "" + msgid "Delete file" msgstr "" @@ -14989,6 +14992,9 @@ msgstr "" msgid "Epic Boards" msgstr "" +msgid "Epic actions" +msgstr "" + msgid "Epic cannot be found." msgstr "" diff --git a/spec/frontend/issues/show/components/edit_actions_spec.js b/spec/frontend/issues/show/components/edit_actions_spec.js index d58bf1be812a20d04a3cc3ee4e43cc7925edfddd..11c43ea4388f162f535bb14b34ed8290812c80fd 100644 --- a/spec/frontend/issues/show/components/edit_actions_spec.js +++ b/spec/frontend/issues/show/components/edit_actions_spec.js @@ -2,16 +2,9 @@ import { GlButton } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { mockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; import IssuableEditActions from '~/issues/show/components/edit_actions.vue'; -import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; import eventHub from '~/issues/show/event_hub'; -import { - getIssueStateQueryResponse, - updateIssueStateQueryResponse, -} from '../mock_data/apollo_mock'; describe('Edit Actions component', () => { let wrapper; @@ -31,8 +24,6 @@ describe('Edit Actions component', () => { }, }; - const modalId = 'delete-issuable-modal-1'; - const createComponent = ({ props, data } = {}) => { fakeApollo = createMockApollo([], mockResolvers); @@ -50,16 +41,13 @@ describe('Edit Actions component', () => { data() { return { issueState: {}, - modalId, ...data, }; }, }); }; - const findModal = () => wrapper.findComponent(DeleteIssueModal); const findEditButtons = () => wrapper.findAllComponents(GlButton); - const findDeleteButton = () => wrapper.findByTestId('issuable-delete-button'); const findSaveButton = () => wrapper.findByTestId('issuable-save-button'); const findCancelButton = () => wrapper.findByTestId('issuable-cancel-button'); @@ -79,23 +67,12 @@ describe('Edit Actions component', () => { }); }); - it('does not render the delete button if canDestroy is false', () => { - createComponent({ props: { canDestroy: false } }); - expect(findDeleteButton().exists()).toBe(false); - }); - it('disables save button when title is blank', () => { createComponent({ props: { formState: { title: '', issue_type: '' } } }); expect(findSaveButton().attributes('disabled')).toBe('true'); }); - it('does not render the delete button if showDeleteButton is false', () => { - createComponent({ props: { showDeleteButton: false } }); - - expect(findDeleteButton().exists()).toBe(false); - }); - describe('updateIssuable', () => { beforeEach(() => { jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); @@ -119,63 +96,4 @@ describe('Edit Actions component', () => { expect(eventHub.$emit).toHaveBeenCalledWith('close.form'); }); }); - - describe('delete issue button', () => { - let trackingSpy; - - beforeEach(() => { - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - }); - - it('tracks clicking on button', () => { - findDeleteButton().vm.$emit('click'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_button', { - label: 'delete_issue', - }); - }); - }); - - describe('delete issue modal', () => { - it('renders', () => { - expect(findModal().props()).toEqual({ - issuePath: 'gitlab-org/gitlab-test/-/issues/1', - issueType: 'Issue', - modalId, - title: 'Delete issue', - }); - }); - }); - - describe('deleteIssuable', () => { - beforeEach(() => { - jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); - }); - - it('does not send the `delete.issuable` event when clicking delete button', () => { - findDeleteButton().vm.$emit('click'); - expect(eventHub.$emit).not.toHaveBeenCalled(); - }); - - it('sends the `delete.issuable` event when clicking the delete confirm button', async () => { - expect(eventHub.$emit).toHaveBeenCalledTimes(0); - findModal().vm.$emit('delete'); - expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable'); - expect(eventHub.$emit).toHaveBeenCalledTimes(1); - }); - }); - - describe('with Apollo cache mock', () => { - it('renders the right delete button text per apollo cache type', async () => { - mockIssueStateData.mockResolvedValue(getIssueStateQueryResponse); - await waitForPromises(); - expect(findDeleteButton().text()).toBe('Delete issue'); - }); - - it('should not change the delete button text per apollo cache mutation', async () => { - mockIssueStateData.mockResolvedValue(updateIssueStateQueryResponse); - await waitForPromises(); - expect(findDeleteButton().text()).toBe('Delete issue'); - }); - }); });