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 `
';
- 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