diff --git a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue index c13bf50eba735e526e961da09c30aaa17495d88f..1dca961b9045658444b19bb44d441c90076120d2 100644 --- a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue +++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue @@ -13,6 +13,11 @@ export default { GlSprintf, }, props: { + visible: { + type: Boolean, + default: false, + required: false, + }, issueCount: { type: Number, required: true, @@ -98,8 +103,14 @@ Once deleted, it cannot be undone or recovered.`), }); } throw error; + }) + .finally(() => { + this.onClose(); }); }, + onClose() { + this.$emit('deleteModalVisible', false); + }, }, primaryProps: { text: s__('Milestones|Delete milestone'), @@ -113,11 +124,13 @@ Once deleted, it cannot be undone or recovered.`), <template> <gl-modal + :visible="visible" modal-id="delete-milestone-modal" :title="title" :action-primary="$options.primaryProps" :action-cancel="$options.cancelProps" @primary="onSubmit" + @hide="onClose" > <gl-sprintf :message="text"> <template #milestoneTitle> diff --git a/app/assets/javascripts/milestones/components/more_actions_dropdown.vue b/app/assets/javascripts/milestones/components/more_actions_dropdown.vue new file mode 100644 index 0000000000000000000000000000000000000000..41000048fa230533cf4167e640bd6bb87cdfe261 --- /dev/null +++ b/app/assets/javascripts/milestones/components/more_actions_dropdown.vue @@ -0,0 +1,237 @@ +<script> +import { + GlButton, + GlIcon, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + GlDisclosureDropdown, + GlTooltipDirective, +} from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; +import PromoteMilestoneModal from '~/milestones/components/promote_milestone_modal.vue'; +import DeleteMilestoneModal from '~/milestones/components/delete_milestone_modal.vue'; + +export default { + components: { + GlButton, + GlIcon, + GlDisclosureDropdownItem, + GlDisclosureDropdownGroup, + GlDisclosureDropdown, + PromoteMilestoneModal, + DeleteMilestoneModal, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: [ + 'id', + 'title', + 'isActive', + 'showDelete', + 'isDetailPage', + 'canReadMilestone', + 'milestoneUrl', + 'editUrl', + 'closeUrl', + 'reopenUrl', + 'promoteUrl', + 'groupName', + 'issueCount', + 'mergeRequestCount', + ], + data() { + return { + isDropdownVisible: false, + isPromotionModalVisible: false, + isDeleteModalVisible: false, + isPromoteModalVisible: false, + }; + }, + computed: { + hasUrl() { + return this.editUrl || this.closeUrl || this.reopenUrl || this.promoteUrl; + }, + copiedToClipboard() { + return this.$options.i18n.copiedToClipboard; + }, + editItem() { + return { + text: this.$options.i18n.edit, + href: this.editUrl, + extraAttrs: { + 'data-testid': 'milestone-edit-item', + }, + }; + }, + promoteItem() { + return { + text: this.$options.i18n.promote, + extraAttrs: { + 'data-testid': 'milestone-promote-item', + }, + }; + }, + closeItem() { + return { + text: this.$options.i18n.close, + href: this.closeUrl, + extraAttrs: { + class: { 'gl-sm-display-none!': this.isDetailPage }, + 'data-testid': 'milestone-close-item', + 'data-method': 'put', + rel: 'nofollow', + }, + }; + }, + reopenItem() { + return { + text: this.$options.i18n.reopen, + href: this.reopenUrl, + extraAttrs: { + class: { 'gl-sm-display-none!': this.isDetailPage }, + 'data-testid': 'milestone-reopen-item', + 'data-method': 'put', + rel: 'nofollow', + }, + }; + }, + deleteItem() { + return { + text: this.$options.i18n.delete, + extraAttrs: { + class: 'gl-text-red-500!', + 'data-testid': 'milestone-delete-item', + }, + }; + }, + copyIdItem() { + return { + text: sprintf(this.$options.i18n.copyTitle, { id: this.id }), + action: () => { + this.$toast.show(this.copiedToClipboard); + }, + extraAttrs: { + 'data-testid': 'copy-milestone-id', + itemprop: 'identifier', + }, + }; + }, + showDropdownTooltip() { + return !this.isDropdownVisible ? this.$options.i18n.actionsLabel : ''; + }, + showTestIdIfNotDetailPage() { + return !this.isDetailPage ? 'milestone-more-actions-dropdown-toggle' : false; + }, + }, + methods: { + showDropdown() { + this.isDropdownVisible = true; + }, + hideDropdown() { + this.isDropdownVisible = false; + }, + setDeleteModalVisibility(visibility = false) { + this.isDeleteModalVisible = visibility; + }, + setPromoteModalVisibility(visibility = false) { + this.isPromoteModalVisible = visibility; + }, + }, + primaryAction: { + text: s__('Milestones|Promote Milestone'), + attributes: { variant: 'confirm' }, + }, + cancelAction: { + text: __('Cancel'), + attributes: {}, + }, + i18n: { + actionsLabel: s__('Milestone|Milestone actions'), + close: __('Close'), + delete: __('Delete'), + edit: __('Edit'), + promote: __('Promote'), + reopen: __('Reopen'), + copyTitle: s__('Milestone|Copy milestone ID: %{id}'), + copiedToClipboard: s__('Milestone|Milestone ID copied to clipboard.'), + }, +}; +</script> + +<template> + <gl-disclosure-dropdown + v-gl-tooltip="showDropdownTooltip" + category="tertiary" + icon="ellipsis_v" + placement="bottom-end" + block + no-caret + :toggle-text="$options.i18n.actionsLabel" + text-sr-only + class="gl-relative gl-w-full gl-sm-w-auto gl-min-w-7" + :data-testid="showTestIdIfNotDetailPage" + @shown="showDropdown" + @hidden="hideDropdown" + > + <template v-if="isDetailPage" #toggle> + <div class="gl-min-h-7"> + <gl-button + class="gl-md-display-none! gl-new-dropdown-toggle gl-absolute gl-top-0 gl-left-0 gl-w-full gl-sm-w-auto" + button-text-classes="gl-w-full" + category="secondary" + :aria-label="$options.i18n.actionsLabel" + :title="$options.i18n.actionsLabel" + > + <span class="gl-new-dropdown-button-text">{{ $options.i18n.actionsLabel }}</span> + <gl-icon class="dropdown-chevron" name="chevron-down" /> + </gl-button> + <gl-button + class="gl-display-none gl-md-display-flex! gl-new-dropdown-toggle gl-new-dropdown-icon-only gl-new-dropdown-toggle-no-caret" + category="tertiary" + icon="ellipsis_v" + :aria-label="$options.i18n.actionsLabel" + :title="$options.i18n.actionsLabel" + data-testid="milestone-more-actions-dropdown-toggle" + /> + </div> + </template> + + <gl-disclosure-dropdown-item v-if="isActive" :item="closeItem" /> + <gl-disclosure-dropdown-item v-else :item="reopenItem" /> + + <gl-disclosure-dropdown-item v-if="editUrl" :item="editItem" /> + + <gl-disclosure-dropdown-item + v-if="promoteUrl" + :item="promoteItem" + @action="setPromoteModalVisibility(true)" + /> + + <gl-disclosure-dropdown-group v-if="canReadMilestone" bordered class="gl-border-t-gray-200!"> + <gl-disclosure-dropdown-item :item="copyIdItem" :data-clipboard-text="id" /> + </gl-disclosure-dropdown-group> + + <gl-disclosure-dropdown-group v-if="showDelete" bordered class="gl-border-t-gray-200!"> + <gl-disclosure-dropdown-item :item="deleteItem" @action="setDeleteModalVisibility(true)" /> + </gl-disclosure-dropdown-group> + + <promote-milestone-modal + :visible="isPromoteModalVisible" + :milestone-title="title" + :promote-url="promoteUrl" + :group-name="groupName" + @promotionModalVisible="setPromoteModalVisibility" + /> + + <delete-milestone-modal + :visible="isDeleteModalVisible" + :issue-count="issueCount" + :merge-request-count="mergeRequestCount" + :milestone-id="id" + :milestone-title="title" + :milestone-url="milestoneUrl" + @deleteModalVisible="setDeleteModalVisibility" + /> + </gl-disclosure-dropdown> +</template> diff --git a/app/assets/javascripts/milestones/components/promote_milestone_modal.vue b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue index 63791dcd011bc9e6361cceab582b72ef35a073f5..3ab63307fe209717d21f48ce3db2587f4b386fd7 100644 --- a/app/assets/javascripts/milestones/components/promote_milestone_modal.vue +++ b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue @@ -9,14 +9,24 @@ export default { components: { GlModal, }, - data() { - return { - milestoneTitle: '', - url: '', - groupName: '', - currentButton: null, - visible: false, - }; + props: { + visible: { + type: Boolean, + default: false, + required: false, + }, + milestoneTitle: { + type: String, + required: true, + }, + promoteUrl: { + type: String, + required: true, + }, + groupName: { + type: String, + required: true, + }, }, computed: { title() { @@ -32,33 +42,10 @@ export default { ); }, }, - mounted() { - this.getButtons().forEach((button) => { - button.addEventListener('click', this.onPromoteButtonClick); - button.removeAttribute('disabled'); - }); - }, - beforeDestroy() { - this.getButtons().forEach((button) => { - button.removeEventListener('click', this.onPromoteButtonClick); - }); - }, methods: { - onPromoteButtonClick({ currentTarget }) { - const { milestoneTitle, url, groupName } = currentTarget.dataset; - currentTarget.setAttribute('disabled', ''); - this.visible = true; - this.milestoneTitle = milestoneTitle; - this.url = url; - this.groupName = groupName; - this.currentButton = currentTarget; - }, - getButtons() { - return document.querySelectorAll('.js-promote-project-milestone-button'); - }, onSubmit() { return axios - .post(this.url, { params: { format: 'json' } }) + .post(this.promoteUrl, { params: { format: 'json' } }) .then((response) => { visitUrl(response.data.url); }) @@ -68,14 +55,11 @@ export default { }); }) .finally(() => { - this.visible = false; + this.onClose(); }); }, onClose() { - this.visible = false; - if (this.currentButton) { - this.currentButton.removeAttribute('disabled'); - } + this.$emit('promotionModalVisible', false); }, }, primaryAction: { @@ -92,9 +76,9 @@ export default { <gl-modal :visible="visible" modal-id="promote-milestone-modal" + :title="title" :action-primary="$options.primaryAction" :action-cancel="$options.cancelAction" - :title="title" @primary="onSubmit" @hide="onClose" > diff --git a/app/assets/javascripts/milestones/index.js b/app/assets/javascripts/milestones/index.js index 403db0865f0fe09b67d324b48990a0751513c2e1..af5b42af710e7d2c4e835598752860a7730c1aaa 100644 --- a/app/assets/javascripts/milestones/index.js +++ b/app/assets/javascripts/milestones/index.js @@ -1,20 +1,14 @@ -import Vue from 'vue'; import initDatePicker from '~/behaviors/date_picker'; -import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import Milestone from '~/milestones/milestone'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; import Sidebar from '~/right_sidebar'; import MountMilestoneSidebar from '~/sidebar/mount_milestone_sidebar'; -import Translate from '~/vue_shared/translate'; import ZenMode from '~/zen_mode'; import TaskList from '~/task_list'; import { TYPE_MILESTONE } from '~/issues/constants'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; -import DeleteMilestoneModal from './components/delete_milestone_modal.vue'; -import PromoteMilestoneModal from './components/promote_milestone_modal.vue'; -import eventHub from './event_hub'; // See app/views/shared/milestones/_description.html.haml export const MILESTONE_DESCRIPTION_ELEMENT = '.milestone-detail .description'; @@ -54,88 +48,3 @@ export function initShow() { }, }); } - -export function initPromoteMilestoneModal() { - Vue.use(Translate); - - const promoteMilestoneModal = document.getElementById('promote-milestone-modal'); - if (!promoteMilestoneModal) { - return null; - } - - return new Vue({ - el: promoteMilestoneModal, - name: 'PromoteMilestoneModalRoot', - render(createElement) { - return createElement(PromoteMilestoneModal); - }, - }); -} - -export function initDeleteMilestoneModal() { - Vue.use(Translate); - - const onRequestFinished = ({ milestoneUrl, successful }) => { - const button = document.querySelector( - `.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`, - ); - - if (!successful) { - button.removeAttribute('disabled'); - } - }; - - const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button'); - - const onRequestStarted = (milestoneUrl) => { - const button = document.querySelector( - `.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`, - ); - button.setAttribute('disabled', ''); - eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished); - }; - - return new Vue({ - el: '#js-delete-milestone-modal', - name: 'DeleteMilestoneModalRoot', - data() { - return { - modalProps: { - milestoneId: -1, - milestoneTitle: '', - milestoneUrl: '', - issueCount: -1, - mergeRequestCount: -1, - }, - }; - }, - mounted() { - eventHub.$on('deleteMilestoneModal.props', this.setModalProps); - deleteMilestoneButtons.forEach((button) => { - button.removeAttribute('disabled'); - button.addEventListener('click', () => { - this.$root.$emit(BV_SHOW_MODAL, 'delete-milestone-modal'); - eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted); - - this.setModalProps({ - milestoneId: parseInt(button.dataset.milestoneId, 10), - milestoneTitle: button.dataset.milestoneTitle, - milestoneUrl: button.dataset.milestoneUrl, - issueCount: parseInt(button.dataset.milestoneIssueCount, 10), - mergeRequestCount: parseInt(button.dataset.milestoneMergeRequestCount, 10), - }); - }); - }); - }, - methods: { - setModalProps(modalProps) { - this.modalProps = modalProps; - }, - }, - render(createElement) { - return createElement(DeleteMilestoneModal, { - props: this.modalProps, - }); - }, - }); -} diff --git a/app/assets/javascripts/milestones/init_more_actions_dropdown.js b/app/assets/javascripts/milestones/init_more_actions_dropdown.js new file mode 100644 index 0000000000000000000000000000000000000000..ce3ac6544f83b1afb62a14ba0618392fabe76c90 --- /dev/null +++ b/app/assets/javascripts/milestones/init_more_actions_dropdown.js @@ -0,0 +1,52 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import MoreActionsDropdown from '~/milestones/components/more_actions_dropdown.vue'; + +export default function InitMoreActionsDropdown() { + const containers = document.querySelectorAll('.js-vue-milestone-actions'); + + if (!containers.length) { + return false; + } + + return containers.forEach((el) => { + const { + id, + title, + isActive, + showDelete, + isDetailPage, + canReadMilestone, + milestoneUrl, + editUrl, + closeUrl, + reopenUrl, + promoteUrl, + groupName, + issueCount, + mergeRequestCount, + } = el.dataset; + + return new Vue({ + el, + name: 'MoreActionsDropdownRoot', + provide: { + id: Number(id), + title, + isActive: parseBoolean(isActive), + showDelete: parseBoolean(showDelete), + isDetailPage: parseBoolean(isDetailPage), + canReadMilestone: parseBoolean(canReadMilestone), + milestoneUrl, + editUrl, + closeUrl, + reopenUrl, + promoteUrl, + groupName, + issueCount: Number(issueCount), + mergeRequestCount: Number(mergeRequestCount), + }, + render: (createElement) => createElement(MoreActionsDropdown), + }); + }); +} diff --git a/app/assets/javascripts/pages/dashboard/milestones/index/index.js b/app/assets/javascripts/pages/dashboard/milestones/index/index.js index 88061d9ca22229b4a314f985fe8bd7a513f53901..730febb38de6c730d0108f959a295cff94e2e193 100644 --- a/app/assets/javascripts/pages/dashboard/milestones/index/index.js +++ b/app/assets/javascripts/pages/dashboard/milestones/index/index.js @@ -1,6 +1,7 @@ import { initNewResourceDropdown } from '~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown'; import { RESOURCE_TYPE_MILESTONE } from '~/vue_shared/components/new_resource_dropdown/constants'; import searchUserGroupsAndProjects from '~/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql'; +import InitMoreActionsDropdown from '~/milestones/init_more_actions_dropdown'; initNewResourceDropdown({ resourceType: RESOURCE_TYPE_MILESTONE, @@ -10,3 +11,4 @@ initNewResourceDropdown({ ...(data?.projects?.nodes ?? []), ], }); +InitMoreActionsDropdown(); diff --git a/app/assets/javascripts/pages/groups/milestones/index/index.js b/app/assets/javascripts/pages/groups/milestones/index/index.js index 01cf830b782448a36462ea9e14079ec485345487..b3b3d7922056e296f0390d7fc8b54e93a908441e 100644 --- a/app/assets/javascripts/pages/groups/milestones/index/index.js +++ b/app/assets/javascripts/pages/groups/milestones/index/index.js @@ -1,3 +1,3 @@ -import { initDeleteMilestoneModal } from '~/milestones'; +import InitMoreActionsDropdown from '~/milestones/init_more_actions_dropdown'; -initDeleteMilestoneModal(); +InitMoreActionsDropdown(); diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js index f2ab5d7837479ba31a4591f63e179177f7740216..502155dc758acd21a3c3952db714dd8998a6cca4 100644 --- a/app/assets/javascripts/pages/groups/milestones/show/index.js +++ b/app/assets/javascripts/pages/groups/milestones/show/index.js @@ -1,4 +1,5 @@ -import { initDeleteMilestoneModal, initShow } from '~/milestones'; +import { initShow } from '~/milestones'; +import InitMoreActionsDropdown from '~/milestones/init_more_actions_dropdown'; initShow(); -initDeleteMilestoneModal(); +InitMoreActionsDropdown(); diff --git a/app/assets/javascripts/pages/projects/milestones/index/index.js b/app/assets/javascripts/pages/projects/milestones/index/index.js index ef1c9ab83dbe153c6bd01ae858bee808ac3b8e63..b3b3d7922056e296f0390d7fc8b54e93a908441e 100644 --- a/app/assets/javascripts/pages/projects/milestones/index/index.js +++ b/app/assets/javascripts/pages/projects/milestones/index/index.js @@ -1,4 +1,3 @@ -import { initDeleteMilestoneModal, initPromoteMilestoneModal } from '~/milestones'; +import InitMoreActionsDropdown from '~/milestones/init_more_actions_dropdown'; -initDeleteMilestoneModal(); -initPromoteMilestoneModal(); +InitMoreActionsDropdown(); diff --git a/app/assets/javascripts/pages/projects/milestones/show/index.js b/app/assets/javascripts/pages/projects/milestones/show/index.js index 16aac7748da82b83017bf47fef561456c8b52118..502155dc758acd21a3c3952db714dd8998a6cca4 100644 --- a/app/assets/javascripts/pages/projects/milestones/show/index.js +++ b/app/assets/javascripts/pages/projects/milestones/show/index.js @@ -1,5 +1,5 @@ -import { initDeleteMilestoneModal, initPromoteMilestoneModal, initShow } from '~/milestones'; +import { initShow } from '~/milestones'; +import InitMoreActionsDropdown from '~/milestones/init_more_actions_dropdown'; initShow(); -initDeleteMilestoneModal(); -initPromoteMilestoneModal(); +InitMoreActionsDropdown(); diff --git a/app/views/shared/milestones/_delete_button.html.haml b/app/views/shared/milestones/_delete_button.html.haml deleted file mode 100644 index 65920a5453f0e01e0fec37589a157d419e510416..0000000000000000000000000000000000000000 --- a/app/views/shared/milestones/_delete_button.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- milestone_url = Gitlab::UrlBuilder.build(milestone, only_path: true) - -= render Pajamas::ButtonComponent.new(category: :tertiary, button_options: { class: 'menu-item js-delete-milestone-button', data: { milestone_id: milestone.id, milestone_title: markdown_field(milestone, :title), milestone_url: milestone_url, milestone_issue_count: milestone.total_issues_count, milestone_merge_request_count: milestone.total_merge_requests_count }, disabled: true }) do - .gl-dropdown-item-text-wrapper.gl-text-red-500 - = _('Delete') -#js-delete-milestone-modal diff --git a/app/views/shared/milestones/_description.html.haml b/app/views/shared/milestones/_description.html.haml index ff1224f89d0cd129a0f23b8496e8db433a560719..e1180d37f74a2257fb11c583e1dd932a95e2e23f 100644 --- a/app/views/shared/milestones/_description.html.haml +++ b/app/views/shared/milestones/_description.html.haml @@ -1,11 +1,6 @@ .detail-page-description.milestone-detail.gl-py-4 %h2.gl-m-0{ data: { testid: "milestone-title-content" } } = markdown_field(milestone, :title) - .gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ itemprop: 'identifier' } - - if can?(current_user, :read_milestone, @milestone) - %span.gl-display-inline-block.gl-vertical-align-middle - = s_('MilestonePage|Milestone ID: %{milestone_id}') % { milestone_id: @milestone.id } - = clipboard_button(title: s_('MilestonePage|Copy milestone ID'), text: @milestone.id) - if milestone.try(:description).present? %div{ data: { testid: "milestone-description-content" } } diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml index 3eae1bcd08079c190e9ad9728730fa225c1b0fab..856e9f10d99047595a8ae81301eee74b6d5f12cf 100644 --- a/app/views/shared/milestones/_header.html.haml +++ b/app/views/shared/milestones/_header.html.haml @@ -11,7 +11,10 @@ = render Pajamas::ButtonComponent.new(icon: 'chevron-double-lg-left', button_options: { 'aria-label' => _('Toggle sidebar'), class: 'btn-grouped !gl-float-right gl-sm-display-none js-sidebar-toggle' }) - if can?(current_user, :admin_milestone, @group || @project) - .milestone-buttons.detail-page-header-actions.gl-display-flex.gl-align-self-start + - can_promote = @project && can_admin_group_milestones? && milestone.project + - can_read_milestone = can?(current_user, :read_milestone, @milestone) + + .milestone-buttons.detail-page-header-actions.gl-display-flex.gl-align-self-start.gl-gap-3 - if milestone.active? = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :close }), method: :put, button_options: { class: 'btn-close gl-display-none gl-md-display-inline-block' }) do = _('Close milestone') @@ -19,38 +22,18 @@ = render Pajamas::ButtonComponent.new(href: update_milestone_path(milestone, { state_event: :activate }), method: :put, button_options: { class: 'gl-display-none gl-md-display-inline-block' }) do = _('Reopen milestone') - .gl-md-ml-3.gl-display-flex.dropdown.gl-dropdown.gl-md-w-auto.gl-w-full - = render Pajamas::ButtonComponent.new(category: :tertiary, icon: 'ellipsis_v', button_options: { class: 'has-tooltip gl-display-none! gl-md-display-inline-flex!', 'aria-label': _('Milestone actions'), data: { toggle: 'dropdown', title: _('Milestone actions'), testid: 'milestone-actions' } }) - = render Pajamas::ButtonComponent.new(button_options: { class: 'btn-block gl-md-display-none!', data: { toggle: 'dropdown' } }) do - = _('Milestone actions') - = sprite_icon "chevron-down", size: 16, css_class: "dropdown-icon gl-icon" - .dropdown-menu.dropdown-menu-right - .gl-dropdown-inner - .gl-dropdown-contents - %ul - %li.gl-dropdown-item - = link_to edit_milestone_path(milestone), class: 'menu-item' do - .gl-dropdown-item-text-wrapper - = _('Edit') - - if milestone.project_milestone? && milestone.project.group - %li.gl-dropdown-item - %button.js-promote-project-milestone-button{ data: { milestone_title: milestone.title, - group_name: milestone.project.group.name, - url: promote_project_milestone_path(milestone.project, milestone)}, - disabled: true, - type: 'button' } - .gl-dropdown-item-text-wrapper - = _('Promote') - #promote-milestone-modal - - if milestone.active? - %li.gl-dropdown-item{ class: "gl-md-display-none!" } - = link_to update_milestone_path(milestone, { state_event: :close }), method: :put, class: 'menu-item' do - .gl-dropdown-item-text-wrapper - = _('Close milestone') - - else - %li.gl-dropdown-item{ class: "gl-md-display-none!" } - = link_to update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'menu-item' do - .gl-dropdown-item-text-wrapper - = _('Reopen milestone') - %li.gl-dropdown-item - = render 'shared/milestones/delete_button', milestone: @milestone + .js-vue-milestone-actions{ data: { id: @milestone.id, + title: milestone.title, + is_active: milestone.active?.to_s, + show_delete: 'true', + is_detail_page: 'true', + can_read_milestone: can_read_milestone.to_s, + milestone_url: Gitlab::UrlBuilder.build(milestone, only_path: true), + edit_url: edit_milestone_path(milestone), + close_url: update_milestone_path(milestone, { state_event: :close }), + reopen_url: update_milestone_path(milestone, { state_event: :activate }), + promote_url: can_promote ? promote_project_milestone_path(milestone.project, milestone) : '', + group_name: can_promote && milestone.project_milestone? && milestone.project.group ? milestone.project.group.name : '', + issue_count: @milestone.issues.count, + merge_request_count: @milestone.merge_requests.count + } } diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index a25efadee5c6643aa7fedffdca48e3b8d03eabd8..c059b49b6c77dd6b7a52b57dfd39cbe8e34dbc97 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -48,39 +48,19 @@ .float-lg-right.light = format(s_('Milestone|%{percentage}%{percent} complete'), percentage: milestone.percent_complete, percent: '%') - if can_admin_milestone + - show_delete = @project.present? || @group.present? .col-1.order-2.order-md-3 - .milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end - = render Pajamas::ButtonComponent.new(category: :tertiary, - size: :small, - icon: 'ellipsis_v', - button_options: { class: 'gl-ml-3 has-tooltip', 'aria_label': _('Milestone actions'), title: _('Milestone actions'), data: { toggle: 'dropdown' } }) - .dropdown-menu.dropdown-menu-right - .gl-dropdown-inner - .gl-dropdown-contents - %ul - %li.gl-dropdown-item - - if milestone.closed? - = render Pajamas::ButtonComponent.new(category: :tertiary, - href: milestone_path(milestone, milestone: { state_event: :activate }), - method: :put, - variant: :link) do - = s_('Milestones|Reopen') - - else - = render Pajamas::ButtonComponent.new(category: :tertiary, - href: milestone_path(milestone, milestone: { state_event: :close }), - method: :put, - variant: :link) do - = s_('Milestones|Close') - %li.gl-dropdown-item - = render Pajamas::ButtonComponent.new(category: :tertiary, - href: edit_milestone_path(milestone), - variant: :link) do - = _('Edit') - - if can_promote - %li.gl-dropdown-item - = render Pajamas::ButtonComponent.new(category: :tertiary, variant: :link, - button_options: { class: 'js-promote-project-milestone-button', disabled: true, data: { toggle: 'tooltip', container: 'body', url: promote_project_milestone_path(milestone.project, milestone), milestone_title: milestone.title, group_name: @project.group.name } }) do - = s_('Promote') - - if @project || @group - %li.gl-dropdown-item - = render 'shared/milestones/delete_button', milestone: milestone + .gl-display-flex.gl-justify-content-end + .js-vue-milestone-actions{ data: { id: milestone.id, + title: milestone.title, + is_active: milestone.active?.to_s, + show_delete: show_delete.to_s, + milestone_url: Gitlab::UrlBuilder.build(milestone, only_path: true), + edit_url: edit_milestone_path(milestone), + close_url: milestone_path(milestone, milestone: { state_event: :close }), + reopen_url: milestone_path(milestone, milestone: { state_event: :activate }), + promote_url: can_promote ? promote_project_milestone_path(milestone.project, milestone) : '', + group_name: can_promote ? @project.group.name : '', + issue_count: milestone.issues.count, + merge_request_count: milestone.merge_requests.count + } } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d1f4bf53cdd5086b96151911d01eab3cd1ad6eb9..a26c309460e6a1f464b8317caed4591839f3cc4e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -32416,9 +32416,6 @@ msgid_plural "Milestones" msgstr[0] "" msgstr[1] "" -msgid "Milestone actions" -msgstr "" - msgid "Milestone due date" msgstr "" @@ -32443,12 +32440,6 @@ msgstr "" msgid "MilestoneCombobox|Select milestone" msgstr "" -msgid "MilestonePage|Copy milestone ID" -msgstr "" - -msgid "MilestonePage|Milestone ID: %{milestone_id}" -msgstr "" - msgid "MilestoneSidebar|Closed:" msgstr "" @@ -32515,9 +32506,6 @@ msgstr "" msgid "Milestones| You’re about to permanently delete the milestone %{milestoneTitle}. This milestone is not currently used in any issues or merge requests." msgstr "" -msgid "Milestones|Close" -msgstr "" - msgid "Milestones|Completed Issues (closed)" msgstr "" @@ -32557,9 +32545,6 @@ msgstr "" msgid "Milestones|Promoting %{milestoneTitle} will make it available for all projects inside %{groupName}. Existing project milestones with the same title will be merged." msgstr "" -msgid "Milestones|Reopen" -msgstr "" - msgid "Milestones|There are no closed milestones" msgstr "" @@ -32578,6 +32563,15 @@ msgstr "" msgid "Milestone|%{percentage}%{percent} complete" msgstr "" +msgid "Milestone|Copy milestone ID: %{id}" +msgstr "" + +msgid "Milestone|Milestone ID copied to clipboard." +msgstr "" + +msgid "Milestone|Milestone actions" +msgstr "" + msgid "Min Value" msgstr "" diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb index 3f03f62604d4584e6c0082685ccbf0c00d78671f..6dcb5a55e687e0eef8f1a3748f80ef7d122538d8 100644 --- a/spec/features/groups/milestone_spec.rb +++ b/spec/features/groups/milestone_spec.rb @@ -70,7 +70,7 @@ end end - context 'when milestones exists' do + context 'when milestones exists', :js do let_it_be(:other_project) { create(:project_empty_repo, group: group) } let_it_be(:active_project_milestone1) do @@ -116,12 +116,42 @@ end page.within('.detail-page-header') do + find_by_testid('milestone-more-actions-dropdown-toggle').click click_link('Edit') end expect(page).to have_selector('.milestone-form') end + it 'shows milestone id' do + page.within(".milestones #milestone_#{active_group_milestone.id}") do + click_link(active_group_milestone.title) + end + + page.within('.detail-page-header') do + find_by_testid('milestone-more-actions-dropdown-toggle').click + end + + expect(page).to have_selector('[data-testid="copy-milestone-id"]') + expect(page).to have_content("Copy milestone ID: #{active_group_milestone.id}") + end + + it 'delete a milestone' do + page.within(".milestones #milestone_#{active_group_milestone.id}") do + click_link(active_group_milestone.title) + end + + page.within('.detail-page-header') do + find_by_testid('milestone-more-actions-dropdown-toggle').click + click_button('Delete') + end + + click_button('Delete milestone') + + expect(page).to have_selector('.milestones') + expect(page).not_to have_selector(".milestones #milestone_#{active_group_milestone.id}") + end + it 'renders milestones' do expect(page).to have_content('v1.0') expect(page).to have_content('v1.1') diff --git a/spec/features/milestone_spec.rb b/spec/features/milestone_spec.rb index b9f46f9831e28bdafc8a625138a50dd6c592025a..620e966938ed1fd619b9b2af87e85314e64ff65d 100644 --- a/spec/features/milestone_spec.rb +++ b/spec/features/milestone_spec.rb @@ -105,18 +105,18 @@ end end - describe 'Deleting a milestone' do + describe 'Deleting a milestone', :js do it "the delete milestone button does not show for unauthorized users" do create(:milestone, project: project, title: 8.7) sign_out(user) visit group_milestones_path(group) - expect(page).to have_selector('.js-delete-milestone-button', count: 0) + expect(page).to have_selector('[data-testid="milestone-delete-item"]', count: 0) end end - describe 'reopen closed milestones' do + describe 'reopen closed milestones', :js do before do create(:milestone, :closed, project: project) end @@ -125,6 +125,7 @@ it 'reopens the milestone' do visit group_milestones_path(group, { state: 'closed' }) + find_by_testid('milestone-more-actions-dropdown-toggle').click click_link 'Reopen' expect(page).not_to have_selector('.badge-danger') @@ -132,10 +133,11 @@ end end - describe 'project milestones page' do + describe 'project milestones page', :js do it 'reopens the milestone' do visit project_milestones_path(project, { state: 'closed' }) + find_by_testid('milestone-more-actions-dropdown-toggle').click click_link 'Reopen' expect(page).not_to have_selector('.badge-danger') diff --git a/spec/features/milestones/user_promotes_milestone_spec.rb b/spec/features/milestones/user_promotes_milestone_spec.rb index 0eacf36cdde39b8ef226aa42fcfe6392aa30d12b..a0a5a90b11a8f51256f9baeb26cbbaaa3d056883 100644 --- a/spec/features/milestones/user_promotes_milestone_spec.rb +++ b/spec/features/milestones/user_promotes_milestone_spec.rb @@ -15,8 +15,10 @@ visit(project_milestones_path(project)) end - it "shows milestone promote button" do - expect(page).to have_selector('.js-promote-project-milestone-button') + it "shows milestone promote button", :js do + find_by_testid('milestone-more-actions-dropdown-toggle').click + + expect(page).to have_selector('[data-testid="milestone-promote-item"]') end end @@ -27,8 +29,10 @@ visit(project_milestones_path(project)) end - it "does not show milestone promote button" do - expect(page).not_to have_selector('.js-promote-project-milestone-button') + it "does not show milestone promote button", :js do + find_by_testid('milestone-more-actions-dropdown-toggle').click + + expect(page).not_to have_selector('[data-testid="milestone-promote-item"]') end end end diff --git a/spec/frontend/milestones/components/more_actions_dropdown_spec.js b/spec/frontend/milestones/components/more_actions_dropdown_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..592955126f1e5948723871716fa300da8836e42c --- /dev/null +++ b/spec/frontend/milestones/components/more_actions_dropdown_spec.js @@ -0,0 +1,293 @@ +import { GlDisclosureDropdownItem, GlDisclosureDropdown } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import moreActionsDropdown from '~/milestones/components/more_actions_dropdown.vue'; +import DeleteMilestoneModal from '~/milestones/components/delete_milestone_modal.vue'; +import PromoteMilestoneModal from '~/milestones/components/promote_milestone_modal.vue'; + +describe('moreActionsDropdown', () => { + let wrapper; + const defaultProvide = { + id: 1, + title: 'Milestone 1', + isActive: true, + showDelete: true, + canReadMilestone: true, + milestoneUrl: '/milestone-url', + editUrl: '/edit-url', + closeUrl: '/close-url', + reopenUrl: '/reopen-url', + promoteUrl: '/promote-url', + groupName: 'test-group', + issueCount: 1, + mergeRequestCount: 2, + isDetailPage: false, + }; + + const createComponent = ({ provideData = {}, propsData = {} } = {}) => { + wrapper = shallowMountExtended(moreActionsDropdown, { + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + provide: { + ...defaultProvide, + ...provideData, + }, + propsData, + stubs: { + GlDisclosureDropdownItem, + DeleteMilestoneModal, + PromoteMilestoneModal, + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const showDropdown = () => { + findDropdown().vm.$emit('show'); + }; + const findDropdownTooltip = () => getBinding(findDropdown().element, 'gl-tooltip'); + const findEditItem = () => wrapper.findByTestId('milestone-edit-item'); + const findPromoteItem = () => wrapper.findByTestId('milestone-promote-item'); + const findPromoteMilestoneModal = () => wrapper.findComponent(PromoteMilestoneModal); + const findCloseItem = () => wrapper.findByTestId('milestone-close-item'); + const findReopenItem = () => wrapper.findByTestId('milestone-reopen-item'); + const findDeleteItem = () => wrapper.findByTestId('milestone-delete-item'); + const findMilestoneIdItem = () => wrapper.findByTestId('copy-milestone-id'); + const findDeleteMilestoneModal = () => wrapper.findComponent(DeleteMilestoneModal); + + describe('dropdown group', () => { + it('renders tooltip', () => { + createComponent(); + + expect(findDropdownTooltip().value).toBe('Milestone actions'); + }); + }); + + describe('edit item', () => { + it('renders with correct value if `editUrl` is set', () => { + const provideData = { + editUrl: '/my-edit-url', + }; + + createComponent({ + provideData, + }); + + expect(findEditItem().attributes('href')).toBe(provideData.editUrl); + }); + + it('does not render if `editUrl` is false', () => { + createComponent({ + provideData: { + editUrl: '', + }, + }); + + expect(findEditItem().exists()).toBe(false); + }); + }); + + describe('promote item', () => { + const provideData = { + promoteUrl: '/my-promote-url', + groupName: 'promote-group', + title: 'Milestone to promote', + }; + + it('renders with correct values if `promoteUrl` is set', () => { + createComponent({ + provideData, + }); + + expect(findPromoteItem().exists()).toBe(true); + expect(findPromoteMilestoneModal().props()).toMatchObject({ + visible: false, + milestoneTitle: provideData.title, + promoteUrl: provideData.promoteUrl, + groupName: provideData.groupName, + }); + }); + + it('click on promote opens confirm modal with correct props', async () => { + createComponent({ + provideData, + }); + + expect(findPromoteMilestoneModal().props('visible')).toBe(false); + + findPromoteItem().trigger('click'); + await nextTick(); + + expect(findPromoteMilestoneModal().props()).toMatchObject({ + visible: true, + milestoneTitle: provideData.title, + promoteUrl: provideData.promoteUrl, + groupName: provideData.groupName, + }); + }); + + it('does not render if `promoteUrl` is false', () => { + createComponent({ + provideData: { + promoteUrl: '', + }, + }); + + expect(findPromoteItem().exists()).toBe(false); + }); + }); + + describe('close item', () => { + it('renders with correct values if `isActive` is set', () => { + const provideData = { + isActive: true, + closeUrl: '/my-close-url', + }; + + createComponent({ + provideData, + }); + + expect(findCloseItem().exists()).toBe(true); + expect(findReopenItem().exists()).toBe(false); + expect(findCloseItem().attributes('href')).toBe(provideData.closeUrl); + }); + + it('does not render if `isActive` is false', () => { + createComponent({ + provideData: { + isActive: false, + }, + }); + + expect(findCloseItem().exists()).toBe(false); + expect(findReopenItem().exists()).toBe(true); + }); + + it('has correct class if `isDetailPage` is true', () => { + createComponent({ + provideData: { + isDetailPage: true, + }, + }); + + expect(findCloseItem().attributes('class')).toContain('gl-sm-display-none!'); + }); + }); + + describe('reopen item', () => { + it('renders with correct values if `isActive` is set', () => { + const provideData = { + isActive: false, + reopenUrl: '/my-reopen-url', + }; + + createComponent({ + provideData, + }); + + expect(findReopenItem().exists()).toBe(true); + expect(findCloseItem().exists()).toBe(false); + expect(findReopenItem().attributes('href')).toBe(provideData.reopenUrl); + }); + + it('does not render if `isActive` is false', () => { + createComponent({ + provideData: { + isActive: true, + }, + }); + + expect(findReopenItem().exists()).toBe(false); + expect(findCloseItem().exists()).toBe(true); + }); + + it('has correct class if `isDetailPage` is true', () => { + createComponent({ + provideData: { + isActive: false, + isDetailPage: true, + }, + }); + + expect(findReopenItem().attributes('class')).toContain('gl-sm-display-none!'); + }); + }); + + describe('delete item', () => { + const provideData = { + issueCount: 1, + mergeRequestCount: 2, + milestoneId: 1, + milestoneTitle: 'Milestone 1', + milestoneUrl: '/milestone-url', + }; + + it('renders with correct values', () => { + createComponent(); + + expect(findDeleteItem().exists()).toBe(true); + expect(findDeleteMilestoneModal().props()).toMatchObject({ + visible: false, + issueCount: provideData.issueCount, + mergeRequestCount: provideData.mergeRequestCount, + milestoneId: provideData.milestoneId, + milestoneTitle: provideData.milestoneTitle, + milestoneUrl: provideData.milestoneUrl, + }); + }); + + it('click on delete opens confirm modal with correct props', async () => { + createComponent(); + + expect(findDeleteMilestoneModal().props('visible')).toBe(false); + + findDeleteItem().trigger('click'); + await nextTick(); + + expect(findDeleteMilestoneModal().props()).toMatchObject({ + visible: true, + issueCount: provideData.issueCount, + mergeRequestCount: provideData.mergeRequestCount, + milestoneId: provideData.milestoneId, + milestoneTitle: provideData.milestoneTitle, + milestoneUrl: provideData.milestoneUrl, + }); + }); + }); + + describe('copy milestone id item', () => { + it('renders copy milestone id with correct id', () => { + createComponent({ + provideData: { + id: 22, + }, + }); + + showDropdown(); + + expect(findMilestoneIdItem().text()).toBe('Copy milestone ID: 22'); + }); + + it('renders if `canReadMilestone` is true', () => { + createComponent({ + provideData: { + canReadMilestone: true, + }, + }); + expect(findMilestoneIdItem().exists()).toBe(true); + }); + + it('does not render if `canReadMilestone` is false', () => { + createComponent({ + provideData: { + canReadMilestone: false, + }, + }); + + expect(findMilestoneIdItem().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/milestones/components/promote_milestone_modal_spec.js b/spec/frontend/milestones/components/promote_milestone_modal_spec.js index e91e792afe827ec1b4bcf8ab3784d392922c55f3..f9130293d51afccad4f6d00720bde7c92c8bbaa7 100644 --- a/spec/frontend/milestones/components/promote_milestone_modal_spec.js +++ b/spec/frontend/milestones/components/promote_milestone_modal_spec.js @@ -1,6 +1,5 @@ import { GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { setHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; @@ -16,44 +15,29 @@ describe('Promote milestone modal', () => { let wrapper; const milestoneMockData = { milestoneTitle: 'v1.0', - url: `${TEST_HOST}/dummy/promote/milestones`, + promoteUrl: `${TEST_HOST}/dummy/promote/milestones`, groupName: 'group', }; - const promoteButton = () => document.querySelector('.js-promote-project-milestone-button'); - - beforeEach(() => { - setHTMLFixture(`<button - class="js-promote-project-milestone-button" - data-group-name="${milestoneMockData.groupName}" - data-milestone-title="${milestoneMockData.milestoneTitle}" - data-url="${milestoneMockData.url}"> - Promote - </button>`); - wrapper = shallowMount(PromoteMilestoneModal); - }); - - describe('Modal opener button', () => { - it('button gets disabled when the modal opens', () => { - expect(promoteButton().disabled).toBe(false); - - promoteButton().click(); - - expect(promoteButton().disabled).toBe(true); - }); - - it('button gets enabled when the modal closes', () => { - promoteButton().click(); - - wrapper.findComponent(GlModal).vm.$emit('hide'); - - expect(promoteButton().disabled).toBe(false); + const createComponent = ({ propsData = {} } = {}) => { + wrapper = shallowMount(PromoteMilestoneModal, { + propsData, + stubs: { + PromoteMilestoneModal, + }, }); - }); + }; describe('Modal title and description', () => { beforeEach(() => { - promoteButton().click(); + createComponent({ + propsData: { + visible: true, + milestoneTitle: milestoneMockData.milestoneTitle, + promoteUrl: milestoneMockData.promoteUrl, + groupName: milestoneMockData.groupName, + }, + }); }); it('contains the proper description', () => { @@ -63,19 +47,28 @@ describe('Promote milestone modal', () => { }); it('contains the correct title', () => { - expect(wrapper.vm.title).toBe('Promote v1.0 to group milestone?'); + expect(wrapper.vm.title).toBe( + `Promote ${milestoneMockData.milestoneTitle} to group milestone?`, + ); }); }); describe('When requesting a milestone promotion', () => { beforeEach(() => { - promoteButton().click(); + createComponent({ + propsData: { + visible: true, + milestoneTitle: milestoneMockData.milestoneTitle, + promoteUrl: milestoneMockData.promoteUrl, + groupName: milestoneMockData.groupName, + }, + }); }); it('redirects when a milestone is promoted', async () => { const responseURL = `${TEST_HOST}/dummy/endpoint`; jest.spyOn(axios, 'post').mockImplementation((url) => { - expect(url).toBe(milestoneMockData.url); + expect(url).toBe(milestoneMockData.promoteUrl); return Promise.resolve({ data: { url: responseURL, @@ -93,7 +86,7 @@ describe('Promote milestone modal', () => { const dummyError = new Error('promoting milestone failed'); dummyError.response = { status: HTTP_STATUS_INTERNAL_SERVER_ERROR }; jest.spyOn(axios, 'post').mockImplementation((url) => { - expect(url).toBe(milestoneMockData.url); + expect(url).toBe(milestoneMockData.promoteUrl); return Promise.reject(dummyError); });