diff --git a/app/components/sortable_lists/move_menu_items.rb b/app/components/sortable_lists/move_menu_items.rb new file mode 100644 index 000000000000..33a88871c580 --- /dev/null +++ b/app/components/sortable_lists/move_menu_items.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module SortableLists + # Shared vocabulary and rendering for the four-direction move items every + # sortable-lists consumer offers (#DREAM-775). + # + # `move_to:` carries the acts_as_list value surfaces that still persist + # server-side build their hrefs from; each drops it on moving to `prev_id`. + class MoveMenuItems + include ActionView::RecordIdentifier + + DIRECTIONS = [ + { direction: :top, label: :label_sort_highest, icon: :"move-to-top", move_to: :highest }.freeze, + { direction: :up, label: :label_sort_higher, icon: :"chevron-up", move_to: :higher }.freeze, + { direction: :down, label: :label_sort_lower, icon: :"chevron-down", move_to: :lower }.freeze, + { direction: :bottom, label: :label_sort_lowest, icon: :"move-to-bottom", move_to: :lowest }.freeze + ].freeze + + def initialize(dom_key:) + @dom_key = dom_key + end + + def render_into(menu) + DIRECTIONS.each { |entry| add_item(menu, entry) } + end + + private + + attr_reader :dom_key + + def add_item(menu, entry) + direction = entry[:direction] + + # The `data:` hash must live at item level so Primer renders it on the + # ActionList `
  • `: the controller's targets and the action-menu API's + # `disableItem`/`enableItem` both address that element, and the click + # action rides it via the bubbled button click. + menu.with_item( + id: dom_target(dom_key, :menu, direction), + label: I18n.t(entry[:label]), + tag: :button, + data: { + sortable_lists__item_target: "moveItem", + sortable_lists__item_direction_param: direction, + action: "click->sortable-lists--item#move" + } + ) do |item| + item.with_leading_visual_icon(icon: entry[:icon]) + end + end + end +end diff --git a/frontend/src/stimulus/controllers/dynamic/contextual-action-menu.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/contextual-action-menu.controller.spec.ts index c137af3a9040..9cc1fed2a5d4 100644 --- a/frontend/src/stimulus/controllers/dynamic/contextual-action-menu.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/contextual-action-menu.controller.spec.ts @@ -462,6 +462,63 @@ describe('Contextual action menu controller', () => { expect(origins).toEqual(['pointer', 'keyboard']); }); + it('dispatches a cancelable pointer beforeOpen before the presenter opens', async () => { + const { card } = renderCard(); + await nextFrame(); + const sequence:string[] = []; + const events:CustomEvent<{ origin:string }>[] = []; + card.addEventListener('contextual-action-menu:beforeOpen', (event) => { + sequence.push('beforeOpen'); + events.push(event as CustomEvent<{ origin:string }>); + }); + vi.spyOn(ContextualActionMenu.prototype, 'openAtPoint').mockImplementation(() => { + sequence.push('present'); + }); + + contextMenu(card); + + expect(sequence).toEqual(['beforeOpen', 'present']); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ cancelable: true, detail: { origin: 'pointer' } }); + }); + + it.each([ + ['Context Menu key', 'ContextMenu', {}], + ['Shift+F10', 'F10', { shiftKey: true }], + ])('dispatches a cancelable keyboard beforeOpen before the presenter opens for %s', async (_label, key, init) => { + const { card } = renderCard(); + await nextFrame(); + const sequence:string[] = []; + const events:CustomEvent<{ origin:string }>[] = []; + card.addEventListener('contextual-action-menu:beforeOpen', (event) => { + sequence.push('beforeOpen'); + events.push(event as CustomEvent<{ origin:string }>); + }); + vi.spyOn(ContextualActionMenu.prototype, 'openAtInvoker').mockImplementation(() => { + sequence.push('present'); + }); + + keydown(card, key, init); + + expect(sequence).toEqual(['beforeOpen', 'present']); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ cancelable: true, detail: { origin: 'keyboard' } }); + }); + + it('cancellation prevents both pointer and keyboard presentation', async () => { + const { card } = renderCard(); + await nextFrame(); + const openAtPoint = vi.spyOn(ContextualActionMenu.prototype, 'openAtPoint'); + const openAtInvoker = vi.spyOn(ContextualActionMenu.prototype, 'openAtInvoker'); + card.addEventListener('contextual-action-menu:beforeOpen', (event) => event.preventDefault()); + + contextMenu(card); + keydown(card, 'ContextMenu'); + + expect(openAtPoint).not.toHaveBeenCalled(); + expect(openAtInvoker).not.toHaveBeenCalled(); + }); + it('leaves the native context menu alone when the card has no action menu', async () => { const { card } = renderCardWithoutMenu(); await nextFrame(); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 4cbb4a39aff5..3afb99c4ceb2 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -1575,6 +1575,85 @@ describe('Sortable lists controller', () => { ])).toEqual([{ type: 'sprint', id: '1' }]); }); + describe('action menu invocation scope', () => { + const selectItem = (item:HTMLElement, init:MouseEventInit = {}):void => { + item.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...init })); + }; + + it('preserves a selected eligible invoker and returns the live ordered batch', async () => { + const { root, items } = renderSelectableRoot(); + await ctx.nextFrame(); + const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType; + selectItem(items[1]); + selectItem(items[0], { ctrlKey: true }); + + expect(controller.selectForAction(items[1])).toMatchObject({ + kind: 'batch' + }); + expect(items.filter((item) => item.hasAttribute('data-batch-selected'))).toEqual([items[0], items[1]]); + }); + + it('replaces an unrelated selection when an unselected eligible invoker opens its menu', async () => { + const { root, items } = renderSelectableRoot(); + await ctx.nextFrame(); + const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType; + selectItem(items[0]); + selectItem(items[1], { ctrlKey: true }); + + expect(controller.selectForAction(items[2])).toMatchObject({ + kind: 'batch' + }); + expect(items.filter((item) => item.hasAttribute('data-batch-selected'))).toEqual([items[2]]); + }); + + it('preserves the current selection when a fixed invoker opens its singular menu', async () => { + const { root, items } = renderSelectableRoot(); + items[2].setAttribute('data-sortable-lists--item-mobility-value', 'fixed'); + await ctx.nextFrame(); + const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType; + selectItem(items[0]); + selectItem(items[1], { ctrlKey: true }); + announceSpy.mockClear(); + + expect(controller.selectForAction(items[2])).toEqual({ kind: 'refused', items: [] }); + expect(items.filter((item) => item.hasAttribute('data-batch-selected'))).toEqual([items[0], items[1]]); + expect(announcedMessages()).toEqual([]); + }); + + it('returns a prospective one-card scope without changing visible selection while busy', async () => { + const { root, items } = renderSelectableRoot(); + await ctx.nextFrame(); + const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType; + selectItem(items[0]); + selectItem(items[1], { ctrlKey: true }); + root.setAttribute('data-sortable-lists-busy', 'true'); + + expect(controller.selectForAction(items[2])).toMatchObject({ + kind: 'batch' + }); + expect(items.filter((item) => item.hasAttribute('data-batch-selected'))).toEqual([items[0], items[1]]); + }); + + it('settles duplicate pre-open delivery once without repeating its selection announcement', async () => { + const { root, items } = renderSelectableRoot(); + await ctx.nextFrame(); + const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType; + selectItem(items[0]); + selectItem(items[1], { ctrlKey: true }); + announceSpy.mockClear(); + + const contextualScope = controller.selectForAction(items[2]); + const popoverScope = controller.selectForAction(items[2]); + + expect(contextualScope).toMatchObject({ kind: 'batch' }); + expect(popoverScope).toMatchObject({ kind: 'batch' }); + expect(items.filter((item) => item.hasAttribute('data-batch-selected'))).toEqual([items[2]]); + expect(announcedMessages()).toEqual([ + ['[selected:1]', { politeness: 'polite' }], + ]); + }); + }); + describe('direct destination moves', () => { const destination = { type: 'inbox', id: null }; diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/action-menu.spec.ts similarity index 57% rename from frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.spec.ts rename to frontend/src/stimulus/controllers/dynamic/sortable-lists/action-menu.spec.ts index 6f712618c002..df4b07d3889a 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/action-menu.spec.ts @@ -26,61 +26,58 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { refreshMenuAvailability } from './menu-availability'; +import type { ActionMenuElement } from '@openproject/primer-view-components/app/components/primer/alpha/action_menu/action_menu_element'; +import { SortableActionMenu } from './action-menu'; function fixture(hideUnavailable = true) { const list = document.createElement('ul'); - list.innerHTML = '
  • '; - const [divider, destination, moveMenu] = Array.from(list.querySelectorAll('li')); + list.innerHTML = '
  • '; + const [groupDivider, destination, moveSubmenu] = Array.from(list.querySelectorAll('li')); const moveItem = document.createElement('li'); - moveItem.setAttribute('data-sortable-lists-item-direction-param', 'top'); - moveMenu.append(moveItem); + moveSubmenu.append(moveItem); const menu = { showItem: (item:HTMLElement) => item.removeAttribute('hidden'), hideItem: (item:HTMLElement) => item.setAttribute('hidden', ''), enableItem: (item:HTMLElement) => item.removeAttribute('aria-disabled'), disableItem: (item:HTMLElement) => item.setAttribute('aria-disabled', 'true'), - }; - const input:Parameters[0] = { - menu, - scope: { kind: 'batch', items: [document.createElement('div'), document.createElement('div')] }, + } as ActionMenuElement; + const projection = new SortableActionMenu(menu, hideUnavailable, null); + const elements = { destinationItems: [destination], moveItems: [moveItem], - moveMenu, - divider, - hideUnavailable, - identifier: 'sortable-lists-item', - availableDestinations: vi.fn(() => []), - moveAvailability: vi.fn(() => ({ top: false, up: false, down: false, bottom: false })), + moveSubmenu, + groupDivider, + invokerGroup: null, + batchGroup: null, }; - return { input, divider, destination, moveMenu, moveItem }; + const scope = { batch: false, count: 1 }; + const availability = { destinationItem: () => false, moveItem: () => false }; + return { projection, elements, scope, availability, destination, moveSubmenu, moveItem, groupDivider }; } describe('menu availability projection', () => { it('ignores the trailing overlay when every real action is hidden', () => { - const { input, divider, destination, moveMenu, moveItem } = fixture(); - refreshMenuAvailability(input); - for (const element of [divider, destination, moveMenu, moveItem]) { + const { projection, elements, scope, availability, groupDivider, destination, moveSubmenu, moveItem } = fixture(); + projection.project(elements, scope, availability); + for (const element of [groupDivider, destination, moveSubmenu, moveItem]) { expect(element.hasAttribute('hidden')).toBe(true); } - expect(input.moveAvailability).toHaveBeenCalled(); }); it('restores the position group when the batch becomes positionable', () => { - const { input, divider, moveMenu, moveItem } = fixture(); - refreshMenuAvailability(input); - input.moveAvailability = vi.fn(() => ({ top: true, up: true, down: false, bottom: false })); - refreshMenuAvailability(input); - for (const element of [divider, moveMenu, moveItem]) { + const { projection, elements, scope, availability, groupDivider, moveSubmenu, moveItem } = fixture(); + projection.project(elements, scope, availability); + projection.project(elements, scope, { ...availability, moveItem: () => true }); + for (const element of [groupDivider, moveSubmenu, moveItem]) { expect(element.hasAttribute('hidden')).toBe(false); } }); it('disables unavailable actions without hiding the group separator', () => { - const { input, divider, destination, moveMenu, moveItem } = fixture(false); - refreshMenuAvailability(input); - expect(divider.hasAttribute('hidden')).toBe(false); - for (const element of [destination, moveMenu, moveItem]) { + const { projection, elements, scope, availability, groupDivider, destination, moveSubmenu, moveItem } = fixture(false); + projection.project(elements, scope, availability); + expect(groupDivider.hasAttribute('hidden')).toBe(false); + for (const element of [destination, moveSubmenu, moveItem]) { expect(element.getAttribute('aria-disabled')).toBe('true'); expect(element.hasAttribute('hidden')).toBe(false); } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/action-menu.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/action-menu.ts new file mode 100644 index 000000000000..b749162fd014 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/action-menu.ts @@ -0,0 +1,310 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import type { ActionMenuElement } from '@openproject/primer-view-components/app/components/primer/alpha/action_menu/action_menu_element'; + +// The plural form that names one card's own menu. A batch of one is that same +// menu, so it never counts a selection down to "1 selected work package". +const SINGULAR_COUNT = 1; + +// The menu's own elements, re-read by the caller on every projection so a +// morph that replaces the subtree is picked up without rebuilding anything. +export interface ActionMenuElements { + destinationItems:HTMLElement[]; + moveItems:HTMLElement[]; + moveSubmenu:HTMLElement|null; + invokerGroup:HTMLElement|null; + batchGroup:HTMLElement|null; + groupDivider:HTMLElement|null; +} + +export interface ActionMenuScope { + batch:boolean; + count:number; +} + +// Answers, for one menu item, whether the action it invokes can run in the +// current scope. A null `moveItem` leaves the move actions untouched. +export interface ActionAvailability { + destinationItem:(item:HTMLElement) => boolean; + moveItem:((item:HTMLElement) => boolean)|null; +} + +/** + * Projects a sortable item's action scope onto its Primer ActionMenu. + * + * It owns only presentation: which actions the menu presents, which of its two + * groups shows, and what the menu is called. It never resolves a scope, never + * decides whether an action is permitted, and holds no selection state — the + * caller answers those through {@link ActionAvailability}. + */ +export class SortableActionMenu { + private open = false; + + constructor( + private readonly menu:ActionMenuElement, + private readonly hideUnavailable:boolean, + private readonly labelKey:string|null, + ) {} + + get isOpen():boolean { + return this.open; + } + + ownsPopoverEvent(event:Event):boolean { + return event.target === this.menu.popoverElement; + } + + isItemActionable(item:HTMLElement):boolean { + return !this.menu.isItemDisabled(item) && !this.menu.isItemHidden(item); + } + + opening():void { + this.open = true; + } + + opened():void { + this.rescueOpenFocus(); + } + + closed():void { + this.open = false; + this.rename(SINGULAR_COUNT); + } + + // A Turbo restore resurrects the tooltip as snapshotted, batch name and + // all; a closed menu always presents the singular one. + settleName():void { + this.rename(SINGULAR_COUNT); + } + + project(elements:ActionMenuElements, scope:ActionMenuScope, availability:ActionAvailability):void { + const presented = this.projectDestinations(elements, availability.destinationItem) + + this.projectMoves(elements, availability.moveItem); + + this.projectGroups(elements, scope, presented); + } + + // Both projections return how many actions the menu is presenting, not how + // many are executable: with hideUnavailable off, unavailable items stay on + // screen disabled, and the group toggle must count them as presented. + private projectDestinations( + { destinationItems }:ActionMenuElements, + allowed:(item:HTMLElement) => boolean, + ):number { + let available = 0; + + for (const item of destinationItems) { + const enabled = allowed(item); + this.setAvailability(item, enabled); + if (enabled) { + available += 1; + } + } + + return this.hideUnavailable ? available : destinationItems.length; + } + + private projectMoves( + { moveItems, moveSubmenu }:ActionMenuElements, + allowed:((item:HTMLElement) => boolean)|null, + ):number { + // A null policy means the item is not in a list yet; leave the menu alone + // until the outlet wiring settles. + if (!allowed) { + return this.presentedMoveActionCount(moveItems, moveSubmenu); + } + + let available = 0; + for (const item of moveItems) { + const enabled = allowed(item); + this.setAvailability(item, enabled); + if (enabled) { + available += 1; + } + } + + if (moveSubmenu) { + this.setAvailability(moveSubmenu, available > 0); + return this.hideUnavailable && available === 0 ? 0 : 1; + } + + return this.hideUnavailable ? available : moveItems.length; + } + + // A nested submenu presents as one action; flat move items count each. + private presentedMoveActionCount(moveItems:HTMLElement[], moveSubmenu:HTMLElement|null):number { + if (moveSubmenu) { + return moveSubmenu.hasAttribute('hidden') ? 0 : 1; + } + + return moveItems.filter((item) => !item.hasAttribute('hidden')).length; + } + + private projectGroups( + { invokerGroup, batchGroup, groupDivider }:ActionMenuElements, + scope:ActionMenuScope, + presentedBatchActionCount:number, + ):void { + const batchActionsPresented = presentedBatchActionCount > 0; + const batch = scope.batch && batchActionsPresented; + + if (invokerGroup) { + if (batch) { + this.rescueFocusFrom(invokerGroup); + } + this.hideGroup(invokerGroup, batch); + } + + if (batchGroup) { + if (!batchActionsPresented) { + this.rescueFocusFrom(batchGroup); + } + this.hideGroup(batchGroup, !batchActionsPresented); + } + + // The divider only separates anything while both groups show. It never + // goes through setAvailability: `disableItem` writes to the item's + // `.ActionListContent`, which a divider does not have. + groupDivider?.toggleAttribute('hidden', batch || !batchActionsPresented); + + this.rename(batch ? scope.count : SINGULAR_COUNT); + } + + // Primer's focus zone stops managing exactly the element `hidden` lands on, + // so hiding a group alone leaves its items in the zone for the arrows to + // step onto. A group that comes back is repopulated by the availability + // pass, which sets each item's own hidden state before this runs. + private hideGroup(group:HTMLElement, hidden:boolean):void { + group.toggleAttribute('hidden', hidden); + + if (!hidden) { + return; + } + + for (const item of group.querySelectorAll('[role="menuitem"]')) { + item.toggleAttribute('hidden', true); + } + } + + // Hiding the group that holds focus would fling focus to the body, and + // Primer closes the whole menu when focus leaves it. Park focus on the + // first menu item that stays presented before the group disappears. + private rescueFocusFrom(group:HTMLElement):void { + const active = this.menu.ownerDocument.activeElement; + if (!(active instanceof HTMLElement) || !group.contains(active)) { + return; + } + + this.presentedItems().find((item) => !group.contains(item))?.focus(); + } + + // Primer's open-time focus matches `:not([hidden]) > [role=menuitem]`, one + // level deep: a singular item whose group alone is hidden still matches, + // and focusing it no-ops. Primer schedules that focus a frame after the + // toggle, so the correction runs a frame later still. + private rescueOpenFocus():void { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!this.open) { + return; + } + + const active = this.menu.ownerDocument.activeElement; + if ( + active instanceof HTMLElement + && this.menu.contains(active) + && active.getAttribute('role') === 'menuitem' + && !active.closest('[hidden]') + ) { + return; + } + + this.presentedItems()[0]?.focus(); + }); + }); + } + + private presentedItems():HTMLElement[] { + return Array.from(this.menu.querySelectorAll('[role="menuitem"]')) + .filter((item) => !item.closest('[hidden]')); + } + + // The invoker is a Primer IconButton whose label-type tooltip owns the + // accessible name — icon_button.rb drops the button's aria-label and points + // aria-labelledby at the — so writing aria-label would be inert. + // The batch name shows only while the menu is open; any projection with the + // popover closed settles back on the singular form. + private rename(count:number):void { + const tooltip = this.nameSource(); + if (!tooltip || !this.labelKey) { + return; + } + + tooltip.textContent = I18n.t(this.labelKey, { count: this.open ? count : SINGULAR_COUNT }); + } + + // Primer labels the list by the invoker button, and accessible-name + // references do not chain: the button's own name comes from a further + // aria-labelledby at the tooltip, which leaves the menu itself unnamed. + // Point the list at the tooltip directly so both carry one name and the + // rename above moves them together. + private nameSource():HTMLElement|null { + const labelId = this.menu.invokerElement?.getAttribute('aria-labelledby'); + const tooltip = labelId ? this.menu.ownerDocument.getElementById(labelId) : null; + if (!labelId || !tooltip) { + return null; + } + + const list = this.menu.querySelector('[role="menu"]'); + if (list && list.getAttribute('aria-labelledby') !== labelId) { + list.setAttribute('aria-labelledby', labelId); + } + + return tooltip; + } + + // Availability goes through the action-menu element's API: disableItem sets + // the ActionListItem--disabled class plus aria-disabled on the item's + // content, and hideItem toggles hidden. It operates on any descendant li, + // including the ones in the nested move submenu. Default is hide; + // hideUnavailable=false switches to disable. + private setAvailability(item:HTMLElement, available:boolean):void { + if (this.hideUnavailable) { + if (available) { + this.menu.showItem(item); + } else { + this.menu.hideItem(item); + } + } else if (available) { + this.menu.enableItem(item); + } else { + this.menu.disableItem(item); + } + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts index 38591b802c66..b0ef22eb5b7f 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts @@ -100,6 +100,9 @@ export interface SortableListData extends Record { // Implemented by the sortable-lists root controller and handed to list/item // controllers via outlet callbacks, so children read shared state through a // typed reference instead of walking the DOM. +// +// Membership stays inside SelectionOrchestrator: children get action scopes, +// availability and the frozen drag batch, never the model itself. export interface SortableListsRoot { readonly element:HTMLElement; readonly busy:boolean; diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts index 72da4217e743..276943e9a844 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.spec.ts @@ -58,7 +58,7 @@ import type { ActionEvent } from '@hotwired/stimulus'; import type ItemControllerType from './item.controller'; import type { SortableListsRoot } from './drag-and-drop'; import type { DestinationIdentity } from './list-dom'; -import type { ActionScope } from './action-scope'; +import { scopeIds, type ActionScope } from './action-scope'; describe('Sortable lists item controller', () => { let draggable:typeof draggableFn; @@ -1370,23 +1370,37 @@ describe('Sortable lists item controller', () => { isItemHidden:ReturnType; } - function renderItemWithMenu(idNumber:number, withDivider = false):{ el:HTMLElement; menu:FakeActionMenu } { + function renderItemWithMenu(idNumber:number):{ el:HTMLElement; menu:FakeActionMenu } { const el = document.createElement('div'); el.dataset.controller = 'sortable-lists--item'; el.setAttribute('data-sortable-lists--item-id-value', String(idNumber)); el.setAttribute('data-sortable-lists--item-type-value', 'work_package'); const menuElement = document.createElement('action-menu'); - // The divider opens the move group, so everything below it is what - // decides whether it still separates anything. - menuElement.innerHTML = withDivider ? '
  • ' : ''; + const invokerGroup = document.createElement('ul'); + invokerGroup.setAttribute('role', 'group'); + invokerGroup.setAttribute('data-sortable-lists--item-target', 'invokerGroup'); + invokerGroup.innerHTML = '
  • '; + const divider = document.createElement('li'); + divider.setAttribute('data-sortable-lists--item-target', 'groupDivider'); + const batchGroup = document.createElement('ul'); + batchGroup.setAttribute('role', 'group'); + batchGroup.setAttribute('data-sortable-lists--item-target', 'batchGroup'); const parent = document.createElement('li'); parent.setAttribute('data-sortable-lists--item-target', 'moveMenu'); parent.innerHTML = ['top', 'up', 'down', 'bottom'].map((direction) => ( `
  • ' )).join(''); - menuElement.appendChild(parent); + batchGroup.appendChild(parent); + // Primer's list wrapper: role=menu labelled by the invoker button, which + // is where the unnamed-menu bug lives — the button's own name comes from + // a further aria-labelledby, and those do not chain. + const list = document.createElement('ul'); + list.setAttribute('role', 'menu'); + list.setAttribute('aria-labelledby', `${idNumber}-menu-button`); + list.append(invokerGroup, divider, batchGroup); + menuElement.append(list); el.appendChild(menuElement); const menu:FakeActionMenu = { @@ -1408,11 +1422,12 @@ describe('Sortable lists item controller', () => { } const liFor = (el:HTMLElement, direction:string) => el.querySelector(`li[data-sortable-lists--item-direction-param="${direction}"]`)!; + const groupFor = (el:HTMLElement, target:string) => el.querySelector(`[data-sortable-lists--item-target="${target}"]`)!; const destinationWithMetadata = (el:HTMLElement, metadata:string) => { const item = document.createElement('li'); item.setAttribute('data-sortable-lists--item-target', 'destinationItem'); item.dataset.sortableListsDestinations = metadata; - el.querySelector('action-menu')!.append(item); + el.querySelector('[data-sortable-lists--item-target="batchGroup"]')!.append(item); return item; }; const destinationFor = (el:HTMLElement, candidates:{ type:string; id:string|null }[]) => ( @@ -1445,10 +1460,26 @@ describe('Sortable lists item controller', () => { function stubMenuRoot(el:HTMLElement, position:{ isFirst:boolean; isLast:boolean }) { const actionScopeFor = vi.fn(():ActionScope => ({ kind: 'refused', items: [] })); + const selectForAction = vi.fn(():ActionScope => ({ kind: 'refused', items: [] })); const availableDestinations = vi.fn((_scope:ActionScope, _candidates:DestinationIdentity[]):DestinationIdentity[] => []); - const root = { ...stubRoot(el, position), actionScopeFor, availableDestinations }; + const moveToDestination = vi.fn(); + const moveInDirection = vi.fn(); + const root = { + ...stubRoot(el, position, moveInDirection), + actionScopeFor, + selectForAction, + availableDestinations, + moveToDestination, + }; - return { root, actionScopeFor, availableDestinations }; + return { + root, + actionScopeFor, + selectForAction, + availableDestinations, + moveToDestination, + moveInDirection, + }; } it('ignores opening tooltips and submenu overlays', async () => { @@ -1493,6 +1524,205 @@ describe('Sortable lists item controller', () => { expect(actionScopeFor).not.toHaveBeenCalled(); }); + const attachMenuPopover = (el:HTMLElement):HTMLElement => { + const actionMenu = el.querySelector('action-menu')!; + const popover = actionMenu.querySelector('anchored-position')!; + popover.setAttribute('popover', ''); + return popover; + }; + + async function mountActionMenuInvocationFixture() { + const { el } = renderItemWithMenu(1); + const card = document.createElement('article'); + card.setAttribute('data-sortable-lists--item-target', 'focus'); + const actionMenu = el.querySelector('action-menu')!; + const popover = attachMenuPopover(el); + card.append(actionMenu); + el.append(card); + destinationFor(el, [{ type: 'inbox', id: null }]); + document.body.appendChild(el); + + const controller = await mountItemController(el); + const scope:ActionScope = { kind: 'batch', items: [el] }; + const sequence:string[] = []; + const actionScopeFor = vi.fn(() => scope); + const selectForAction = vi.fn(() => { + sequence.push('prepare'); + return scope; + }); + const availableDestinations = vi.fn((projectedScope:ActionScope, candidates:DestinationIdentity[]) => { + sequence.push(`project:${scopeIds(projectedScope).join(',')}`); + return candidates; + }); + const root = { + ...stubRoot(el, { isFirst: true, isLast: true }), + actionScopeFor, + selectForAction, + availableDestinations, + }; + controller.connectRoot(root); + await menuCtx!.nextFrame(); + sequence.length = 0; + actionScopeFor.mockClear(); + selectForAction.mockClear(); + availableDestinations.mockClear(); + + return { + el, + card, + popover, + controller, + root, + scope, + sequence, + actionScopeFor, + selectForAction, + availableDestinations, + }; + } + + it.each([ + ['pointer context menu', 'pointer'], + ['Context Menu key', 'keyboard'], + ['Shift+F10', 'keyboard'], + ])('settles action scope before projecting a %s invocation', async (_label, origin) => { + const { + el, card, scope, sequence, selectForAction, availableDestinations, + } = await mountActionMenuInvocationFixture(); + + card.dispatchEvent(new CustomEvent('contextual-action-menu:beforeOpen', { + bubbles: true, + cancelable: true, + detail: { origin }, + })); + + expect(selectForAction).toHaveBeenCalledWith(el); + expect(availableDestinations).toHaveBeenCalledWith(scope, [{ type: 'inbox', id: null }]); + expect(sequence).toEqual(['prepare', 'project:1']); + }); + + it.each([ + 'More button pointer invocation', + 'More button keyboard invocation', + ])('settles action scope before projecting a %s', async () => { + const { + el, popover, scope, sequence, selectForAction, availableDestinations, + } = await mountActionMenuInvocationFixture(); + + popover.dispatchEvent(new ToggleEvent('beforetoggle', { + oldState: 'closed', + newState: 'open', + })); + + expect(selectForAction).toHaveBeenCalledWith(el); + expect(availableDestinations).toHaveBeenCalledWith(scope, [{ type: 'inbox', id: null }]); + expect(sequence).toEqual(['prepare', 'project:1']); + }); + + it('ignores closing and unrelated popover pre-toggle events', async () => { + const { card, popover, selectForAction } = await mountActionMenuInvocationFixture(); + const tooltip = document.createElement('div'); + tooltip.setAttribute('popover', ''); + card.append(tooltip); + + popover.dispatchEvent(new ToggleEvent('beforetoggle', { + oldState: 'open', + newState: 'closed', + })); + tooltip.dispatchEvent(new ToggleEvent('beforetoggle', { + oldState: 'closed', + newState: 'open', + })); + + expect(selectForAction).not.toHaveBeenCalled(); + }); + + it('does nothing when a pre-open event arrives after its root disconnects', async () => { + const { + controller, card, popover, selectForAction, + } = await mountActionMenuInvocationFixture(); + controller.disconnectRoot(); + + card.dispatchEvent(new CustomEvent('contextual-action-menu:beforeOpen', { + bubbles: true, + cancelable: true, + detail: { origin: 'pointer' }, + })); + popover.dispatchEvent(new ToggleEvent('beforetoggle', { + oldState: 'closed', + newState: 'open', + })); + + expect(selectForAction).not.toHaveBeenCalled(); + }); + + it('projects one stable scope when contextual and popover pre-open events both arrive', async () => { + const { + card, popover, scope, selectForAction, availableDestinations, + } = await mountActionMenuInvocationFixture(); + + card.dispatchEvent(new CustomEvent('contextual-action-menu:beforeOpen', { + bubbles: true, + cancelable: true, + detail: { origin: 'pointer' }, + })); + popover.dispatchEvent(new ToggleEvent('beforetoggle', { + oldState: 'closed', + newState: 'open', + })); + + expect(selectForAction).toHaveBeenCalledTimes(2); + expect(availableDestinations).toHaveBeenCalledTimes(2); + expect(availableDestinations.mock.calls.map(([projectedScope]) => projectedScope)).toEqual([scope, scope]); + }); + + it('late-refreshes only for the card menu popover, not a nested submenu', async () => { + const { + popover, actionScopeFor, availableDestinations, + } = await mountActionMenuInvocationFixture(); + const nestedPopover = document.createElement('anchored-position'); + nestedPopover.setAttribute('popover', ''); + popover.append(nestedPopover); + + popover.dispatchEvent(new ToggleEvent('toggle', { + oldState: 'closed', + newState: 'open', + })); + + expect(actionScopeFor).toHaveBeenCalledTimes(1); + expect(availableDestinations).toHaveBeenCalledTimes(1); + actionScopeFor.mockClear(); + availableDestinations.mockClear(); + + nestedPopover.dispatchEvent(new ToggleEvent('toggle', { + oldState: 'closed', + newState: 'open', + })); + + expect(actionScopeFor).not.toHaveBeenCalled(); + expect(availableDestinations).not.toHaveBeenCalled(); + }); + + it('handles a contextual pre-open from the item itself when no focus target exists', async () => { + const { el } = renderItemWithMenu(1); + document.body.appendChild(el); + const controller = await mountItemController(el); + const scope:ActionScope = { kind: 'batch', items: [el] }; + const selectForAction = vi.fn(() => scope); + controller.connectRoot({ + ...stubRoot(el, { isFirst: true, isLast: true }), + selectForAction, + }); + + el.dispatchEvent(new CustomEvent('contextual-action-menu:beforeOpen', { + bubbles: true, + cancelable: true, + detail: { origin: 'pointer' }, + })); + + expect(selectForAction).toHaveBeenCalledWith(el); + }); + it('hides up/top for a first item and shows the rest', async () => { const { el, menu } = renderItemWithMenu(1); document.body.appendChild(el); @@ -1552,7 +1782,7 @@ describe('Sortable lists item controller', () => { }); it('projects deferred destination items for the selected invoker', async () => { - const { el, menu } = renderItemWithMenu(1, true); + const { el, menu } = renderItemWithMenu(1); document.body.appendChild(el); const controller = await mountItemController(el); const scope:ActionScope = { kind: 'batch', items: [el]}; @@ -1601,9 +1831,9 @@ describe('Sortable lists item controller', () => { menu.showItem.mockClear(); activeScope = prospectiveScope; - const menuElement = el.querySelector('action-menu')!; + const popover = attachMenuPopover(el); const toggle = new ToggleEvent('toggle', { newState: 'open', oldState: 'closed' }); - menuElement.querySelector('anchored-position')!.dispatchEvent(toggle); + popover.dispatchEvent(toggle); expect(actionScopeFor).toHaveBeenLastCalledWith(el); expect(availableDestinations).toHaveBeenCalledWith(prospectiveScope, [{ type: 'sprint', id: '1' }]); @@ -1613,7 +1843,7 @@ describe('Sortable lists item controller', () => { }); it('shows available batch position directions when every destination action is hidden', async () => { - const { el, menu } = renderItemWithMenu(1, true); + const { el, menu } = renderItemWithMenu(1); document.body.appendChild(el); const controller = await mountItemController(el); // Batch size is the member count: two elements, not one element with @@ -1630,7 +1860,6 @@ describe('Sortable lists item controller', () => { await menuCtx!.nextFrame(); const moveMenu = el.querySelector('li[data-sortable-lists--item-target="moveMenu"]')!; - const divider = el.querySelector('li[data-sortable-lists--item-target="moveDivider"]')!; expect(menu.hideItem).toHaveBeenCalledWith(moveToSprint); expect(menu.hideItem).toHaveBeenCalledWith(moveToInbox); expect(menu.showItem).toHaveBeenCalledWith(moveMenu); @@ -1638,14 +1867,38 @@ describe('Sortable lists item controller', () => { expect(menu.showItem).toHaveBeenCalledWith(liFor(el, 'up')); expect(menu.showItem).toHaveBeenCalledWith(liFor(el, 'down')); expect(menu.hideItem).toHaveBeenCalledWith(liFor(el, 'bottom')); - expect(divider.hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'invokerGroup').hasAttribute('hidden')).toBe(true); + expect(groupFor(el, 'batchGroup').hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'groupDivider').hasAttribute('hidden')).toBe(true); + }); + + // Primer's focus zone stops managing exactly the element the `hidden` + // attribute lands on, so a group hidden on its own leaves its items in + // the zone and the arrows step onto rows that cannot take focus. + it('hides the items of a hidden group, not just the group', async () => { + const { el } = renderItemWithMenu(1); + document.body.appendChild(el); + const controller = await mountItemController(el); + const scope:ActionScope = { kind: 'batch', items: [el, document.createElement('li')] }; + const { root, actionScopeFor, availableDestinations } = stubMenuRoot(el, { isFirst: false, isLast: false }); + actionScopeFor.mockReturnValue(scope); + availableDestinations.mockReturnValue([]); + root.moveAvailability = () => ({ top: false, up: true, down: true, bottom: false }); + const singularItem = groupFor(el, 'invokerGroup').querySelector('button')!; + singularItem.setAttribute('role', 'menuitem'); + controller.connectRoot(root); + controller.moveItemTargetConnected(); + await Promise.resolve(); + + expect(groupFor(el, 'invokerGroup').hasAttribute('hidden')).toBe(true); + expect(singularItem.hasAttribute('hidden')).toBe(true); }); it('hides an all-unavailable batch position submenu and its directions', async () => { - const { el, menu } = renderItemWithMenu(1, true); + const { el, menu } = renderItemWithMenu(1); document.body.appendChild(el); const controller = await mountItemController(el); - const scope:ActionScope = { kind: 'batch', items: [el] }; + const scope:ActionScope = { kind: 'batch', items: [el, document.createElement('li')] }; const { root, actionScopeFor, availableDestinations } = stubMenuRoot(el, { isFirst: false, isLast: false }); actionScopeFor.mockReturnValue(scope); availableDestinations.mockReturnValue([]); @@ -1656,20 +1909,21 @@ describe('Sortable lists item controller', () => { await menuCtx!.nextFrame(); const moveMenu = el.querySelector('li[data-sortable-lists--item-target="moveMenu"]')!; - const divider = el.querySelector('li[data-sortable-lists--item-target="moveDivider"]')!; expect(menu.hideItem).toHaveBeenCalledWith(moveToSprint); expect(menu.hideItem).toHaveBeenCalledWith(moveMenu); for (const direction of ['top', 'up', 'down', 'bottom']) { expect(menu.hideItem).toHaveBeenCalledWith(liFor(el, direction)); } - expect(divider.hasAttribute('hidden')).toBe(true); + expect(groupFor(el, 'invokerGroup').hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'batchGroup').hasAttribute('hidden')).toBe(true); + expect(groupFor(el, 'groupDivider').hasAttribute('hidden')).toBe(true); actionScopeFor.mockReturnValue({ kind: 'batch', items: [el] }); root.moveAvailability = () => ({ top: true, up: true, down: false, bottom: false }); el.querySelector('action-menu > anchored-position')!.dispatchEvent(new ToggleEvent('toggle', { newState: 'open' })); expect(moveMenu.hasAttribute('hidden')).toBe(false); expect(liFor(el, 'top').hasAttribute('hidden')).toBe(false); - expect(divider.hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'groupDivider').hasAttribute('hidden')).toBe(false); }); it('keeps only the current owner destination for a confined batch scope', async () => { @@ -1704,10 +1958,47 @@ describe('Sortable lists item controller', () => { const moveToSprint = destinationFor(el, [{ type: 'sprint', id: '12' }]); await menuCtx!.nextFrame(); - expect(availableDestinations).toHaveBeenCalledWith(scope, [{ type: 'sprint', id: '12' }]); + expect(availableDestinations).not.toHaveBeenCalled(); expect(menu.hideItem).toHaveBeenCalledWith(moveToSprint); }); + it('hides a stale batch action skeleton for a fixed singular invoker', async () => { + const { el } = renderItemWithMenu(1); + el.setAttribute('data-sortable-lists--item-mobility-value', 'fixed'); + document.body.appendChild(el); + const controller = await mountItemController(el); + const { + root, + actionScopeFor, + selectForAction, + availableDestinations, + moveToDestination, + moveInDirection, + } = stubMenuRoot(el, { isFirst: false, isLast: false }); + const scope:ActionScope = { kind: 'refused', items: [] }; + actionScopeFor.mockReturnValue(scope); + // Keep the collaborators deliberately permissive: the item controller + // must project the already-settled singular scope, not let stale batch + // markup look actionable while the root's defences would reject it. + availableDestinations.mockImplementation((_scope, candidates) => candidates); + controller.connectRoot(root); + + const destination = destinationFor(el, [{ type: 'sprint', id: '1' }]); + await menuCtx!.nextFrame(); + + expect(destination).toHaveAttribute('hidden'); + expect(el.querySelector('[data-sortable-lists--item-target="moveMenu"]')).toHaveAttribute('hidden'); + + liFor(el, 'down').click(); + const destinationEvent = new Event('click') as ActionEvent; + Object.defineProperty(destinationEvent, 'currentTarget', { value: destination }); + controller.moveToDestination(destinationEvent); + + expect(moveInDirection).not.toHaveBeenCalled(); + expect(moveToDestination).not.toHaveBeenCalled(); + expect(selectForAction).not.toHaveBeenCalled(); + }); + it.each([ ['invalid JSON', '{invalid'], ['an empty destination type', '[{"type":"","id":null}]'], @@ -1727,11 +2018,11 @@ describe('Sortable lists item controller', () => { expect(availableDestinations).not.toHaveBeenCalled(); }); - // Regression: the divider is rendered server-side from a permission check - // alone, so an item with nowhere to move used to be left with a separator - // and nothing below it. - it('hides the divider when nothing below it is left visible', async () => { - const { el } = renderItemWithMenu(1, true); + // Regression: the batch group and its divider are rendered server-side + // from a permission check alone, so an item with nowhere to move used to + // be left with a separator and nothing below it. + it('hides the batch group and divider when it presents no action', async () => { + const { el } = renderItemWithMenu(1); const overlay = document.createElement('anchored-position'); overlay.setAttribute('popover', 'auto'); el.querySelector('action-menu')!.append(overlay); @@ -1741,27 +2032,29 @@ describe('Sortable lists item controller', () => { controller.moveItemTargetConnected(); await Promise.resolve(); - const divider = el.querySelector('li[data-sortable-lists--item-target="moveDivider"]')!; - expect(divider.hasAttribute('hidden')).toBe(true); + expect(groupFor(el, 'invokerGroup').hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'batchGroup').hasAttribute('hidden')).toBe(true); + expect(groupFor(el, 'groupDivider').hasAttribute('hidden')).toBe(true); }); - it('keeps the divider while something below it is still visible', async () => { - const { el } = renderItemWithMenu(1, true); + it('keeps the batch group and divider while an action is presented', async () => { + const { el } = renderItemWithMenu(1); document.body.appendChild(el); const controller = await mountItemController(el); controller.connectRoot(stubRoot(el, { isFirst: true, isLast: false })); controller.moveItemTargetConnected(); await Promise.resolve(); - const divider = el.querySelector('li[data-sortable-lists--item-target="moveDivider"]')!; - expect(divider.hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'invokerGroup').hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'batchGroup').hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'groupDivider').hasAttribute('hidden')).toBe(false); }); - // A divider has no `.ActionListContent`, so routing it through - // `disableItem` would throw — and in this mode the group stays visible - // anyway, only disabled. - it('leaves the divider alone when hideUnavailable is off', async () => { - const { el } = renderItemWithMenu(1, true); + // With hideUnavailable off the consumer asked for unavailable items to + // stay on screen, only disabled — so a group whose items are all + // unavailable still presents them and must not disappear. + it('keeps the batch group when unavailable items stay presented', async () => { + const { el } = renderItemWithMenu(1); el.setAttribute('data-sortable-lists--item-hide-unavailable-value', 'false'); document.body.appendChild(el); const controller = await mountItemController(el); @@ -1769,8 +2062,307 @@ describe('Sortable lists item controller', () => { controller.moveItemTargetConnected(); await Promise.resolve(); - const divider = el.querySelector('li[data-sortable-lists--item-target="moveDivider"]')!; - expect(divider.hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'invokerGroup').hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'batchGroup').hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'groupDivider').hasAttribute('hidden')).toBe(false); + }); + + it('hides the singular group for a disabled-mode batch with no available action', async () => { + const { el, menu } = renderItemWithMenu(1); + el.setAttribute('data-sortable-lists--item-hide-unavailable-value', 'false'); + document.body.appendChild(el); + const controller = await mountItemController(el); + const { root, actionScopeFor } = stubMenuRoot(el, { isFirst: true, isLast: true }); + const scope:ActionScope = { kind: 'batch', items: [el, document.createElement('li')] }; + actionScopeFor.mockReturnValue(scope); + root.moveAvailability = () => ({ top: false, up: false, down: false, bottom: false }); + controller.connectRoot(root); + controller.moveItemTargetConnected(); + await Promise.resolve(); + + expect(menu.disableItem).toHaveBeenCalledWith(liFor(el, 'top')); + expect(groupFor(el, 'invokerGroup').hasAttribute('hidden')).toBe(true); + expect(groupFor(el, 'batchGroup').hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'groupDivider').hasAttribute('hidden')).toBe(true); + }); + + it('keeps the singular group for a one-card batch scope', async () => { + const { el } = renderItemWithMenu(1); + document.body.appendChild(el); + const controller = await mountItemController(el); + const { root, actionScopeFor } = stubMenuRoot(el, { isFirst: false, isLast: false }); + const scope:ActionScope = { kind: 'batch', items: [el] }; + actionScopeFor.mockReturnValue(scope); + controller.connectRoot(root); + controller.moveItemTargetConnected(); + await Promise.resolve(); + + expect(groupFor(el, 'invokerGroup').hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'batchGroup').hasAttribute('hidden')).toBe(false); + expect(groupFor(el, 'groupDivider').hasAttribute('hidden')).toBe(false); + }); + + // Hiding the group that holds focus would fling focus to the body, and + // Primer closes the whole menu when focus leaves it. + it('parks focus on a presented batch item before hiding its group', async () => { + const { el } = renderItemWithMenu(1); + document.body.appendChild(el); + const controller = await mountItemController(el); + const { root, actionScopeFor } = stubMenuRoot(el, { isFirst: false, isLast: false }); + const scope:ActionScope = { kind: 'batch', items: [el, document.createElement('li')] }; + actionScopeFor.mockReturnValue(scope); + const singularButton = groupFor(el, 'invokerGroup').querySelector('button')!; + singularButton.setAttribute('role', 'menuitem'); + const batchButton = document.createElement('button'); + batchButton.setAttribute('role', 'menuitem'); + groupFor(el, 'batchGroup').append(batchButton); + singularButton.focus(); + expect(document.activeElement).toBe(singularButton); + + controller.connectRoot(root); + controller.moveItemTargetConnected(); + await Promise.resolve(); + + expect(groupFor(el, 'invokerGroup').hasAttribute('hidden')).toBe(true); + expect(document.activeElement).toBe(batchButton); + }); + + // Primer's open-time focus matches `:not([hidden]) > [role=menuitem]`, + // a check one level deep: the first singular item still matches when only + // its group
      is hidden, and focusing it no-ops, so reopening an + // already-loaded batch menu would strand focus on the invoker. + it('settles reopen focus on the first visible batch item', async () => { + const { el } = renderItemWithMenu(1); + const popover = attachMenuPopover(el); + const invokerButton = document.createElement('button'); + el.querySelector('action-menu')!.append(invokerButton); + document.body.appendChild(el); + const controller = await mountItemController(el); + const { root, actionScopeFor } = stubMenuRoot(el, { isFirst: false, isLast: false }); + const scope:ActionScope = { kind: 'batch', items: [el, document.createElement('li')] }; + actionScopeFor.mockReturnValue(scope); + const singularItem = groupFor(el, 'invokerGroup').querySelector('button')!; + singularItem.setAttribute('role', 'menuitem'); + const batchButton = document.createElement('button'); + batchButton.setAttribute('role', 'menuitem'); + groupFor(el, 'batchGroup').append(batchButton); + controller.connectRoot(root); + + popover.dispatchEvent(new ToggleEvent('toggle', { newState: 'open', oldState: 'closed' })); + await menuCtx!.nextFrame(); + await menuCtx!.nextFrame(); + popover.dispatchEvent(new ToggleEvent('toggle', { newState: 'closed', oldState: 'open' })); + invokerButton.focus(); + + popover.dispatchEvent(new ToggleEvent('toggle', { newState: 'open', oldState: 'closed' })); + await menuCtx!.nextFrame(); + await menuCtx!.nextFrame(); + + expect(document.activeElement).toBe(batchButton); + }); + + describe('menu renaming', () => { + beforeEach(() => { + window.I18n.store({ + en: { + js: { + backlogs: { + action_menu: { + menu_label: { + one: 'Work package actions', + other: 'Actions for %{count} selected work packages', + }, + }, + }, + }, + }, + }); + }); + + // A Primer IconButton renders a label-type tooltip: the button carries + // NO aria-label — icon_button.rb deletes it and points aria-labelledby + // at the , whose text is both the visible tooltip and the + // accessible name. A fixture with an aria-label would conceal a + // controller that wrote the inert attribute instead. + const attachMenuInvoker = (el:HTMLElement, label = 'Work package actions'):{ invoker:HTMLElement; tooltip:HTMLElement } => { + const actionMenu = el.querySelector('action-menu')!; + const invoker = document.createElement('button'); + const tooltip = document.createElement('tool-tip'); + tooltip.id = `${el.getAttribute('data-sortable-lists--item-id-value')}-menu-tooltip`; + tooltip.textContent = label; + invoker.setAttribute('aria-labelledby', tooltip.id); + actionMenu.append(invoker, tooltip); + Object.assign(actionMenu, { invokerElement: invoker }); + return { invoker, tooltip }; + }; + + async function mountRenamableMenu({ + withKey = true, + tooltipText = 'Work package actions', + } = {}) { + const { el } = renderItemWithMenu(1); + if (withKey) { + el.setAttribute( + 'data-sortable-lists--item-menu-label-key-value', + 'js.backlogs.action_menu.menu_label', + ); + } + const { invoker, tooltip } = attachMenuInvoker(el, tooltipText); + const popover = attachMenuPopover(el); + document.body.appendChild(el); + const controller = await mountItemController(el); + const { root, actionScopeFor } = stubMenuRoot(el, { isFirst: false, isLast: false }); + + return { + el, invoker, tooltip, popover, controller, root, actionScopeFor, + }; + } + + const openMenu = (popover:HTMLElement) => { + popover.dispatchEvent(new ToggleEvent('beforetoggle', { newState: 'open', oldState: 'closed' })); + popover.dispatchEvent(new ToggleEvent('toggle', { newState: 'open', oldState: 'closed' })); + }; + + it('renames the invoker tooltip while an open menu presents a batch and reverts for singular', async () => { + const { + el, invoker, tooltip, popover, controller, root, actionScopeFor, + } = await mountRenamableMenu(); + const batchScope:ActionScope = { kind: 'batch', items: [el, document.createElement('li')] }; + actionScopeFor.mockReturnValue(batchScope); + controller.connectRoot(root); + openMenu(popover); + + expect(tooltip.textContent).toBe('Actions for 2 selected work packages'); + expect(invoker.getAttribute('aria-label')).toBeNull(); + + const singularScope:ActionScope = { kind: 'refused', items: [] }; + actionScopeFor.mockReturnValue(singularScope); + controller.moveItemTargetConnected(); + await Promise.resolve(); + + expect(tooltip.textContent).toBe('Work package actions'); + }); + + it('keeps the singular name for a one-card batch scope', async () => { + const { + el, tooltip, popover, controller, root, actionScopeFor, + } = await mountRenamableMenu(); + const scope:ActionScope = { kind: 'batch', items: [el] }; + actionScopeFor.mockReturnValue(scope); + controller.connectRoot(root); + openMenu(popover); + + expect(tooltip.textContent).toBe('Work package actions'); + }); + + it('keeps the singular name for a batch that presents no action', async () => { + const { + el, tooltip, popover, controller, root, actionScopeFor, + } = await mountRenamableMenu(); + const scope:ActionScope = { kind: 'batch', items: [el, document.createElement('li')] }; + actionScopeFor.mockReturnValue(scope); + root.moveAvailability = () => ({ top: false, up: false, down: false, bottom: false }); + controller.connectRoot(root); + openMenu(popover); + + expect(tooltip.textContent).toBe('Work package actions'); + }); + + it('restores the singular name when the menu closes', async () => { + const { + el, tooltip, popover, controller, root, actionScopeFor, + } = await mountRenamableMenu(); + const batchScope:ActionScope = { kind: 'batch', items: [el, document.createElement('li')] }; + actionScopeFor.mockReturnValue(batchScope); + controller.connectRoot(root); + openMenu(popover); + expect(tooltip.textContent).toBe('Actions for 2 selected work packages'); + + popover.dispatchEvent(new ToggleEvent('toggle', { newState: 'closed', oldState: 'open' })); + + expect(tooltip.textContent).toBe('Work package actions'); + }); + + // A morph can reconnect targets while the popover is closed; the closed + // menu must keep presenting the singular name no matter the scope. + it('does not apply the batch name while the menu is closed', async () => { + const { + el, tooltip, controller, root, actionScopeFor, + } = await mountRenamableMenu(); + const batchScope:ActionScope = { kind: 'batch', items: [el, document.createElement('li')] }; + actionScopeFor.mockReturnValue(batchScope); + controller.connectRoot(root); + controller.moveItemTargetConnected(); + await Promise.resolve(); + + expect(tooltip.textContent).toBe('Work package actions'); + }); + + // A Turbo back/forward restore resurrects the DOM as it was snapshotted, + // batch tooltip text included; the fresh controller must present the + // server-rendered singular name, not adopt the snapshot's text. + it('recovers the singular name from a snapshot carrying the batch one', async () => { + const { tooltip } = await mountRenamableMenu({ + tooltipText: 'Actions for 2 selected work packages', + }); + + expect(tooltip.textContent).toBe('Work package actions'); + }); + + it('leaves the name alone when no label key is configured', async () => { + const { + el, tooltip, popover, controller, root, actionScopeFor, + } = await mountRenamableMenu({ withKey: false }); + const scope:ActionScope = { kind: 'batch', items: [el, document.createElement('li')] }; + actionScopeFor.mockReturnValue(scope); + controller.connectRoot(root); + openMenu(popover); + + expect(tooltip.textContent).toBe('Work package actions'); + }); + + // Server-side the menu is labelled by its invoker button, whose own name + // comes from a further aria-labelledby at the tooltip — and accessible + // name references do not chain, so the menu resolves to nothing. Follow + // the reference the assistive technology would, not the tooltip text. + const menuName = (el:HTMLElement):string|null => { + const labelId = el.querySelector('[role="menu"]')?.getAttribute('aria-labelledby'); + return labelId ? el.ownerDocument.getElementById(labelId)?.textContent ?? null : null; + }; + + it('names the menu itself, not only its invoker', async () => { + const { + el, popover, controller, root, actionScopeFor, + } = await mountRenamableMenu(); + const batchScope:ActionScope = { kind: 'batch', items: [el, document.createElement('li')] }; + actionScopeFor.mockReturnValue(batchScope); + controller.connectRoot(root); + openMenu(popover); + + expect(menuName(el)).toBe('Actions for 2 selected work packages'); + + popover.dispatchEvent(new ToggleEvent('toggle', { newState: 'closed', oldState: 'open' })); + + expect(menuName(el)).toBe('Work package actions'); + }); + + it('names the menu for a singular scope', async () => { + const { + el, popover, controller, root, + } = await mountRenamableMenu(); + controller.connectRoot(root); + openMenu(popover); + + expect(menuName(el)).toBe('Work package actions'); + }); + + // A consumer that names no batch scope still gets a named menu: the + // reference is repointed whatever the labels say. + it('names the menu when no label key is configured', async () => { + const { el } = await mountRenamableMenu({ withKey: false }); + + expect(menuName(el)).toBe('Work package actions'); + }); }); it('delegates an enabled click to the root and no-ops a disabled one', async () => { diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts index e31e165b7cf3..fad066a917d0 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -61,14 +61,29 @@ import { type DestinationIdentity, } from './list-dom'; import { webLinkHref } from './external-data'; +import { + SortableActionMenu, + type ActionAvailability, + type ActionMenuElements, +} from './action-menu'; import { renderDragPreview } from './preview'; -import { scopeIds } from './action-scope'; -import { refreshMenuAvailability } from './menu-availability'; +import { scopeIds, type ActionScope } from './action-scope'; type CleanupFn = () => void; export default class ItemController extends Controller implements RootAwareChild { - static targets = ['handle', 'preview', 'destinationItem', 'moveItem', 'moveMenu', 'moveDivider', 'focus']; + static targets = [ + 'handle', + 'preview', + 'destinationItem', + 'moveItem', + 'moveMenu', + 'invokerGroup', + 'batchGroup', + 'groupDivider', + 'focus', + ]; + static elements = { menu: 'action-menu' }; static values = { @@ -76,6 +91,12 @@ export default class ItemController extends Controller implements R type: String, externalUrl: String, hideUnavailable: { type: Boolean, default: true }, + label: String, + // I18n key naming the menu, pluralized on the size of the scope it acts + // on. The invoker tooltip's own text is no restore source, since a Turbo + // snapshot can capture it mid-rename; a consumer that sets no key keeps + // the server-rendered name in every scope. + menuLabelKey: String, // See ItemMobility in list-dom. A `confined` item is still a full drag // source; only the lists the batch's permitted set names accept it. mobility: { type: String, default: 'free' }, @@ -88,6 +109,10 @@ export default class ItemController extends Controller implements R declare readonly externalUrlValue:string; declare readonly hasExternalUrlValue:boolean; declare readonly hideUnavailableValue:boolean; + declare readonly labelValue:string; + declare readonly hasLabelValue:boolean; + declare readonly menuLabelKeyValue:string; + declare readonly hasMenuLabelKeyValue:boolean; declare readonly handleTarget:HTMLElement; declare readonly hasHandleTarget:boolean; @@ -97,8 +122,12 @@ export default class ItemController extends Controller implements R declare readonly moveItemTargets:HTMLElement[]; declare readonly moveMenuTarget:HTMLElement; declare readonly hasMoveMenuTarget:boolean; - declare readonly moveDividerTarget:HTMLElement; - declare readonly hasMoveDividerTarget:boolean; + declare readonly invokerGroupTarget:HTMLElement; + declare readonly hasInvokerGroupTarget:boolean; + declare readonly batchGroupTarget:HTMLElement; + declare readonly hasBatchGroupTarget:boolean; + declare readonly groupDividerTarget:HTMLElement; + declare readonly hasGroupDividerTarget:boolean; declare readonly focusTarget:HTMLElement; declare readonly hasFocusTarget:boolean; @@ -112,6 +141,8 @@ export default class ItemController extends Controller implements R private dropIndicatorElement?:HTMLElement; private root?:SortableListsRoot; private refreshToken?:object; + private menu?:SortableActionMenu; + private projectedMenuElement?:ActionMenuElement; private readonly onMenuToggle = (event:Event):void => { // The toggle event does not bubble, so listen in capture phase; recompute @@ -119,17 +150,45 @@ export default class ItemController extends Controller implements R // shifted siblings meanwhile. Read newState by duck typing rather than // `instanceof ToggleEvent` so a browser without the ToggleEvent global // cannot throw. - if (this.hasMenuElement - && event.target === this.menuElement.popoverElement - && (event as ToggleEvent).newState === 'open') { + const menu = this.actionMenu; + if (!menu?.ownsPopoverEvent(event)) { + return; + } + + const { newState } = event as ToggleEvent; + if (newState === 'open') { + menu.opening(); this.refreshActionAvailability(); + menu.opened(); + } else if (newState === 'closed') { + menu.closed(); + } + }; + + private readonly onContextualBeforeOpen = (event:Event):void => { + const focusTarget = this.hasFocusTarget ? this.focusTarget : null; + if (event.target === this.element || event.target === focusTarget) { + this.prepareActionMenu(); + } + }; + + private readonly onMenuBeforeToggle = (event:Event):void => { + const menu = this.actionMenu; + if ((event as ToggleEvent).newState !== 'open' || !menu?.ownsPopoverEvent(event)) { + return; } + + menu.opening(); + this.prepareActionMenu(); }; connect():void { this.warnOnMissingValues(); this.register(); + this.element.addEventListener('contextual-action-menu:beforeOpen', this.onContextualBeforeOpen); + this.element.addEventListener('beforetoggle', this.onMenuBeforeToggle, true); this.element.addEventListener('toggle', this.onMenuToggle, true); + this.actionMenu?.settleName(); } disconnect():void { @@ -140,6 +199,8 @@ export default class ItemController extends Controller implements R this.clearDropIndicator(); this.cleanupFn?.(); this.cleanupFn = undefined; + this.element.removeEventListener('contextual-action-menu:beforeOpen', this.onContextualBeforeOpen); + this.element.removeEventListener('beforetoggle', this.onMenuBeforeToggle, true); this.element.removeEventListener('toggle', this.onMenuToggle, true); this.disconnectRoot(); } @@ -167,7 +228,11 @@ export default class ItemController extends Controller implements R move(event:ActionEvent):void { const item = event.currentTarget; - if (!isOrderableItem(this.element) || !this.menuItemActionable(item)) { + if (!isOrderableItem(this.element) || !this.hasMenuElement || !(item instanceof HTMLElement)) { + return; + } + + if (!this.actionMenu?.isItemActionable(item)) { return; } @@ -204,7 +269,11 @@ export default class ItemController extends Controller implements R moveToDestination(event:ActionEvent):void { const item = event.currentTarget; - if (!this.menuItemActionable(item)) { + if (!this.hasMenuElement || !(item instanceof HTMLElement)) { + return; + } + + if (!this.actionMenu?.isItemActionable(item)) { return; } @@ -515,30 +584,88 @@ export default class ItemController extends Controller implements R this.dropIndicatorElement = undefined; } - private refreshActionAvailability():void { + private prepareActionMenu():void { + const scope = this.root?.selectForAction(this.element); + if (scope) { + this.refreshActionAvailability(scope); + } + } + + private refreshActionAvailability(preparedScope?:ActionScope):void { this.refreshToken = undefined; const root = this.root; - if (!root || !this.hasMenuElement || (this.destinationItemTargets.length === 0 && this.moveItemTargets.length === 0)) { + const menu = this.actionMenu; + if (!root || !menu || (this.destinationItemTargets.length === 0 && this.moveItemTargets.length === 0)) { return; } - refreshMenuAvailability({ - menu: this.menuElement, - scope: root.actionScopeFor(this.element), + const scope = preparedScope ?? root.actionScopeFor(this.element); + + menu.project( + this.menuElements(), + { batch: scope.kind === 'batch' && scope.items.length > 1, count: scope.items.length }, + this.actionAvailability(root, scope), + ); + } + + // Rebuilt whenever a morph hands the blessing a new element, carrying the + // open state across so a morph mid-popover does not read as a fresh close. + private get actionMenu():SortableActionMenu|null { + if (!this.hasMenuElement) { + return null; + } + + if (this.projectedMenuElement !== this.menuElement) { + const wasOpen = this.menu?.isOpen ?? false; + this.projectedMenuElement = this.menuElement; + this.menu = new SortableActionMenu(this.menuElement, this.hideUnavailableValue, this.menuLabelKey()); + if (wasOpen) { + this.menu.opening(); + } + } + + return this.menu ?? null; + } + + private menuLabelKey():string|null { + return this.hasMenuLabelKeyValue ? this.menuLabelKeyValue : null; + } + + private menuElements():ActionMenuElements { + return { destinationItems: this.destinationItemTargets, moveItems: this.moveItemTargets, - moveMenu: this.hasMoveMenuTarget ? this.moveMenuTarget : null, - divider: this.hasMoveDividerTarget ? this.moveDividerTarget : null, - hideUnavailable: this.hideUnavailableValue, - identifier: this.identifier, - availableDestinations: (scope, candidates) => root.availableDestinations(scope, candidates), - moveAvailability: () => root.moveAvailability(this.element), - }); + moveSubmenu: this.hasMoveMenuTarget ? this.moveMenuTarget : null, + invokerGroup: this.hasInvokerGroupTarget ? this.invokerGroupTarget : null, + batchGroup: this.hasBatchGroupTarget ? this.batchGroupTarget : null, + groupDivider: this.hasGroupDividerTarget ? this.groupDividerTarget : null, + }; } - private menuItemActionable(item:EventTarget|null):item is HTMLElement { - return this.hasMenuElement && item instanceof HTMLElement - && !this.menuElement.isItemDisabled(item) && !this.menuElement.isItemHidden(item); + // A card that is not itself orderable offers no action in a singular scope: + // it is an addressable position, not something a menu can move. + private actionAvailability(root:SortableListsRoot, scope:ActionScope):ActionAvailability { + if (scope.kind === 'refused' && !isOrderableItem(this.element)) { + return { destinationItem: () => false, moveItem: () => false }; + } + + // Null availability means the item is not in a list yet. + const moves = root.moveAvailability(this.element); + + return { + destinationItem: (item) => { + const candidates = this.destinationCandidates(item); + return candidates.length > 0 && root.availableDestinations(scope, candidates).length > 0; + }, + moveItem: moves + // Outside a Stimulus action there is no event.params, so read the + // param's backing attribute directly. + ? (item) => { + const direction = item.getAttribute(`data-${this.identifier}-direction-param`); + return isMoveDirection(direction) && moves[direction]; + } + : null, + }; } private destinationCandidates(item:HTMLElement):DestinationIdentity[] { diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.ts deleted file mode 100644 index f4e72ef23e61..000000000000 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.ts +++ /dev/null @@ -1,84 +0,0 @@ -//-- copyright -// OpenProject is an open source project management software. -// Copyright (C) the OpenProject GmbH -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License version 3. -// -// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: -// Copyright (C) 2006-2013 Jean-Philippe Lang -// Copyright (C) 2010-2013 the ChiliProject Team -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program; if not, write to the Free Software -// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -// -// See COPYRIGHT and LICENSE files for more details. -//++ - -import type { ActionMenuElement } from '@openproject/primer-view-components/app/components/primer/alpha/action_menu/action_menu_element'; -import type { ActionScope } from './action-scope'; -import type { SortableListsRoot } from './drag-and-drop'; -import { isMoveDirection, parseDestinationCandidates, type DestinationIdentity } from './list-dom'; - -interface MenuAvailabilityInput { - menu:Pick; - scope:ActionScope; - destinationItems:HTMLElement[]; - moveItems:HTMLElement[]; - moveMenu:HTMLElement|null; - divider:HTMLElement|null; - hideUnavailable:boolean; - identifier:string; - availableDestinations:(scope:ActionScope, candidates:DestinationIdentity[]) => DestinationIdentity[]; - moveAvailability:() => ReturnType; -} - -export function refreshMenuAvailability(input:MenuAvailabilityInput):void { - const setAvailability = (item:HTMLElement, available:boolean):void => { - if (input.hideUnavailable) { - input.menu[available ? 'showItem' : 'hideItem'](item); - } else { - input.menu[available ? 'enableItem' : 'disableItem'](item); - } - }; - - for (const item of input.destinationItems) { - const candidates = parseDestinationCandidates(item.dataset.sortableListsDestinations); - setAvailability(item, candidates.length > 0 && input.availableDestinations(input.scope, candidates).length > 0); - } - - const availability = input.moveAvailability(); - if (availability) { - let available = 0; - for (const item of input.moveItems) { - const direction = item.getAttribute(`data-${input.identifier}-direction-param`); - const enabled = isMoveDirection(direction) && availability[direction]; - setAvailability(item, enabled); - if (enabled) available += 1; - } - if (input.moveMenu) setAvailability(input.moveMenu, available > 0); - } - - if (input.divider && input.hideUnavailable) { - let sibling = input.divider.nextElementSibling; - while (sibling) { - if (sibling instanceof HTMLLIElement && !sibling.hasAttribute('hidden')) { - input.divider.removeAttribute('hidden'); - return; - } - sibling = sibling.nextElementSibling; - } - input.divider.setAttribute('hidden', 'hidden'); - } -} diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.spec.ts index 53557e83f409..999d2031f243 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.spec.ts @@ -809,6 +809,19 @@ describe('SelectionOrchestrator', () => { expect(selectedIds()).toEqual(['2']); }); + // A refused invoker must not re-anchor the selection: the following + // Shift range still extends from the card the user actually picked. + it('reports a fixed action invoker without changing the selection anchor', () => { + item('3').setAttribute('data-sortable-lists--item-mobility-value', 'fixed'); + const orchestrator = new SelectionOrchestrator(hostFor(root)); + orchestrator.handleClick(clickOn(item('1'))); + + orchestrator.selectForAction(item('3')); + orchestrator.handleClick(clickOn(item('2'), { shiftKey: true })); + + expect(selectedIds()).toEqual(['1', '2']); + }); + it('keeps the selected batch for an action on a member', () => { const orchestrator = new SelectionOrchestrator(hostFor(root)); orchestrator.handleClick(clickOn(item('1'))); diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb index 02e53d4c5e3f..c56e90a121a7 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_item_component.rb @@ -110,6 +110,7 @@ def row_data sortable_lists__item_label_value: work_package.to_fs(:caption), sortable_lists__item_type_value: "work_package", sortable_lists__item_mobility_value: mobility, + sortable_lists__item_menu_label_key_value: "js.backlogs.action_menu.menu_label", # Native drag payload for external consumers; the same absolute URL # as the card menu's "Copy URL to clipboard" item. The label above # doubles as the link text of the text/html flavour. diff --git a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb index b339a5285b40..66051aad8c22 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb +++ b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.html.erb @@ -29,109 +29,118 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Primer::Alpha::ActionMenu::List.new(menu_id:)) do |list| - list.with_item( - id: dom_target(work_package, :menu, :open_details), - tag: :a, - label: t(:"js.button_open_details"), - href: project_backlogs_backlog_details_path(project, work_package), - content_arguments: { data: { action: "backlogs--work-package#openSplitPane:prevent" } } - ) do |item| - item.with_leading_visual_icon(icon: :"op-view-split") - end + list.with_group(data: { sortable_lists__item_target: "invokerGroup" }) do |group| + group.with_item( + id: dom_target(work_package, :menu, :open_details), + label: t(:"js.button_open_details"), + content_arguments: { + tag: :a, + href: project_backlogs_backlog_details_path(project, work_package), + tabindex: -1, + data: { action: "backlogs--work-package#openSplitPane:prevent" } + } + ) do |item| + item.with_leading_visual_icon(icon: :"op-view-split") + end - list.with_item( - id: dom_target(work_package, :menu, :open_fullscreen), - tag: :a, - label: t(:"js.button_open_fullscreen"), - href: work_package_path(work_package), - content_arguments: { data: { turbo_frame: "_top" } } - ) do |item| - item.with_leading_visual_icon(icon: :"screen-full") - end + group.with_item( + id: dom_target(work_package, :menu, :open_fullscreen), + label: t(:"js.button_open_fullscreen"), + content_arguments: { + tag: :a, + href: work_package_path(work_package), + tabindex: -1, + data: { turbo_frame: "_top" } + } + ) do |item| + item.with_leading_visual_icon(icon: :"screen-full") + end - list.with_item( - id: dom_target(work_package, :menu, :copy_url_to_clipboard), - tag: :"clipboard-copy", - label: t(".action_menu.copy_url_to_clipboard"), - content_arguments: { value: work_package_url(work_package) } - ) do |item| - item.with_leading_visual_icon(icon: :copy) - end + group.with_item( + id: dom_target(work_package, :menu, :copy_url_to_clipboard), + label: t(".action_menu.copy_url_to_clipboard"), + content_arguments: { tag: :"clipboard-copy", value: work_package_url(work_package), tabindex: -1 } + ) do |item| + item.with_leading_visual_icon(icon: :copy) + end - list.with_item( - id: dom_target(work_package, :menu, :copy_work_package_id), - tag: :"clipboard-copy", - label: t(".action_menu.copy_work_package_id"), - content_arguments: { value: work_package.display_id } - ) do |item| - item.with_leading_visual_icon(icon: :hash) + group.with_item( + id: dom_target(work_package, :menu, :copy_work_package_id), + label: t(".action_menu.copy_work_package_id"), + content_arguments: { tag: :"clipboard-copy", value: work_package.display_id, tabindex: -1 } + ) do |item| + item.with_leading_visual_icon(icon: :hash) + end end if show_move_submenu? - list.with_divider(data: { sortable_lists__item_target: "moveDivider" }) - - if show_move_to_inbox? - list.with_item( - id: dom_target(work_package, :menu, :move_to_inbox), - label: t(".action_menu.move_to_inbox"), - tag: :button, - data: destination_data(inbox_list_type, []).merge( - action: "click->sortable-lists--item#moveToDestination:prevent" - ) - ) do |item| - item.with_leading_visual_icon(icon: :inbox) + list.with_divider(data: { sortable_lists__item_target: "groupDivider" }) + + list.with_group(data: { sortable_lists__item_target: "batchGroup" }) do |group| + if show_move_to_inbox? + group.with_item( + id: dom_target(work_package, :menu, :move_to_inbox), + label: t(".action_menu.move_to_inbox"), + data: destination_data(inbox_list_type, []).merge( + action: "click->sortable-lists--item#moveToDestination:prevent" + ), + content_arguments: { tabindex: -1 } + ) do |item| + item.with_leading_visual_icon(icon: :inbox) + end end - end - if show_move_to_backlog_bucket? - list.with_item( - id: dom_target(work_package, :menu, :move_to_backlog_bucket), - label: t(".action_menu.move_to_backlog_bucket"), - tag: :button, - href: move_to_bucket_dialog_project_backlogs_work_packages_path(project), - form_arguments: { method: :post }, - content_arguments: { - data: { - controller: "async-dialog", - action: "async-dialog:beforeLoad->sortable-lists--item#prepareDialog" - } - }, - data: destination_data(bucket_list_type, bucket_ids) - ) do |item| - item.with_leading_visual_icon(icon: :package) + if show_move_to_backlog_bucket? + group.with_item( + id: dom_target(work_package, :menu, :move_to_backlog_bucket), + label: t(".action_menu.move_to_backlog_bucket"), + href: move_to_bucket_dialog_project_backlogs_work_packages_path(project), + form_arguments: { method: :post }, + content_arguments: { + tabindex: -1, + data: { + controller: "async-dialog", + action: "async-dialog:beforeLoad->sortable-lists--item#prepareDialog" + } + }, + data: destination_data(bucket_list_type, bucket_ids) + ) do |item| + item.with_leading_visual_icon(icon: :package) + end end - end - if show_move_to_sprint? - list.with_item( - id: dom_target(work_package, :menu, :move_to_sprint), - label: t(".action_menu.move_to_sprint"), - tag: :button, - href: move_to_sprint_dialog_project_backlogs_work_packages_path(project), - form_arguments: { method: :post }, - content_arguments: { - data: { - controller: "async-dialog", - action: "async-dialog:beforeLoad->sortable-lists--item#prepareDialog" - } - }, - data: destination_data(sprint_list_type, sprint_ids) - ) do |item| - item.with_leading_visual_icon(icon: :zap) + if show_move_to_sprint? + group.with_item( + id: dom_target(work_package, :menu, :move_to_sprint), + label: t(".action_menu.move_to_sprint"), + href: move_to_sprint_dialog_project_backlogs_work_packages_path(project), + form_arguments: { method: :post }, + content_arguments: { + tabindex: -1, + data: { + controller: "async-dialog", + action: "async-dialog:beforeLoad->sortable-lists--item#prepareDialog" + } + }, + data: destination_data(sprint_list_type, sprint_ids) + ) do |item| + item.with_leading_visual_icon(icon: :zap) + end end - end - if show_move_items? - list.with_item( - component_klass: Primer::Alpha::ActionMenu::SubMenuItem, - label: t(".action_menu.move_menu"), - select_variant: :none, - form_arguments: {}, - data: { sortable_lists__item_target: "moveMenu" } - ) do |move_menu| - move_menu.with_leading_visual_icon(icon: :"op-arrow-in") - - build_move_menu(move_menu) + if show_move_items? + group.with_item( + component_klass: Primer::Alpha::ActionMenu::SubMenuItem, + label: t(".action_menu.move_menu"), + select_variant: :none, + form_arguments: {}, + content_arguments: { tabindex: -1 }, + data: { sortable_lists__item_target: "moveMenu" } + ) do |move_menu| + move_menu.with_leading_visual_icon(icon: :"op-arrow-in") + + move_menu_items.render_into(move_menu) + end end end end diff --git a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb index 56e2bf1b916d..10f90339be43 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_menu_component.rb @@ -54,9 +54,6 @@ def menu_id private - # Positional moves reorder within the card's own list, which the server - # allows even for a read-only work package, so they gate on the page-level - # permission alone. Only the cross-container moves below require movable?. def show_move_items? sortable? end @@ -77,30 +74,8 @@ def show_move_submenu? show_move_items? || show_move_to_sprint? || show_move_to_inbox? || show_move_to_backlog_bucket? end - def build_move_menu(menu) - build_move_item(menu, label: :label_sort_highest, direction: "top", icon: :"move-to-top") - build_move_item(menu, label: :label_sort_higher, direction: "up", icon: :"chevron-up") - build_move_item(menu, label: :label_sort_lower, direction: "down", icon: :"chevron-down") - build_move_item(menu, label: :label_sort_lowest, direction: "bottom", icon: :"move-to-bottom") - end - - def build_move_item(menu, label:, icon:, direction:) - # The `data:` hash must live on the item level so Primer renders it on - # the ActionList `
    • `: the controller's `
    • ` targets and the - # action-menu API's `disableItem`/`enableItem` both address that element, - # and the click action rides it via the bubbled button click. - menu.with_item( - id: dom_target(work_package, :menu, label), - label: I18n.t(label), - tag: :button, - data: { - sortable_lists__item_target: "moveItem", - sortable_lists__item_direction_param: direction, - action: "click->sortable-lists--item#move" - } - ) do |item| - item.with_leading_visual_icon(icon:) - end + def move_menu_items + SortableLists::MoveMenuItems.new(dom_key: work_package) end def inbox_list_type diff --git a/modules/backlogs/config/locales/js-en.yml b/modules/backlogs/config/locales/js-en.yml index 48973394f3b6..bf8b2564818f 100644 --- a/modules/backlogs/config/locales/js-en.yml +++ b/modules/backlogs/config/locales/js-en.yml @@ -30,6 +30,13 @@ en: js: backlogs: + action_menu: + # A complete standalone string per plural form, never the singular + # name plus a suffix. `one` names the menu of a single card, so it + # says what that menu does rather than counting a selection of one. + menu_label: + one: "Work package actions" + other: "Actions for %{count} selected work packages" announcements: batch_too_large: "Cannot move %{count} work packages at once. Select no more than %{max}." fallback_item_label: "Work package" diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb index 6c97cf845e0f..045a62ac1c18 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_item_component_spec.rb @@ -82,12 +82,20 @@ sortable_lists__item_id_value: work_package.id, sortable_lists__item_type_value: "work_package", sortable_lists__item_label_value: work_package.to_fs(:caption), - sortable_lists__item_mobility_value: "free" + sortable_lists__item_mobility_value: "free", + sortable_lists__item_menu_label_key_value: "js.backlogs.action_menu.menu_label" ) expect(item.row_args[:draggable]).to be(true) expect(item.row_args).not_to include(:tabindex) end + # The client renames the menu from this one key, so its singular form has + # to be the very name the card's invoker tooltip already renders. + it "names a singular menu exactly as the card's own tooltip does" do + expect(I18n.t("js.backlogs.action_menu.menu_label", count: 1)) + .to eq(I18n.t(:"open_project.common.work_package_card_component.menu.label_actions")) + end + context "with classic mode", with_settings: { work_packages_identifier: "classic" } do it "exposes the absolute ID-based work package URL for external drag consumers" do diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb index 9836ab41cbc8..6900f1dbf9ad 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_menu_component_spec.rb @@ -169,16 +169,32 @@ def render_component(work_package: self.work_package, end end - it "shows a divider before the Move submenu" do + it "shows a divider between the singular and batch-capable actions" do render_component - expect(page).to have_css(".ActionList-sectionDivider") + expect(page).to have_css(".ActionList-sectionDivider[data-sortable-lists--item-target='groupDivider']") end - it "wires the divider up to the item controller, so it can be hidden with the group" do + it "separates the invoker's own actions from the batch-capable ones", :aggregate_failures do render_component - expect(page).to have_css(".ActionList-sectionDivider[data-sortable-lists--item-target='moveDivider']") + invoker_group = page.find("ul[role='group'][data-sortable-lists--item-target='invokerGroup']") + expect(invoker_group).to have_css("a[id$='_menu_open_details']") + expect(invoker_group).to have_css("a[id$='_menu_open_fullscreen']") + expect(invoker_group).to have_css("clipboard-copy[id$='_menu_copy_url_to_clipboard']") + expect(invoker_group).to have_css("clipboard-copy[id$='_menu_copy_work_package_id']") + + batch_group = page.find("ul[role='group'][data-sortable-lists--item-target='batchGroup']") + expect(batch_group).to have_css("li[data-sortable-lists--item-target~='destinationItem']") + expect(batch_group).to have_css("li[data-sortable-lists--item-target='moveMenu']") + expect(batch_group).to have_no_css("a[id$='_menu_open_details']") + end + + it "renders both groups without headings", :aggregate_failures do + render_component + + expect(page).to have_no_css("ul[role='group'][aria-labelledby]") + expect(page).to have_no_css("ul[role='group'] h3") end it "shows the Move to position submenu with incoming-arrow icon" do diff --git a/modules/backlogs/spec/features/work_packages/batch_action_menu_spec.rb b/modules/backlogs/spec/features/work_packages/batch_action_menu_spec.rb new file mode 100644 index 000000000000..8640ad57ac8e --- /dev/null +++ b/modules/backlogs/spec/features/work_packages/batch_action_menu_spec.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require_relative "../../support/pages/backlog" + +RSpec.describe "Backlogs batch action menus", :js, :selenium, :settings_reset do + let!(:project) do + create(:project, types: [type], enabled_module_names: %w(work_package_tracking backlogs)) + end + let(:type) { create(:type) } + let(:permissions) { %i[view_sprints manage_sprint_items view_work_packages edit_work_packages] } + let(:role) { create(:project_role, permissions:) } + let!(:sprint) { create(:sprint, project:, name: "Sprint") } + let!(:stories) do + Array.new(4) do |index| + create(:work_package, project:, type:, sprint:, position: index + 1) + end + end + let(:backlogs_page) { Pages::Backlog.new(project) } + + current_user do + create(:user, member_with_roles: { project => role }) + end + + before do + backlogs_page.visit! + end + + %i[more right_click context_menu_key shift_f10].each do |entry_point| + context "via #{entry_point.to_s.tr('_', ' ')}" do + it "preserves a selected batch and presents only its batch menu" do + backlogs_page.select_cards(stories[1], stories[0]) + + menu = backlogs_page.open_card_menu(stories[1], via: entry_point) + + backlogs_page.expect_selected_cards_in_order(stories[0], stories[1]) + backlogs_page.expect_card_menu_loaded(stories[1]) + backlogs_page.expect_action_menu_groups(invoker: stories[1], selected_count: 2) + expect(menu).to have_no_selector(:menuitem, text: I18n.t(:"js.button_open_details")) + expect(menu).to have_selector(:menuitem, text: "Move to backlog inbox", focused: true) + backlogs_page.expect_card_menu_anchor(stories[1], via: entry_point) + + backlogs_page.dismiss_card_menu(stories[1], via: entry_point) + backlogs_page.expect_selected_cards_in_order(stories[0], stories[1]) + backlogs_page.expect_menu_invoker_name(stories[1], "Work package actions") + + # Reopening skips the deferred-fragment load, so focus must be + # corrected past Primer's open-time pick of the hidden singular item. + reopened_menu = backlogs_page.open_card_menu(stories[1], via: entry_point) + expect(reopened_menu).to have_selector(:menuitem, text: "Move to backlog inbox", focused: true) + backlogs_page.dismiss_card_menu(stories[1], via: entry_point) + end + + it "replaces an old batch when an unselected card invokes the one-card menu" do + backlogs_page.select_cards(stories[0], stories[1]) + + backlogs_page.open_card_menu(stories[2], via: entry_point) + + backlogs_page.expect_selected_cards_in_order(stories[2]) + backlogs_page.expect_card_menu_loaded(stories[2]) + backlogs_page.expect_action_menu_groups(invoker: stories[2], selected_count: 1) + + backlogs_page.dismiss_card_menu(stories[2], via: entry_point) + backlogs_page.expect_selected_cards_in_order(stories[2]) + end + end + end + + it "restores card focus when a contextual batch menu is light-dismissed" do + backlogs_page.select_cards(stories[0], stories[1]) + backlogs_page.open_card_menu(stories[1], via: :right_click) + + backlogs_page.light_dismiss_card_menu(stories[1]) + + backlogs_page.expect_work_package_card_focused(stories[1]) + backlogs_page.expect_selected_cards_in_order(stories[0], stories[1]) + end + + it "keeps every singular action bound to its invoking card", :aggregate_failures do + stories.first(2).each do |invoker| + menu = backlogs_page.open_card_menu(invoker, via: :more) + + expect(menu.find(:menuitem, text: I18n.t(:"js.button_open_details"))[:href]) + .to end_with("/backlogs/backlog/details/#{invoker.id}") + expect(menu.find(:menuitem, text: I18n.t(:"js.button_open_fullscreen"))[:href]) + .to end_with("/work_packages/#{invoker.id}") + expect(menu.find(:menuitem, text: "Copy URL to clipboard")[:value]) + .to end_with("/work_packages/#{invoker.id}") + expect(menu.find(:menuitem, text: "Copy work package ID")[:value]).to eq(invoker.id.to_s) + + backlogs_page.dismiss_card_menu(invoker, via: :more) + end + end + + it "passes the ordered batch to a destination dialog and a positional move" do + create(:sprint, project:, name: "Destination") + backlogs_page.visit! + backlogs_page.select_contiguous_cards(stories[1], stories[2]) + + backlogs_page.activate_batch_menu_action( + invoker: stories[2], + action: "Move to sprint", + via: :context_menu_key + ) + backlogs_page.expect_destination_dialog("Move to sprint", work_packages: [stories[1], stories[2]]) + backlogs_page.cancel_destination_dialog("Move to sprint") + + backlogs_page.activate_batch_menu_action( + invoker: stories[1], + action: "Move down", + via: :shift_f10 + ) + + moved_order = [stories[0], stories[3], stories[1], stories[2]] + backlogs_page.expect_sprint_items_in_order(sprint, items: moved_order) + + backlogs_page.visit! + backlogs_page.expect_sprint_items_in_order(sprint, items: moved_order) + end + + it "keeps positional actions when the destination intersection is empty" do + sprint.update_columns(status: "completed") + inbox_stories = Array.new(3) do |index| + create(:work_package, project:, type:, position: index + 1) + end + backlogs_page.visit! + backlogs_page.select_contiguous_cards(inbox_stories[0], inbox_stories[1]) + + backlogs_page.open_card_menu(inbox_stories[0], via: :right_click) + + backlogs_page.expect_action_menu_groups(invoker: inbox_stories[0], selected_count: 2) + backlogs_page.expect_open_menu_actions( + invoker: inbox_stories[0], + present: ["Move to position"], + absent: ["Move to sprint", "Move to backlog bucket", "Move to backlog inbox"] + ) + end + + it "keeps destination actions when a sparse selection has no valid position" do + create(:sprint, project:, name: "Destination") + backlogs_page.visit! + backlogs_page.select_cards(stories[0], stories[2]) + + backlogs_page.open_card_menu(stories[0], via: :more) + + backlogs_page.expect_action_menu_groups(invoker: stories[0], selected_count: 2) + backlogs_page.expect_open_menu_actions( + invoker: stories[0], + present: ["Move to sprint"], + absent: ["Move to position"] + ) + end + + context "when every card is fixed" do + let(:permissions) { %i[view_sprints view_work_packages] } + + it "shows the unchanged singular menu for a fixed card" do + backlogs_page.open_card_menu(stories[0], via: :more) + + backlogs_page.expect_fixed_action_menu(invoker: stories[0]) + backlogs_page.expect_no_selected_cards + end + end +end diff --git a/modules/backlogs/spec/features/work_packages/batch_selection_spec.rb b/modules/backlogs/spec/features/work_packages/batch_selection_spec.rb index b03959102b89..c58245942443 100644 --- a/modules/backlogs/spec/features/work_packages/batch_selection_spec.rb +++ b/modules/backlogs/spec/features/work_packages/batch_selection_spec.rb @@ -218,7 +218,8 @@ expect(page).to have_css("[data-batch-selected]", count: 2) - backlogs_page.open_work_package_details(story3) + backlogs_page.send_work_package_card_keys(story3, [:enter]) + backlogs_page.expect_details_view(story3) expect(page).to have_css("[data-batch-selected]", count: 2) expect(backlogs_page.selected_card_ids).to contain_exactly(story1.id.to_s, story2.id.to_s) diff --git a/modules/backlogs/spec/features/work_packages/card_context_menu_spec.rb b/modules/backlogs/spec/features/work_packages/card_context_menu_spec.rb index 547c74bdfd6f..0e6f3a557c79 100644 --- a/modules/backlogs/spec/features/work_packages/card_context_menu_spec.rb +++ b/modules/backlogs/spec/features/work_packages/card_context_menu_spec.rb @@ -70,7 +70,7 @@ end it "opens the lazily loaded menu on right-click and focuses its first action" do - backlogs_page.right_click_work_package_card(sprint_wp) + backlogs_page.open_card_menu(sprint_wp, via: :right_click) backlogs_page.within_work_package_context_menu(sprint_wp) do |menu| expect(menu).to have_selector(:menuitem, text: I18n.t(:"js.button_open_details"), focused: true) @@ -124,7 +124,7 @@ # position of its own: same menu, same card, so the two ways of asking for it # have no reason to disagree. Only the focus return stays contextual. it "opens the same menu from the keyboard with Shift+F10" do - backlogs_page.send_work_package_card_keys(sprint_wp, %i[shift f10]) + backlogs_page.open_card_menu(sprint_wp, via: :shift_f10) backlogs_page.within_work_package_context_menu(sprint_wp) do |menu| expect(menu).to have_selector(:menuitem, text: I18n.t(:"js.button_open_details"), focused: true) diff --git a/modules/backlogs/spec/features/work_packages/move_via_menu_spec.rb b/modules/backlogs/spec/features/work_packages/move_via_menu_spec.rb index 06d5512802c2..56349314f44a 100644 --- a/modules/backlogs/spec/features/work_packages/move_via_menu_spec.rb +++ b/modules/backlogs/spec/features/work_packages/move_via_menu_spec.rb @@ -84,8 +84,12 @@ # helper: a same-list menu move now settles via a background fetch, not a # frame reload, so the assertion right below has to observe the DOM before # that request has any chance to complete. - backlogs_page - .click_in_sprint_story_move_menu(sprint_wp2, "Move up", wait: false) + backlogs_page.activate_batch_menu_action( + invoker: sprint_wp2, + action: "Move up", + via: :more, + wait: false + ) backlogs_page .expect_sprint_items_in_order(sprint, diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index 7109c93bb1b5..c897f6d3f5c0 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -330,13 +330,139 @@ def click_in_inbox_menu(item_name) end end - def within_work_package_menu(work_package, &) + def within_work_package_menu(work_package, activated: false, &) within_work_package(work_package) do button = find(:button, accessible_name: "Work package actions") within(open_controlled_menu(button), &) end - dismiss_menu(work_package) + dismiss_menu(work_package, activated:) + end + + # The four user entry points Backlogs supports, with the native key and + # pointer synthesis and the deferred-menu wait kept out of the callers. + def open_card_menu(work_package, via:) + case via + when :more + within_work_package(work_package) do + find(:button, accessible_name: "Work package actions").click + end + when :right_click + right_click_work_package_card(work_package) + when :context_menu_key + # Selenium's W3C key table does not expose the dedicated Context Menu + # key. Dispatch its browser-level KeyboardEvent against a focused card + # so this path still differs from Shift+F10 at the controller boundary. + work_package_card(work_package).execute_script(<<~JS) + this.focus({ focusVisible: true, preventScroll: true }); + this.dispatchEvent(new KeyboardEvent('keydown', { + key: 'ContextMenu', + bubbles: true, + cancelable: true + })); + JS + when :shift_f10 + send_work_package_card_keys(work_package, %i[shift f10]) + else + raise ArgumentError, "Unknown card-menu entry point: #{via.inspect}" + end + + work_package_action_menu(work_package) + end + + def expect_action_menu_groups(invoker:, selected_count:) + # The menu list arrives via a deferred fragment; a node captured before + # the swap goes stale, so settle the load before scoping into the menu. + expect_card_menu_loaded(invoker) + menu = work_package_action_menu(invoker) + singular_group = "ul[role='group'][data-sortable-lists--item-target='invokerGroup']" + batch_group = "ul[role='group'][data-sortable-lists--item-target='batchGroup']" + + if selected_count > 1 + expect(menu).to have_css("#{singular_group}[hidden]", visible: :all) + expect(menu).to have_css(batch_group) + expect_menu_invoker_name( + invoker, + I18n.t("js.backlogs.action_menu.menu_label", count: selected_count) + ) + else + expect(menu).to have_css(singular_group) + expect(menu).to have_css(batch_group) + expect_menu_invoker_name(invoker, "Work package actions") + end + end + + def expect_menu_invoker_name(work_package, name) + within_work_package(work_package) do + expect(page).to have_button(accessible_name: name) + end + end + + def activate_singular_menu_action(invoker:, action:, via: :more) + open_card_menu(invoker, via:).find(:menuitem, text: action, exact_text: true).click + end + + def activate_batch_menu_action(invoker:, action:, via: :more, wait: true) + menu = open_card_menu(invoker, via:) + + if %w[Move\ to\ top Move\ up Move\ down Move\ to\ bottom].include?(action) + wait_for_backlogs_turbo_stream(wait:) do + open_move_submenu(menu).find(:menuitem, text: action, exact_text: true, visible: :all).click + end + else + menu.find(:menuitem, text: action, exact_text: true).click + end + end + + def expect_card_menu_anchor(work_package, via:) + if via == :right_click + expect_menu_anchored_contextually(work_package) + else + expect_menu_anchored_at_button(work_package) + end + end + + def expect_card_menu_loaded(work_package) + expect(page.find(menu_owner_overlay_selector(work_package))) + .to have_no_css("include-fragment[src]", visible: :all) + end + + def dismiss_card_menu(work_package, via:) + send_keys_to_focused_element(:escape) + expect_no_work_package_context_menu(work_package) + + if via == :more + within_work_package(work_package) do + expect(page).to have_button(accessible_name: "Work package actions", focused: true) + end + else + expect_work_package_card_focused(work_package) + end + end + + def light_dismiss_card_menu(work_package) + find(:heading, I18n.t(:label_backlog_and_sprints), level: 2).click + expect_no_work_package_context_menu(work_package) + end + + def expect_open_menu_actions(invoker:, present:, absent:) + menu = work_package_action_menu(invoker) + present.each do |label| + expect(menu).to have_selector(:menuitem, text: label, exact_text: true) + end + absent.each do |label| + expect(menu).to have_no_selector(:menuitem, text: label, exact_text: true) + end + end + + def expect_fixed_action_menu(invoker:) + menu = work_package_action_menu(invoker) + + expect(menu).to have_css("ul[role='group'][data-sortable-lists--item-target='invokerGroup']") + expect(menu).to have_no_css("[data-sortable-lists--item-target='batchGroup']") + expect(menu).to have_selector(:menuitem, text: I18n.t(:"js.button_open_details")) + expect(menu).to have_selector(:menuitem, text: I18n.t(:"js.button_open_fullscreen")) + expect(menu).to have_no_selector(:menuitem, text: "Move to position") end def work_package_card(work_package) @@ -508,7 +634,7 @@ def scroll_past_work_package_card(work_package, distance:) end def click_in_work_package_menu(work_package, item_name, wait: true) - within_work_package_menu(work_package) do |submenu| + within_work_package_menu(work_package, activated: true) do |submenu| wait_for_turbo_stream(wait:) do submenu.find(:menuitem, text: item_name).click end @@ -1314,6 +1440,10 @@ def menu_owner_overlay_selector(menu_owner) "##{ActionView::RecordIdentifier.dom_target(menu_owner, :menu)}-overlay" end + def work_package_action_menu(work_package) + page.find(menu_owner_overlay_selector(work_package)).find(:menu) + end + # Located by the lock's accessible name so the expectation fails if the # icon ever loses the text that explains it. def readonly_lock_selector @@ -1722,22 +1852,39 @@ def open_move_submenu(menu) page.find(:menu, id: move_item["aria-controls"]) end - def dismiss_menu(menu_owner) + # Dismisses with Escape rather than an outside click: the compact batch + # menu can leave a live menuitem under the overlay's centre point, so a + # click there activates an action instead of closing the menu. + # Activating an item closes the menu through Primer itself, so the caller + # only waits for that to happen. Pressing Escape on top of it races the + # dialog the same activation is still loading: the modal guard below reads + # the DOM before `showModal`, and the key then arrives after it and closes + # the dialog, which `StreamActions.dialog` removes from the DOM on close. + def dismiss_menu(menu_owner, activated: false) selector = menu_owner_overlay_selector(menu_owner) + return expect(page).to have_no_css(selector, visible: :visible) if activated return unless page.has_css?(selector, visible: true, wait: 0) return if page.has_selector?(:modal, wait: 0) - find(selector, wait: 0).click - rescue Capybara::ElementNotFound, - Ferrum::CoordinatesNotFoundError, - Selenium::WebDriver::Error::ElementNotInteractableError - # Menu actions can close the menu or open a modal between the checks - # above and the click; once the menu is gone or a modal owns focus, - # the overlay is unclickable (Cuprite raises CoordinatesNotFoundError, - # Selenium ElementNotInteractableError) and dismissal is moot. - raise unless page.has_selector?(:modal, wait: 0) || - page.has_no_css?(selector, visible: true, wait: 0) + press_escape + # Escape closes only the top-most popover; an open submenu absorbs the + # first press and the menu itself needs a second one. + press_escape if page.has_css?(selector, visible: true, wait: 0) + + expect(page).to have_no_css(selector, visible: :visible) + end + + # Neither driver may deliver the key through the focused element: + # Selenium's Session#send_keys refuses once a closing submenu parked focus + # on a hidden item, and Cuprite's clicks its receiver first. Both paths + # below dispatch the key without touching any element. + def press_escape + if using_cuprite? + page.driver.browser.keyboard.type(:escape) + else + send_keys_to_focused_element(:escape) + end end def headed_section_titles(id_prefix:) diff --git a/spec/components/sortable_lists/move_menu_items_spec.rb b/spec/components/sortable_lists/move_menu_items_spec.rb new file mode 100644 index 000000000000..7c2d0cc24909 --- /dev/null +++ b/spec/components/sortable_lists/move_menu_items_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe SortableLists::MoveMenuItems, type: :component do + describe "DIRECTIONS" do + it "carries direction, label, icon and legacy move_to for every entry, in menu order" do + expect(described_class::DIRECTIONS).to eq( + [ + { direction: :top, label: :label_sort_highest, icon: :"move-to-top", move_to: :highest }, + { direction: :up, label: :label_sort_higher, icon: :"chevron-up", move_to: :higher }, + { direction: :down, label: :label_sort_lower, icon: :"chevron-down", move_to: :lower }, + { direction: :bottom, label: :label_sort_lowest, icon: :"move-to-bottom", move_to: :lowest } + ] + ) + end + + it "is frozen as an array and entry by entry" do + expect(described_class::DIRECTIONS).to be_frozen + expect(described_class::DIRECTIONS).to all(be_frozen) + end + end + + describe "#render_into" do + before do + render_inline(Primer::Alpha::ActionMenu::List.new(menu_id: "test-menu")) do |list| + described_class.new(dom_key: :story).render_into(list) + end + end + + it "renders one item per direction, in menu order" do + expect(page).to have_css("li", count: 4) + expect(page.all("li").map { it.text.strip }) + .to eq(["Move to top", "Move up", "Move down", "Move to bottom"]) + end + + # The wiring must sit on the `
    • `, not the button: the controller's targets + # and the action-menu API's disableItem/enableItem both address that element. + it "renders every item with its dom_key id, label, icon and Stimulus wiring", + :aggregate_failures do + [ + ["top", "story_menu_top", "Move to top", "octicon-move-to-top"], + ["up", "story_menu_up", "Move up", "octicon-chevron-up"], + ["down", "story_menu_down", "Move down", "octicon-chevron-down"], + ["bottom", "story_menu_bottom", "Move to bottom", "octicon-move-to-bottom"] + ].each do |direction, id, label, icon| + item = page.find( + :element, :li, + "data-sortable-lists--item-direction-param": direction, + "data-sortable-lists--item-target": "moveItem", + "data-action": "click->sortable-lists--item#move" + ) + expect(item).to have_element(:button, id:, text: label) + expect(item).to have_css(".#{icon}") + end + end + end +end