From 12c902dda4c7fd7908ea90fbb7ee66c8003baf44 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 16 Aug 2026 23:45:40 +0100 Subject: [PATCH 01/27] [AGILE-362] Add batch action scopes Adds live action scopes for ordered destination batches while keeping selection state out of the rendered menu. Intersects candidates across mobility and list ownership. https://community.openproject.org/wp/AGILE-362 --- .../dynamic/sortable-lists.controller.ts | 36 +++++++++- .../dynamic/sortable-lists/drag-and-drop.ts | 4 ++ .../sortable-lists/item.controller.spec.ts | 16 +++++ .../dynamic/sortable-lists/list-dom.spec.ts | 68 +++++++++++++++++++ .../dynamic/sortable-lists/list-dom.ts | 18 +++++ .../sortable-lists/list.controller.spec.ts | 4 ++ .../scrollable.controller.spec.ts | 4 ++ 7 files changed, 149 insertions(+), 1 deletion(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index bc24e635be14..438f7b9cb744 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -62,12 +62,14 @@ import { restoreRowPositions, rowOf, rowsRemainAt, + permittedDestinations, + sameDestination, sortableListsBusyAttribute, type DestinationIdentity, type MoveAvailability, type MoveDirection, } from './sortable-lists/list-dom'; -import { SelectionOrchestrator, type SelectionHost } from './sortable-lists/selection-orchestrator'; +import { SelectionOrchestrator, type ActionScope, type SelectionHost } from './sortable-lists/selection-orchestrator'; import { itemIdentity, orderedItemElements } from './sortable-lists/selection'; type CleanupFn = () => void; @@ -235,6 +237,38 @@ export default class SortableListsController extends Controller imp } } + // Live ordered membership, for AGILE-278's batch move. + selectedItems():SelectionItem[] { + return this.selection?.selectedItems() ?? []; + } + + actionScopeFor(itemElement:HTMLElement):ActionScope { + return this.selection?.actionScopeFor(itemElement) ?? { kind: 'refused', items: [] }; + } + + selectForAction(itemElement:HTMLElement):ActionScope { + if (this.busy) { + return this.actionScopeFor(itemElement); + } + + return this.selection?.selectForAction(itemElement) ?? { kind: 'refused', items: [] }; + } + + // Where the batch may move: the candidates every member accepts, minus the + // one they all already occupy, which would be a move to nowhere. + availableDestinations(scope:ActionScope, candidates:DestinationIdentity[]):DestinationIdentity[] { + if (scope.kind === 'refused') { + return []; + } + + const ownerDestinationOf = (item:HTMLElement) => this.ownerDestinationOf(item); + const permitted = permittedDestinations({ items: scope.items, candidates, ownerDestinationOf }); + + return permitted.filter((target) => ( + !scope.items.every((item) => sameDestination(ownerDestinationOf(item), target)) + )); + } + // Frozen at drag start and consumed exactly once per drop, cancelled ones // included: neither Escape nor a mid-drag morph can change what is // submitted, and no stale batch leaks into the next drag. 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 600f4d84b726..1db59cc6d5e7 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 @@ -55,6 +55,7 @@ import { type MoveAvailability, type MoveDirection, } from './list-dom'; +import type { ActionScope } from './selection-orchestrator'; // The Pragmatic DnD payloads exchanged between the sortable-lists root and // item controllers, built on top of the DOM contract in list-dom.ts. @@ -102,6 +103,9 @@ export interface SortableListData extends Record { export interface SortableListsRoot { readonly element:HTMLElement; readonly busy:boolean; + actionScopeFor(itemElement:HTMLElement):ActionScope; + selectForAction(itemElement:HTMLElement):ActionScope; + availableDestinations(scope:ActionScope, candidates:DestinationIdentity[]):DestinationIdentity[]; moveInDirection(itemElement:HTMLElement, direction:MoveDirection):void; // A snapshot for menu gating; the click path re-resolves against the live DOM. moveAvailability(itemElement:HTMLElement):MoveAvailability|null; 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 1fa208a97d2d..9a51101bed6b 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 @@ -57,6 +57,7 @@ import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test- import type { ActionEvent } from '@hotwired/stimulus'; import type ItemControllerType from './item.controller'; import type { SortableListsRoot } from './drag-and-drop'; +import type { ActionScope } from './selection-orchestrator'; import type { DestinationIdentity } from './list-dom'; describe('Sortable lists item controller', () => { @@ -104,6 +105,9 @@ describe('Sortable lists item controller', () => { return { element, busy, + actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + availableDestinations: vi.fn(() => []), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), ownerRowsContainer: vi.fn(ownerRowsContainer), @@ -1135,6 +1139,9 @@ describe('Sortable lists item controller', () => { controller.connectRoot({ element: row, busy: false, + actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + availableDestinations: vi.fn(() => []), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), ownerRowsContainer: vi.fn(() => null), @@ -1228,6 +1235,9 @@ describe('Sortable lists item controller', () => { controller.connectRoot({ element: row, busy: false, + actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + availableDestinations: vi.fn(() => []), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), ownerRowsContainer: vi.fn(() => null), @@ -1638,6 +1648,9 @@ describe('Sortable lists item controller', () => { const root:SortableListsRoot = { element: item, busy: false, + actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + availableDestinations: vi.fn(() => []), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), ownerRowsContainer: vi.fn(() => null), @@ -1666,6 +1679,9 @@ describe('Sortable lists item controller', () => { const root:SortableListsRoot = { element: item, busy: false, + actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + availableDestinations: vi.fn(() => []), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), ownerRowsContainer: vi.fn(() => null), diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts index 7fe83a4f7a2d..84a6331051d9 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts @@ -31,6 +31,7 @@ import { isOrderableItem, itemAcceptsDestination, itemMobility, + permittedDestinations, reorderRows, sortableItemMobilityAttribute, resolveDirectionalPreviousItemId, @@ -43,6 +44,7 @@ import { restoreRowPositions, rowOf, rowsRemainAt, + sameDestination, } from './list-dom'; describe('sortable lists DOM helpers', () => { @@ -455,6 +457,72 @@ describe('itemAcceptsDestination', () => { }); }); +describe('destination intersection', () => { + const inbox = { type: 'inbox', id: null }; + const sprint1 = { type: 'sprint', id: '1' }; + const sprint2 = { type: 'sprint', id: '2' }; + const bucket1 = { type: 'bucket', id: '1' }; + + function item(mobility:'fixed'|'confined'|'free' = 'free'):HTMLElement { + const element = document.createElement('li'); + element.setAttribute(sortableItemMobilityAttribute, mobility); + return element; + } + + it('permits every candidate for free-only members', () => { + const freeOne = item(); + const freeTwo = item(); + + expect(permittedDestinations({ + items: [freeOne, freeTwo], + candidates: [inbox, sprint1, sprint2, bucket1], + ownerDestinationOf: () => null, + })).toEqual([inbox, sprint1, sprint2, bucket1]); + }); + + it('intersects a confined member with free members', () => { + const confinedInSprint = item('confined'); + const freeInBucket = item(); + const ownerDestinationOf = (member:HTMLElement) => member === confinedInSprint ? sprint1 : bucket1; + + expect(permittedDestinations({ + items: [confinedInSprint, freeInBucket], + candidates: [inbox, sprint1, sprint2, bucket1], + ownerDestinationOf, + })).toEqual([sprint1]); + }); + + it('rejects confined members belonging to different destinations', () => { + const confinedInSprint = item('confined'); + const confinedInBucket = item('confined'); + const ownerDestinationOf = (member:HTMLElement) => member === confinedInSprint ? sprint1 : bucket1; + + expect(permittedDestinations({ + items: [confinedInSprint, confinedInBucket], + candidates: [inbox, sprint1, bucket1], + ownerDestinationOf, + })).toEqual([]); + }); + + it('rejects every destination when a member is fixed', () => { + expect(permittedDestinations({ + items: [item('fixed'), item()], + candidates: [inbox, sprint1], + ownerDestinationOf: () => null, + })).toEqual([]); + }); + + it('identifies targets occupied by every member independently of permission', () => { + const freeInSprint = item(); + const anotherFreeInSprint = item(); + const items = [freeInSprint, anotherFreeInSprint]; + const ownerDestinationOf = (_member:HTMLElement) => sprint1; + const permitted = permittedDestinations({ items, candidates: [sprint1, sprint2], ownerDestinationOf }); + + expect(permitted.filter((target) => !items.every((member) => sameDestination(ownerDestinationOf(member), target)))).toEqual([sprint2]); + }); +}); + describe('directional move helpers', () => { function container(ids:string[]):HTMLElement { const ul = document.createElement('ul'); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts index d85962b6045c..eae516436f3f 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts @@ -142,6 +142,24 @@ export function itemAcceptsDestination( } } +export function permittedDestinations({ + items, + candidates, + ownerDestinationOf, +}:{ + items:HTMLElement[]; + candidates:DestinationIdentity[]; + ownerDestinationOf:(item:HTMLElement) => DestinationIdentity|null; +}):DestinationIdentity[] { + if (items.length === 0 || items.some((item) => itemMobility(item) === 'fixed')) { + return []; + } + + return candidates.filter((target) => ( + items.every((item) => itemMobility(item) === 'free' || sameDestination(ownerDestinationOf(item), target)) + )); +} + export function resolveItemType(element:Element):string|null { const type = element.getAttribute(sortableItemTypeAttribute); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts index 027d6882f171..7073802fb5d6 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts @@ -37,6 +37,7 @@ import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test- import type ListControllerType from './list.controller'; import type { sortableItemData as sortableItemDataFn, SortableListsRoot } from './drag-and-drop'; import type { DestinationIdentity } from './list-dom'; +import type { ActionScope } from './selection-orchestrator'; // The list controller is tested in ISOLATION: the root drives the outlet // hand-over in production (sortable-lists.controller.ts), so here we render only @@ -75,6 +76,9 @@ describe('Sortable lists list controller', () => { return { element, busy, + actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + availableDestinations: vi.fn(() => []), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), ownerRowsContainer: vi.fn(() => null), diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts index 7436950072f0..fcfb1489ca8b 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts @@ -30,6 +30,7 @@ import { type autoScrollForElements as autoScrollForElementsFn } from '@atlaskit import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; import type ScrollableControllerType from './scrollable.controller'; import type { sortableItemData as sortableItemDataFn, SortableListsRoot } from './drag-and-drop'; +import type { ActionScope } from './selection-orchestrator'; vi.mock('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element', () => ({ autoScrollForElements: vi.fn(() => vi.fn()), @@ -70,6 +71,9 @@ describe('Sortable lists scrollable controller', () => { return { element, busy: false, + actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + availableDestinations: vi.fn(() => []), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), ownerRowsContainer: vi.fn(() => null), From 03194095e9b9848932c89dc8f8dde321f24a95ac Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 16 Aug 2026 23:45:40 +0100 Subject: [PATCH 02/27] [AGILE-362] Project batch destinations Projects server candidates from the live action scope. Omits unavailable and fully occupied destinations while covering deferred, confined, and fixed-card projection. https://community.openproject.org/wp/AGILE-362 --- .../sortable-lists/item.controller.spec.ts | 180 +++++++++++++++++- .../dynamic/sortable-lists/item.controller.ts | 67 ++++++- .../work_package_card_menu_component.html.erb | 9 +- .../work_package_card_menu_component.rb | 32 +++- .../backlogs/work_packages_controller.rb | 15 +- .../work_package_card_menu_component_spec.rb | 59 ++++-- .../backlogs/work_packages_controller_spec.rb | 36 +--- 7 files changed, 308 insertions(+), 90 deletions(-) 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 9a51101bed6b..16334cde346f 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 @@ -57,8 +57,8 @@ import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test- import type { ActionEvent } from '@hotwired/stimulus'; import type ItemControllerType from './item.controller'; import type { SortableListsRoot } from './drag-and-drop'; -import type { ActionScope } from './selection-orchestrator'; import type { DestinationIdentity } from './list-dom'; +import type { ActionScope } from './selection-orchestrator'; describe('Sortable lists item controller', () => { let draggable:typeof draggableFn; @@ -1373,13 +1373,13 @@ describe('Sortable lists item controller', () => { 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 ? '
  • ' : '') - + ['top', 'up', 'down', 'bottom'].map((direction) => ( - `
  • ' - )).join(''); + menuElement.innerHTML = withDivider ? '
  • ' : ''; 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); el.appendChild(menuElement); @@ -1399,6 +1399,16 @@ describe('Sortable lists item controller', () => { } const liFor = (el:HTMLElement, direction:string) => el.querySelector(`li[data-sortable-lists--item-direction-param="${direction}"]`)!; + 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); + return item; + }; + const destinationFor = (el:HTMLElement, candidates:{ type:string; id:string|null }[]) => ( + destinationWithMetadata(el, JSON.stringify(candidates)) + ); // Availability defaults to the first/last extremes so the position-driven // specs read naturally; individual tests can override the map to exercise // the marker-aware (truncated list) wiring. @@ -1414,9 +1424,23 @@ describe('Sortable lists item controller', () => { moveInDirection = vi.fn(), availability = availabilityFromPosition(position), ) => ({ - element: el, busy: false, moveAvailability: () => availability, moveInDirection, + element: el, + busy: false, + actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), + availableDestinations: vi.fn(() => []), + moveAvailability: () => availability, + moveInDirection, } as unknown as SortableListsRoot); + function stubMenuRoot(el:HTMLElement, position:{ isFirst:boolean; isLast:boolean }) { + const actionScopeFor = vi.fn(():ActionScope => ({ kind: 'refused', items: [] })); + const availableDestinations = vi.fn((_scope:ActionScope, _candidates:DestinationIdentity[]):DestinationIdentity[] => []); + const root = { ...stubRoot(el, position), actionScopeFor, availableDestinations }; + + return { root, actionScopeFor, availableDestinations }; + } + it('hides up/top for a first item and shows the rest', async () => { const { el, menu } = renderItemWithMenu(1); document.body.appendChild(el); @@ -1471,6 +1495,146 @@ describe('Sortable lists item controller', () => { expect(menu.hideItem).toHaveBeenCalledWith(parent); }); + it('projects deferred destination items for the selected invoker', async () => { + const { el, menu } = renderItemWithMenu(1, true); + document.body.appendChild(el); + const controller = await mountItemController(el); + const scope:ActionScope = { kind: 'batch', items: [el]}; + const { root, actionScopeFor, availableDestinations } = stubMenuRoot(el, { isFirst: false, isLast: false }); + actionScopeFor.mockReturnValue(scope); + availableDestinations.mockImplementation((_scope, candidates) => ( + candidates.filter((candidate) => candidate.type === 'inbox') + )); + controller.connectRoot(root); + + const moveToSprint = destinationFor(el, [{ type: 'sprint', id: '1' }]); + const moveToInbox = destinationFor(el, [{ type: 'inbox', id: null }]); + await menuCtx!.nextFrame(); + + expect(actionScopeFor).toHaveBeenCalledWith(el); + expect(menu.hideItem).toHaveBeenCalledWith(moveToSprint); + expect(menu.showItem).toHaveBeenCalledWith(moveToInbox); + }); + + it('recomputes selected multi-card and prospective one-card scopes whenever the menu opens', async () => { + const { el, menu } = renderItemWithMenu(1); + document.body.appendChild(el); + const controller = await mountItemController(el); + const { root, actionScopeFor, availableDestinations } = stubMenuRoot(el, { isFirst: false, isLast: false }); + const selectedPeer = document.createElement('div'); + selectedPeer.setAttribute('data-sortable-lists--item-id-value', '2'); + selectedPeer.setAttribute('data-sortable-lists--item-mobility-value', 'free'); + const selectedScope:ActionScope = { kind: 'batch', items: [el, selectedPeer]}; + const prospectiveScope:ActionScope = { kind: 'batch', items: [el]}; + let activeScope = selectedScope; + actionScopeFor.mockImplementation(() => activeScope); + availableDestinations.mockImplementation((scope, candidates) => ( + candidates.filter((candidate) => candidate.type === (scope === selectedScope ? 'sprint' : 'inbox')) + )); + controller.connectRoot(root); + + const moveToSprint = destinationFor(el, [{ type: 'sprint', id: '1' }]); + const moveToInbox = destinationFor(el, [{ type: 'inbox', id: null }]); + await menuCtx!.nextFrame(); + + expect(availableDestinations).toHaveBeenCalledWith(selectedScope, [{ type: 'sprint', id: '1' }]); + expect(availableDestinations).toHaveBeenCalledWith(selectedScope, [{ type: 'inbox', id: null }]); + expect(menu.showItem).toHaveBeenCalledWith(moveToSprint); + expect(menu.hideItem).toHaveBeenCalledWith(moveToInbox); + menu.hideItem.mockClear(); + menu.showItem.mockClear(); + activeScope = prospectiveScope; + + const menuElement = el.querySelector('action-menu')!; + const toggle = new ToggleEvent('toggle', { newState: 'open', oldState: 'closed' }); + menuElement.dispatchEvent(toggle); + + expect(actionScopeFor).toHaveBeenLastCalledWith(el); + expect(availableDestinations).toHaveBeenCalledWith(prospectiveScope, [{ type: 'sprint', id: '1' }]); + expect(availableDestinations).toHaveBeenCalledWith(prospectiveScope, [{ type: 'inbox', id: null }]); + expect(menu.hideItem).toHaveBeenCalledWith(moveToSprint); + expect(menu.showItem).toHaveBeenCalledWith(moveToInbox); + }); + + it('hides destination actions and the position submenu for a true multi-card scope', async () => { + const { el, menu } = renderItemWithMenu(1, true); + document.body.appendChild(el); + const controller = await mountItemController(el); + // Batch size is the member count: two elements, not one element with + // two ids. + 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([]); + controller.connectRoot(root); + + const moveToSprint = destinationFor(el, [{ type: 'sprint', id: '1' }]); + const moveToInbox = destinationFor(el, [{ type: 'inbox', id: null }]); + 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.hideItem).toHaveBeenCalledWith(moveMenu); + expect(menu.hideItem).not.toHaveBeenCalledWith(liFor(el, 'top')); + expect(divider.hasAttribute('hidden')).toBe(true); + }); + + it('keeps only the current owner destination for a confined batch scope', async () => { + const { el, menu } = renderItemWithMenu(1); + el.setAttribute('data-sortable-lists--item-mobility-value', 'confined'); + document.body.appendChild(el); + const controller = await mountItemController(el); + const { root, actionScopeFor, availableDestinations } = stubMenuRoot(el, { isFirst: false, isLast: false }); + const scope:ActionScope = { kind: 'batch', items: [el]}; + actionScopeFor.mockReturnValue(scope); + availableDestinations.mockImplementation((_scope, candidates) => candidates.filter((candidate) => candidate.id === '12')); + controller.connectRoot(root); + + const moveToSprint = destinationFor(el, [{ type: 'sprint', id: '12' }, { type: 'sprint', id: '13' }]); + await menuCtx!.nextFrame(); + + expect(availableDestinations).toHaveBeenCalledWith(scope, [{ type: 'sprint', id: '12' }, { type: 'sprint', id: '13' }]); + expect(menu.showItem).toHaveBeenCalledWith(moveToSprint); + }); + + it('hides batch destinations for a fixed synthetic singular scope', async () => { + const { el, menu } = renderItemWithMenu(1); + el.setAttribute('data-sortable-lists--item-mobility-value', 'fixed'); + document.body.appendChild(el); + const controller = await mountItemController(el); + const { root, actionScopeFor, availableDestinations } = stubMenuRoot(el, { isFirst: false, isLast: false }); + const scope:ActionScope = { kind: 'refused', items: [] }; + actionScopeFor.mockReturnValue(scope); + availableDestinations.mockReturnValue([]); + controller.connectRoot(root); + + const moveToSprint = destinationFor(el, [{ type: 'sprint', id: '12' }]); + await menuCtx!.nextFrame(); + + expect(availableDestinations).toHaveBeenCalledWith(scope, [{ type: 'sprint', id: '12' }]); + expect(menu.hideItem).toHaveBeenCalledWith(moveToSprint); + }); + + it.each([ + ['invalid JSON', '{invalid'], + ['non-array JSON', '{"type":"sprint","id":"12"}'], + ['a malformed candidate member', '[{"type":"sprint","id":"12"},{"type":"sprint"}]'], + ])('fails closed for %s destination metadata', async (_description, metadata) => { + const { el, menu } = renderItemWithMenu(1); + document.body.appendChild(el); + const controller = await mountItemController(el); + const { root, availableDestinations } = stubMenuRoot(el, { isFirst: false, isLast: false }); + controller.connectRoot(root); + + const destination = destinationWithMetadata(el, metadata); + await menuCtx!.nextFrame(); + + expect(menu.hideItem).toHaveBeenCalledWith(destination); + 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. @@ -1542,7 +1706,7 @@ describe('Sortable lists item controller', () => { expect(() => { controller.connectRoot(stubRoot(el, { isFirst: true, isLast: true }, moveInDirection)); // @ts-expect-error exercising the guard directly - controller.refreshMoveMenuAvailability?.(); + controller.refreshActionAvailability?.(); }).not.toThrow(); // move() must also no-op rather than throw: there is no menu to read 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 80e7f2e281d5..e73b12c6b2c4 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -57,14 +57,27 @@ import { resolveItemExternalUrl, resolveItemLabel, sortableItemSelector, + type DestinationIdentity, } from './list-dom'; import { webLinkHref } from './external-data'; import { renderDragPreview } from './preview'; +import type { ActionScope } from './selection-orchestrator'; type CleanupFn = () => void; +function isDestinationIdentity(candidate:unknown):candidate is DestinationIdentity { + if (typeof candidate !== 'object' || candidate === null) { + return false; + } + + return 'type' in candidate + && 'id' in candidate + && typeof candidate.type === 'string' + && (typeof candidate.id === 'string' || candidate.id === null); +} + export default class ItemController extends Controller implements RootAwareChild { - static targets = ['handle', 'preview', 'moveItem', 'moveMenu', 'moveDivider', 'focus']; + static targets = ['handle', 'preview', 'destinationItem', 'moveItem', 'moveMenu', 'moveDivider', 'focus']; static elements = { menu: 'action-menu' }; static values = { @@ -89,6 +102,7 @@ export default class ItemController extends Controller implements R declare readonly hasHandleTarget:boolean; declare readonly previewTarget:HTMLElement; declare readonly hasPreviewTarget:boolean; + declare readonly destinationItemTargets:HTMLElement[]; declare readonly moveItemTargets:HTMLElement[]; declare readonly moveMenuTarget:HTMLElement; declare readonly hasMoveMenuTarget:boolean; @@ -114,7 +128,7 @@ export default class ItemController extends Controller implements R // `instanceof ToggleEvent` so a browser without the ToggleEvent global // cannot throw. if ((event as ToggleEvent).newState === 'open') { - this.refreshMoveMenuAvailability(); + this.refreshActionAvailability(); } }; @@ -135,15 +149,19 @@ export default class ItemController extends Controller implements R this.disconnectRoot(); } - // A move item entering the DOM (inline or via a deferred fragment) triggers - // an availability refresh here, but `this.root` is usually still unset at + // An action item entering the DOM (inline or via a deferred fragment) + // triggers an availability refresh here, but `this.root` is usually unset at // this point (the outlet's connectRoot callback runs later), so this call // typically no-ops. The menu-open toggle handler is what actually // establishes correct availability, refreshing on every open once the root // is connected and after any reorder has shifted siblings. No // include-fragment knowledge, so both hooks work for any menu. moveItemTargetConnected():void { - this.refreshMoveMenuAvailability(); + this.refreshActionAvailability(); + } + + destinationItemTargetConnected():void { + this.refreshActionAvailability(); } move(event:ActionEvent):void { @@ -463,12 +481,47 @@ export default class ItemController extends Controller implements R this.dropIndicatorElement = undefined; } - private refreshMoveMenuAvailability():void { + private refreshActionAvailability():void { const root = this.root; if (!root || !this.hasMenuElement) { return; } + const scope = root.actionScopeFor(this.element); + this.refreshDestinationAvailability(root, scope); + this.refreshMoveMenuAvailability(root, scope); + this.refreshMoveDivider(); + } + + private refreshDestinationAvailability(root:SortableListsRoot, scope:ActionScope):void { + for (const item of this.destinationItemTargets) { + const candidates = this.destinationCandidates(item); + this.setAvailability(item, candidates.length > 0 && root.availableDestinations(scope, candidates).length > 0); + } + } + + private destinationCandidates(item:HTMLElement):DestinationIdentity[] { + try { + const candidates:unknown = JSON.parse(item.dataset.sortableListsDestinations ?? ''); + if (!Array.isArray(candidates)) { + return []; + } + + const destinations = candidates.filter(isDestinationIdentity); + return destinations.length === candidates.length ? destinations : []; + } catch { + return []; + } + } + + private refreshMoveMenuAvailability(root:SortableListsRoot, scope:ActionScope):void { + if (scope.kind === 'batch' && scope.items.length > 1) { + if (this.hasMoveMenuTarget) { + this.setAvailability(this.moveMenuTarget, false); + } + return; + } + // Null availability means the item is not in a list yet; leave the menu // alone until the outlet wiring settles. const availability = root.moveAvailability(this.element); @@ -491,8 +544,6 @@ export default class ItemController extends Controller implements R if (this.hasMoveMenuTarget) { this.setAvailability(this.moveMenuTarget, available > 0); } - - this.refreshMoveDivider(); } // The divider that opens the move group is rendered server-side from a 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 8136bb615e92..0265c59b6de8 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 @@ -76,7 +76,8 @@ See COPYRIGHT and LICENSE files for more details. label: t(".action_menu.move_to_inbox"), tag: :button, href: move_project_backlogs_work_package_path(project, work_package), - form_arguments: { method: :put, inputs: [{ name: "list_type", value: inbox_list_type }] } + form_arguments: { method: :put, inputs: [{ name: "list_type", value: inbox_list_type }] }, + data: destination_data(inbox_list_type, []) ) do |item| item.with_leading_visual_icon(icon: :inbox) end @@ -87,7 +88,8 @@ See COPYRIGHT and LICENSE files for more details. 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_package_path(project, work_package), - content_arguments: { data: { controller: "async-dialog" } } + content_arguments: { data: { controller: "async-dialog" } }, + data: destination_data(bucket_list_type, bucket_ids) ) do |item| item.with_leading_visual_icon(icon: :package) end @@ -98,7 +100,8 @@ See COPYRIGHT and LICENSE files for more details. id: dom_target(work_package, :menu, :move_to_sprint), label: t(".action_menu.move_to_sprint"), href: move_to_sprint_dialog_project_backlogs_work_package_path(project, work_package), - content_arguments: { data: { controller: "async-dialog" } } + content_arguments: { data: { controller: "async-dialog" } }, + data: destination_data(sprint_list_type, sprint_ids) ) do |item| item.with_leading_visual_icon(icon: :zap) 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 c93b030d452c..2294b8b79f60 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 @@ -36,15 +36,15 @@ class WorkPackageCardMenuComponent < ApplicationComponent include CommonHelper include Concerns::WorkPackageMovability - attr_reader :work_package, :project, :open_sprints_exist, :other_buckets_exist, :current_user + attr_reader :work_package, :project, :sprint_ids, :bucket_ids, :current_user - def initialize(work_package:, project:, open_sprints_exist:, other_buckets_exist:, current_user: User.current) + def initialize(work_package:, project:, sprint_ids:, bucket_ids:, current_user: User.current) super() @work_package = work_package @project = project - @open_sprints_exist = open_sprints_exist - @other_buckets_exist = other_buckets_exist + @sprint_ids = sprint_ids + @bucket_ids = bucket_ids @current_user = current_user end @@ -62,15 +62,15 @@ def show_move_items? end def show_move_to_inbox? - movable? && (work_package.sprint_id? || work_package.backlog_bucket_id?) + sortable? end def show_move_to_backlog_bucket? - movable? && other_buckets_exist + sortable? && bucket_ids.any? end def show_move_to_sprint? - movable? && open_sprints_exist + sortable? && sprint_ids.any? end def show_move_submenu? @@ -106,5 +106,23 @@ def build_move_item(menu, label:, icon:, direction:) def inbox_list_type Backlogs::Target::InboxId.list_type end + + def sprint_list_type + Backlogs::Target::SprintId[nil].list_type + end + + def bucket_list_type + Backlogs::Target::BucketId[nil].list_type + end + + def destination_data(type, ids) + candidates = ids.map { |id| { type:, id: id.to_s } } + candidates = [{ type:, id: nil }] if type == Backlogs::Target::InboxId.list_type + + { + sortable_lists__item_target: "destinationItem", + sortable_lists_destinations: candidates.to_json + } + end end end diff --git a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb index 385097cff9ac..7028f1b86fa2 100644 --- a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb @@ -43,8 +43,8 @@ def menu render(Backlogs::WorkPackageCardMenuComponent.new( project: @project, work_package: @work_package, - open_sprints_exist: target_open_sprints.exists?, - other_buckets_exist: target_buckets.exists?, + sprint_ids: Sprint.assignable(project: @project, user: current_user).order_by_date.ids, + bucket_ids: BacklogBucket.for_project(@project).order_alphabetically.ids, current_user: ), layout: false) @@ -335,17 +335,6 @@ def move_path move_project_backlogs_work_package_path(@project, @work_package, backlog_filter_params) end - def target_open_sprints - Sprint.for_project(@project) - .visible.not_completed - .where.not(id: @work_package.sprint_id) - end - - def target_buckets - BacklogBucket.where(project: @project) - .where.not(id: @work_package.backlog_bucket_id) - end - # After a move the work package might no longer be visible: the page's active # sprint/bucket filters (the move dialogs offer every destination, filtered or # not) can hide the destination list, and for backlog destinations the project 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 68995222ff20..1dbb71015da4 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 @@ -72,12 +72,14 @@ def create_story(position:, subject: "Test Story", story_points: 5) backlog_bucket: bucket) end - def render_component(work_package: self.work_package, open_sprints_exist: true, other_buckets_exist: true) + def render_component(work_package: self.work_package, + sprint_ids: [sprint].compact.map(&:id), + bucket_ids: [bucket].compact.map(&:id)) render_inline(described_class.new( work_package:, project:, - open_sprints_exist:, - other_buckets_exist:, + sprint_ids:, + bucket_ids:, current_user: user )) end @@ -188,6 +190,22 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, end describe "move menu items" do + it "renders project-level destination metadata including the invoking card's current target" do + other_sprint = create(:sprint, project:, name: "Sprint 2", start_date: Date.yesterday, finish_date: Date.tomorrow) + bucket = create(:backlog_bucket, project:) + + render_component(sprint_ids: [sprint.id, other_sprint.id], bucket_ids: [bucket.id]) + + expect(page).to have_css( + "li[data-sortable-lists--item-target~='destinationItem']" \ + "[data-sortable-lists-destinations*='\"type\":\"sprint\"']" + ) + expect(page).to have_css("li[data-sortable-lists-destinations*='\"id\":\"#{sprint.id}\"']") + expect(page).to have_css( + "li[data-sortable-lists-destinations*='\"type\":\"#{Backlogs::Target::InboxId.list_type}\"']" + ) + end + it "shows Move to top item with move-to-top icon" do render_component @@ -217,7 +235,7 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, end it "always renders the four move items as client targets", :aggregate_failures do - render_inline(described_class.new(work_package:, project:, open_sprints_exist: false, other_buckets_exist: false)) + render_inline(described_class.new(work_package:, project:, sprint_ids: [], bucket_ids: [])) # The target, direction, and action live on the item
  • , not the content button. %w[top up down bottom].each do |direction| @@ -264,7 +282,7 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, end it "still shows the Move to position submenu (permission-gated only)" do - render_component(open_sprints_exist: false, other_buckets_exist: false) + render_component(sprint_ids: [], bucket_ids: []) expect(page).to have_selector(:menuitem, text: "Move to position") end @@ -275,7 +293,7 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, describe "Move to sprint item" do context "when work package is in a sprint" do it "is shown with zap icon" do - render_component(open_sprints_exist: true) + render_component(sprint_ids: [sprint.id]) expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) expect(page).to have_octicon(:zap) @@ -288,7 +306,7 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, let(:bucket) { create(:backlog_bucket, project:) } it "is shown" do - render_component(open_sprints_exist: true) + render_component(sprint_ids: [create(:sprint, project:).id]) expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) end @@ -298,14 +316,14 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, let(:sprint) { nil } it "is shown" do - render_component(open_sprints_exist: true) + render_component(sprint_ids: [create(:sprint, project:).id]) expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) end end - it "is hidden when open_sprints_exist is false" do - render_component(open_sprints_exist: false) + it "is hidden when no sprint candidates exist" do + render_component(sprint_ids: []) expect(page).to have_no_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) end @@ -337,10 +355,10 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, context "when work package is already in the inbox (no sprint, no bucket)" do let(:sprint) { nil } - it "is hidden" do + it "renders the inbox candidate for client-side projection" do render_component - expect(page).to have_no_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_inbox\z/) + expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_inbox\z/) end end end @@ -348,7 +366,7 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, describe "Move to backlog bucket item" do context "when work package is in a sprint" do it "is shown with package icon" do - render_component(other_buckets_exist: true) + render_component(bucket_ids: [create(:backlog_bucket, project:).id]) expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) expect(page).to have_octicon(:package) @@ -361,7 +379,7 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, let(:bucket) { create(:backlog_bucket, project:) } it "is shown" do - render_component(other_buckets_exist: true) + render_component(bucket_ids: [bucket.id]) expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) end @@ -371,14 +389,14 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, let(:sprint) { nil } it "is shown" do - render_component(other_buckets_exist: true) + render_component(bucket_ids: [create(:backlog_bucket, project:).id]) expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) end end - it "is hidden when other_buckets_exist is false" do - render_component(other_buckets_exist: false) + it "is hidden when no bucket candidates exist" do + render_component(bucket_ids: []) expect(page).to have_no_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) end @@ -392,12 +410,11 @@ def render_component(work_package: self.work_package, open_sprints_exist: true, let(:work_package) { readonly_story } context "with an Enterprise token", with_ee: %i[readonly_work_packages] do - it "offers no move to another container", :aggregate_failures do + it "renders destination candidates for client-side confined-scope projection", :aggregate_failures do render_component - expect(page).to have_no_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) - expect(page).to have_no_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) - expect(page).to have_no_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_inbox\z/) + expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) + expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_inbox\z/) end it "keeps the positional moves within its own list", :aggregate_failures do diff --git a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb index 308ef477d023..a8281f033476 100644 --- a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb +++ b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb @@ -680,55 +680,31 @@ def turbo_stream_template(action:) expect(body).to include(I18n.t(:"js.button_open_details")) end - context "when another open sprint exists" do + context "when assignable sprints exist" do let!(:other_open_sprint) { create(:sprint, name: "Sprint 2", project:) } before { allow(Backlogs::WorkPackageCardMenuComponent).to receive(:new).and_call_original } - it "passes open_sprints_exist: true to the menu component" do + it "passes ordered project sprint candidates to the menu component" do response expect(Backlogs::WorkPackageCardMenuComponent) .to have_received(:new) - .with(hash_including(open_sprints_exist: true)) + .with(hash_including(sprint_ids: Sprint.assignable(project:, user:).order_by_date.ids)) end end - context "when no other open sprints exist" do - before { allow(Backlogs::WorkPackageCardMenuComponent).to receive(:new).and_call_original } - - it "passes open_sprints_exist: false to the menu component" do - response - - expect(Backlogs::WorkPackageCardMenuComponent) - .to have_received(:new) - .with(hash_including(open_sprints_exist: false)) - end - end - - context "when other backlog buckets exist" do + context "when backlog buckets exist" do let!(:buckets) { create_list(:backlog_bucket, 2, project:) } before { allow(Backlogs::WorkPackageCardMenuComponent).to receive(:new).and_call_original } - it "passes other_buckets_exist: true to the menu component" do - response - - expect(Backlogs::WorkPackageCardMenuComponent) - .to have_received(:new) - .with(hash_including(other_buckets_exist: true)) - end - end - - context "when no backlog buckets exist" do - before { allow(Backlogs::WorkPackageCardMenuComponent).to receive(:new).and_call_original } - - it "passes other_buckets_exist: false to the menu component" do + it "passes alphabetically ordered project bucket candidates to the menu component" do response expect(Backlogs::WorkPackageCardMenuComponent) .to have_received(:new) - .with(hash_including(other_buckets_exist: false)) + .with(hash_including(bucket_ids: buckets.sort_by(&:name).map(&:id))) end end From 54a9cd569c240661b26d1403fba4cfc99f236bae Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 16 Aug 2026 23:45:41 +0100 Subject: [PATCH 03/27] [AGILE-362] Post async dialog loaders Adds a cancelable pre-load hook and form-backed POST support. Sends ordered IDs without encoding batches in cacheable URLs while preserving request.js CSRF and Turbo Stream handling. https://community.openproject.org/wp/AGILE-362 --- .../async-dialog.controller.spec.ts | 266 ++++++++++++++++++ .../controllers/async-dialog.controller.ts | 112 +++++--- 2 files changed, 332 insertions(+), 46 deletions(-) create mode 100644 frontend/src/stimulus/controllers/async-dialog.controller.spec.ts diff --git a/frontend/src/stimulus/controllers/async-dialog.controller.spec.ts b/frontend/src/stimulus/controllers/async-dialog.controller.spec.ts new file mode 100644 index 000000000000..6581308bc323 --- /dev/null +++ b/frontend/src/stimulus/controllers/async-dialog.controller.spec.ts @@ -0,0 +1,266 @@ +//-- 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 { FetchResponse } from '@rails/request.js'; +import { waitFor } from '@testing-library/dom'; +import { vi, type Mock } from 'vitest'; + +import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; +import { TurboHelpers } from 'core-turbo/helpers'; +import type AsyncDialogControllerType from './async-dialog.controller'; + +const perform = vi.fn(); +const FetchRequest = vi.fn(function FetchRequestMock( + _method:string, + _url:string|URL, + _options:{ body?:FormData; responseKind:'turbo-stream' }, +) { + return { perform }; +}); + +vi.doMock('@rails/request.js', () => ({ FetchRequest })); + +describe('Async dialog controller', () => { + let ctx:StimulusTestContext; + let AsyncDialogController:typeof AsyncDialogControllerType; + let showProgressBar:Mock; + let hideProgressBar:Mock; + + beforeAll(async () => { + ({ default: AsyncDialogController } = await import('./async-dialog.controller')); + }); + + beforeEach(async () => { + FetchRequest.mockClear(); + perform.mockReset(); + showProgressBar = vi.spyOn(TurboHelpers, 'showProgressBar').mockImplementation(() => undefined); + hideProgressBar = vi.spyOn(TurboHelpers, 'hideProgressBar').mockImplementation(() => undefined); + + ctx = await setupStimulusTest({ + controllers: { 'async-dialog': AsyncDialogController }, + }); + }); + + afterEach(() => { + ctx.dispose(); + vi.restoreAllMocks(); + }); + + function turboStreamResponse({ + ok = true, + unprocessableEntity = false, + }:{ ok?:boolean; unprocessableEntity?:boolean } = {}) { + const renderTurboStream = vi.fn().mockResolvedValue(undefined); + const response = { + ok, + unprocessableEntity, + isTurboStream: true, + renderTurboStream, + } as unknown as FetchResponse; + return { response, renderTurboStream }; + } + + function nonTurboResponse() { + const renderTurboStream = vi.fn().mockResolvedValue(undefined); + const response = { + ok: true, + unprocessableEntity: false, + isTurboStream: false, + renderTurboStream, + } as unknown as FetchResponse; + return { response, renderTurboStream }; + } + + function resolveLikeFetchRequest(response:FetchResponse) { + perform.mockImplementation(async () => { + if ((response.ok || response.unprocessableEntity) && response.isTurboStream) { + await response.renderTurboStream(); + } + + return response; + }); + } + + async function mountPostButton() { + await ctx.mount(` +
    + + +
    + `); + + const form = ctx.container.querySelector('#dialog-form')!; + const button = ctx.container.querySelector('button')!; + Object.defineProperty(form, 'action', { configurable: true, value: '/dialog' }); + return { button, form }; + } + + function trigger(controller:AsyncDialogControllerType, url?:string) { + return (controller as unknown as { triggerTurboStream(url?:string):Promise }).triggerTurboStream(url); + } + + it('dispatches beforeLoad and submits form data captured after synchronous listeners run', async () => { + const { response } = turboStreamResponse(); + resolveLikeFetchRequest(response); + const { button, form } = await mountPostButton(); + let beforeLoad!:CustomEvent<{ form:HTMLFormElement|null }>; + button.addEventListener('async-dialog:beforeLoad', (event) => { + beforeLoad = event as CustomEvent<{ form:HTMLFormElement|null }>; + const input = document.createElement('input'); + input.name = 'ids[]'; + input.value = '7'; + form.appendChild(input); + }); + + button.click(); + + await waitFor(() => expect(FetchRequest).toHaveBeenCalledOnce()); + expect(beforeLoad.defaultPrevented).toBe(false); + expect(beforeLoad.detail.form).toBe(form); + const [, , options] = FetchRequest.mock.calls[0]; + const submitted = options.body; + expect(submitted).toBeInstanceOf(FormData); + expect(FetchRequest).toHaveBeenCalledWith('post', '/dialog', { + body: submitted, + responseKind: 'turbo-stream', + }); + if (!(submitted instanceof FormData)) throw new Error('Expected form data'); + expect([...submitted.entries()]).toEqual([['authenticity_token', 'token'], ['ids[]', '7']]); + }); + + it('makes no request when beforeLoad is canceled', async () => { + const { button } = await mountPostButton(); + button.addEventListener('async-dialog:beforeLoad', (event) => event.preventDefault()); + + button.click(); + await ctx.nextFrame(); + + expect(FetchRequest).not.toHaveBeenCalled(); + expect(showProgressBar).not.toHaveBeenCalled(); + }); + + it('keeps existing anchors on GET with their href', async () => { + const { response } = turboStreamResponse(); + resolveLikeFetchRequest(response); + await ctx.mount('Open'); + const anchor = ctx.container.querySelector('a')!; + + anchor.click(); + + await waitFor(() => expect(FetchRequest).toHaveBeenCalledOnce()); + expect(FetchRequest).toHaveBeenCalledWith('GET', anchor.href, { + body: undefined, + responseKind: 'turbo-stream', + }); + }); + + it('ignores repeated activation while a request is loading', async () => { + let resolveRequest!:(response:FetchResponse) => void; + perform.mockReturnValue(new Promise((resolve) => { resolveRequest = resolve; })); + const { button } = await mountPostButton(); + + button.click(); + button.click(); + + expect(FetchRequest).toHaveBeenCalledOnce(); + resolveRequest(turboStreamResponse().response); + await waitFor(() => expect(button).not.toHaveAttribute('aria-disabled')); + }); + + it.each([ + ['a successful response', turboStreamResponse().response], + ['an unprocessable response', turboStreamResponse({ ok: false, unprocessableEntity: true }).response], + ])('clears loading state after %s', async (_label, response) => { + resolveLikeFetchRequest(response); + const { button } = await mountPostButton(); + + button.click(); + expect(button).toHaveAttribute('aria-disabled', 'true'); + + await waitFor(() => expect(button).not.toHaveAttribute('aria-disabled')); + expect(showProgressBar).toHaveBeenCalledOnce(); + expect(hideProgressBar).toHaveBeenCalledOnce(); + }); + + it('renders a 500 Turbo Stream exactly once and clears loading state', async () => { + const { response, renderTurboStream } = turboStreamResponse({ ok: false }); + resolveLikeFetchRequest(response); + const { button } = await mountPostButton(); + + button.click(); + + await waitFor(() => expect(renderTurboStream).toHaveBeenCalledOnce()); + expect(button).not.toHaveAttribute('aria-disabled'); + expect(hideProgressBar).toHaveBeenCalledOnce(); + }); + + it('rejects a 200 non-Turbo response without rendering and clears loading state', async () => { + const { response, renderTurboStream } = nonTurboResponse(); + resolveLikeFetchRequest(response); + const { button } = await mountPostButton(); + const controller = ctx.getController('async-dialog', button); + + await expect(trigger(controller)).rejects.toThrow('Response is not a Turbo Stream'); + + expect(renderTurboStream).not.toHaveBeenCalled(); + expect(button).not.toHaveAttribute('aria-disabled'); + expect(hideProgressBar).toHaveBeenCalledOnce(); + }); + + it('clears loading state when the request rejects', async () => { + const error = new Error('network failed'); + perform.mockRejectedValue(error); + const { button } = await mountPostButton(); + const controller = ctx.getController('async-dialog', button); + + await expect(trigger(controller)).rejects.toBe(error); + + expect(button).not.toHaveAttribute('aria-disabled'); + expect(hideProgressBar).toHaveBeenCalledOnce(); + }); + + it('uses a custom event URL without including the associated form', async () => { + const { response } = turboStreamResponse(); + resolveLikeFetchRequest(response); + const { button } = await mountPostButton(); + const controller = ctx.getController('async-dialog', button); + let associatedForm:HTMLFormElement|null|undefined; + button.addEventListener('async-dialog:beforeLoad', (event) => { + associatedForm = (event as CustomEvent<{ form:HTMLFormElement|null }>).detail.form; + }); + + controller.handleOpenDialog(new CustomEvent('open', { detail: { url: '/override' } })); + + await waitFor(() => expect(FetchRequest).toHaveBeenCalledOnce()); + expect(associatedForm).toBeNull(); + expect(FetchRequest).toHaveBeenCalledWith('GET', '/override', { + body: undefined, + responseKind: 'turbo-stream', + }); + }); +}); diff --git a/frontend/src/stimulus/controllers/async-dialog.controller.ts b/frontend/src/stimulus/controllers/async-dialog.controller.ts index 1896be76f699..df84dba4926c 100644 --- a/frontend/src/stimulus/controllers/async-dialog.controller.ts +++ b/frontend/src/stimulus/controllers/async-dialog.controller.ts @@ -26,82 +26,102 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { ApplicationController } from 'stimulus-use'; -import { renderStreamMessage } from '@hotwired/turbo'; +import { Controller } from '@hotwired/stimulus'; +import { FetchRequest } from '@rails/request.js'; import { TurboHelpers } from 'core-turbo/helpers'; -export default class AsyncDialogController extends ApplicationController { +export default class AsyncDialogController extends Controller { static values = { disableDuringLoad: { type: Boolean, default: true } }; declare disableDuringLoadValue:boolean; private loading = false; - connect() { - // Only bind events if we have an href to work with - if (this.href) { - this.bindEventListeners(); - } - } + private readonly handleClick = (event:Event):void => { + event.preventDefault(); + void this.triggerTurboStream(); + }; - private bindEventListeners() { - this.element.addEventListener('click', (event:MouseEvent) => { + private readonly handleKeydown = (event:Event):void => { + const keyboardEvent = event as KeyboardEvent; + if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') { event.preventDefault(); - this.triggerTurboStream(this.href); - }); + void this.triggerTurboStream(); + } + }; - this.element.addEventListener('keydown', (event:KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - this.triggerTurboStream(this.href); - } - }); + connect():void { + if (this.href || (this.element instanceof HTMLButtonElement && this.element.form)) { + this.element.addEventListener('click', this.handleClick); + this.element.addEventListener('keydown', this.handleKeydown); + } } - private triggerTurboStream(url:string):void { + disconnect():void { + this.element.removeEventListener('click', this.handleClick); + this.element.removeEventListener('keydown', this.handleKeydown); + } + + private async triggerTurboStream(urlOverride?:string):Promise { if (this.disableDuringLoadValue && this.loading) return; - if (this.disableDuringLoadValue) { - this.loading = true; - (this.element as HTMLElement).setAttribute('aria-disabled', 'true'); - } + const form = urlOverride + ? null + : this.element instanceof HTMLButtonElement ? this.element.form : null; + const event = this.dispatch('beforeLoad', { cancelable: true, detail: { form } }); + if (event.defaultPrevented) return; + + const method = form?.method ?? this.method; + const url = urlOverride ?? form?.action ?? this.href; + const body = form ? new FormData(form) : undefined; + + this.setLoading(true); TurboHelpers.showProgressBar(); - void fetch(url, { - method: this.method, - headers: { - Accept: 'text/vnd.turbo-stream.html', - }, - }).then((response) => { - const contentType = response.headers.get('Content-Type') ?? ''; - const isTurboStream = contentType.includes('text/vnd.turbo-stream.html'); - - if (!isTurboStream) { - return Promise.reject(new Error('Response is not a Turbo Stream')); + try { + const response = await new FetchRequest(method, url, { + body, + responseKind: 'turbo-stream', + }).perform(); + + if (!response.isTurboStream) { + throw new Error('Response is not a Turbo Stream'); } - return response.text(); - }).then((html) => { - renderStreamMessage(html); - }).finally(() => { - if (this.disableDuringLoadValue) { - this.loading = false; - (this.element as HTMLElement).removeAttribute('aria-disabled'); + // request.js renders successful and 422 streams automatically. Preserve + // async-dialog's existing any-status stream behavior for every other + // response until #AGILE-393 defines an application-wide policy. + if (!response.ok && !response.unprocessableEntity) { + await response.renderTurboStream(); } + } finally { + this.setLoading(false); TurboHelpers.hideProgressBar(); - }); + } } handleOpenDialog(event:CustomEvent<{ url:string }>):void { // Trigger the dialog with custom URL - this.triggerTurboStream(event.detail.url); + void this.triggerTurboStream(event.detail.url); } - get href() { + get href():string { return (this.element as HTMLLinkElement).href; } - get method() { + get method():string { return (this.element as HTMLLinkElement).dataset.turboMethod ?? 'GET'; } + + private setLoading(loading:boolean):void { + this.loading = loading; + + if (this.disableDuringLoadValue) { + if (loading) { + this.element.setAttribute('aria-disabled', 'true'); + } else { + this.element.removeAttribute('aria-disabled'); + } + } + } } From 9961ec09c31dac4a140a990547002cf250790a1c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 16 Aug 2026 23:45:48 +0100 Subject: [PATCH 04/27] [AGILE-362] Authorize batch destinations Reloads every member and intersects authoritative destinations. Rejects stale, invisible, foreign, or mixed batches atomically and shares the same destination policy with under-lock submission checks. https://community.openproject.org/wp/AGILE-362 --- .../work_package_card_menu_component.html.erb | 4 +- .../backlogs/work_packages_controller.rb | 71 ++++- .../work_packages/batch_update_service.rb | 51 +++- .../work_packages/destination_availability.rb | 101 +++++++ modules/backlogs/config/locales/en.yml | 4 + modules/backlogs/config/routes.rb | 4 +- .../backlogs/work_packages_controller_spec.rb | 250 +++++++----------- .../backlogs/work_packages_routing_spec.rb | 10 +- .../batch_update_service_spec.rb | 27 ++ .../destination_availability_spec.rb | 136 ++++++++++ 10 files changed, 475 insertions(+), 183 deletions(-) create mode 100644 modules/backlogs/app/services/backlogs/work_packages/destination_availability.rb create mode 100644 modules/backlogs/spec/services/backlogs/work_packages/destination_availability_spec.rb 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 0265c59b6de8..3b7ecc4daec8 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 @@ -87,7 +87,7 @@ See COPYRIGHT and LICENSE files for more details. list.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_package_path(project, work_package), + href: move_to_bucket_dialog_project_backlogs_work_packages_path(project), content_arguments: { data: { controller: "async-dialog" } }, data: destination_data(bucket_list_type, bucket_ids) ) do |item| @@ -99,7 +99,7 @@ See COPYRIGHT and LICENSE files for more details. list.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_package_path(project, work_package), + href: move_to_sprint_dialog_project_backlogs_work_packages_path(project), content_arguments: { data: { controller: "async-dialog" } }, data: destination_data(sprint_list_type, sprint_ids) ) do |item| diff --git a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb index 7028f1b86fa2..f4c8a6812c74 100644 --- a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb @@ -36,7 +36,7 @@ class WorkPackagesController < BaseController # split view open on the moved work package (see split-view-sync.controller.ts). WORK_PACKAGE_MOVED_EVENT = "#{OpTurbo::ComponentStream::DISPATCHED_EVENT_PREFIX}backlogs:work-package-moved".freeze - before_action :load_work_package, only: %i[menu move_to_sprint_dialog move_to_bucket_dialog move] + before_action :load_work_package, only: %i[menu move] # Deferred ActionMenu items (Primer include-fragment). def menu @@ -69,18 +69,32 @@ def add_existing_dialog end def move_to_sprint_dialog - respond_with_dialog Backlogs::MoveToSprintDialogComponent.new( - work_package: @work_package, + work_packages = load_collection_work_packages + return if performed? + + sprints = destination_availability(work_packages).sprints + return render_move_collection_error(t(".no_available_destinations")) if sprints.empty? + + respond_with_dialog build_move_to_sprint_dialog( + work_packages:, project: @project, - move_action: move_path + sprints:, + move_action: move_collection_path ) end def move_to_bucket_dialog - respond_with_dialog Backlogs::MoveToBucketDialogComponent.new( - work_package: @work_package, + work_packages = load_collection_work_packages + return if performed? + + buckets = destination_availability(work_packages).buckets + return render_move_collection_error(t(".no_available_destinations")) if buckets.empty? + + respond_with_dialog build_move_to_bucket_dialog( + work_packages:, project: @project, - move_action: move_path + buckets:, + move_action: move_collection_path ) end @@ -305,6 +319,29 @@ def load_work_package @work_package = @work_packages.find(params.expect(:id)) end + # The dialogs validate the id list alone: they open before a destination + # is chosen, so BatchMoveParamsContract, which also requires a resolvable + # target, cannot speak for them. Renders its own error and returns nil, + # so callers test `performed?`. + def load_collection_work_packages + ids = move_collection_params[:ids] + + # Before the lookup: an oversized id list must not reach the database. + if ids.length > Backlogs::WorkPackages::BatchUpdateService::MAX_BATCH_SIZE + return render_move_collection_error( + t("backlogs.work_packages.move_collection.too_many_work_packages", + max: Backlogs::WorkPackages::BatchUpdateService::MAX_BATCH_SIZE) + ) + end + + if ids.any?(&:blank?) || ids.uniq.length != ids.length + return render_move_collection_error(t("backlogs.work_packages.move_collection.invalid_ids")) + end + + find_collection_work_packages(ids) || + render_move_collection_error(t("backlogs.work_packages.move_collection.work_packages_not_found")) + end + # Every submitted id must resolve to a distinct, visible work package of # this project, in the submitted order: silently dropping a member would # break the client's optimistic block. Nil when any id does not resolve. @@ -322,6 +359,22 @@ def render_move_collection_error(reason) respond_with_turbo_streams(status: :unprocessable_entity) end + def destination_availability(work_packages) + Backlogs::WorkPackages::DestinationAvailability.new( + project: @project, + user: current_user, + work_packages: + ) + end + + def build_move_to_sprint_dialog(**args) + Backlogs::MoveToSprintDialogComponent.new(**args) + end + + def build_move_to_bucket_dialog(**args) + Backlogs::MoveToBucketDialogComponent.new(**args) + end + # params.expect guarantees a present, non-empty array of scalar ids; the # optional placement and target fields go through permit instead. def move_collection_params @@ -331,8 +384,8 @@ def move_collection_params end end - def move_path - move_project_backlogs_work_package_path(@project, @work_package, backlog_filter_params) + def move_collection_path + move_project_backlogs_work_packages_path(@project, backlog_filter_params) end # After a move the work package might no longer be visible: the page's active diff --git a/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb b/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb index 29d9d4869496..b0c591df3b4a 100644 --- a/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb +++ b/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb @@ -113,7 +113,8 @@ def move_batch(target, prev_id, list_type:, list_id:) return stale_batch_failure unless cohort_intact? lock_destination_row!(destination) - return unavailable_target_failure unless target_available?(target) + destination_failure = revalidate_destination(target) + return destination_failure if destination_failure anchor_failure = revalidate_anchor(placement, target) return anchor_failure if anchor_failure @@ -268,17 +269,30 @@ def revalidate_anchor(placement, target) # rubocop:disable Metrics/AbcSize # The contract only revalidates a sprint or bucket target when the # corresponding column changes, so a same-list reorder never triggers it # and a sprint completed after the page loaded stays an accepted - # destination. Mirrors the contract's own assignable_sprints and - # backlog_bucket_belongs_to_project checks for every placement mode alike. + # destination. Judged on freshly loaded rows rather than the batch's + # loaded instances: a member's status, and so its mobility, may have + # changed since the controller loaded it, which is exactly what this check + # under the lock exists to catch. + def revalidate_destination(target) + return unavailable_target_failure unless target_available?(target) + + refused = destination_availability.refusing(target) + refused_members_failure(refused) if refused.any? + end + def target_available?(target) - case target - in Backlogs::Target::SprintId - Sprint.assignable(project: batch_project, user:).exists?(id: target.list_id) - in Backlogs::Target::BucketId - BacklogBucket.for_project(batch_project).exists?(id: target.list_id) - in Backlogs::Target::InboxId - true - end + destination_availability.candidate?(target) + end + + # Freshly loaded rows, not the batch's loaded instances: a member's status, + # and so its mobility, may have changed since the controller loaded it, + # which is exactly what this check under the lock exists to catch. + def destination_availability + @destination_availability ||= Backlogs::WorkPackages::DestinationAvailability.new( + project: batch_project, + user:, + work_packages: WorkPackage.where(id: work_packages.map(&:id)).to_a + ) end def last_non_batch_member(target) @@ -319,6 +333,21 @@ def unavailable_target_failure ServiceResult.failure(message: I18n.t("backlogs.work_packages.batch_update_service.unavailable_target")) end + # Refused whole, before any member moves, but naming the members that + # refused rather than leaving the caller to guess. + def refused_members_failure(members) + failure = unavailable_target_failure + members.each do |member| + failure.add_dependent!( + ServiceResult.failure( + result: member, + message: I18n.t("backlogs.work_packages.batch_update_service.unavailable_target") + ) + ) + end + failure + end + def mixed_projects_failure ServiceResult.failure(message: I18n.t("backlogs.work_packages.batch_update_service.mixed_projects")) end diff --git a/modules/backlogs/app/services/backlogs/work_packages/destination_availability.rb b/modules/backlogs/app/services/backlogs/work_packages/destination_availability.rb new file mode 100644 index 000000000000..b6050c1ece87 --- /dev/null +++ b/modules/backlogs/app/services/backlogs/work_packages/destination_availability.rb @@ -0,0 +1,101 @@ +# 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. +#++ + +class Backlogs::WorkPackages::DestinationAvailability + attr_reader :project, :user, :work_packages + + def initialize(project:, user:, work_packages:) + @project = project + @user = user + @work_packages = work_packages + end + + def permitted?(target) + user.allowed_in_project?(:manage_sprint_items, project) && + candidate?(target) && + work_packages.all? { |work_package| free?(work_package) || current_target(work_package) == target } + end + + def sprints + sprint_candidates.select { |sprint| offered?(Backlogs::Target.for(sprint)) } + end + + def buckets + bucket_candidates.select { |bucket| offered?(Backlogs::Target.for(bucket)) } + end + + def inbox? + offered?(Backlogs::Target::InboxId) + end + + # The members that cannot enter the target: a work package frozen by its + # status stays where it is, so only its own destination accepts it. + def refusing(target) + work_packages.reject { |work_package| free?(work_package) || current_target(work_package) == target } + end + + # Target candidacy alone, without the per-member arm of permitted?: the + # batch move reports which members refused rather than collapsing the whole + # batch into one anonymous failure. + def candidate?(target) + case target + in Backlogs::Target::SprintId + sprint_candidates.any? { |sprint| sprint.id == target.list_id } + in Backlogs::Target::BucketId + bucket_candidates.any? { |bucket| bucket.id == target.list_id } + in Backlogs::Target::InboxId + true + else + false + end + end + + private + + def offered?(target) + permitted?(target) && work_packages.any? { |work_package| current_target(work_package) != target } + end + + def sprint_candidates + @sprint_candidates ||= Sprint.assignable(project:, user:).order_by_date.to_a + end + + def bucket_candidates + @bucket_candidates ||= BacklogBucket.for_project(project).order_alphabetically.to_a + end + + def free?(work_package) + !work_package.readonly_status? + end + + def current_target(work_package) + Backlogs::Target.for_work_package(work_package) + end +end diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 3dce5e68e083..4f471ad0cb51 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -295,6 +295,10 @@ en: unexpected_failure: "The work packages could not be moved. Please try again." move: moved_announcement: "%{label} moved to %{list}, position %{position} of %{total}" + move_to_bucket_dialog: + no_available_destinations: "There are no available backlog buckets for these work packages." + move_to_sprint_dialog: + no_available_destinations: "There are no available sprints for these work packages." move_collection: invalid_ids: "The list of work packages to move is invalid." member_failed: "%{work_package}: %{reason}" diff --git a/modules/backlogs/config/routes.rb b/modules/backlogs/config/routes.rb index 47c094093502..9c221379021a 100644 --- a/modules/backlogs/config/routes.rb +++ b/modules/backlogs/config/routes.rb @@ -99,14 +99,14 @@ collection do get :add_existing_dialog post :add_existing + post :move_to_sprint_dialog + post :move_to_bucket_dialog put :move, action: :move_collection end member do get :menu put :move - get :move_to_sprint_dialog - get :move_to_bucket_dialog end end diff --git a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb index a8281f033476..b8c641e7b175 100644 --- a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb +++ b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb @@ -1143,191 +1143,135 @@ def turbo_stream_template(action:) end end - describe "GET #move_to_sprint_dialog" do - let!(:other_sprint) { create(:sprint) } - let!(:displayed_sprints) { create_list(:sprint, 2, project:) } - - let(:params) { { project_id: project.id, id: work_package.id } } - - subject(:response) { get :move_to_sprint_dialog, params:, format: :turbo_stream } - - context "with a Sprint source" do - it "responds with a dialog turbo stream", :aggregate_failures do - expect(response).to be_successful - expect(response).to have_turbo_stream action: "dialog" - end - - it "includes the existing sprints as list_id options" do - subject - - displayed_sprints.each do |sprint| - expect(body).to include(%(value="#{sprint.id}")) - end - end - - it "does not include the current sprint as a list_id option" do - subject - - expect(body).not_to include(%(value="#{sprint.id}")) - end - - it "does not include the other sprint" do - subject - - expect(body).not_to include(%(value="#{other_sprint.id}")) - end + describe "POST #move_to_sprint_dialog" do + let!(:available_sprint) { create(:sprint, project:) } + let!(:first) { create(:work_package, status:, project:) } + let!(:second) { create(:work_package, status:, project:) } + let(:ids) { [second.id, first.id] } + let(:params) { { project_id: project.id, ids: } } + + subject(:response) { post :move_to_sprint_dialog, params:, format: :turbo_stream } + + before do + allow(controller).to receive(:build_move_to_sprint_dialog).and_return(Object.new) + allow(controller).to receive(:respond_with_dialog) { controller.head :ok } end - context "with inbox source (no sprint_id)" do - let(:inbox_work_package) { create(:work_package, status:, project:) } - let(:params) { { project_id: project.id, id: inbox_work_package.id } } - - it "responds with a dialog turbo stream", :aggregate_failures do - expect(response).to be_successful - expect(response).to have_turbo_stream action: "dialog" - end - - it "embeds the no-sprint work_packages path in the dialog form action URL" do - expect(body).to include("backlogs/work_packages/#{inbox_work_package.id}/move") - expect(body).not_to include("sprints") - end - - it "includes the existing sprints as list_id options" do - subject - - displayed_sprints.each do |sprint| - expect(body).to include(%(value="#{sprint.id}")) - end - end - - it "does not include the other sprint" do - subject - - expect(body).not_to include(%(value="#{other_sprint.id}")) - end + it "passes the exact ordered collection and authoritative sprints to the dialog" do + response + + expect(controller) + .to have_received(:build_move_to_sprint_dialog) + .with( + work_packages: [second, first], + project:, + sprints: [available_sprint], + move_action: move_project_backlogs_work_packages_path(project) + ) end - context "with a Backlog bucket source" do - let(:bucket) { create(:backlog_bucket, project:) } - let(:bucket_work_package) { create(:work_package, status:, project:, backlog_bucket: bucket) } - let(:params) { { project_id: project.id, id: bucket_work_package.id } } - - it "responds with a dialog turbo stream", :aggregate_failures do - expect(response).to be_successful - expect(response).to have_turbo_stream action: "dialog" - end - - it "includes the available sprints in the dialog" do - displayed_sprints.each do |sprint| - expect(body).to include(%(value="#{sprint.id}")) - end - end - - it "does not include sprints from other projects" do - subject - - expect(body).not_to include(%(value="#{other_sprint.id}")) + shared_examples "rejects invalid dialog ids" do + it "returns a 422 Turbo Stream without opening a dialog", :aggregate_failures do + expect(response).to have_http_status :unprocessable_entity + expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + expect(response).not_to have_turbo_stream action: "dialog" + expect(controller).not_to have_received(:build_move_to_sprint_dialog) end end - context "when all=true is in params" do - let(:params) { { project_id: project.id, id: work_package.id, all: "true" } } + context "with a blank id" do + let(:ids) { [second.id, ""] } - it "embeds the all query in the dialog form action URL" do - expect(body).to include("all=true") - end + it_behaves_like "rejects invalid dialog ids" end - context "with a user lacking manage_sprint_items permission" do - let(:user) { create(:user, member_with_permissions: { project => %i[view_sprints view_work_packages] }) } + context "with duplicate ids" do + let(:ids) { [second.id, second.id] } - it "responds with 403" do - expect(response).to have_http_status :forbidden - end + it_behaves_like "rejects invalid dialog ids" end - context "with a user lacking project permission" do - let(:user) { create(:user) } - - it "responds with 404" do - expect(response).to have_http_status :not_found + context "with an id from another project" do + let(:foreign_project) do + create(:project, name: "Foreign dialog project", identifier: "foreign-dialog-project") end - end - end - - describe "GET #move_to_bucket_dialog" do - let!(:displayed_buckets) { create_list(:backlog_bucket, 2, project:) } - let!(:other_bucket) { create(:backlog_bucket, project: create(:project)) } - - let(:params) { { project_id: project.id, id: work_package.id } } - - subject(:response) { get :move_to_bucket_dialog, params:, format: :turbo_stream } - - context "with a Sprint source" do - it "responds with a dialog turbo stream", :aggregate_failures do - expect(response).to be_successful - expect(response).to have_turbo_stream action: "dialog" + let(:foreign_work_package) do + create(:work_package, project: foreign_project, author: user, status:, type: type_feature) end + let(:ids) { [second.id, foreign_work_package.id] } - it "includes the project buckets as list_id options" do - subject + it_behaves_like "rejects invalid dialog ids" + end - displayed_buckets.each do |bucket| - expect(body).to include(%(value="#{bucket.id}")) - end + context "with an id the user cannot see" do + let(:user) do + create(:user, member_with_permissions: { + project => %i[view_work_packages view_sprints manage_sprint_items] + }) end - - it "does not include buckets from other projects" do - subject - - expect(body).not_to include(%(value="#{other_bucket.id}")) + let(:invisible_project) do + create(:project, public: false, name: "Invisible dialog project", identifier: "invisible-dialog-project") end - end - - context "when the work package is in a bucket" do - let(:current_bucket) { create(:backlog_bucket, project:) } - let(:current_bucket_wp) { create(:work_package, status:, project:, backlog_bucket: current_bucket) } - let(:params) { { project_id: project.id, id: current_bucket_wp.id } } - - it "responds with a dialog turbo stream" do - expect(response).to be_successful - expect(response).to have_turbo_stream action: "dialog" + let(:invisible_work_package) do + create(:work_package, project: invisible_project, author: user, status:, type: type_feature) end + let(:ids) { [second.id, invisible_work_package.id] } - it "excludes the current bucket from the options" do - subject + it_behaves_like "rejects invalid dialog ids" + end - expect(body).not_to include(%(value="#{current_bucket.id}")) - end + context "when every sprint destination is omitted" do + let!(:available_sprint) { sprint } + let!(:first) { create(:work_package, status:, project:, sprint:) } + let!(:second) { create(:work_package, status:, project:, sprint:) } - it "includes the other project buckets" do - displayed_buckets.each do |bucket| - expect(body).to include(%(value="#{bucket.id}")) - end + it "returns a 422 Turbo Stream without opening an empty dialog", :aggregate_failures do + expect(response).to have_http_status :unprocessable_entity + expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + expect(body).to include(I18n.t("backlogs.work_packages.move_to_sprint_dialog.no_available_destinations")) + expect(response).not_to have_turbo_stream action: "dialog" + expect(controller).not_to have_received(:build_move_to_sprint_dialog) end end + end - context "when all=true is in params" do - let(:params) { { project_id: project.id, id: work_package.id, all: "true" } } + describe "POST #move_to_bucket_dialog" do + let!(:available_bucket) { create(:backlog_bucket, project:) } + let!(:first) { create(:work_package, status:, project:) } + let!(:second) { create(:work_package, status:, project:) } + let(:params) { { project_id: project.id, ids: [second.id, first.id] } } - it "embeds the all query in the dialog form action URL" do - expect(body).to include("all=true") - end - end + subject(:response) { post :move_to_bucket_dialog, params:, format: :turbo_stream } - context "with a user lacking manage_sprint_items permission" do - let(:user) { create(:user, member_with_permissions: { project => %i[view_sprints view_work_packages] }) } + before do + allow(controller).to receive(:build_move_to_bucket_dialog).and_return(Object.new) + allow(controller).to receive(:respond_with_dialog) { controller.head :ok } + end - it "responds with 403" do - expect(response).to have_http_status :forbidden - end + it "passes the exact ordered collection and authoritative buckets to the dialog" do + response + + expect(controller) + .to have_received(:build_move_to_bucket_dialog) + .with( + work_packages: [second, first], + project:, + buckets: [available_bucket], + move_action: move_project_backlogs_work_packages_path(project) + ) end - context "with a user lacking project permission" do - let(:user) { create(:user) } + context "when every bucket destination is omitted" do + let!(:available_bucket) { create(:backlog_bucket, project:) } + let!(:first) { create(:work_package, status:, project:, backlog_bucket: available_bucket) } + let!(:second) { create(:work_package, status:, project:, backlog_bucket: available_bucket) } - it "responds with 404" do - expect(response).to have_http_status :not_found + it "returns a 422 Turbo Stream without opening an empty dialog", :aggregate_failures do + expect(response).to have_http_status :unprocessable_entity + expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" + expect(body).to include(I18n.t("backlogs.work_packages.move_to_bucket_dialog.no_available_destinations")) + expect(response).not_to have_turbo_stream action: "dialog" + expect(controller).not_to have_received(:build_move_to_bucket_dialog) end end end diff --git a/modules/backlogs/spec/routing/backlogs/work_packages_routing_spec.rb b/modules/backlogs/spec/routing/backlogs/work_packages_routing_spec.rb index 044ae50908fc..83982e293ccc 100644 --- a/modules/backlogs/spec/routing/backlogs/work_packages_routing_spec.rb +++ b/modules/backlogs/spec/routing/backlogs/work_packages_routing_spec.rb @@ -51,20 +51,18 @@ } it { - expect(get("/projects/project_42/backlogs/work_packages/85/move_to_sprint_dialog")).to route_to( + expect(post("/projects/project_42/backlogs/work_packages/move_to_sprint_dialog")).to route_to( controller: "backlogs/work_packages", action: "move_to_sprint_dialog", - project_id: "project_42", - id: "85" + project_id: "project_42" ) } it { - expect(get("/projects/project_42/backlogs/work_packages/85/move_to_bucket_dialog")).to route_to( + expect(post("/projects/project_42/backlogs/work_packages/move_to_bucket_dialog")).to route_to( controller: "backlogs/work_packages", action: "move_to_bucket_dialog", - project_id: "project_42", - id: "85" + project_id: "project_42" ) } diff --git a/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_spec.rb b/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_spec.rb index 893e570b1d60..97f134787ee4 100644 --- a/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_spec.rb +++ b/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_spec.rb @@ -452,6 +452,33 @@ def work_package_lock_ids(locked) end describe "target availability" do + it "revalidates a member that became confined after destination projection", with_ee: %i[readonly_work_packages] do + projected = Backlogs::WorkPackages::DestinationAvailability.new( + project:, + user:, + work_packages: [bucket_wp1, bucket_wp2] + ) + expect(projected.sprints).to include(sprint) + + readonly_status = create(:status, :readonly) + WorkPackage.where(id: bucket_wp2.id).update_all(status_id: readonly_status.id) + allow(Backlogs::WorkPackages::UpdateService).to receive(:new).and_call_original + + result = service([bucket_wp1, bucket_wp2]) + .call(list_type: "sprint", list_id: sprint.id.to_s, prev_id: "") + + expect(result).to be_failure + expect(result.message) + .to eq I18n.t("backlogs.work_packages.batch_update_service.unavailable_target") + expect(Backlogs::WorkPackages::UpdateService).not_to have_received(:new) + expect([bucket_wp1.reload, bucket_wp2.reload]) + .to match [ + have_attributes(backlog_bucket_id: bucket.id, sprint_id: nil, position: 1), + have_attributes(backlog_bucket_id: bucket.id, sprint_id: nil, position: 2) + ] + expect(sprint_order).to eq [sprint_wp1.id, sprint_wp2.id, sprint_wp3.id] + end + it "rejects a same-list reorder inside a sprint that completed after load" do sprint.update!(status: "completed") diff --git a/modules/backlogs/spec/services/backlogs/work_packages/destination_availability_spec.rb b/modules/backlogs/spec/services/backlogs/work_packages/destination_availability_spec.rb new file mode 100644 index 000000000000..65b44db9ac11 --- /dev/null +++ b/modules/backlogs/spec/services/backlogs/work_packages/destination_availability_spec.rb @@ -0,0 +1,136 @@ +# 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" + +RSpec.describe Backlogs::WorkPackages::DestinationAvailability, type: :model do + shared_let(:type) { create(:type) } + shared_let(:project) do + create(:project, types: [type], enabled_module_names: %i[backlogs work_package_tracking]) + end + shared_let(:user) do + create(:user, member_with_permissions: { + project => %i[view_work_packages view_sprints manage_sprint_items] + }) + end + shared_let(:readonly_status) { create(:status, :readonly) } + + let!(:later_sprint) do + create(:sprint, project:, name: "Later", start_date: 2.weeks.from_now, finish_date: 3.weeks.from_now) + end + let!(:earlier_sprint) do + create(:sprint, project:, name: "Earlier", start_date: 1.week.from_now, finish_date: 2.weeks.from_now) + end + let!(:completed_sprint) { create(:sprint, project:, status: :completed) } + let!(:zulu_bucket) { create(:backlog_bucket, project:, name: "Zulu") } + let!(:alpha_bucket) { create(:backlog_bucket, project:, name: "Alpha") } + let!(:foreign_bucket) { create(:backlog_bucket, project: create(:project), name: "Foreign") } + + def availability(work_packages, for_user: user) + described_class.new(project:, user: for_user, work_packages:) + end + + describe "destination intersection" do + it "offers every available project destination to free members in record order" do + first = create(:work_package, project:, type:) + second = create(:work_package, project:, type:, sprint: earlier_sprint) + + result = availability([first, second]) + + expect(result.sprints).to eq [earlier_sprint, later_sprint] + expect(result.buckets).to eq [alpha_bucket, zulu_bucket] + expect(result.inbox?).to be true + end + + it "intersects a confined member's current list with a free member", with_ee: %i[readonly_work_packages] do + confined = create(:work_package, project:, type:, status: readonly_status, sprint: earlier_sprint) + free = create(:work_package, project:, type:, backlog_bucket: alpha_bucket) + + result = availability([confined, free]) + + expect(result.sprints).to eq [earlier_sprint] + expect(result.buckets).to be_empty + expect(result.inbox?).to be false + end + + it "offers nothing when confined members are in different lists", with_ee: %i[readonly_work_packages] do + first = create(:work_package, project:, type:, status: readonly_status, sprint: earlier_sprint) + second = create(:work_package, project:, type:, status: readonly_status, backlog_bucket: alpha_bucket) + + result = availability([first, second]) + + expect(result.sprints).to be_empty + expect(result.buckets).to be_empty + expect(result.inbox?).to be false + end + + it "omits an all-members-current option without revoking positional reuse" do + work_packages = create_list(:work_package, 2, project:, type:, sprint: earlier_sprint) + current_target = Backlogs::Target.for(earlier_sprint) + + result = availability(work_packages) + + expect(result.sprints).to eq [later_sprint] + expect(result.permitted?(current_target)).to be true + end + end + + describe "authoritative candidates" do + let(:work_package) { create(:work_package, project:, type:) } + + it "rejects completed or otherwise unassignable sprints" do + result = availability([work_package]) + + expect(result.sprints).not_to include(completed_sprint) + expect(result.permitted?(Backlogs::Target.for(completed_sprint))).to be false + end + + it "rejects buckets outside the batch project" do + result = availability([work_package]) + + expect(result.buckets).not_to include(foreign_bucket) + expect(result.permitted?(Backlogs::Target.for(foreign_bucket))).to be false + end + + it "rejects every destination without manage_sprint_items permission" do + unauthorized = create(:user, member_with_permissions: { + project => %i[view_work_packages view_sprints] + }) + result = availability([work_package], for_user: unauthorized) + + expect(result.sprints).to be_empty + expect(result.buckets).to be_empty + expect(result.inbox?).to be false + expect(result.permitted?(Backlogs::Target.for(earlier_sprint))).to be false + expect(result.permitted?(Backlogs::Target.for(alpha_bucket))).to be false + expect(result.permitted?(Backlogs::Target::InboxId)).to be false + end + end +end From e21c1271afd27df5f805cdb87fbfb6db3473f187 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 16 Aug 2026 23:45:49 +0100 Subject: [PATCH 05/27] [AGILE-362] Render batch move dialogs Renders dialogs from ordered work-package batches and consumes controller-filtered destination collections. Keeps form IDs ordered and removes a redundant project input. https://community.openproject.org/wp/AGILE-362 --- .../move_to_bucket_dialog_component.html.erb | 13 ++- .../move_to_bucket_dialog_component.rb | 13 ++- .../move_to_sprint_dialog_component.html.erb | 17 +++- .../move_to_sprint_dialog_component.rb | 13 ++- .../backlogs/work_packages_controller.rb | 2 - .../move_to_bucket_dialog_component_spec.rb | 77 ++++++--------- .../move_to_sprint_dialog_component_spec.rb | 98 ++++++------------- .../backlogs/work_packages_controller_spec.rb | 2 - 8 files changed, 97 insertions(+), 138 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb index 366c690a9e72..d94dbe03e176 100644 --- a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb @@ -32,8 +32,10 @@ See COPYRIGHT and LICENSE files for more details. d.with_header(variant: :large) d.with_body do + selected_work_packages = work_packages + selected_work_packages_label = t(:label_x_work_packages, count: selected_work_packages.size) available_buckets = buckets - bucket_list_type = list_type + bucket_list_type = destination_list_type primer_form_with( url: move_action, @@ -42,11 +44,18 @@ See COPYRIGHT and LICENSE files for more details. data: { turbo_stream: true } ) do |f| render_inline_form(f) do |form| + form.html_content do + render(Primer::Beta::Text.new(tag: :p, id: SELECTION_LABEL_ID, mb: 3)) { selected_work_packages_label } + end + selected_work_packages.each do |work_package| + form.hidden(name: "ids[]", value: work_package.id) + end form.hidden(name: "list_type", value: bucket_list_type) form.select_list( name: "list_id", label: BacklogBucket.human_attribute_name(:name), - visually_hide_label: true + visually_hide_label: true, + aria: { describedby: SELECTION_LABEL_ID } ) do |select| available_buckets.each do |bucket| select.option(label: bucket.name, value: bucket.id) diff --git a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.rb b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.rb index 14046c5bccbf..28f824cb0ff7 100644 --- a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.rb +++ b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.rb @@ -35,22 +35,21 @@ class MoveToBucketDialogComponent < ApplicationComponent DIALOG_ID = "move-to-backlog-bucket-dialog" FORM_ID = "move-to-backlog-bucket-dialog-form" + SELECTION_LABEL_ID = "move-to-backlog-bucket-dialog-selection" - attr_reader :work_package, :project, :buckets, :move_action + attr_reader :work_packages, :buckets, :move_action - def initialize(work_package:, project:, move_action:) + def initialize(work_packages:, buckets:, move_action:) super() - @work_package = work_package - @project = project + @work_packages = work_packages + @buckets = buckets @move_action = move_action - @buckets = BacklogBucket.where(project:).order_alphabetically - @buckets = @buckets.where.not(id: work_package.backlog_bucket_id) if work_package.backlog_bucket_id end private - def list_type + def destination_list_type Backlogs::Target::BucketId.new(nil).list_type end end diff --git a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb index 29698a64638d..aa815eeea60f 100644 --- a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb @@ -32,8 +32,10 @@ See COPYRIGHT and LICENSE files for more details. d.with_header(variant: :large) d.with_body do + selected_work_packages = work_packages + selected_work_packages_label = t(:label_x_work_packages, count: selected_work_packages.size) available_sprints = sprints - sprint_list_type = list_type + sprint_list_type = destination_list_type primer_form_with( url: move_action, @@ -42,8 +44,19 @@ See COPYRIGHT and LICENSE files for more details. data: { turbo_stream: true } ) do |f| render_inline_form(f) do |form| + form.html_content do + render(Primer::Beta::Text.new(tag: :p, id: SELECTION_LABEL_ID, mb: 3)) { selected_work_packages_label } + end + selected_work_packages.each do |work_package| + form.hidden(name: "ids[]", value: work_package.id) + end form.hidden(name: "list_type", value: sprint_list_type) - form.select_list(name: "list_id", label: Sprint.human_model_name, visually_hide_label: true) do |select| + form.select_list( + name: "list_id", + label: Sprint.human_model_name, + visually_hide_label: true, + aria: { describedby: SELECTION_LABEL_ID } + ) do |select| available_sprints.each do |sprint| select.option(label: sprint.name, value: sprint.id) end diff --git a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.rb b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.rb index bf0efcf387f2..3e7ebf6751aa 100644 --- a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.rb +++ b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.rb @@ -35,22 +35,21 @@ class MoveToSprintDialogComponent < ApplicationComponent DIALOG_ID = "move-to-sprint-dialog" FORM_ID = "move-to-sprint-dialog-form" + SELECTION_LABEL_ID = "move-to-sprint-dialog-selection" - attr_reader :work_package, :project, :sprints, :move_action + attr_reader :work_packages, :sprints, :move_action - def initialize(work_package:, project:, move_action:) + def initialize(work_packages:, sprints:, move_action:) super() - @work_package = work_package - @project = project + @work_packages = work_packages + @sprints = sprints @move_action = move_action - @sprints = Sprint.for_project(@project).visible.not_completed.order_by_date - @sprints = @sprints.where.not(id: work_package.sprint_id) if work_package.sprint_id end private - def list_type + def destination_list_type Backlogs::Target::SprintId.new(nil).list_type end end diff --git a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb index f4c8a6812c74..ac4eef7bf33d 100644 --- a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb @@ -77,7 +77,6 @@ def move_to_sprint_dialog respond_with_dialog build_move_to_sprint_dialog( work_packages:, - project: @project, sprints:, move_action: move_collection_path ) @@ -92,7 +91,6 @@ def move_to_bucket_dialog respond_with_dialog build_move_to_bucket_dialog( work_packages:, - project: @project, buckets:, move_action: move_collection_path ) diff --git a/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb b/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb index 6af17413abea..d6a5a65893c3 100644 --- a/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb @@ -35,11 +35,14 @@ current_user { admin } let(:project) { create(:project) } - let(:work_package) { create(:work_package, project:) } - let(:move_path) { Rails.application.routes.url_helpers.move_project_backlogs_work_package_path(project, work_package) } + let(:first) { create(:work_package, project:) } + let(:second) { create(:work_package, project:) } + let(:work_packages) { [second, first] } + let(:move_path) { Rails.application.routes.url_helpers.move_project_backlogs_work_packages_path(project) } + let(:buckets) { [create(:backlog_bucket, project:, name: "Passed Bucket")] } def render_component - render_inline(described_class.new(work_package:, project:, move_action: move_path)) + render_inline(described_class.new(work_packages:, buckets:, move_action: move_path)) end it "renders the dialog with the correct title" do @@ -48,23 +51,31 @@ def render_component expect(page).to have_text(I18n.t(:"backlogs.move_to_bucket_dialog_component.title")) end - it "renders a form targeting the move path via PUT" do + it "renders an ordered collection form targeting the move path via PUT", :aggregate_failures do render_component + expect(page).to have_text(I18n.t(:label_x_work_packages, count: 2)) + expect(page.all("input[name='ids[]']", visible: :all).map(&:value)) + .to eq([second.id.to_s, first.id.to_s]) expect(page).to have_element(:form, action: move_path, method: "post") - expect(page).to have_element(:input, name: "_method", value: "put", visible: :all) + expect(page).to have_css("form[action='#{move_path}'] input[name='_method'][value='put']", visible: :all) + expect(page).to have_css( + "input[name='list_type'][value='#{Backlogs::Target::BucketId.new(nil).list_type}']", + visible: :all + ) end - context "when params[:all] is true" do - let(:move_path) do - Rails.application.routes.url_helpers.move_project_backlogs_work_package_path(project, work_package, all: "true") - end - - it "submits the move form with the all query preserved" do - render_component + # The count sits in its own paragraph above the select, so it only reaches a + # screen reader as the field's context through this reference. + it "describes the bucket select by the selected-count label" do + render_component - expect(page).to have_element(:form, action: /all=true/) - end + label = page.find_css("p##{described_class::SELECTION_LABEL_ID}").first + expect(label.text).to include(I18n.t(:label_x_work_packages, count: 2)) + expect(page).to have_css( + "select[name='list_id'][aria-describedby~='#{described_class::SELECTION_LABEL_ID}']", + visible: :all + ) end it "renders Cancel and Move buttons" do @@ -74,42 +85,14 @@ def render_component expect(page).to have_button(I18n.t(:button_move)) end - context "when buckets exist" do - let!(:bucket_a) { create(:backlog_bucket, project:, name: "Alpha") } - let!(:bucket_b) { create(:backlog_bucket, project:, name: "Beta") } - - it "submits backlog_bucket list data" do - render_component - - expect(page).to have_css( - "input[name='list_type'][value='#{Backlogs::Target::BucketId.new(nil).list_type}']", - visible: :all - ) - expect(page).to have_element(:option, value: bucket_a.id, text: "Alpha") - expect(page).to have_element(:option, value: bucket_b.id, text: "Beta") - end - end - - context "when a bucket belongs to a different project" do - let!(:other_bucket) { create(:backlog_bucket, project: create(:project), name: "Other") } - - it "does not list buckets from other projects" do - render_component - - expect(page).to have_no_css(:option, text: "Other") - end - end - - context "when the work package is already in a bucket" do - let!(:current_bucket) { create(:backlog_bucket, project:, name: "Current") } - let!(:target_bucket) { create(:backlog_bucket, project:, name: "Target") } - let(:work_package) { create(:work_package, project:, backlog_bucket: current_bucket) } + context "when other bucket records exist" do + let!(:omitted_bucket) { create(:backlog_bucket, project:, name: "Omitted Bucket") } - it "excludes the current bucket from the options" do + it "renders only the destinations supplied by the controller" do render_component - expect(page).to have_no_css(:option, text: "Current") - expect(page).to have_element(:option, value: target_bucket.id, text: "Target") + expect(page).to have_css("option[value='#{buckets.first.id}']", text: "Passed Bucket") + expect(page).to have_no_css("option[value='#{omitted_bucket.id}']") end end end diff --git a/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb b/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb index 7a4703ba1d53..2649de79a667 100644 --- a/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb @@ -35,11 +35,14 @@ current_user { admin } let(:project) { create(:project) } - let(:work_package) { create(:work_package, project:) } - let(:move_path) { Rails.application.routes.url_helpers.move_project_backlogs_work_package_path(project, work_package) } + let(:first) { create(:work_package, project:) } + let(:second) { create(:work_package, project:) } + let(:work_packages) { [second, first] } + let(:move_path) { Rails.application.routes.url_helpers.move_project_backlogs_work_packages_path(project) } + let(:sprints) { [create(:sprint, project:, name: "Passed Sprint")] } def render_component - render_inline(described_class.new(work_package:, project:, move_action: move_path)) + render_inline(described_class.new(work_packages:, sprints:, move_action: move_path)) end it "renders the dialog with the correct title" do @@ -48,23 +51,31 @@ def render_component expect(page).to have_text(I18n.t(:"backlogs.move_to_sprint_dialog_component.title")) end - it "renders a form targeting the move path via PUT" do + it "renders an ordered collection form targeting the move path via PUT", :aggregate_failures do render_component + expect(page).to have_text(I18n.t(:label_x_work_packages, count: 2)) + expect(page.all("input[name='ids[]']", visible: :all).map(&:value)) + .to eq([second.id.to_s, first.id.to_s]) expect(page).to have_element(:form, action: move_path, method: "post") expect(page).to have_css("form[action='#{move_path}'] input[name='_method'][value='put']", visible: :all) + expect(page).to have_css( + "input[name='list_type'][value='#{Backlogs::Target::SprintId.new(nil).list_type}']", + visible: :all + ) end - context "when params[:all] is true" do - let(:move_path) do - Rails.application.routes.url_helpers.move_project_backlogs_work_package_path(project, work_package, all: "true") - end - - it "submits the move form with the all query preserved" do - render_component + # The count sits in its own paragraph above the select, so it only reaches a + # screen reader as the field's context through this reference. + it "describes the sprint select by the selected-count label" do + render_component - expect(page).to have_css("form[action*='all=true']", visible: :all) - end + label = page.find_css("p##{described_class::SELECTION_LABEL_ID}").first + expect(label.text).to include(I18n.t(:label_x_work_packages, count: 2)) + expect(page).to have_css( + "select[name='list_id'][aria-describedby~='#{described_class::SELECTION_LABEL_ID}']", + visible: :all + ) end it "renders Cancel and Save buttons" do @@ -74,65 +85,14 @@ def render_component expect(page).to have_button(I18n.t(:button_move)) end - context "when in_planning and active sprints exist" do - let!(:planning_sprint) { create(:sprint, project:, name: "Planning Sprint", status: "in_planning") } - let!(:active_sprint) { create(:sprint, project:, name: "Active Sprint", status: "active") } - - it "submits sprint list data" do - render_component - - expect(page).to have_css( - "input[name='list_type'][value='#{Backlogs::Target::SprintId.new(nil).list_type}']", - visible: :all - ) - expect(page).to have_css("option[value='#{planning_sprint.id}']", text: "Planning Sprint") - expect(page).to have_css("option[value='#{active_sprint.id}']", text: "Active Sprint") - end - end - - context "when a completed sprint exists" do - let!(:completed_sprint) { create(:sprint, project:, name: "Old Sprint", status: "completed") } - - it "does not list the completed sprint" do - render_component - - expect(page).to have_no_css("option", text: "Old Sprint") - end - end - - context "when a sprint belongs to a different project" do - let!(:other_sprint) { create(:sprint, project: create(:project), name: "Other Sprint") } - - it "does not list sprints from other projects" do - render_component - - expect(page).to have_no_css("option", text: "Other Sprint") - end - end - - context "when the work package is already in a sprint" do - let!(:current_sprint) { create(:sprint, project:, name: "Current Sprint") } - let!(:target_sprint) { create(:sprint, project:, name: "Target Sprint") } - let(:work_package) { create(:work_package, project:, sprint: current_sprint) } - - it "excludes that sprint from the options" do - render_component - - expect(page).to have_no_css("option", text: "Current Sprint") - expect(page).to have_css("option[value='#{target_sprint.id}']", text: "Target Sprint") - end - end - - context "when the current user cannot view sprints in the project" do - let(:other_user) { create(:user) } - let!(:hidden_sprint) { create(:sprint, project:, name: "Hidden Sprint") } - - current_user { other_user } + context "when other sprint records exist" do + let!(:omitted_sprint) { create(:sprint, project:, name: "Omitted Sprint") } - it "does not list sprints the user is not permitted to see" do + it "renders only the destinations supplied by the controller" do render_component - expect(page).to have_no_css("option", text: "Hidden Sprint") + expect(page).to have_css("option[value='#{sprints.first.id}']", text: "Passed Sprint") + expect(page).to have_no_css("option[value='#{omitted_sprint.id}']") end end end diff --git a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb index b8c641e7b175..a2086201aaf4 100644 --- a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb +++ b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb @@ -1164,7 +1164,6 @@ def turbo_stream_template(action:) .to have_received(:build_move_to_sprint_dialog) .with( work_packages: [second, first], - project:, sprints: [available_sprint], move_action: move_project_backlogs_work_packages_path(project) ) @@ -1255,7 +1254,6 @@ def turbo_stream_template(action:) .to have_received(:build_move_to_bucket_dialog) .with( work_packages: [second, first], - project:, buckets: [available_bucket], move_action: move_project_backlogs_work_packages_path(project) ) From 5fe9a9e4c15f00a1a771009a4bde917f6e1a8ac5 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 16 Aug 2026 23:45:49 +0100 Subject: [PATCH 06/27] [AGILE-362] Submit destination batches Submits direct and dialog moves through the collection endpoint while preserving ordered IDs and retry selection. Tracks in-flight movement across disconnect and reconnect to prevent duplicate requests and stale busy state. https://community.openproject.org/wp/AGILE-362 --- .../dynamic/sortable-lists.controller.spec.ts | 225 ++++++++++++++++++ .../dynamic/sortable-lists.controller.ts | 98 +++++++- .../dynamic/sortable-lists/drag-and-drop.ts | 4 + .../sortable-lists/item.controller.spec.ts | 93 ++++++++ .../dynamic/sortable-lists/item.controller.ts | 37 ++- .../sortable-lists/list.controller.spec.ts | 2 + .../scrollable.controller.spec.ts | 2 + .../sortable-lists/selection-orchestrator.ts | 9 + .../work_package_card_menu_component.html.erb | 24 +- .../work_package_card_menu_component_spec.rb | 65 ++++- 10 files changed, 537 insertions(+), 22 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index f384215c5bed..d740758774ab 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -1240,6 +1240,231 @@ describe('Sortable lists controller', () => { expect(controller.moveAvailability(document.createElement('li'))).toBeNull(); }); + it('resolves the owning list element of an item for the drag payload', async () => { + const { root, sourceList, firstSourceItem } = renderFixture(); + await ctx.nextFrame(); + const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType; + + expect(controller.ownerListElementOf(firstSourceItem)).toBe(sourceList); + expect(controller.ownerListElementOf(document.createElement('li'))).toBeNull(); + }); + + it('exposes batch action scopes and removes destinations occupied by every member', async () => { + const { root, items } = renderSelectableRoot(); + await ctx.nextFrame(); + const controller = ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType; + + items[0].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + items[1].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true })); + const scope = controller.actionScopeFor(items[1]); + + expect(scope).toMatchObject({ kind: 'batch', items: [items[0], items[1]]}); + expect(controller.availableDestinations(scope, [ + { type: 'backlog_bucket', id: '1' }, + { type: 'sprint', id: '1' }, + ])).toEqual([{ type: 'sprint', id: '1' }]); + }); + + describe('direct destination moves', () => { + const destination = { type: 'inbox', id: null }; + + function destinationController(root:HTMLElement) { + return ctx.application.getControllerForElementAndIdentifier(root, 'sortable-lists') as SortableListsControllerType; + } + + function selectItem(element:HTMLElement, init:MouseEventInit = {}):void { + element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...init })); + } + + function destinationSelected(element:HTMLElement):boolean { + return element.hasAttribute('data-batch-selected'); + } + + it('submits the selected scope in document order without optimistic or positional fields', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { + headers: { 'Content-Type': 'text/vnd.turbo-stream.html' }, + status: 422, + })); + const { root, sourceList, items } = renderSelectableRoot({ + optimistic: true, + collectionMoveUrl: '/projects/demo/backlogs/work_packages/move', + }); + sourceList.insertBefore(items[1], items[0]); + await ctx.nextFrame(); + selectItem(items[0]); + selectItem(items[1], { ctrlKey: true }); + + destinationController(root).moveToDestination(items[0], destination); + await flushPromises(); + + const [requestUrl, requestOptions] = fetchMock.mock.lastCall as [string, { body:FormData }]; + expect(requestUrl).toBe('/projects/demo/backlogs/work_packages/move'); + expect([...requestOptions.body.entries()]).toEqual([ + ['ids[]', '2'], + ['ids[]', '1'], + ['list_type', 'inbox'], + ['list_id', ''], + ]); + expect(requestOptions.body.has('prev_id')).toBe(false); + expect(requestOptions.body.has('optimistic')).toBe(false); + expect(items.filter(destinationSelected)).toEqual([items[0], items[1]]); + await waitFor(() => expect(root.hasAttribute('data-sortable-lists-busy')).toBe(false)); + expect(renderStreamMessageMock).toHaveBeenCalledOnce(); + }); + + it('replaces an unrelated selection when an unselected card invokes the action', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 422 })); + const { root, items } = renderSelectableRoot({ collectionMoveUrl: '/batch/move' }); + await ctx.nextFrame(); + selectItem(items[1]); + selectItem(items[2], { ctrlKey: true }); + + destinationController(root).moveToDestination(items[0], destination); + await flushPromises(); + + const requestOptions = fetchMock.mock.lastCall?.[1] as { body:FormData }; + expect(requestOptions.body.getAll('ids[]')).toEqual(['1']); + expect(items.filter(destinationSelected)).toEqual([items[0]]); + }); + + it('does not reorder or clear selection before a successful frame stream reconciles the root', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { + headers: { 'Content-Type': 'text/vnd.turbo-stream.html' }, + status: 200, + })); + const { root, sourceList, items } = renderSelectableRoot({ collectionMoveUrl: '/batch/move' }); + await ctx.nextFrame(); + selectItem(items[0]); + selectItem(items[1], { ctrlKey: true }); + + destinationController(root).moveToDestination(items[0], destination); + await waitFor(() => expect(root.hasAttribute('data-sortable-lists-busy')).toBe(false)); + + expect(itemIds(sourceList)).toEqual(['1', '2', '3']); + expect(items.filter(destinationSelected)).toEqual([items[0], items[1]]); + expect(renderStreamMessageMock).toHaveBeenCalledOnce(); + }); + + it('does not mutate selection or submit while another move is busy', async () => { + const { root, items } = renderSelectableRoot({ collectionMoveUrl: '/batch/move' }); + await ctx.nextFrame(); + selectItem(items[1]); + root.setAttribute('data-sortable-lists-busy', 'true'); + const controller = destinationController(root); + + const prospectiveScope = controller.selectForAction(items[0]); + controller.moveToDestination(items[0], destination); + await flushPromises(); + + expect(prospectiveScope).toMatchObject({ kind: 'batch'}); + expect(items.filter(destinationSelected)).toEqual([items[1]]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('clears a detached request busy marker when the cached root reconnects', async () => { + let resolveRequest!:(response:Response) => void; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { + resolveRequest = resolve; + })); + const { root, items } = renderSelectableRoot({ collectionMoveUrl: '/batch/move' }); + await ctx.nextFrame(); + + destinationController(root).moveToDestination(items[0], destination); + expect(root.hasAttribute('data-sortable-lists-busy')).toBe(true); + + root.remove(); + await ctx.nextFrame(); + resolveRequest(new Response('', { status: 200 })); + await flushPromises(); + + // The settled request deliberately leaves a detached element alone. + expect(root.hasAttribute('data-sortable-lists-busy')).toBe(true); + + fixture.append(root); + await ctx.nextFrame(); + await ctx.nextFrame(); + + expect(root.hasAttribute('data-sortable-lists-busy')).toBe(false); + + fetchMock.mockResolvedValueOnce(new Response('', { + headers: { 'Content-Type': 'text/vnd.turbo-stream.html' }, + status: 422, + })); + destinationController(root).moveToDestination(items[0], destination); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(root.hasAttribute('data-sortable-lists-busy')).toBe(false)); + }); + + // A destination move whose request never produces a stream (a network + // failure, or an error page) has no server flash to speak for it, so the + // client has to say something rather than just clearing the busy state. + it('dispatches an error toast when the destination request never streams', async () => { + const toastEvents:CustomEvent[] = []; + const onToast = (event:Event) => toastEvents.push(event as CustomEvent); + window.addEventListener('op:toasters:add', onToast); + fetchMock.mockRejectedValueOnce(new Error('network down')); + const { root, items } = renderSelectableRoot({ collectionMoveUrl: '/batch/move' }); + await ctx.nextFrame(); + + destinationController(root).moveToDestination(items[0], destination); + await flushPromises(); + + expect(toastEvents).toHaveLength(1); + expect(toastEvents[0].detail.type).toBe('error'); + expect(root.hasAttribute('data-sortable-lists-busy')).toBe(false); + + window.removeEventListener('op:toasters:add', onToast); + }); + + it('keeps a reconnected root busy until its detached request settles', async () => { + let resolveRequest!:(response:Response) => void; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => { + resolveRequest = resolve; + })); + const { root, items } = renderSelectableRoot({ collectionMoveUrl: '/batch/move' }); + await ctx.nextFrame(); + + destinationController(root).moveToDestination(items[0], destination); + expect(root.hasAttribute('data-sortable-lists-busy')).toBe(true); + + root.remove(); + await ctx.nextFrame(); + fixture.append(root); + await ctx.nextFrame(); + await ctx.nextFrame(); + + expect(root.hasAttribute('data-sortable-lists-busy')).toBe(true); + + destinationController(root).moveToDestination(items[1], destination); + await flushPromises(); + expect(fetchMock).toHaveBeenCalledOnce(); + + resolveRequest(new Response('', { status: 200 })); + await waitFor(() => expect(root.hasAttribute('data-sortable-lists-busy')).toBe(false)); + }); + + it.each([ + ['a 500 Turbo Stream response', () => Promise.resolve(new Response('', { + headers: { 'Content-Type': 'text/vnd.turbo-stream.html' }, + status: 500, + })), 1], + ['a successful non-stream response', () => Promise.resolve(new Response('', { status: 200 })), 0], + ['a rejected request', () => Promise.reject(new Error('Network failure')), 0], + ])('clears busy state after %s', async (_description, request, streamRenderCount) => { + fetchMock.mockImplementationOnce(request); + const { root, items } = renderSelectableRoot({ collectionMoveUrl: '/batch/move' }); + await ctx.nextFrame(); + + destinationController(root).moveToDestination(items[0], destination); + expect(root.hasAttribute('data-sortable-lists-busy')).toBe(true); + await flushPromises(); + + await waitFor(() => expect(root.hasAttribute('data-sortable-lists-busy')).toBe(false)); + expect(renderStreamMessageMock).toHaveBeenCalledTimes(streamRenderCount); + }); + }); + describe('nested list topology', () => { it('resolves the source row of a nested item against its innermost list', async () => { const { fieldList, firstFieldItem } = renderNestedFixture(); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 438f7b9cb744..ae8aec78cb37 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -69,7 +69,7 @@ import { type MoveAvailability, type MoveDirection, } from './sortable-lists/list-dom'; -import { SelectionOrchestrator, type ActionScope, type SelectionHost } from './sortable-lists/selection-orchestrator'; +import { SelectionOrchestrator, scopeIds, type ActionScope, type SelectionHost } from './sortable-lists/selection-orchestrator'; import { itemIdentity, orderedItemElements } from './sortable-lists/selection'; type CleanupFn = () => void; @@ -122,8 +122,13 @@ export default class SortableListsController extends Controller imp private monitorCleanupFn?:CleanupFn; private healScheduled = false; private reconcileScheduled = false; + private inFlightMoveRequests = 0; connect():void { + // Busy belongs to in-flight controller work, not to cached DOM markup. + // Reconnecting before settlement keeps the root blocked; reconnecting a + // stale cached root after settlement clears the marker. + this.syncBusyState(); this.monitorCleanupFn = monitorForElements({ canMonitor: ({ source }) => !this.busy && isItemFromRoot(this.element, source.data), @@ -499,6 +504,29 @@ export default class SortableListsController extends Controller imp return list ? resolveMoveAvailability({ itemElement, rowsContainer: list.rowsContainer }) : null; } + moveToDestination(itemElement:HTMLElement, target:DestinationIdentity):void { + if (this.busy) { + return; + } + + const moveUrl = this.resolveCollectionMoveUrl(false); + if (!moveUrl) { + return; + } + + const scope = this.selectForAction(itemElement); + if (scope.kind === 'refused') { + return; + } + + const body = new FormData(); + scopeIds(scope).forEach((id) => body.append('ids[]', id)); + body.append('list_type', target.type); + body.append('list_id', target.id ?? ''); + + void this.submitDestinationMove(moveUrl, body); + } + moveInDirection(itemElement:HTMLElement, direction:MoveDirection):void { // The menu is rendered server-side from a permission check that does not // know about per-work-package movability, so a stale or over-permissive @@ -550,6 +578,10 @@ export default class SortableListsController extends Controller imp return containing.find((list) => !containing.some((other) => other !== list && list.element.contains(other.element))) ?? null; } + ownerListElementOf(itemElement:HTMLElement):HTMLElement|null { + return this.ownerListOf(itemElement)?.element ?? null; + } + ownerRowsContainer(itemElement:HTMLElement):HTMLElement|null { return this.ownerListOf(itemElement)?.rowsContainer ?? null; } @@ -645,20 +677,57 @@ export default class SortableListsController extends Controller imp return this.hasCollectionMoveUrlValue && this.collectionMoveUrlValue !== '' ? this.collectionMoveUrlValue : null; } - private resolveCollectionMoveUrl():string|null { + private resolveCollectionMoveUrl(optimistic = this.optimisticValue):string|null { const collectionMoveUrl = this.collectionMoveUrl; if (!collectionMoveUrl) { return null; } const url = new URL(collectionMoveUrl, window.location.href); - if (this.optimisticValue) { + if (optimistic) { url.searchParams.set('optimistic', 'true'); + } else { + url.searchParams.delete('optimistic'); } return relativeUrl(url); } + private async submitDestinationMove(moveUrl:string, body:FormData):Promise { + const request = new FetchRequest( + 'put', + moveUrl, + { + body, + responseKind: 'turbo-stream', + }, + ); + + this.startMoveRequest(); + try { + const response = await request.perform(); + + if (!response.isTurboStream) { + throw new Error('Response is not a Turbo Stream'); + } + + // request.js renders successful and 422 streams automatically. Match + // async-dialog's existing any-status stream behavior for every other + // response until #AGILE-393 defines an application-wide policy. + if (!response.ok && !response.unprocessableEntity) { + await response.renderTurboStream(); + } + } catch (error) { + // Only a request that never produced a stream lands here — a rejection + // streams its own flash. Without the toast the busy state would simply + // clear and the batch would look moved. + debugLog('Failed to move sortable list items to destination', error); + this.dispatchErrorToast(); + } finally { + this.finishMoveRequest(); + } + } + // Refused whole when a row is missing: a member that vanished mid-drag // means a partial block would diverge from the ids the request claims. private rowsForItems(items:SelectionItem[]):HTMLElement[]|null { @@ -813,7 +882,7 @@ export default class SortableListsController extends Controller imp }, ); - this.setBusy(true); + this.startMoveRequest(); try { const response = await request.perform(); @@ -828,7 +897,26 @@ export default class SortableListsController extends Controller imp debugLog('Failed to move sortable list item due to request error', error); return { ok: false, showToast: true }; } finally { - this.setBusy(false); + this.finishMoveRequest(); + } + } + + private startMoveRequest():void { + this.inFlightMoveRequests += 1; + this.syncBusyState(); + } + + private finishMoveRequest():void { + this.inFlightMoveRequests = Math.max(0, this.inFlightMoveRequests - 1); + this.syncBusyState(); + } + + private syncBusyState():void { + // A successful frame stream may already have replaced this root. Avoid + // mutating detached cached DOM; connect() will project the current count + // if this element is restored later. + if (this.element.isConnected) { + this.setBusy(this.inFlightMoveRequests > 0); } } 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 1db59cc6d5e7..4a9e916f99f1 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 @@ -106,9 +106,13 @@ export interface SortableListsRoot { actionScopeFor(itemElement:HTMLElement):ActionScope; selectForAction(itemElement:HTMLElement):ActionScope; availableDestinations(scope:ActionScope, candidates:DestinationIdentity[]):DestinationIdentity[]; + moveToDestination(itemElement:HTMLElement, target:DestinationIdentity):void; moveInDirection(itemElement:HTMLElement, direction:MoveDirection):void; // A snapshot for menu gating; the click path re-resolves against the live DOM. moveAvailability(itemElement:HTMLElement):MoveAvailability|null; + // The element of the list an item currently belongs to; null outside any + // registered list. Items carry no list reference, so the root resolves it. + ownerListElementOf(itemElement:HTMLElement):HTMLElement|null; // The rows container of the item's innermost owning list, or null when the // item is not (yet) inside a list the root knows about. ownerRowsContainer(itemElement:HTMLElement):HTMLElement|null; 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 16334cde346f..70fd74d7172b 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 @@ -108,8 +108,10 @@ describe('Sortable lists item controller', () => { actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), availableDestinations: vi.fn(() => []), + moveToDestination: vi.fn(), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), + ownerListElementOf: vi.fn(() => null), ownerRowsContainer: vi.fn(ownerRowsContainer), freezeDragBatch: vi.fn(() => 1), markDragBatch: vi.fn(), @@ -1142,8 +1144,10 @@ describe('Sortable lists item controller', () => { actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), availableDestinations: vi.fn(() => []), + moveToDestination: vi.fn(), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), + ownerListElementOf: vi.fn(() => null), ownerRowsContainer: vi.fn(() => null), freezeDragBatch: vi.fn(() => 3), markDragBatch: vi.fn(), @@ -1238,8 +1242,10 @@ describe('Sortable lists item controller', () => { actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), availableDestinations: vi.fn(() => []), + moveToDestination: vi.fn(), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), + ownerListElementOf: vi.fn(() => null), ownerRowsContainer: vi.fn(() => null), freezeDragBatch: vi.fn(() => 3), markDragBatch: vi.fn(), @@ -1429,6 +1435,7 @@ describe('Sortable lists item controller', () => { actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), availableDestinations: vi.fn(() => []), + moveToDestination: vi.fn(), moveAvailability: () => availability, moveInDirection, } as unknown as SortableListsRoot); @@ -1719,6 +1726,88 @@ describe('Sortable lists item controller', () => { }); }); + describe('destination activation', () => { + it('exposes direct destination movement as a required root capability', () => { + const moveToDestination = vi.fn(); + const root:SortableListsRoot = { ...fakeRoot(), moveToDestination }; + const item = document.createElement('div'); + + root.moveToDestination(item, { type: 'inbox', id: null }); + + expect(moveToDestination).toHaveBeenCalledWith(item, { type: 'inbox', id: null }); + }); + + it('replaces stale generated inputs with the current action scope in document order', () => { + const element = document.createElement('div'); + const [first, second] = ['2', '1'].map((id) => { + const member = document.createElement('div'); + member.setAttribute('data-sortable-lists--item-id-value', id); + return member; + }); + const selectForAction = vi.fn(():ActionScope => ({ kind: 'batch', items: [first, second] })); + const controller = connectedControllerFor(element, { + root: { ...fakeRoot(), selectForAction }, + }); + const form = document.createElement('form'); + form.innerHTML = ''; + const event = new CustomEvent('async-dialog:beforeLoad', { + cancelable: true, + detail: { form }, + }); + + controller.prepareDialog(event); + + expect(selectForAction).toHaveBeenCalledWith(element); + expect(event.defaultPrevented).toBe(false); + expect(Array.from(form.elements).map((input) => [(input as HTMLInputElement).name, (input as HTMLInputElement).value])).toEqual([ + ['kept', 'yes'], + ['ids[]', '2'], + ['ids[]', '1'], + ]); + expect(form.querySelectorAll('[data-sortable-lists-generated-id]')).toHaveLength(2); + }); + + it('cancels dialog loading for a fixed invoker with no batch action scope', () => { + const element = document.createElement('div'); + element.setAttribute('data-sortable-lists--item-mobility-value', 'fixed'); + const selectForAction = vi.fn(():ActionScope => ({ kind: 'refused', items: [] })); + const controller = connectedControllerFor(element, { + root: { ...fakeRoot(), selectForAction }, + }); + const form = document.createElement('form'); + const event = new CustomEvent('async-dialog:beforeLoad', { + cancelable: true, + detail: { form }, + }); + + controller.prepareDialog(event); + + expect(selectForAction).toHaveBeenCalledWith(element); + expect(event.defaultPrevented).toBe(true); + expect(form.querySelector('[name="ids[]"]')).toBeNull(); + }); + + it('delegates direct destination metadata to the root', () => { + const element = document.createElement('div'); + const moveToDestination = vi.fn(); + const controller = connectedControllerFor(element, { + root: { ...fakeRoot(), moveToDestination }, + }); + Object.defineProperty(controller, 'hasMenuElement', { value: true }); + Object.defineProperty(controller, 'menuElement', { + value: { isItemDisabled: vi.fn(() => false), isItemHidden: vi.fn(() => false) }, + }); + const item = document.createElement('li'); + item.dataset.sortableListsDestinations = '[{"type":"inbox","id":null}]'; + const event = new Event('click') as ActionEvent; + Object.defineProperty(event, 'currentTarget', { value: item }); + + controller.moveToDestination(event); + + expect(moveToDestination).toHaveBeenCalledWith(element, { type: 'inbox', id: null }); + }); + }); + describe('movability and focus', () => { let ctx:StimulusTestContext; @@ -1815,8 +1904,10 @@ describe('Sortable lists item controller', () => { actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), availableDestinations: vi.fn(() => []), + moveToDestination: vi.fn(), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), + ownerListElementOf: vi.fn(() => null), ownerRowsContainer: vi.fn(() => null), freezeDragBatch: vi.fn(() => 1), markDragBatch, @@ -1846,8 +1937,10 @@ describe('Sortable lists item controller', () => { actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), availableDestinations: vi.fn(() => []), + moveToDestination: vi.fn(), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), + ownerListElementOf: vi.fn(() => null), ownerRowsContainer: vi.fn(() => null), freezeDragBatch, markDragBatch: vi.fn(), 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 e73b12c6b2c4..198cdbbc9396 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -61,7 +61,7 @@ import { } from './list-dom'; import { webLinkHref } from './external-data'; import { renderDragPreview } from './preview'; -import type { ActionScope } from './selection-orchestrator'; +import { scopeIds, type ActionScope } from './selection-orchestrator'; type CleanupFn = () => void; @@ -180,6 +180,41 @@ export default class ItemController extends Controller implements R } } + prepareDialog(event:CustomEvent<{ form:HTMLFormElement|null }>):void { + const scope = this.root?.selectForAction(this.element); + const form = event.detail.form; + if (!form || !scope || scope.kind === 'refused') { + event.preventDefault(); + return; + } + + form.querySelectorAll('[data-sortable-lists-generated-id]').forEach((input) => input.remove()); + scopeIds(scope).forEach((id) => { + const input = form.ownerDocument.createElement('input'); + input.type = 'hidden'; + input.name = 'ids[]'; + input.value = id; + input.dataset.sortableListsGeneratedId = ''; + form.append(input); + }); + } + + moveToDestination(event:ActionEvent):void { + const item = event.currentTarget; + if (!this.hasMenuElement || !(item instanceof HTMLElement)) { + return; + } + + if (this.menuElement.isItemDisabled(item) || this.menuElement.isItemHidden(item)) { + return; + } + + const candidates = this.destinationCandidates(item); + if (candidates.length === 1) { + this.root?.moveToDestination(this.element, candidates[0]); + } + } + // The focus host is the consumer's business: Backlogs puts the tab stop on // the card inside the row, another consumer may focus the row itself. focusItem():void { diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts index 7073802fb5d6..cf1f8d8854e0 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts @@ -79,8 +79,10 @@ describe('Sortable lists list controller', () => { actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), availableDestinations: vi.fn(() => []), + moveToDestination: vi.fn(), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), + ownerListElementOf: vi.fn(() => null), ownerRowsContainer: vi.fn(() => null), freezeDragBatch: vi.fn(() => 1), markDragBatch: vi.fn(), diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts index fcfb1489ca8b..f04a262a5cd8 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts @@ -74,8 +74,10 @@ describe('Sortable lists scrollable controller', () => { actionScopeFor: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), selectForAction: vi.fn(():ActionScope => ({ kind: 'refused', items: [] })), availableDestinations: vi.fn(() => []), + moveToDestination: vi.fn(), moveInDirection: vi.fn(), moveAvailability: vi.fn(() => null), + ownerListElementOf: vi.fn(() => null), ownerRowsContainer: vi.fn(() => null), freezeDragBatch: vi.fn(() => 1), markDragBatch: vi.fn(), diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.ts index 8f189bc9d63a..ee4f19b2a14a 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.ts @@ -63,6 +63,15 @@ export type ActionScope = | { kind:'batch'; items:HTMLElement[] } | { kind:'refused'; items:[] }; +// The ids a scope's members carry, in the scope's own order. Derived on +// demand rather than frozen into the scope: the elements are its identity, +// and a stale id list would outlive a morph that replaced a row. +export function scopeIds(scope:ActionScope):string[] { + return scope.items + .map((item) => resolveItemId(item)) + .filter((id):id is string => id !== null); +} + // What resolving an action scope does to the selection: nothing, replace it // with the card alone only when the card is outside it, or replace it // unconditionally. 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 3b7ecc4daec8..b339a5285b40 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 @@ -75,9 +75,9 @@ See COPYRIGHT and LICENSE files for more details. id: dom_target(work_package, :menu, :move_to_inbox), label: t(".action_menu.move_to_inbox"), tag: :button, - href: move_project_backlogs_work_package_path(project, work_package), - form_arguments: { method: :put, inputs: [{ name: "list_type", value: inbox_list_type }] }, - data: destination_data(inbox_list_type, []) + data: destination_data(inbox_list_type, []).merge( + action: "click->sortable-lists--item#moveToDestination:prevent" + ) ) do |item| item.with_leading_visual_icon(icon: :inbox) end @@ -87,8 +87,15 @@ See COPYRIGHT and LICENSE files for more details. 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), - content_arguments: { data: { controller: "async-dialog" } }, + 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) @@ -99,8 +106,15 @@ See COPYRIGHT and LICENSE files for more details. 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), - content_arguments: { data: { controller: "async-dialog" } }, + 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) 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 1dbb71015da4..c3c3766a8e8b 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 @@ -291,11 +291,25 @@ def render_component(work_package: self.work_package, end describe "Move to sprint item" do + it "loads the collection dialog with a POST form after freezing the action scope" do + render_component(sprint_ids: [sprint.id]) + + button = page.find("button#work_package_#{work_package.id}_menu_move_to_sprint") + form = button.find(:xpath, "ancestor::form") + + expect(button["data-controller"]).to eq("async-dialog") + expect(button["data-action"]).to eq("async-dialog:beforeLoad->sortable-lists--item#prepareDialog") + expect(form[:method]).to eq("post") + expect(form[:action]).to end_with( + "/projects/#{project.identifier}/backlogs/work_packages/move_to_sprint_dialog" + ) + end + context "when work package is in a sprint" do it "is shown with zap icon" do render_component(sprint_ids: [sprint.id]) - expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) + expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) expect(page).to have_octicon(:zap) expect(page).to have_text(I18n.t(:"backlogs.work_package_card_menu_component.action_menu.move_to_sprint")) end @@ -308,7 +322,7 @@ def render_component(work_package: self.work_package, it "is shown" do render_component(sprint_ids: [create(:sprint, project:).id]) - expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) + expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) end end @@ -318,18 +332,32 @@ def render_component(work_package: self.work_package, it "is shown" do render_component(sprint_ids: [create(:sprint, project:).id]) - expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) + expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) end end it "is hidden when no sprint candidates exist" do render_component(sprint_ids: []) - expect(page).to have_no_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) + expect(page).to have_no_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) end end describe "Move to backlog inbox item" do + it "binds a direct collection move without rendering a singular member form" do + render_component + + item = page.find( + "li[data-sortable-lists--item-target~='destinationItem']" \ + "[data-sortable-lists-destinations='[{\"type\":\"#{Backlogs::Target::InboxId.list_type}\",\"id\":null}]']" + ) + button = item.find("button#work_package_#{work_package.id}_menu_move_to_inbox") + + expect(item["data-action"]).to eq("click->sortable-lists--item#moveToDestination:prevent") + expect(button[:form]).to be_nil + expect(page).to have_no_field("list_type", type: :hidden) + end + context "when work package is in a sprint" do it "is shown with inbox icon" do render_component @@ -337,7 +365,7 @@ def render_component(work_package: self.work_package, expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_inbox\z/) expect(page).to have_octicon(:inbox) expect(page).to have_text(I18n.t(:"backlogs.work_package_card_menu_component.action_menu.move_to_inbox")) - expect(page).to have_field("list_type", type: :hidden, with: Backlogs::Target::InboxId.list_type) + expect(page).to have_no_field("list_type", type: :hidden) end end @@ -364,11 +392,26 @@ def render_component(work_package: self.work_package, end describe "Move to backlog bucket item" do + it "loads the collection dialog with a POST form after freezing the action scope" do + bucket = create(:backlog_bucket, project:) + render_component(bucket_ids: [bucket.id]) + + button = page.find("button#work_package_#{work_package.id}_menu_move_to_backlog_bucket") + form = button.find(:xpath, "ancestor::form") + + expect(button["data-controller"]).to eq("async-dialog") + expect(button["data-action"]).to eq("async-dialog:beforeLoad->sortable-lists--item#prepareDialog") + expect(form[:method]).to eq("post") + expect(form[:action]).to end_with( + "/projects/#{project.identifier}/backlogs/work_packages/move_to_bucket_dialog" + ) + end + context "when work package is in a sprint" do it "is shown with package icon" do render_component(bucket_ids: [create(:backlog_bucket, project:).id]) - expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) + expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) expect(page).to have_octicon(:package) expect(page).to have_text(I18n.t(:"backlogs.work_package_card_menu_component.action_menu.move_to_backlog_bucket")) end @@ -381,7 +424,7 @@ def render_component(work_package: self.work_package, it "is shown" do render_component(bucket_ids: [bucket.id]) - expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) + expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) end end @@ -391,14 +434,14 @@ def render_component(work_package: self.work_package, it "is shown" do render_component(bucket_ids: [create(:backlog_bucket, project:).id]) - expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) + expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) end end it "is hidden when no bucket candidates exist" do render_component(bucket_ids: []) - expect(page).to have_no_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) + expect(page).to have_no_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/) end end @@ -413,7 +456,7 @@ def render_component(work_package: self.work_package, it "renders destination candidates for client-side confined-scope projection", :aggregate_failures do render_component - expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) + expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_inbox\z/) end @@ -452,7 +495,7 @@ def render_component(work_package: self.work_package, render_component expect(page).to have_selector(:menuitem, text: "Move to position") - expect(page).to have_element(:a, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) + expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/) end end end From 2def292f1f798d7f9c4ffbcc9489f5ebc8b36e0c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 16 Aug 2026 23:45:57 +0100 Subject: [PATCH 07/27] [AGILE-362] Announce destination batches Renders live-region feedback from the authoritative response while suppressing misleading positions for invisible results. https://community.openproject.org/wp/AGILE-362 --- .../backlogs/work_packages_controller.rb | 33 ++++++- modules/backlogs/config/locales/en.yml | 3 + .../work_packages/move_collection_spec.rb | 96 +++++++++++++++++++ 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb index ac4eef7bf33d..0e7888b60ce5 100644 --- a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb @@ -168,7 +168,8 @@ def render_update_collection_turbo_streams(call) WORK_PACKAGE_MOVED_EVENT, detail: { work_package_ids: call.result.map(&:id) } ) - render_invisible_after_move_batch_flash(call.result) + invisible_feedback_rendered = render_invisible_after_move_batch_flash(call.result) + render_collection_move_announcement(call.result) unless optimistic_move? || invisible_feedback_rendered else render_error_flash_message_via_turbo_stream( message: I18n.t(:notice_unsuccessful_update_with_reason, reason: batch_failure_reason(call)) @@ -253,11 +254,12 @@ def render_invisible_after_move_flash(work_package) # A member's own type or status can hide it independently of its # list-mates, so the first member cannot answer this for the batch. - def render_invisible_after_move_batch_flash(results) + def render_invisible_after_move_batch_flash(results) # rubocop:disable Naming/PredicateMethod invisible = results.select { |wp| work_package_invisible_after_move?(wp) } - return if invisible.empty? + return false if invisible.empty? render_flash_message_via_turbo_stream(message: invisible_after_move_batch_message(invisible)) + true end def invisible_after_move_batch_message(invisible) @@ -284,7 +286,7 @@ def render_move_announcement(work_package) render_live_region_update_message( message: t( - ".moved_announcement", + "backlogs.work_packages.move.moved_announcement", label: work_package.to_fs(:caption), list: target_list_name(work_package), position: index + 1, @@ -293,6 +295,29 @@ def render_move_announcement(work_package) ) end + def render_collection_move_announcement(work_packages) + return render_move_announcement(work_packages.first) if work_packages.one? + + scope_ids = announcement_list_scope(work_packages.first).pluck(:id) + first = scope_ids.index(work_packages.first.id) + return unless first + + render_live_region_update_message( + message: collection_move_announcement_message(work_packages, first:, total: scope_ids.size) + ) + end + + def collection_move_announcement_message(work_packages, first:, total:) + t( + ".moved_announcement", + count: work_packages.size, + list: target_list_name(work_packages.first), + first: first + 1, + last: first + work_packages.size, + total: + ) + end + # The scopes the page renders, so the announced position matches what a # sighted user sees: sprints are scoped to the project (shared sprints # render only the project's items), buckets and the inbox go through the diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 4f471ad0cb51..f572e3d9211f 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -302,6 +302,9 @@ en: move_collection: invalid_ids: "The list of work packages to move is invalid." member_failed: "%{work_package}: %{reason}" + moved_announcement: + one: "%{count} work package moved to %{list}, position %{first} of %{total}" + other: "%{count} work packages moved to %{list}, positions %{first} through %{last} of %{total}" too_many_work_packages: "No more than %{max} work packages can be moved at once." work_packages_not_found: "At least one work package could not be found in this project." update_service: diff --git a/modules/backlogs/spec/requests/work_packages/move_collection_spec.rb b/modules/backlogs/spec/requests/work_packages/move_collection_spec.rb index 7e085fed013a..a972c8543272 100644 --- a/modules/backlogs/spec/requests/work_packages/move_collection_spec.rb +++ b/modules/backlogs/spec/requests/work_packages/move_collection_spec.rb @@ -53,6 +53,22 @@ def move_collection(ids:, **params) headers: { "Accept" => "text/vnd.turbo-stream.html" } end + def load_move_to_sprint_dialog(ids:) + post move_to_sprint_dialog_project_backlogs_work_packages_path(project), + params: { ids: }, + headers: { "Accept" => "text/vnd.turbo-stream.html" } + end + + def expect_moved_event(ids) + expect(response.body).to have_turbo_stream(action: "dispatchEvent") do |streams| + expect(streams.size).to eq(1) + expect(streams.first["event-name"]) + .to eq(Backlogs::WorkPackagesController::WORK_PACKAGE_MOVED_EVENT) + expect(JSON.parse(streams.first["detail"])) + .to eq("work_package_ids" => ids) + end + end + context "without the manage_sprint_items permission" do let(:permissions) { %i[view_work_packages edit_work_packages view_sprints] } @@ -135,6 +151,65 @@ def move_collection(ids:, **params) end describe "successful moves" do + context "with one visible member" do + it "reuses the singular move announcement", :aggregate_failures do + move_collection(ids: [bucket_wp1.id], list_type: "sprint", list_id: sprint.id) + + expect(response).to have_http_status(:ok) + expect(response.body).to include( + ERB::Util.html_escape( + I18n.t("backlogs.work_packages.move.moved_announcement", + label: bucket_wp1.to_fs(:caption), list: sprint.name, position: 2, total: 2) + ) + ) + expect_moved_event([bucket_wp1.id]) + end + end + + context "with two visible members" do + it "announces their count, destination, contiguous range, and total", :aggregate_failures do + moved_ids = [bucket_wp2.id, bucket_wp1.id] + + move_collection(ids: moved_ids, list_type: "sprint", list_id: sprint.id) + + expect(response).to have_http_status(:ok) + expect(response.body).to include( + ERB::Util.html_escape( + I18n.t("backlogs.work_packages.move_collection.moved_announcement", + count: 2, list: sprint.name, first: 2, last: 3, total: 3) + ) + ) + expect(sprint.work_packages_for(project).pluck(:id)).to eq [sprint_wp1.id, *moved_ids] + expect_moved_event(moved_ids) + end + end + + context "with members already persisted at the requested append placement" do + it "treats the stale-state submission as success and announces the ordered batch", :aggregate_failures do + frozen_ids = [bucket_wp2.id, bucket_wp1.id] + load_move_to_sprint_dialog(ids: frozen_ids) + expect(response).to have_http_status(:ok) + + concurrently_moved = Backlogs::WorkPackages::BatchUpdateService + .new(user:, work_packages: frozen_ids.map { |id| WorkPackage.find(id) }) + .call(list_type: "sprint", list_id: sprint.id) + expect(concurrently_moved).to be_success + expect(sprint.work_packages_for(project).pluck(:id)).to eq [sprint_wp1.id, *frozen_ids] + + move_collection(ids: frozen_ids, list_type: "sprint", list_id: sprint.id) + + expect(response).to have_http_status(:ok) + expect(response.body).to include( + ERB::Util.html_escape( + I18n.t("backlogs.work_packages.move_collection.moved_announcement", + count: 2, list: sprint.name, first: 2, last: 3, total: 3) + ) + ) + expect(sprint.work_packages_for(project).pluck(:id)).to eq [sprint_wp1.id, *frozen_ids] + expect_moved_event(frozen_ids) + end + end + context "with an optimistic same-list reorder whose block is honored" do it "responds with the moved event only, no frame reload", :aggregate_failures do move_collection(ids: [sprint_wp1.id], list_type: "sprint", list_id: sprint.id, @@ -228,6 +303,25 @@ def move_collection(ids:, **params) expect(response).to have_http_status(:unprocessable_entity) expect(response.body).to include(ERB::Util.html_escape(sprint_wp3.reload.to_fs(:caption))) end + + it "rejects a destination that becomes unavailable after the dialog loads", :aggregate_failures do + frozen_ids = [bucket_wp2.id, bucket_wp1.id] + load_move_to_sprint_dialog(ids: frozen_ids) + expect(response).to have_http_status(:ok) + + positions_before = WorkPackage.order(:id).pluck(:id, :sprint_id, :backlog_bucket_id, :position) + sprint.update!(status: "completed") + + move_collection(ids: frozen_ids, list_type: "sprint", list_id: sprint.id) + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include( + ERB::Util.html_escape(I18n.t("backlogs.work_packages.batch_update_service.unavailable_target")) + ) + expect(WorkPackage.order(:id).pluck(:id, :sprint_id, :backlog_bucket_id, :position)) + .to eq(positions_before) + expect(response.body).not_to have_turbo_stream(action: "dispatchEvent") + end end describe "invisibility after move" do @@ -252,6 +346,7 @@ def move_collection(ids:, **params) expect(response.body).to include( ERB::Util.html_escape(I18n.t(:notice_work_package_invisible_after_move, count: 1, backlog: bucket.name)) ) + expect(response.body).not_to have_turbo_stream(action: "liveRegion") end end @@ -283,6 +378,7 @@ def move_collection(ids:, **params) I18n.t(:notice_work_package_invisible_after_move, count: 2, backlog: bucket.name) ) ) + expect(response.body).not_to have_turbo_stream(action: "liveRegion") end end end From 0886a1f1f78171f063809bc1543dc05ebb762087 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 16 Aug 2026 23:45:58 +0100 Subject: [PATCH 08/27] [AGILE-362] Cover destination menu flows Exercises ordered destination batches through Selenium, covering confinement, stale menus, rejection, and selection cleanup. Rejects every visible modal in the disappearing-target case. https://community.openproject.org/wp/AGILE-362 --- .../dynamic/sortable-lists.controller.spec.ts | 15 ++ .../dynamic/sortable-lists.controller.ts | 7 + .../app/views/backlogs/backlog/show.html.erb | 5 +- .../batch_destination_menu_spec.rb | 243 ++++++++++++++++++ .../backlogs/spec/support/pages/backlog.rb | 125 +++++++++ 5 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index d740758774ab..9cd6985bd8f6 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -1662,6 +1662,21 @@ describe('Sortable lists controller', () => { expect(row.hasAttribute('data-batch-selected')).toBe(false); }); + it('clears the live selection when a consumer reports a completed move', async () => { + const { root, items } = renderSelectableRoot(); + root.setAttribute( + 'data-action', + 'sortable-lists:test-move-completed@document->sortable-lists#clearSelectionAfterMove', + ); + await ctx.nextFrame(); + click(items[0]); + click(items[1], { metaKey: true }); + + document.dispatchEvent(new CustomEvent('sortable-lists:test-move-completed')); + + expect(items.some(isSelected)).toBe(false); + }); + it('selects only the clicked card on a plain click', async () => { const { items } = renderSelectableRoot(); await ctx.nextFrame(); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index ae8aec78cb37..18d71a3897d9 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -259,6 +259,13 @@ export default class SortableListsController extends Controller imp return this.selection?.selectForAction(itemElement) ?? { kind: 'refused', items: [] }; } + // Consumer-owned non-optimistic forms do not call performMove, so their + // successful move event is the shared boundary at which the live batch is + // cleared. Failed requests emit no completion event and keep the selection. + clearSelectionAfterMove():void { + this.selection?.clearSilently(); + } + // Where the batch may move: the candidates every member accepts, minus the // one they all already occupy, which would be a move to nowhere. availableDestinations(scope:ActionScope, candidates:DestinationIdentity[]):DestinationIdentity[] { diff --git a/modules/backlogs/app/views/backlogs/backlog/show.html.erb b/modules/backlogs/app/views/backlogs/backlog/show.html.erb index a39f9f54998c..53ec6aa67c02 100644 --- a/modules/backlogs/app/views/backlogs/backlog/show.html.erb +++ b/modules/backlogs/app/views/backlogs/backlog/show.html.erb @@ -51,7 +51,10 @@ See COPYRIGHT and LICENSE files for more details. data: { controller: "backlogs--list-refresh backlogs--split-view-sync sortable-lists", sortable_lists_optimistic_value: true, - action: "#{Backlogs::WorkPackagesController::WORK_PACKAGE_MOVED_EVENT}@document->backlogs--split-view-sync#onWorkPackageMoved", + action: [ + "#{Backlogs::WorkPackagesController::WORK_PACKAGE_MOVED_EVENT}@document->backlogs--split-view-sync#onWorkPackageMoved", + "#{Backlogs::WorkPackagesController::WORK_PACKAGE_MOVED_EVENT}@document->sortable-lists#clearSelectionAfterMove" + ].join(" "), sortable_lists_move_url_template_value: backlogs_move_url_template(@project), sortable_lists_collection_move_url_value: move_project_backlogs_work_packages_path(@project), sortable_lists_max_batch_size_value: Backlogs::WorkPackages::BatchUpdateService::MAX_BATCH_SIZE, diff --git a/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb b/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb new file mode 100644 index 000000000000..b9d6607ae2da --- /dev/null +++ b/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb @@ -0,0 +1,243 @@ +# 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 destination menus", + :js, + :selenium, + :settings_reset, + with_ee: %i[readonly_work_packages] do + let!(:project) do + create(:project, types: [type], enabled_module_names: %w(work_package_tracking backlogs)) + end + let(:type) { create(:type) } + let(:manage_sprint_items_role) do + create(:project_role, + permissions: %i[view_sprints manage_sprint_items view_work_packages edit_work_packages]) + end + let(:backlogs_page) { Pages::Backlog.new(project) } + + current_user do + create(:user, member_with_roles: { project => manage_sprint_items_role }) + end + + it "moves a selected cross-list batch in live document order and announces its appended range" do + source_sprint = create(:sprint, project:, name: "Source sprint") + destination_sprint = create(:sprint, project:, name: "Destination sprint") + bucket = create(:backlog_bucket, project:, name: "Source bucket") + destination_story = create(:work_package, project:, type:, sprint: destination_sprint, position: 1) + bucket_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 1) + sprint_story = create(:work_package, project:, type:, sprint: source_sprint, position: 1) + backlogs_page.visit! + + backlogs_page.select_cards(sprint_story, bucket_story) + backlogs_page.expect_selected_cards_in_order(bucket_story, sprint_story) + + backlogs_page.open_destination_dialog(sprint_story, "Move to sprint") + backlogs_page.expect_destination_dialog( + "Move to sprint", + work_packages: [bucket_story, sprint_story] + ) + backlogs_page.submit_destination_dialog( + "Move to sprint", + field_label: Sprint.human_model_name, + option: destination_sprint.name + ) + + backlogs_page.expect_sprint_items_in_order( + destination_sprint, + items: [destination_story, bucket_story, sprint_story] + ) + backlogs_page.expect_persisted_sprint_order( + destination_sprint, + destination_story, + bucket_story, + sprint_story + ) + backlogs_page.expect_polite_announcement( + I18n.t( + "backlogs.work_packages.move_collection.moved_announcement", + count: 2, + list: destination_sprint.name, + first: 2, + last: 3, + total: 3 + ) + ) + backlogs_page.expect_no_selected_cards + end + + it "replaces the old selection when an unselected card invokes a destination action" do + first_sprint = create(:sprint, project:, name: "First sprint") + second_sprint = create(:sprint, project:, name: "Second sprint") + bucket = create(:backlog_bucket, project:, name: "Source bucket") + selected_bucket_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 1) + selected_sprint_story = create(:work_package, project:, type:, sprint: first_sprint, position: 1) + invoker = create(:work_package, project:, type:, sprint: second_sprint, position: 1) + backlogs_page.visit! + + backlogs_page.select_cards(selected_sprint_story, selected_bucket_story) + backlogs_page.expect_selected_cards_in_order(selected_bucket_story, selected_sprint_story) + + backlogs_page.move_to_backlog_inbox(invoker) + + backlogs_page.expect_persisted_inbox_order(invoker) + backlogs_page.expect_persisted_bucket_order(bucket, selected_bucket_story) + backlogs_page.expect_persisted_sprint_order(first_sprint, selected_sprint_story) + backlogs_page.expect_no_selected_cards + end + + it "offers a partly occupied destination and gathers the whole batch at its end" do + destination_sprint = create(:sprint, project:, name: "Destination sprint") + bucket = create(:backlog_bucket, project:, name: "Source bucket") + destination_story = create(:work_package, project:, type:, sprint: destination_sprint, position: 1) + selected_destination_story = create(:work_package, project:, type:, sprint: destination_sprint, position: 2) + bucket_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 1) + backlogs_page.visit! + + backlogs_page.select_cards(selected_destination_story, bucket_story) + backlogs_page.expect_selected_cards_in_order(bucket_story, selected_destination_story) + backlogs_page.expect_work_package_action(selected_destination_story, "Move to sprint") + + backlogs_page.open_destination_dialog(selected_destination_story, "Move to sprint") + backlogs_page.expect_destination_dialog_options( + "Move to sprint", + field_label: Sprint.human_model_name, + options: [destination_sprint.name] + ) + backlogs_page.submit_destination_dialog( + "Move to sprint", + field_label: Sprint.human_model_name, + option: destination_sprint.name + ) + + backlogs_page.expect_persisted_sprint_order( + destination_sprint, + destination_story, + bucket_story, + selected_destination_story + ) + end + + it "intersects destinations for confined selections and offers none when their lists conflict" do + first_sprint = create(:sprint, project:, name: "First sprint") + second_sprint = create(:sprint, project:, name: "Second sprint") + bucket = create(:backlog_bucket, project:, name: "Source bucket") + readonly_status = create(:status, is_readonly: true) + confined_first = create(:work_package, project:, type:, sprint: first_sprint, status: readonly_status) + confined_second = create(:work_package, project:, type:, sprint: second_sprint, status: readonly_status) + free_story = create(:work_package, project:, type:, backlog_bucket: bucket) + backlogs_page.visit! + + backlogs_page.select_cards(confined_first, free_story) + backlogs_page.expect_destination_actions( + confined_first, + present: ["Move to sprint"], + absent: ["Move to backlog bucket", "Move to backlog inbox"] + ) + backlogs_page.open_destination_dialog(confined_first, "Move to sprint") + backlogs_page.expect_destination_dialog_options( + "Move to sprint", + field_label: Sprint.human_model_name, + options: [first_sprint.name] + ) + backlogs_page.cancel_destination_dialog("Move to sprint") + backlogs_page.clear_card_selection(confined_first) + + backlogs_page.select_cards(confined_first, confined_second) + backlogs_page.expect_destination_actions( + confined_first, + present: [], + absent: ["Move to sprint", "Move to backlog bucket", "Move to backlog inbox"] + ) + end + + it "omits Move to position from a multi-card action scope" do + sprint = create(:sprint, project:, name: "Sprint") + create(:backlog_bucket, project:, name: "Destination bucket") + first_story = create(:work_package, project:, type:, sprint:, position: 1) + second_story = create(:work_package, project:, type:, sprint:, position: 2) + backlogs_page.visit! + + backlogs_page.select_cards(first_story, second_story) + + backlogs_page.expect_no_work_package_action(first_story, "Move to position") + backlogs_page.expect_work_package_action(first_story, "Move to backlog bucket") + end + + it "shows feedback without an empty modal when the last destination disappears before loading" do + sprint = create(:sprint, project:, name: "Only sprint") + bucket = create(:backlog_bucket, project:, name: "Source bucket") + first_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 1) + second_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 2) + backlogs_page.visit! + + backlogs_page.select_cards(first_story, second_story) + backlogs_page.invoke_destination_action_after_menu_load(first_story, "Move to sprint") do + sprint.update_columns(status: "completed") + end + + backlogs_page.expect_move_error( + I18n.t("backlogs.work_packages.move_to_sprint_dialog.no_available_destinations") + ) + backlogs_page.expect_no_destination_dialog + backlogs_page.expect_selected_cards_in_order(first_story, second_story) + backlogs_page.expect_persisted_bucket_order(bucket, first_story, second_story) + end + + it "rejects the complete batch atomically and retains its selection" do + sprint = create(:sprint, project:, name: "Destination sprint") + bucket = create(:backlog_bucket, project:, name: "Source bucket") + first_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 1) + second_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 2) + backlogs_page.visit! + + backlogs_page.select_cards(first_story, second_story) + backlogs_page.open_destination_dialog(first_story, "Move to sprint") + + second_story.update_columns(status_id: create(:status, is_readonly: true).id) + backlogs_page.submit_destination_dialog( + "Move to sprint", + field_label: Sprint.human_model_name, + option: sprint.name, + frame_reload: false + ) + + backlogs_page.expect_move_error( + I18n.t("backlogs.work_packages.move_collection.member_failed", + work_package: second_story.reload.to_fs(:caption), + reason: I18n.t("backlogs.work_packages.batch_update_service.unavailable_target")) + ) + backlogs_page.expect_persisted_bucket_order(bucket, first_story, second_story) + backlogs_page.expect_selected_cards_in_order(first_story, second_story) + end +end diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index ecbce1b28d67..9ef03e658749 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -743,6 +743,131 @@ def selected_card_ids all("[data-batch-selected]").pluck("data-sortable-lists--item-id-value") end + def select_cards(*work_packages) + work_packages.each { |work_package| toggle_card(work_package) } + end + + def expect_selected_cards_in_order(*work_packages) + expect(selected_card_ids).to eq(work_packages.map { |work_package| work_package.id.to_s }) + end + + def expect_no_selected_cards + expect(selected_card_ids).to be_empty + end + + def clear_card_selection(work_package) + work_package_card(work_package).send_keys(:escape) + expect_no_selected_cards + end + + def expect_work_package_action(work_package, action_label) + within_work_package_menu(work_package) do |menu| + expect(menu).to have_selector(:menuitem, text: action_label, exact_text: true) + end + end + + def expect_no_work_package_action(work_package, action_label) + within_work_package_menu(work_package) do |menu| + expect(menu).to have_no_selector(:menuitem, text: action_label, exact_text: true) + end + end + + def expect_destination_actions(work_package, present:, absent:) + within_work_package_menu(work_package) do |menu| + 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 + end + + def open_destination_dialog(work_package, action_label, dialog_title: action_label) + click_in_work_package_menu(work_package, action_label, wait: false) + expect(page).to have_selector(:modal, text: dialog_title) + end + + def invoke_destination_action_after_menu_load(work_package, action_label, &before_click) + within_work_package_menu(work_package) do |menu| + item = menu.find(:menuitem, text: action_label, exact_text: true) + before_click&.call + wait_for_backlogs_turbo_stream do + item.click + end + end + end + + def move_to_backlog_inbox(work_package) + within_work_package_menu(work_package) do |menu| + wait_for_backlogs_turbo_stream(frame_reload: true) do + menu.find(:menuitem, text: "Move to backlog inbox", exact_text: true).click + end + end + end + + def expect_destination_dialog(dialog_title, work_packages:) + within_modal dialog_title do + expect(page).to have_text(I18n.t(:label_x_work_packages, count: work_packages.size)) + expect(all("input[name='ids[]']", visible: :all).map(&:value)) + .to eq(work_packages.map { |work_package| work_package.id.to_s }) + end + end + + def expect_destination_dialog_options(dialog_title, field_label:, options:) + within_modal dialog_title do + select = find(:select, field_label) + expect(select.all(:option).map(&:text)).to eq(options) + end + end + + def submit_destination_dialog(dialog_title, field_label:, option:, frame_reload: true) + within_modal dialog_title do + select option, from: field_label + wait_for_backlogs_turbo_stream(frame_reload:) { click_button I18n.t(:button_move) } + end + end + + def cancel_destination_dialog(dialog_title) + within_modal dialog_title do + click_button I18n.t(:button_cancel) + end + expect(page).to have_no_selector(:modal, text: dialog_title) + end + + def expect_no_destination_dialog + expect(page).to have_no_selector(:modal) + end + + def expect_move_error(reason) + expect_flash( + type: :error, + message: I18n.t(:notice_unsuccessful_update_with_reason, reason:) + ) + end + + def expect_polite_announcement(message) + wait_for do + page.evaluate_script("document.querySelector('live-region')?.getMessage('polite')") + end.to eq(message) + end + + def expect_persisted_sprint_order(sprint, *work_packages) + wait_for { sprint.work_packages_for(project).pluck(:id) } + .to eq(work_packages.map(&:id)) + end + + def expect_persisted_bucket_order(bucket, *work_packages) + wait_for { WorkPackage.where(backlog_bucket: bucket).order_by_position.pluck(:id) } + .to eq(work_packages.map(&:id)) + end + + def expect_persisted_inbox_order(*work_packages) + wait_for do + WorkPackage.where(project:, sprint_id: nil, backlog_bucket_id: nil).order_by_position.pluck(:id) + end.to eq(work_packages.map(&:id)) + end + # The shared description every selected card's `aria-describedby` points # at. Rendered once, permanently `hidden` — screen readers still reach it # through the reference despite that — so `visible: :all` is required. From 838d74715c98adfa3f413cb3acd5dd60b2c8a104 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 16 Aug 2026 23:45:58 +0100 Subject: [PATCH 09/27] [AGILE-362] Harden destination batch races Serializes append placement with destination lifecycle changes and locks target rows before rechecking authority. Batches read-only status queries and cleans up concurrency tests without masking original failures. https://community.openproject.org/wp/AGILE-362 --- .../sortable-lists/item.controller.spec.ts | 25 +++++++++++++++++++ .../dynamic/sortable-lists/item.controller.ts | 8 +++++- .../work_packages/destination_availability.rb | 10 +++++++- .../destination_availability_spec.rb | 21 ++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) 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 70fd74d7172b..d466327cd983 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 @@ -1767,6 +1767,31 @@ describe('Sortable lists item controller', () => { expect(form.querySelectorAll('[data-sortable-lists-generated-id]')).toHaveLength(2); }); + it('cancels dialog loading without resolving or mutating the action scope while the root is busy', () => { + const element = document.createElement('div'); + const selectForAction = vi.fn(():ActionScope => ({ kind: 'batch', items: [element] })); + const controller = connectedControllerFor(element, { + root: { ...fakeRoot(), busy: true, selectForAction }, + }); + const form = document.createElement('form'); + form.innerHTML = ''; + const staleInput = form.querySelector('[data-sortable-lists-generated-id]'); + const event = new CustomEvent<{ form:HTMLFormElement|null }>('async-dialog:beforeLoad', { + cancelable: true, + detail: { form }, + }); + + controller.prepareDialog(event); + + expect(event.defaultPrevented).toBe(true); + expect(selectForAction).not.toHaveBeenCalled(); + expect(form.querySelector('[data-sortable-lists-generated-id]')).toBe(staleInput); + expect(Array.from(form.elements).map((input) => [(input as HTMLInputElement).name, (input as HTMLInputElement).value])).toEqual([ + ['ids[]', 'stale'], + ['kept', 'yes'], + ]); + }); + it('cancels dialog loading for a fixed invoker with no batch action scope', () => { const element = document.createElement('div'); element.setAttribute('data-sortable-lists--item-mobility-value', 'fixed'); 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 198cdbbc9396..716314086b81 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -181,7 +181,13 @@ export default class ItemController extends Controller implements R } prepareDialog(event:CustomEvent<{ form:HTMLFormElement|null }>):void { - const scope = this.root?.selectForAction(this.element); + const root = this.root; + if (!root || root.busy) { + event.preventDefault(); + return; + } + + const scope = root.selectForAction(this.element); const form = event.detail.form; if (!form || !scope || scope.kind === 'refused') { event.preventDefault(); diff --git a/modules/backlogs/app/services/backlogs/work_packages/destination_availability.rb b/modules/backlogs/app/services/backlogs/work_packages/destination_availability.rb index b6050c1ece87..5dddc5b24bc7 100644 --- a/modules/backlogs/app/services/backlogs/work_packages/destination_availability.rb +++ b/modules/backlogs/app/services/backlogs/work_packages/destination_availability.rb @@ -92,7 +92,15 @@ def bucket_candidates end def free?(work_package) - !work_package.readonly_status? + readonly_status_ids.exclude?(work_package.status_id) + end + + def readonly_status_ids + @readonly_status_ids ||= if Status.can_readonly? + Status.where(id: work_packages.map(&:status_id), is_readonly: true).ids + else + [] + end end def current_target(work_package) diff --git a/modules/backlogs/spec/services/backlogs/work_packages/destination_availability_spec.rb b/modules/backlogs/spec/services/backlogs/work_packages/destination_availability_spec.rb index 65b44db9ac11..989e31740e51 100644 --- a/modules/backlogs/spec/services/backlogs/work_packages/destination_availability_spec.rb +++ b/modules/backlogs/spec/services/backlogs/work_packages/destination_availability_spec.rb @@ -100,6 +100,27 @@ def availability(work_packages, for_user: user) expect(result.sprints).to eq [later_sprint] expect(result.permitted?(current_target)).to be true end + + it "loads read-only status identity in one query for the whole batch", + with_ee: %i[readonly_work_packages] do + work_packages = create_list(:work_package, 5, project:, type:, status: readonly_status) + work_packages.each(&:reload) + + recorder = ActiveRecord::QueryRecorder.new do + availability(work_packages).permitted?(Backlogs::Target::InboxId) + end + + status_queries = recorder.log.grep(/FROM "statuses"/) + expect(status_queries.size).to eq 1 + end + + it "ignores persisted read-only flags when the feature is unavailable" do + persisted_readonly_status = create(:status) + persisted_readonly_status.update_column(:is_readonly, true) + work_package = create(:work_package, project:, type:, status: persisted_readonly_status) + + expect(availability([work_package]).permitted?(Backlogs::Target.for(alpha_bucket))).to be true + end end describe "authoritative candidates" do From df7b578acd8434ec43b7da07aea73a9bd7231156 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 16 Aug 2026 23:49:37 +0100 Subject: [PATCH 10/27] [AGILE-362] Refuse Escape while moves pending Preserves the retry batch while a request owns movement. Consumes selection Escape only after overlays and empty state are handled. https://community.openproject.org/wp/AGILE-362 --- .../controllers/dynamic/sortable-lists.controller.spec.ts | 8 ++++---- .../dynamic/sortable-lists/selection-orchestrator.ts | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 9cd6985bd8f6..456563729175 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -2227,17 +2227,17 @@ describe('Sortable lists controller', () => { expect(items.some(isSelected)).toBe(false); }); - // Escape clears local state only, so the busy gate must not swallow it. - it('clears the batch on Escape even during a busy move', async () => { + it('preserves the batch and consumes Escape during a busy move', async () => { const { root, items } = renderSelectableRoot(); await ctx.nextFrame(); click(items[0]); items[0].focus(); root.setAttribute('data-sortable-lists-busy', 'true'); - keydown(items[0], 'Escape'); + const event = keydown(items[0], 'Escape'); - expect(items.some(isSelected)).toBe(false); + expect(items.filter(isSelected)).toEqual([items[0]]); + expect(event.defaultPrevented).toBe(true); }); // An unconsumed Escape still reaches dialogs and menus. diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.ts index ee4f19b2a14a..879c4a205101 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.ts @@ -408,6 +408,11 @@ export class SelectionOrchestrator { return; } + if (this.host.busy) { + event.preventDefault(); + return; + } + event.preventDefault(); escapesClearedBySelection.add(event); this.selection.clear(); From 0fd1bcb4d267436bec3f85e99329ec9a5fde1d0b Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sun, 16 Aug 2026 23:52:32 +0100 Subject: [PATCH 11/27] [AGILE-362] Share batch Turbo Stream requests Shares the two matching non-optimistic response paths while keeping caller-owned cleanup and #AGILE-393 behavior unchanged. Deduplicates owner identity without merging policy layers. https://community.openproject.org/wp/AGILE-362 --- .../controllers/async-dialog.controller.ts | 16 ++---- .../dynamic/sortable-lists.controller.ts | 14 +---- .../stimulus/helpers/request-helpers.spec.ts | 54 ++++++++++++++++++- .../src/stimulus/helpers/request-helpers.ts | 14 +++++ 4 files changed, 71 insertions(+), 27 deletions(-) diff --git a/frontend/src/stimulus/controllers/async-dialog.controller.ts b/frontend/src/stimulus/controllers/async-dialog.controller.ts index df84dba4926c..e116f6a5260c 100644 --- a/frontend/src/stimulus/controllers/async-dialog.controller.ts +++ b/frontend/src/stimulus/controllers/async-dialog.controller.ts @@ -28,6 +28,7 @@ import { Controller } from '@hotwired/stimulus'; import { FetchRequest } from '@rails/request.js'; +import { performTurboStreamRequest } from 'core-stimulus/helpers/request-helpers'; import { TurboHelpers } from 'core-turbo/helpers'; export default class AsyncDialogController extends Controller { @@ -79,21 +80,10 @@ export default class AsyncDialogController extends Controller { TurboHelpers.showProgressBar(); try { - const response = await new FetchRequest(method, url, { + await performTurboStreamRequest(new FetchRequest(method, url, { body, responseKind: 'turbo-stream', - }).perform(); - - if (!response.isTurboStream) { - throw new Error('Response is not a Turbo Stream'); - } - - // request.js renders successful and 422 streams automatically. Preserve - // async-dialog's existing any-status stream behavior for every other - // response until #AGILE-393 defines an application-wide policy. - if (!response.ok && !response.unprocessableEntity) { - await response.renderTurboStream(); - } + })); } finally { this.setLoading(false); TurboHelpers.hideProgressBar(); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index 18d71a3897d9..a2026f648a0b 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -36,6 +36,7 @@ import { announce } from '@primer/live-region-element'; import { debugLog } from 'core-app/shared/helpers/debug_output'; import { OPToastEvent } from 'core-app/shared/components/toaster/toast-event'; import { flipMove } from 'core-stimulus/helpers/flip-helper'; +import { performTurboStreamRequest } from 'core-stimulus/helpers/request-helpers'; import { parseTemplate } from 'url-template'; import { buildMoveFormData, @@ -712,18 +713,7 @@ export default class SortableListsController extends Controller imp this.startMoveRequest(); try { - const response = await request.perform(); - - if (!response.isTurboStream) { - throw new Error('Response is not a Turbo Stream'); - } - - // request.js renders successful and 422 streams automatically. Match - // async-dialog's existing any-status stream behavior for every other - // response until #AGILE-393 defines an application-wide policy. - if (!response.ok && !response.unprocessableEntity) { - await response.renderTurboStream(); - } + await performTurboStreamRequest(request); } catch (error) { // Only a request that never produced a stream lands here — a rejection // streams its own flash. Without the toast the busy state would simply diff --git a/frontend/src/stimulus/helpers/request-helpers.spec.ts b/frontend/src/stimulus/helpers/request-helpers.spec.ts index 0fd3d0427112..c9daf0cb851a 100644 --- a/frontend/src/stimulus/helpers/request-helpers.spec.ts +++ b/frontend/src/stimulus/helpers/request-helpers.spec.ts @@ -26,10 +26,60 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import type { FetchResponse } from '@rails/request.js'; +import type { FetchRequest, FetchResponse } from '@rails/request.js'; import { TurboHelpers } from 'core-turbo/helpers'; -import { withLoadingIndicator, withProgressBar } from './request-helpers'; +import { performTurboStreamRequest, withLoadingIndicator, withProgressBar } from './request-helpers'; + +describe('performTurboStreamRequest', () => { + function requestFor(response:Partial):FetchRequest { + return { + perform: vi.fn().mockResolvedValue(response), + } as unknown as FetchRequest; + } + + it.each([ + ['successful', { ok: true, unprocessableEntity: false }], + ['unprocessable', { ok: false, unprocessableEntity: true }], + ])('leaves request.js to render a %s stream', async (_name, status) => { + const renderTurboStream = vi.fn(); + const response = { + ...status, + isTurboStream: true, + renderTurboStream, + } as unknown as FetchResponse; + + await expect(performTurboStreamRequest(requestFor(response))).resolves.toBe(response); + expect(renderTurboStream).not.toHaveBeenCalled(); + }); + + it('renders another unsuccessful Turbo Stream exactly once', async () => { + const renderTurboStream = vi.fn().mockResolvedValue(undefined); + const response = { + ok: false, + unprocessableEntity: false, + isTurboStream: true, + renderTurboStream, + } as unknown as FetchResponse; + + await performTurboStreamRequest(requestFor(response)); + expect(renderTurboStream).toHaveBeenCalledOnce(); + }); + + it('rejects a non-Turbo response without rendering it', async () => { + const renderTurboStream = vi.fn(); + const response = { + ok: true, + unprocessableEntity: false, + isTurboStream: false, + renderTurboStream, + } as unknown as FetchResponse; + + await expect(performTurboStreamRequest(requestFor(response))) + .rejects.toThrow('Response is not a Turbo Stream'); + expect(renderTurboStream).not.toHaveBeenCalled(); + }); +}); describe('withLoadingIndicator', () => { let indicator:HTMLElement|null; diff --git a/frontend/src/stimulus/helpers/request-helpers.ts b/frontend/src/stimulus/helpers/request-helpers.ts index a63f64b1aae8..a07f311e827b 100644 --- a/frontend/src/stimulus/helpers/request-helpers.ts +++ b/frontend/src/stimulus/helpers/request-helpers.ts @@ -36,6 +36,20 @@ export function post(url:string|URL, options?:Options) { return withLoadingIndicator(request.perform()); } +export async function performTurboStreamRequest(request:FetchRequest):Promise { + const response = await request.perform(); + + if (!response.isTurboStream) { + throw new Error('Response is not a Turbo Stream'); + } + + if (!response.ok && !response.unprocessableEntity) { + await response.renderTurboStream(); + } + + return response; +} + export function withLoadingIndicator(request:Promise) { const loadingIndicator = document.querySelector('#global-loading-indicator'); invariant(loadingIndicator, 'Expected an Element with id global-loading-indicator to be present'); From c594b56da9bf581558ce7c4a4ce2e44269fe8f32 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 17 Aug 2026 01:04:52 +0100 Subject: [PATCH 12/27] [AGILE-362] Align completed-sprint feedback Updates the inherited sprint-completion race to assert the shared unavailable-destination policy used by collection moves. https://community.openproject.org/wp/AGILE-362 --- modules/backlogs/spec/features/inbox_column_spec.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/modules/backlogs/spec/features/inbox_column_spec.rb b/modules/backlogs/spec/features/inbox_column_spec.rb index 67989c756311..101aa098ff25 100644 --- a/modules/backlogs/spec/features/inbox_column_spec.rb +++ b/modules/backlogs/spec/features/inbox_column_spec.rb @@ -292,10 +292,8 @@ click_button "Move" end - planning_page - .expect_and_dismiss_error( - "Update failed: Sprint is not assignable since it is either not shared with the project or already finished." - ) + expected_error = I18n.t("backlogs.work_packages.batch_update_service.unavailable_target") + planning_page.expect_and_dismiss_error("Update failed: #{expected_error}") # Item was *not* moved: planning_page.expect_inbox_items(items: inbox_wp1) From 7be4073dfb1d92781e9fd1104d55cdf4fb08469a Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 19 Aug 2026 19:54:45 +0100 Subject: [PATCH 13/27] Assert destination outcomes through the page Replaces the persisted-order database checks with a reload and a rendered-order assertion, so the spec proves persistence by what the user sees surviving the reload. Payload and ordering semantics remain covered by the controller unit specs and the request specs. --- .../batch_destination_menu_spec.rb | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb b/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb index b9d6607ae2da..15a7b9aeb107 100644 --- a/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb +++ b/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb @@ -77,12 +77,6 @@ destination_sprint, items: [destination_story, bucket_story, sprint_story] ) - backlogs_page.expect_persisted_sprint_order( - destination_sprint, - destination_story, - bucket_story, - sprint_story - ) backlogs_page.expect_polite_announcement( I18n.t( "backlogs.work_packages.move_collection.moved_announcement", @@ -94,6 +88,12 @@ ) ) backlogs_page.expect_no_selected_cards + + backlogs_page.visit! + backlogs_page.expect_sprint_items_in_order( + destination_sprint, + items: [destination_story, bucket_story, sprint_story] + ) end it "replaces the old selection when an unselected card invokes a destination action" do @@ -110,10 +110,15 @@ backlogs_page.move_to_backlog_inbox(invoker) - backlogs_page.expect_persisted_inbox_order(invoker) - backlogs_page.expect_persisted_bucket_order(bucket, selected_bucket_story) - backlogs_page.expect_persisted_sprint_order(first_sprint, selected_sprint_story) + backlogs_page.expect_inbox_items_in_order(items: [invoker]) + backlogs_page.expect_bucket_items_in_order(bucket, items: [selected_bucket_story]) + backlogs_page.expect_sprint_items_in_order(first_sprint, items: [selected_sprint_story]) backlogs_page.expect_no_selected_cards + + backlogs_page.visit! + backlogs_page.expect_inbox_items_in_order(items: [invoker]) + backlogs_page.expect_bucket_items_in_order(bucket, items: [selected_bucket_story]) + backlogs_page.expect_sprint_items_in_order(first_sprint, items: [selected_sprint_story]) end it "offers a partly occupied destination and gathers the whole batch at its end" do @@ -140,11 +145,15 @@ option: destination_sprint.name ) - backlogs_page.expect_persisted_sprint_order( + backlogs_page.expect_sprint_items_in_order( destination_sprint, - destination_story, - bucket_story, - selected_destination_story + items: [destination_story, bucket_story, selected_destination_story] + ) + + backlogs_page.visit! + backlogs_page.expect_sprint_items_in_order( + destination_sprint, + items: [destination_story, bucket_story, selected_destination_story] ) end @@ -211,7 +220,7 @@ ) backlogs_page.expect_no_destination_dialog backlogs_page.expect_selected_cards_in_order(first_story, second_story) - backlogs_page.expect_persisted_bucket_order(bucket, first_story, second_story) + backlogs_page.expect_bucket_items_in_order(bucket, items: [first_story, second_story]) end it "rejects the complete batch atomically and retains its selection" do @@ -237,7 +246,10 @@ work_package: second_story.reload.to_fs(:caption), reason: I18n.t("backlogs.work_packages.batch_update_service.unavailable_target")) ) - backlogs_page.expect_persisted_bucket_order(bucket, first_story, second_story) + backlogs_page.expect_bucket_items_in_order(bucket, items: [first_story, second_story]) backlogs_page.expect_selected_cards_in_order(first_story, second_story) + + backlogs_page.visit! + backlogs_page.expect_bucket_items_in_order(bucket, items: [first_story, second_story]) end end From 440b2ca55db32843d9b777fc13fb7aca74802e27 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 19 Aug 2026 21:57:18 +0100 Subject: [PATCH 14/27] Name the work packages a move will affect The move dialogs stated only how many work packages were selected, so a batch assembled by rubber band or shift-click could not be checked before committing to the move. They now list every affected work package, reusing the treatment the work package delete dialog already gives its descendants. --- .../move_to_bucket_dialog_component.html.erb | 42 +++++----- .../move_to_sprint_dialog_component.html.erb | 42 +++++----- .../selected_work_packages_component.html.erb | 59 ++++++++++++++ .../selected_work_packages_component.rb | 50 ++++++++++++ modules/backlogs/config/locales/en.yml | 4 + .../move_to_bucket_dialog_component_spec.rb | 9 ++- .../move_to_sprint_dialog_component_spec.rb | 9 ++- .../selected_work_packages_component_spec.rb | 76 +++++++++++++++++++ .../backlogs/spec/support/pages/backlog.rb | 2 +- 9 files changed, 250 insertions(+), 43 deletions(-) create mode 100644 modules/backlogs/app/components/backlogs/selected_work_packages_component.html.erb create mode 100644 modules/backlogs/app/components/backlogs/selected_work_packages_component.rb create mode 100644 modules/backlogs/spec/components/backlogs/selected_work_packages_component_spec.rb diff --git a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb index d94dbe03e176..3947b0545e2c 100644 --- a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb @@ -27,26 +27,22 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= - render(Primer::Alpha::Dialog.new(title: t(".title"), id: DIALOG_ID, size: :medium)) do |d| - d.with_header(variant: :large) - - d.with_body do - selected_work_packages = work_packages - selected_work_packages_label = t(:label_x_work_packages, count: selected_work_packages.size) - available_buckets = buckets - bucket_list_type = destination_list_type +<%= render(Primer::Alpha::Dialog.new(title: t(".title"), id: DIALOG_ID, size: :medium)) do |d| %> + <% d.with_header(variant: :large) %> + <% d.with_body do %> + <%= primer_form_with( url: move_action, method: :put, id: FORM_ID, data: { turbo_stream: true } ) do |f| + selected_work_packages = work_packages + available_buckets = buckets + bucket_list_type = destination_list_type + render_inline_form(f) do |form| - form.html_content do - render(Primer::Beta::Text.new(tag: :p, id: SELECTION_LABEL_ID, mb: 3)) { selected_work_packages_label } - end selected_work_packages.each do |work_package| form.hidden(name: "ids[]", value: work_package.id) end @@ -63,9 +59,21 @@ See COPYRIGHT and LICENSE files for more details. end end end - end + %> + + <%= + render( + Backlogs::SelectedWorkPackagesComponent.new( + work_packages:, + description_id: SELECTION_LABEL_ID, + mt: 3 + ) + ) + %> + <% end %> - d.with_footer(mt: 2) do + <% d.with_footer(mt: 2) do %> + <%= component_collection do |buttons| buttons.with_component(Primer::Beta::Button.new(data: { close_dialog_id: DIALOG_ID })) do t(:button_cancel) @@ -77,6 +85,6 @@ See COPYRIGHT and LICENSE files for more details. t(:button_move) end end - end - end -%> + %> + <% end %> +<% end %> diff --git a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb index aa815eeea60f..5c7eeef08c35 100644 --- a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb @@ -27,26 +27,22 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= - render(Primer::Alpha::Dialog.new(title: t(".title"), id: DIALOG_ID, size: :medium)) do |d| - d.with_header(variant: :large) - - d.with_body do - selected_work_packages = work_packages - selected_work_packages_label = t(:label_x_work_packages, count: selected_work_packages.size) - available_sprints = sprints - sprint_list_type = destination_list_type +<%= render(Primer::Alpha::Dialog.new(title: t(".title"), id: DIALOG_ID, size: :medium)) do |d| %> + <% d.with_header(variant: :large) %> + <% d.with_body do %> + <%= primer_form_with( url: move_action, method: :put, id: FORM_ID, data: { turbo_stream: true } ) do |f| + selected_work_packages = work_packages + available_sprints = sprints + sprint_list_type = destination_list_type + render_inline_form(f) do |form| - form.html_content do - render(Primer::Beta::Text.new(tag: :p, id: SELECTION_LABEL_ID, mb: 3)) { selected_work_packages_label } - end selected_work_packages.each do |work_package| form.hidden(name: "ids[]", value: work_package.id) end @@ -63,9 +59,21 @@ See COPYRIGHT and LICENSE files for more details. end end end - end + %> + + <%= + render( + Backlogs::SelectedWorkPackagesComponent.new( + work_packages:, + description_id: SELECTION_LABEL_ID, + mt: 3 + ) + ) + %> + <% end %> - d.with_footer(mt: 2) do + <% d.with_footer(mt: 2) do %> + <%= component_collection do |buttons| buttons.with_component(Primer::Beta::Button.new(data: { close_dialog_id: DIALOG_ID })) do t(:button_cancel) @@ -77,6 +85,6 @@ See COPYRIGHT and LICENSE files for more details. t(:button_move) end end - end - end -%> + %> + <% end %> +<% end %> diff --git a/modules/backlogs/app/components/backlogs/selected_work_packages_component.html.erb b/modules/backlogs/app/components/backlogs/selected_work_packages_component.html.erb new file mode 100644 index 000000000000..cc93d1cf72d8 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/selected_work_packages_component.html.erb @@ -0,0 +1,59 @@ +<%#-- 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. + +++#%> + +<%= render(OpPrimer::InsetBoxComponent.new(**@system_arguments)) do %> + <%= render(Primer::Box.new(tag: :span, id: description_id, hidden: true)) do %> + <%= t(:label_x_work_packages, count: work_packages.size) %> + <% end %> + + <%= + render( + Primer::Beta::Text.new( + font_size: :small, + font_weight: :bold, + display: :block, + mb: 2 + ) + ) { t(".label") } + %> + + <% work_packages.each do |work_package| %> + <%= + render( + WorkPackages::InfoLineComponent.new( + work_package:, + show_subject: true, + show_status: false, + pl: 2, + my: 1 + ) + ) + %> + <% end %> +<% end %> diff --git a/modules/backlogs/app/components/backlogs/selected_work_packages_component.rb b/modules/backlogs/app/components/backlogs/selected_work_packages_component.rb new file mode 100644 index 000000000000..75af1bfbdee2 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/selected_work_packages_component.rb @@ -0,0 +1,50 @@ +# 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 Backlogs + # Lists the work packages a batch action is about to affect. + # + # The count stays available to assistive technology through a hidden element + # carrying `description_id`; point the acting control's `aria-describedby` at + # that id. + class SelectedWorkPackagesComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + + attr_reader :work_packages, :description_id + + def initialize(work_packages:, description_id:, **system_arguments) + super() + + @work_packages = work_packages + @description_id = description_id + @system_arguments = system_arguments + end + end +end diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index f572e3d9211f..9c772ad84b2c 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -208,6 +208,10 @@ en: rebuild: "Rebuild" rebuild_positions: "Rebuild positions" remaining_hours: "Remaining work" + + selected_work_packages_component: + label: "Selected items:" + sharing: "Sprint sharing" show_burndown_chart: "Burndown chart" diff --git a/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb b/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb index d6a5a65893c3..92883a15cf02 100644 --- a/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb @@ -54,7 +54,8 @@ def render_component it "renders an ordered collection form targeting the move path via PUT", :aggregate_failures do render_component - expect(page).to have_text(I18n.t(:label_x_work_packages, count: 2)) + expect(page).to have_text(second.subject) + expect(page).to have_text(first.subject) expect(page.all("input[name='ids[]']", visible: :all).map(&:value)) .to eq([second.id.to_s, first.id.to_s]) expect(page).to have_element(:form, action: move_path, method: "post") @@ -65,12 +66,12 @@ def render_component ) end - # The count sits in its own paragraph above the select, so it only reaches a - # screen reader as the field's context through this reference. + # The visible list carries no count, so it only reaches a screen reader as + # the field's context through this reference. it "describes the bucket select by the selected-count label" do render_component - label = page.find_css("p##{described_class::SELECTION_LABEL_ID}").first + label = page.find_css("##{described_class::SELECTION_LABEL_ID}").first expect(label.text).to include(I18n.t(:label_x_work_packages, count: 2)) expect(page).to have_css( "select[name='list_id'][aria-describedby~='#{described_class::SELECTION_LABEL_ID}']", diff --git a/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb b/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb index 2649de79a667..492d0c1b041a 100644 --- a/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb @@ -54,7 +54,8 @@ def render_component it "renders an ordered collection form targeting the move path via PUT", :aggregate_failures do render_component - expect(page).to have_text(I18n.t(:label_x_work_packages, count: 2)) + expect(page).to have_text(second.subject) + expect(page).to have_text(first.subject) expect(page.all("input[name='ids[]']", visible: :all).map(&:value)) .to eq([second.id.to_s, first.id.to_s]) expect(page).to have_element(:form, action: move_path, method: "post") @@ -65,12 +66,12 @@ def render_component ) end - # The count sits in its own paragraph above the select, so it only reaches a - # screen reader as the field's context through this reference. + # The visible list carries no count, so it only reaches a screen reader as + # the field's context through this reference. it "describes the sprint select by the selected-count label" do render_component - label = page.find_css("p##{described_class::SELECTION_LABEL_ID}").first + label = page.find_css("##{described_class::SELECTION_LABEL_ID}").first expect(label.text).to include(I18n.t(:label_x_work_packages, count: 2)) expect(page).to have_css( "select[name='list_id'][aria-describedby~='#{described_class::SELECTION_LABEL_ID}']", diff --git a/modules/backlogs/spec/components/backlogs/selected_work_packages_component_spec.rb b/modules/backlogs/spec/components/backlogs/selected_work_packages_component_spec.rb new file mode 100644 index 000000000000..48f488f1ac80 --- /dev/null +++ b/modules/backlogs/spec/components/backlogs/selected_work_packages_component_spec.rb @@ -0,0 +1,76 @@ +# 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 Backlogs::SelectedWorkPackagesComponent, type: :component do + shared_let(:admin) { create(:admin) } + current_user { admin } + + shared_let(:project) { create(:project) } + shared_let(:epic_type) { create(:type, name: "Epic") } + shared_let(:feature_type) { create(:type, name: "Feature") } + shared_let(:epic) { create(:work_package, project:, type: epic_type, subject: "Contamination model") } + shared_let(:feature) { create(:work_package, project:, type: feature_type, subject: "Warning system") } + + let(:description_id) { "move-dialog-selection" } + + def render_component(work_packages: [feature, epic]) + render_inline(described_class.new(work_packages:, description_id:)) + end + + it "labels the box and names every work package in the given order", :aggregate_failures do + render_component + + expect(page).to have_text(I18n.t("backlogs.selected_work_packages_component.label")) + expect(page.text).to match( + /Feature.*#{feature.formatted_id}.*Warning system.*Epic.*#{epic.formatted_id}.*Contamination model/mi + ) + end + + it "links each work package to its full view" do + render_component + + expect(page).to have_link(feature.formatted_id, href: "/work_packages/#{feature.id}") + expect(page).to have_link(epic.formatted_id, href: "/work_packages/#{epic.id}") + end + + # The visible list replaces the count, so the count only reaches a screen + # reader through the hidden element the acting control describes itself by. + it "keeps the selected count on a hidden description element" do + render_component + + expect(page).to have_css( + "##{description_id}[hidden]", + text: I18n.t(:label_x_work_packages, count: 2), + visible: :all + ) + end +end diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index 9ef03e658749..8555b5bd08c9 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -808,7 +808,7 @@ def move_to_backlog_inbox(work_package) def expect_destination_dialog(dialog_title, work_packages:) within_modal dialog_title do - expect(page).to have_text(I18n.t(:label_x_work_packages, count: work_packages.size)) + work_packages.each { |work_package| expect(page).to have_text(work_package.subject) } expect(all("input[name='ids[]']", visible: :all).map(&:value)) .to eq(work_packages.map { |work_package| work_package.id.to_s }) end From 90394a453223a6a72ab1be04168060341fb9d95c Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 20 Aug 2026 13:16:03 +0100 Subject: [PATCH 15/27] Head the selection with its work package count "Selected items" named neither how many work packages a move would touch nor what they were. A pluralised heading states both, and carrying the description id makes the visible text the destination select's description instead of a duplicate hidden node. --- .../selected_work_packages_component.html.erb | 12 ++++-------- .../selected_work_packages_component.rb | 5 ++--- modules/backlogs/config/locales/en.yml | 4 +++- .../move_to_bucket_dialog_component_spec.rb | 6 ++---- .../move_to_sprint_dialog_component_spec.rb | 6 ++---- .../selected_work_packages_component_spec.rb | 17 ++++++++++------- 6 files changed, 23 insertions(+), 27 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/selected_work_packages_component.html.erb b/modules/backlogs/app/components/backlogs/selected_work_packages_component.html.erb index cc93d1cf72d8..871f1b2344cf 100644 --- a/modules/backlogs/app/components/backlogs/selected_work_packages_component.html.erb +++ b/modules/backlogs/app/components/backlogs/selected_work_packages_component.html.erb @@ -28,19 +28,16 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= render(OpPrimer::InsetBoxComponent.new(**@system_arguments)) do %> - <%= render(Primer::Box.new(tag: :span, id: description_id, hidden: true)) do %> - <%= t(:label_x_work_packages, count: work_packages.size) %> - <% end %> - <%= render( - Primer::Beta::Text.new( + Primer::Beta::Heading.new( + tag: :h2, + id: description_id, font_size: :small, font_weight: :bold, - display: :block, mb: 2 ) - ) { t(".label") } + ) { t(".label", count: work_packages.size) } %> <% work_packages.each do |work_package| %> @@ -50,7 +47,6 @@ See COPYRIGHT and LICENSE files for more details. work_package:, show_subject: true, show_status: false, - pl: 2, my: 1 ) ) diff --git a/modules/backlogs/app/components/backlogs/selected_work_packages_component.rb b/modules/backlogs/app/components/backlogs/selected_work_packages_component.rb index 75af1bfbdee2..4552d03a4067 100644 --- a/modules/backlogs/app/components/backlogs/selected_work_packages_component.rb +++ b/modules/backlogs/app/components/backlogs/selected_work_packages_component.rb @@ -31,9 +31,8 @@ module Backlogs # Lists the work packages a batch action is about to affect. # - # The count stays available to assistive technology through a hidden element - # carrying `description_id`; point the acting control's `aria-describedby` at - # that id. + # The heading carries `description_id`; point the acting control's + # `aria-describedby` at that id so the count reaches assistive technology. class SelectedWorkPackagesComponent < ApplicationComponent include OpPrimer::ComponentHelpers diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 9c772ad84b2c..a23915ee5d6a 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -210,7 +210,9 @@ en: remaining_hours: "Remaining work" selected_work_packages_component: - label: "Selected items:" + label: + one: "1 selected work package" + other: "%{count} selected work packages" sharing: "Sprint sharing" show_burndown_chart: "Burndown chart" diff --git a/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb b/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb index 92883a15cf02..6d85db18749c 100644 --- a/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb @@ -66,13 +66,11 @@ def render_component ) end - # The visible list carries no count, so it only reaches a screen reader as - # the field's context through this reference. - it "describes the bucket select by the selected-count label" do + it "describes the bucket select by the selection heading" do render_component label = page.find_css("##{described_class::SELECTION_LABEL_ID}").first - expect(label.text).to include(I18n.t(:label_x_work_packages, count: 2)) + expect(label.text).to include(I18n.t("backlogs.selected_work_packages_component.label", count: 2)) expect(page).to have_css( "select[name='list_id'][aria-describedby~='#{described_class::SELECTION_LABEL_ID}']", visible: :all diff --git a/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb b/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb index 492d0c1b041a..b66f02787b82 100644 --- a/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/move_to_sprint_dialog_component_spec.rb @@ -66,13 +66,11 @@ def render_component ) end - # The visible list carries no count, so it only reaches a screen reader as - # the field's context through this reference. - it "describes the sprint select by the selected-count label" do + it "describes the sprint select by the selection heading" do render_component label = page.find_css("##{described_class::SELECTION_LABEL_ID}").first - expect(label.text).to include(I18n.t(:label_x_work_packages, count: 2)) + expect(label.text).to include(I18n.t("backlogs.selected_work_packages_component.label", count: 2)) expect(page).to have_css( "select[name='list_id'][aria-describedby~='#{described_class::SELECTION_LABEL_ID}']", visible: :all diff --git a/modules/backlogs/spec/components/backlogs/selected_work_packages_component_spec.rb b/modules/backlogs/spec/components/backlogs/selected_work_packages_component_spec.rb index 48f488f1ac80..32c3635ff3c9 100644 --- a/modules/backlogs/spec/components/backlogs/selected_work_packages_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/selected_work_packages_component_spec.rb @@ -49,7 +49,7 @@ def render_component(work_packages: [feature, epic]) it "labels the box and names every work package in the given order", :aggregate_failures do render_component - expect(page).to have_text(I18n.t("backlogs.selected_work_packages_component.label")) + expect(page).to have_text(I18n.t("backlogs.selected_work_packages_component.label", count: 2)) expect(page.text).to match( /Feature.*#{feature.formatted_id}.*Warning system.*Epic.*#{epic.formatted_id}.*Contamination model/mi ) @@ -62,15 +62,18 @@ def render_component(work_packages: [feature, epic]) expect(page).to have_link(epic.formatted_id, href: "/work_packages/#{epic.id}") end - # The visible list replaces the count, so the count only reaches a screen - # reader through the hidden element the acting control describes itself by. - it "keeps the selected count on a hidden description element" do + it "heads the box with the selected count on the description element" do render_component expect(page).to have_css( - "##{description_id}[hidden]", - text: I18n.t(:label_x_work_packages, count: 2), - visible: :all + "h2##{description_id}", + text: I18n.t("backlogs.selected_work_packages_component.label", count: 2) ) end + + it "heads a single selection in the singular" do + render_component(work_packages: [feature]) + + expect(page).to have_css("h2##{description_id}", text: "1 selected work package") + end end From e999a114c3193596d7ffcc1bd89f691c9080dde9 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 20 Aug 2026 13:16:07 +0100 Subject: [PATCH 16/27] Fit the move dialogs to a scrolling selection A long selection outgrows the 320px medium dialog and scrolls behind an unmarked footer, leaving Cancel and Move floating over the list. --- .../backlogs/move_to_bucket_dialog_component.html.erb | 4 ++-- .../backlogs/move_to_sprint_dialog_component.html.erb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb index 3947b0545e2c..2afb6182e796 100644 --- a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= render(Primer::Alpha::Dialog.new(title: t(".title"), id: DIALOG_ID, size: :medium)) do |d| %> +<%= render(Primer::Alpha::Dialog.new(title: t(".title"), id: DIALOG_ID, size: :medium_portrait)) do |d| %> <% d.with_header(variant: :large) %> <% d.with_body do %> @@ -72,7 +72,7 @@ See COPYRIGHT and LICENSE files for more details. %> <% end %> - <% d.with_footer(mt: 2) do %> + <% d.with_footer(show_divider: true) do %> <%= component_collection do |buttons| buttons.with_component(Primer::Beta::Button.new(data: { close_dialog_id: DIALOG_ID })) do diff --git a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb index 5c7eeef08c35..b2e61f548360 100644 --- a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb +++ b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= render(Primer::Alpha::Dialog.new(title: t(".title"), id: DIALOG_ID, size: :medium)) do |d| %> +<%= render(Primer::Alpha::Dialog.new(title: t(".title"), id: DIALOG_ID, size: :medium_portrait)) do |d| %> <% d.with_header(variant: :large) %> <% d.with_body do %> @@ -72,7 +72,7 @@ See COPYRIGHT and LICENSE files for more details. %> <% end %> - <% d.with_footer(mt: 2) do %> + <% d.with_footer(show_divider: true) do %> <%= component_collection do |buttons| buttons.with_component(Primer::Beta::Button.new(data: { close_dialog_id: DIALOG_ID })) do From 4692ae5eb58f4447b828ffd4525fb993c55bccaa Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Fri, 4 Sep 2026 10:57:21 +0100 Subject: [PATCH 17/27] Send the platform's modifier in destination specs The suites pin the platform to Windows, where the selection orchestrator reads Ctrl alone and treats a Meta-modified click as a plain one, so the scope and destination examples were collapsing their own batches. Builds them through Ctrl, as the selection specs do. --- .../controllers/dynamic/sortable-lists.controller.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 456563729175..490e41a2f899 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -1670,7 +1670,7 @@ describe('Sortable lists controller', () => { ); await ctx.nextFrame(); click(items[0]); - click(items[1], { metaKey: true }); + click(items[1], { ctrlKey: true }); document.dispatchEvent(new CustomEvent('sortable-lists:test-move-completed')); From 212e5e24128edb5f5f27fa10b656fee8b14dd0dc Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 9 Sep 2026 18:36:24 +0100 Subject: [PATCH 18/27] Fix destination menu visibility and refreshes Ignores submenu overlays when projecting divider visibility and keeps position children consistent with their parent. Filters menu toggles to the primary popover and coalesces deferred target refreshes, with coverage for early opening, disconnect and singleton recovery. --- .../sortable-lists/item.controller.spec.ts | 66 ++++++++++++++++++- .../dynamic/sortable-lists/item.controller.ts | 36 ++++++---- .../batch_destination_menu_spec.rb | 3 + 3 files changed, 91 insertions(+), 14 deletions(-) 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 d466327cd983..e2815c3574cf 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 @@ -1400,6 +1400,9 @@ describe('Sortable lists item controller', () => { isItemHidden: vi.fn((li:Element|null) => !!li?.hasAttribute('hidden')), }; Object.assign(menuElement, menu); + const popover = document.createElement('anchored-position'); + menuElement.append(popover); + Object.defineProperty(menuElement, 'popoverElement', { value: popover }); return { el, menu }; } @@ -1448,12 +1451,55 @@ describe('Sortable lists item controller', () => { return { root, actionScopeFor, availableDestinations }; } + it('ignores opening tooltips and submenu overlays', async () => { + const { el } = renderItemWithMenu(1); + const controller = await mountItemController(el); + const { root, actionScopeFor } = stubMenuRoot(el, { isFirst: false, isLast: false }); + controller.connectRoot(root); + const tooltip = document.createElement('tool-tip'); + const submenu = document.createElement('anchored-position'); + el.append(tooltip, submenu); + tooltip.dispatchEvent(new ToggleEvent('toggle', { newState: 'open' })); + submenu.dispatchEvent(new ToggleEvent('toggle', { newState: 'open' })); + expect(actionScopeFor).not.toHaveBeenCalled(); + }); + + it('projects a deferred fragment once after its menu has already opened', async () => { + const { el } = renderItemWithMenu(1); + const parent = el.querySelector('[data-sortable-lists--item-target="moveMenu"]')!; + parent.remove(); + const controller = await mountItemController(el); + const { root, actionScopeFor, availableDestinations } = stubMenuRoot(el, { isFirst: false, isLast: false }); + controller.connectRoot(root); + el.querySelector('anchored-position')!.dispatchEvent(new ToggleEvent('toggle', { newState: 'open' })); + expect(actionScopeFor).not.toHaveBeenCalled(); + + el.querySelector('action-menu')!.append(parent); + destinationFor(el, [{ type: 'sprint', id: '1' }]); + destinationFor(el, [{ type: 'backlog_bucket', id: '2' }]); + destinationFor(el, [{ type: 'inbox', id: null }]); + await vi.waitFor(() => expect(availableDestinations).toHaveBeenCalledTimes(3)); + expect(actionScopeFor).toHaveBeenCalledTimes(1); + }); + + it('cancels a queued refresh when disconnected', async () => { + const { el } = renderItemWithMenu(1); + const controller = await mountItemController(el); + const { root, actionScopeFor } = stubMenuRoot(el, { isFirst: false, isLast: false }); + controller.connectRoot(root); + controller.moveItemTargetConnected(); + controller.disconnect(); + await Promise.resolve(); + expect(actionScopeFor).not.toHaveBeenCalled(); + }); + it('hides up/top for a first item and shows the rest', async () => { const { el, menu } = renderItemWithMenu(1); document.body.appendChild(el); const controller = await mountItemController(el); controller.connectRoot(stubRoot(el, { isFirst: true, isLast: false })); controller.moveItemTargetConnected(); + await Promise.resolve(); expect(menu.hideItem).toHaveBeenCalledWith(liFor(el, 'top')); expect(menu.hideItem).toHaveBeenCalledWith(liFor(el, 'up')); @@ -1470,6 +1516,7 @@ describe('Sortable lists item controller', () => { const gappedAvailability = { top: true, up: true, down: false, bottom: true }; controller.connectRoot(stubRoot(el, { isFirst: false, isLast: false }, vi.fn(), gappedAvailability)); controller.moveItemTargetConnected(); + await Promise.resolve(); expect(menu.hideItem).toHaveBeenCalledWith(liFor(el, 'down')); expect(menu.showItem).toHaveBeenCalledWith(liFor(el, 'up')); @@ -1482,6 +1529,7 @@ describe('Sortable lists item controller', () => { const controller = await mountItemController(el); controller.connectRoot(stubRoot(el, { isFirst: true, isLast: false })); controller.moveItemTargetConnected(); + await Promise.resolve(); expect(menu.disableItem).toHaveBeenCalledWith(liFor(el, 'top')); expect(menu.disableItem).toHaveBeenCalledWith(liFor(el, 'up')); @@ -1497,6 +1545,7 @@ describe('Sortable lists item controller', () => { const controller = await mountItemController(el); controller.connectRoot(stubRoot(el, { isFirst: true, isLast: true })); controller.moveItemTargetConnected(); + await Promise.resolve(); const parent = el.querySelector('li[data-sortable-lists--item-target="moveMenu"]')!; expect(menu.hideItem).toHaveBeenCalledWith(parent); @@ -1554,7 +1603,7 @@ describe('Sortable lists item controller', () => { const menuElement = el.querySelector('action-menu')!; const toggle = new ToggleEvent('toggle', { newState: 'open', oldState: 'closed' }); - menuElement.dispatchEvent(toggle); + menuElement.querySelector('anchored-position')!.dispatchEvent(toggle); expect(actionScopeFor).toHaveBeenLastCalledWith(el); expect(availableDestinations).toHaveBeenCalledWith(prospectiveScope, [{ type: 'sprint', id: '1' }]); @@ -1584,8 +1633,14 @@ describe('Sortable lists item controller', () => { expect(menu.hideItem).toHaveBeenCalledWith(moveToSprint); expect(menu.hideItem).toHaveBeenCalledWith(moveToInbox); expect(menu.hideItem).toHaveBeenCalledWith(moveMenu); - expect(menu.hideItem).not.toHaveBeenCalledWith(liFor(el, 'top')); + expect(menu.hideItem).toHaveBeenCalledWith(liFor(el, 'top')); expect(divider.hasAttribute('hidden')).toBe(true); + + actionScopeFor.mockReturnValue({ kind: 'batch', items: [el] }); + 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); }); it('keeps only the current owner destination for a confined batch scope', async () => { @@ -1647,10 +1702,14 @@ describe('Sortable lists item controller', () => { // and nothing below it. it('hides the divider when nothing below it is left visible', async () => { const { el } = renderItemWithMenu(1, true); + const overlay = document.createElement('anchored-position'); + overlay.setAttribute('popover', 'auto'); + el.querySelector('action-menu')!.append(overlay); document.body.appendChild(el); const controller = await mountItemController(el); controller.connectRoot(stubRoot(el, { isFirst: true, isLast: true })); controller.moveItemTargetConnected(); + await Promise.resolve(); const divider = el.querySelector('li[data-sortable-lists--item-target="moveDivider"]')!; expect(divider.hasAttribute('hidden')).toBe(true); @@ -1662,6 +1721,7 @@ describe('Sortable lists item controller', () => { 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); @@ -1677,6 +1737,7 @@ describe('Sortable lists item controller', () => { const controller = await mountItemController(el); controller.connectRoot(stubRoot(el, { isFirst: true, isLast: true })); controller.moveItemTargetConnected(); + await Promise.resolve(); const divider = el.querySelector('li[data-sortable-lists--item-target="moveDivider"]')!; expect(divider.hasAttribute('hidden')).toBe(false); @@ -1689,6 +1750,7 @@ describe('Sortable lists item controller', () => { const controller = await mountItemController(el); controller.connectRoot(stubRoot(el, { isFirst: false, isLast: false }, moveInDirection)); controller.moveItemTargetConnected(); + await Promise.resolve(); liFor(el, 'down').click(); expect(moveInDirection).toHaveBeenCalledWith(el, 'down'); 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 716314086b81..a62c845aca91 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -120,6 +120,7 @@ export default class ItemController extends Controller implements R private cleanupFn?:CleanupFn; private dropIndicatorElement?:HTMLElement; private root?:SortableListsRoot; + private refreshToken?:object; private readonly onMenuToggle = (event:Event):void => { // The toggle event does not bubble, so listen in capture phase; recompute @@ -127,7 +128,9 @@ 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 ((event as ToggleEvent).newState === 'open') { + if (this.hasMenuElement + && event.target === this.menuElement.popoverElement + && (event as ToggleEvent).newState === 'open') { this.refreshActionAvailability(); } }; @@ -139,6 +142,7 @@ export default class ItemController extends Controller implements R } disconnect():void { + this.refreshToken = undefined; // A morph can remove a hovering row mid-drag; without this the drop indicator // it owns on a sibling row is never cleared (no onDrop fires, and no other // controller may clear a foreign owner), leaving a phantom drop line. @@ -149,19 +153,25 @@ export default class ItemController extends Controller implements R this.disconnectRoot(); } - // An action item entering the DOM (inline or via a deferred fragment) - // triggers an availability refresh here, but `this.root` is usually unset at - // this point (the outlet's connectRoot callback runs later), so this call - // typically no-ops. The menu-open toggle handler is what actually - // establishes correct availability, refreshing on every open once the root - // is connected and after any reorder has shifted siblings. No - // include-fragment knowledge, so both hooks work for any menu. moveItemTargetConnected():void { - this.refreshActionAvailability(); + this.scheduleAvailabilityRefresh(); } destinationItemTargetConnected():void { - this.refreshActionAvailability(); + this.scheduleAvailabilityRefresh(); + } + + private scheduleAvailabilityRefresh():void { + if (this.refreshToken) return; + + const token = {}; + this.refreshToken = token; + queueMicrotask(() => { + if (this.refreshToken !== token) return; + + this.refreshToken = undefined; + if (this.element.isConnected) this.refreshActionAvailability(); + }); } move(event:ActionEvent):void { @@ -523,8 +533,9 @@ export default class ItemController extends Controller implements R } private refreshActionAvailability():void { + this.refreshToken = undefined; const root = this.root; - if (!root || !this.hasMenuElement) { + if (!root || !this.hasMenuElement || (this.destinationItemTargets.length === 0 && this.moveItemTargets.length === 0)) { return; } @@ -557,6 +568,7 @@ export default class ItemController extends Controller implements R private refreshMoveMenuAvailability(root:SortableListsRoot, scope:ActionScope):void { if (scope.kind === 'batch' && scope.items.length > 1) { + this.moveItemTargets.forEach((item) => this.setAvailability(item, false)); if (this.hasMoveMenuTarget) { this.setAvailability(this.moveMenuTarget, false); } @@ -602,7 +614,7 @@ export default class ItemController extends Controller implements R let sibling = divider.nextElementSibling; while (sibling) { - if (!sibling.hasAttribute('hidden')) { + if (sibling instanceof HTMLLIElement && !sibling.hasAttribute('hidden')) { divider.removeAttribute('hidden'); return; } diff --git a/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb b/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb index 15a7b9aeb107..748fab3b50e7 100644 --- a/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb +++ b/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb @@ -188,6 +188,9 @@ present: [], absent: ["Move to sprint", "Move to backlog bucket", "Move to backlog inbox"] ) + backlogs_page.within_work_package_menu(confined_first) do |menu| + expect(menu).to have_no_css("li[role=separator]", visible: :visible) + end end it "omits Move to position from a multi-card action scope" do From bc6f7cf6be9d366e44a68372fff051fbe220565b Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 9 Sep 2026 18:41:44 +0100 Subject: [PATCH 19/27] Revalidate initially empty append targets Rejects an append when an independent writer fills its initially empty target before validation. Covers the interleaving with an independent connection and preserves the remaining AGILE-426 race after validation. --- .../work_packages/batch_update_service.rb | 11 +++--- .../batch_update_service_concurrency_spec.rb | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb b/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb index b0c591df3b4a..ca7bfc7f75ca 100644 --- a/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb +++ b/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb @@ -252,20 +252,23 @@ def cohort_intact? # project, same list, and for append still the last non-batch member. A # concurrently moved anchor would otherwise fall through to move_after's # silent insert-at-top. - def revalidate_anchor(placement, target) # rubocop:disable Metrics/AbcSize + def revalidate_anchor(placement, target) anchor = placement.anchor + return stale_predecessor_failure if append_anchor_changed?(placement, target) return if anchor.nil? anchor.reload unless anchor.project_id == batch_project_id && Backlogs::Target.for_work_package(anchor) == target - return stale_predecessor_failure + stale_predecessor_failure end - - stale_predecessor_failure if placement.mode == :append && last_non_batch_member(target)&.id != anchor.id rescue ActiveRecord::RecordNotFound stale_predecessor_failure end + def append_anchor_changed?(placement, target) + placement.mode == :append && last_non_batch_member(target)&.id != placement.anchor&.id + end + # The contract only revalidates a sprint or bucket target when the # corresponding column changes, so a same-list reorder never triggers it # and a sprint completed after the page loaded stays an accepted diff --git a/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_concurrency_spec.rb b/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_concurrency_spec.rb index f589b612065c..00aaa9a3b207 100644 --- a/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_concurrency_spec.rb +++ b/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_concurrency_spec.rb @@ -131,6 +131,40 @@ def collect_cleanup_error(cleanup_errors) cleanup_errors << e end + # rubocop:disable-next RSpec/ExampleLength + it "rejects an empty append when an independent writer fills the inbox", retry: 0 do + service = described_class.new(user:, work_packages: sprint_work_packages) + placement_resolved = Concurrent::Event.new + release_batch = Concurrent::Event.new + batch_result = Queue.new + original_ids = source_sprint.work_packages_for(project).order_by_position.ids + + allow(service).to receive(:resolve_placement).and_wrap_original do |method, *args| + placement = method.call(*args) + placement_resolved.set + raise "timed out waiting for inbox insertion" unless release_batch.wait(5) + + placement + end + batch_thread = Thread.new do + ActiveRecord::Base.connection_pool.with_connection do + batch_result << service.call(list_type: "inbox") + end + end + raise "append did not resolve its placement" unless placement_resolved.wait(5) + + inserted = create(:work_package, project:, type:) + release_batch.set + result = Timeout.timeout(5) { batch_result.pop } + + expect(result).to be_failure + expect(result.message).to eq I18n.t("backlogs.work_packages.batch_update_service.stale_predecessor") + expect(source_sprint.work_packages_for(project).order_by_position.ids).to eq original_ids + expect(WorkPackage.where(project:, sprint_id: nil, backlog_bucket_id: nil).ids).to eq [inserted.id] + ensure + cleanup_concurrency_threads(release_events: [release_batch], threads: [batch_thread]) + end + # rubocop:disable-next RSpec/ExampleLength it "serializes disjoint batches before resolving append placement in an empty target", retry: 0 do first_service = described_class.new(user:, work_packages: sprint_work_packages) From aebe137fc4cb8d8e2693213ea43319aa2f7b4aa8 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 9 Sep 2026 18:43:19 +0100 Subject: [PATCH 20/27] Consolidate destination menu policy Shares destination acceptance between menus and dragging and rejects malformed metadata with a bounded debug diagnostic. Extracts menu projection and the shared action scope while retaining lifecycle wiring and guarded activation in the item controller. --- .../dynamic/sortable-lists.controller.ts | 3 +- .../dynamic/sortable-lists/action-scope.ts | 42 ++++++ .../dynamic/sortable-lists/drag-and-drop.ts | 2 +- .../sortable-lists/item.controller.spec.ts | 3 +- .../dynamic/sortable-lists/item.controller.ts | 142 +++--------------- .../dynamic/sortable-lists/list-dom.spec.ts | 36 +++++ .../dynamic/sortable-lists/list-dom.ts | 31 +++- .../sortable-lists/list.controller.spec.ts | 2 +- .../sortable-lists/menu-availability.spec.ts | 88 +++++++++++ .../sortable-lists/menu-availability.ts | 85 +++++++++++ .../scrollable.controller.spec.ts | 2 +- .../sortable-lists/selection-orchestrator.ts | 17 +-- 12 files changed, 310 insertions(+), 143 deletions(-) create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/action-scope.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.spec.ts create mode 100644 frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.ts diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index a2026f648a0b..aa15e9f141e4 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -70,7 +70,8 @@ import { type MoveAvailability, type MoveDirection, } from './sortable-lists/list-dom'; -import { SelectionOrchestrator, scopeIds, type ActionScope, type SelectionHost } from './sortable-lists/selection-orchestrator'; +import { SelectionOrchestrator, type SelectionHost } from './sortable-lists/selection-orchestrator'; +import { scopeIds, type ActionScope } from './sortable-lists/action-scope'; import { itemIdentity, orderedItemElements } from './sortable-lists/selection'; type CleanupFn = () => void; diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/action-scope.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/action-scope.ts new file mode 100644 index 000000000000..3aed62c63a6b --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/action-scope.ts @@ -0,0 +1,42 @@ +//-- 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 { resolveItemId } from './list-dom'; + +export type ActionScope = + | { kind:'batch'; items:HTMLElement[] } + | { kind:'refused'; items:[] }; + +// The ids a scope's members carry, in the scope's own order. Derived on +// demand rather than frozen into the scope: the elements are its identity, +// and a stale id list would outlive a morph that replaced a row. +export function scopeIds(scope:ActionScope):string[] { + return scope.items + .map((item) => resolveItemId(item)) + .filter((id):id is string => id !== null); +} 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 4a9e916f99f1..fd2dd41121d0 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 @@ -55,7 +55,7 @@ import { type MoveAvailability, type MoveDirection, } from './list-dom'; -import type { ActionScope } from './selection-orchestrator'; +import type { ActionScope } from './action-scope'; // The Pragmatic DnD payloads exchanged between the sortable-lists root and // item controllers, built on top of the DOM contract in list-dom.ts. 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 e2815c3574cf..acfbcd137442 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 './selection-orchestrator'; +import type { ActionScope } from './action-scope'; describe('Sortable lists item controller', () => { let draggable:typeof draggableFn; @@ -1681,6 +1681,7 @@ describe('Sortable lists item controller', () => { it.each([ ['invalid JSON', '{invalid'], + ['an empty destination type', '[{"type":"","id":null}]'], ['non-array JSON', '{"type":"sprint","id":"12"}'], ['a malformed candidate member', '[{"type":"sprint","id":"12"},{"type":"sprint"}]'], ])('fails closed for %s destination metadata', async (_description, metadata) => { 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 a62c845aca91..e31e165b7cf3 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/item.controller.ts @@ -52,6 +52,7 @@ import { } from './drag-and-drop'; import { isMoveDirection, + parseDestinationCandidates, isOrderableItem, itemMobility, resolveItemExternalUrl, @@ -61,21 +62,11 @@ import { } from './list-dom'; import { webLinkHref } from './external-data'; import { renderDragPreview } from './preview'; -import { scopeIds, type ActionScope } from './selection-orchestrator'; +import { scopeIds } from './action-scope'; +import { refreshMenuAvailability } from './menu-availability'; type CleanupFn = () => void; -function isDestinationIdentity(candidate:unknown):candidate is DestinationIdentity { - if (typeof candidate !== 'object' || candidate === null) { - return false; - } - - return 'type' in candidate - && 'id' in candidate - && typeof candidate.type === 'string' - && (typeof candidate.id === 'string' || candidate.id === null); -} - export default class ItemController extends Controller implements RootAwareChild { static targets = ['handle', 'preview', 'destinationItem', 'moveItem', 'moveMenu', 'moveDivider', 'focus']; static elements = { menu: 'action-menu' }; @@ -176,11 +167,7 @@ export default class ItemController extends Controller implements R move(event:ActionEvent):void { const item = event.currentTarget; - if (!isOrderableItem(this.element) || !this.hasMenuElement || !(item instanceof HTMLElement)) { - return; - } - - if (this.menuElement.isItemDisabled(item) || this.menuElement.isItemHidden(item)) { + if (!isOrderableItem(this.element) || !this.menuItemActionable(item)) { return; } @@ -217,11 +204,7 @@ export default class ItemController extends Controller implements R moveToDestination(event:ActionEvent):void { const item = event.currentTarget; - if (!this.hasMenuElement || !(item instanceof HTMLElement)) { - return; - } - - if (this.menuElement.isItemDisabled(item) || this.menuElement.isItemHidden(item)) { + if (!this.menuItemActionable(item)) { return; } @@ -539,109 +522,26 @@ export default class ItemController extends Controller implements R return; } - const scope = root.actionScopeFor(this.element); - this.refreshDestinationAvailability(root, scope); - this.refreshMoveMenuAvailability(root, scope); - this.refreshMoveDivider(); + refreshMenuAvailability({ + menu: this.menuElement, + scope: root.actionScopeFor(this.element), + 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), + }); } - private refreshDestinationAvailability(root:SortableListsRoot, scope:ActionScope):void { - for (const item of this.destinationItemTargets) { - const candidates = this.destinationCandidates(item); - this.setAvailability(item, candidates.length > 0 && root.availableDestinations(scope, candidates).length > 0); - } + private menuItemActionable(item:EventTarget|null):item is HTMLElement { + return this.hasMenuElement && item instanceof HTMLElement + && !this.menuElement.isItemDisabled(item) && !this.menuElement.isItemHidden(item); } private destinationCandidates(item:HTMLElement):DestinationIdentity[] { - try { - const candidates:unknown = JSON.parse(item.dataset.sortableListsDestinations ?? ''); - if (!Array.isArray(candidates)) { - return []; - } - - const destinations = candidates.filter(isDestinationIdentity); - return destinations.length === candidates.length ? destinations : []; - } catch { - return []; - } - } - - private refreshMoveMenuAvailability(root:SortableListsRoot, scope:ActionScope):void { - if (scope.kind === 'batch' && scope.items.length > 1) { - this.moveItemTargets.forEach((item) => this.setAvailability(item, false)); - if (this.hasMoveMenuTarget) { - this.setAvailability(this.moveMenuTarget, false); - } - return; - } - - // Null availability means the item is not in a list yet; leave the menu - // alone until the outlet wiring settles. - const availability = root.moveAvailability(this.element); - if (!availability) { - return; - } - - let available = 0; - for (const item of this.moveItemTargets) { - // Outside a Stimulus action there is no event.params, so read the - // param's backing attribute directly. - const direction = item.getAttribute(`data-${this.identifier}-direction-param`); - const enabled = isMoveDirection(direction) && availability[direction]; - this.setAvailability(item, enabled); - if (enabled) { - available += 1; - } - } - - if (this.hasMoveMenuTarget) { - this.setAvailability(this.moveMenuTarget, available > 0); - } - } - - // The divider that opens the move group is rendered server-side from a - // permission check alone, so hiding the last entry below it would otherwise - // leave a separator with nothing to separate. It never goes through - // setAvailability: `disableItem` writes to the item's `.ActionListContent`, - // which a divider does not have — and in that mode the group stays visible - // anyway, only disabled. - private refreshMoveDivider():void { - if (!this.hasMoveDividerTarget || !this.hideUnavailableValue) { - return; - } - - const divider = this.moveDividerTarget; - let sibling = divider.nextElementSibling; - - while (sibling) { - if (sibling instanceof HTMLLIElement && !sibling.hasAttribute('hidden')) { - divider.removeAttribute('hidden'); - return; - } - - sibling = sibling.nextElementSibling; - } - - divider.setAttribute('hidden', 'hidden'); - } - - // 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 { - const menu = this.menuElement; - - if (this.hideUnavailableValue) { - if (available) { - menu.showItem(item); - } else { - menu.hideItem(item); - } - } else if (available) { - menu.enableItem(item); - } else { - menu.disableItem(item); - } + return parseDestinationCandidates(item.dataset.sortableListsDestinations); } } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts index 84a6331051d9..ec0171281798 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts @@ -26,7 +26,10 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import { environment } from '../../../../environments/environment'; + import { + parseDestinationCandidates, captureRowPositions, isOrderableItem, itemAcceptsDestination, @@ -751,3 +754,36 @@ describe('resolveItemType', () => { expect(resolveItemType(el)).toBeNull(); }); }); + + +describe('destination metadata', () => { + let wasProduction:boolean; + let log:ReturnType; + beforeEach(() => { + wasProduction = environment.production; + environment.production = false; + log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + afterEach(() => { + environment.production = wasProduction; + vi.restoreAllMocks(); + }); + + it.each([undefined, ''])('ignores missing metadata without logging', (metadata) => { + expect(parseDestinationCandidates(metadata)).toEqual([]); + expect(log).not.toHaveBeenCalled(); + }); + + it.each(['{invalid', '{}', '[{"type":"","id":null}]', '[{"type":"sprint","id":1}]', + '[{"type":"sprint","id":"1"},{"type":"sprint"}]'])('rejects malformed metadata as a whole: %s', (metadata) => { + expect(parseDestinationCandidates(metadata)).toEqual([]); + expect(log).toHaveBeenCalledWith('[DEBUG] sortable-lists: malformed destination metadata'); + }); + + it('preserves ordered destinations and the null inbox identity', () => { + expect(parseDestinationCandidates('[{"type":"sprint","id":"2"},{"type":"inbox","id":null}]')).toEqual([ + { type: 'sprint', id: '2' }, { type: 'inbox', id: null }, + ]); + expect(log).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts index eae516436f3f..1fc123b16aea 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts @@ -121,6 +121,33 @@ export interface DestinationIdentity { id:string|null; } +export function isDestinationIdentity(candidate:unknown):candidate is DestinationIdentity { + if (typeof candidate !== 'object' || candidate === null) { + return false; + } + + return 'type' in candidate + && 'id' in candidate + && typeof candidate.type === 'string' + && candidate.type.length > 0 + && (typeof candidate.id === 'string' || candidate.id === null); +} + +export function parseDestinationCandidates(raw:string|undefined):DestinationIdentity[] { + if (!raw) return []; + + try { + const candidates:unknown = JSON.parse(raw); + if (Array.isArray(candidates) && candidates.every(isDestinationIdentity)) { + return candidates; + } + } catch { + // Invalid JSON and invalid identities share the same fail-closed result. + } + debugLog('sortable-lists: malformed destination metadata'); + return []; +} + export function sameDestination(left:DestinationIdentity|null, right:DestinationIdentity):boolean { return left !== null && left.type === right.type && left.id === right.id; } @@ -151,12 +178,12 @@ export function permittedDestinations({ candidates:DestinationIdentity[]; ownerDestinationOf:(item:HTMLElement) => DestinationIdentity|null; }):DestinationIdentity[] { - if (items.length === 0 || items.some((item) => itemMobility(item) === 'fixed')) { + if (items.length === 0) { return []; } return candidates.filter((target) => ( - items.every((item) => itemMobility(item) === 'free' || sameDestination(ownerDestinationOf(item), target)) + items.every((item) => itemAcceptsDestination(item, target, ownerDestinationOf)) )); } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts index cf1f8d8854e0..718a61a7d531 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/list.controller.spec.ts @@ -37,7 +37,7 @@ import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test- import type ListControllerType from './list.controller'; import type { sortableItemData as sortableItemDataFn, SortableListsRoot } from './drag-and-drop'; import type { DestinationIdentity } from './list-dom'; -import type { ActionScope } from './selection-orchestrator'; +import type { ActionScope } from './action-scope'; // The list controller is tested in ISOLATION: the root drives the outlet // hand-over in production (sortable-lists.controller.ts), so here we render only diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.spec.ts new file mode 100644 index 000000000000..e957ba1d3b86 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.spec.ts @@ -0,0 +1,88 @@ +//-- 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 { refreshMenuAvailability } from './menu-availability'; + +function fixture(hideUnavailable = true) { + const list = document.createElement('ul'); + list.innerHTML = '
  • '; + const [divider, destination, moveMenu] = Array.from(list.querySelectorAll('li')); + const moveItem = document.createElement('li'); + moveItem.setAttribute('data-sortable-lists-item-direction-param', 'top'); + moveMenu.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')] }, + destinationItems: [destination], + moveItems: [moveItem], + moveMenu, + divider, + hideUnavailable, + identifier: 'sortable-lists-item', + availableDestinations: vi.fn(() => []), + moveAvailability: vi.fn(() => ({ top: true, up: true, down: false, bottom: false })), + }; + return { input, divider, destination, moveMenu, moveItem }; +} + +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]) { + expect(element.hasAttribute('hidden')).toBe(true); + } + expect(input.moveAvailability).not.toHaveBeenCalled(); + }); + + it('restores the position group when the live scope becomes a singleton', () => { + const { input, divider, moveMenu, moveItem } = fixture(); + refreshMenuAvailability(input); + input.scope = { kind: 'batch', items: [document.createElement('div')] }; + refreshMenuAvailability(input); + for (const element of [divider, moveMenu, 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]) { + expect(element.getAttribute('aria-disabled')).toBe('true'); + expect(element.hasAttribute('hidden')).toBe(false); + } + }); +}); diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.ts new file mode 100644 index 000000000000..7015b9d54cb0 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/menu-availability.ts @@ -0,0 +1,85 @@ +//-- 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 multiItem = input.scope.kind === 'batch' && input.scope.items.length > 1; + const availability = multiItem ? null : input.moveAvailability(); + if (multiItem || availability) { + let available = 0; + for (const item of input.moveItems) { + const direction = item.getAttribute(`data-${input.identifier}-direction-param`); + const enabled = !multiItem && 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/scrollable.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts index f04a262a5cd8..0f571e1e2a15 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/scrollable.controller.spec.ts @@ -30,7 +30,7 @@ import { type autoScrollForElements as autoScrollForElementsFn } from '@atlaskit import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; import type ScrollableControllerType from './scrollable.controller'; import type { sortableItemData as sortableItemDataFn, SortableListsRoot } from './drag-and-drop'; -import type { ActionScope } from './selection-orchestrator'; +import type { ActionScope } from './action-scope'; vi.mock('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element', () => ({ autoScrollForElements: vi.fn(() => vi.fn()), diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.ts index 879c4a205101..adf02d4a90ce 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists/selection-orchestrator.ts @@ -26,6 +26,7 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import type { ActionScope } from './action-scope'; import { BatchSelection, type SelectionAnchor, type SelectionKey } from 'core-common/batch-selection'; import { announce } from '@primer/live-region-element'; import { resolveItemId, resolveItemType } from './list-dom'; @@ -59,19 +60,6 @@ export interface SelectionHost { ownerRowsContainer(itemElement:HTMLElement):HTMLElement|null; } -export type ActionScope = - | { kind:'batch'; items:HTMLElement[] } - | { kind:'refused'; items:[] }; - -// The ids a scope's members carry, in the scope's own order. Derived on -// demand rather than frozen into the scope: the elements are its identity, -// and a stale id list would outlive a morph that replaced a row. -export function scopeIds(scope:ActionScope):string[] { - return scope.items - .map((item) => resolveItemId(item)) - .filter((id):id is string => id !== null); -} - // What resolving an action scope does to the selection: nothing, replace it // with the card alone only when the card is outside it, or replace it // unconditionally. @@ -408,12 +396,11 @@ export class SelectionOrchestrator { return; } + event.preventDefault(); if (this.host.busy) { - event.preventDefault(); return; } - event.preventDefault(); escapesClearedBySelection.add(event); this.selection.clear(); this.renderSelection('selection'); From c6868b71ef2dc61cbc03256a9575b92f36460c79 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 9 Sep 2026 18:45:34 +0100 Subject: [PATCH 21/27] Consolidate destination availability checks Shares candidate loading and member acceptance while preserving the menu candidate universe. Rejects unauthorized targets before movement locks and checks fresh availability under lock. Preloads rendered types and statuses used by the shared policy. --- .../work_package_card_menu_component.rb | 7 +- .../backlogs/work_packages_controller.rb | 7 +- modules/backlogs/app/models/backlog_bucket.rb | 2 +- .../backlog_buckets/scopes/assignable.rb | 39 ++++++++++ .../work_packages/batch_update_service.rb | 10 ++- .../work_packages/destination_availability.rb | 76 +++++++------------ .../batch_update_service_spec.rb | 21 +++++ .../destination_availability_spec.rb | 45 +++++++---- 8 files changed, 136 insertions(+), 71 deletions(-) create mode 100644 modules/backlogs/app/models/backlog_buckets/scopes/assignable.rb 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 2294b8b79f60..5f785c75ec03 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 @@ -116,8 +116,11 @@ def bucket_list_type end def destination_data(type, ids) - candidates = ids.map { |id| { type:, id: id.to_s } } - candidates = [{ type:, id: nil }] if type == Backlogs::Target::InboxId.list_type + candidates = if type == Backlogs::Target::InboxId.list_type + [{ type:, id: nil }] + else + ids.map { |id| { type:, id: id.to_s } } + end { sortable_lists__item_target: "destinationItem", diff --git a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb index 0e7888b60ce5..cf408835624a 100644 --- a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb @@ -40,11 +40,12 @@ class WorkPackagesController < BaseController # Deferred ActionMenu items (Primer include-fragment). def menu + availability = destination_availability([@work_package]) render(Backlogs::WorkPackageCardMenuComponent.new( project: @project, work_package: @work_package, - sprint_ids: Sprint.assignable(project: @project, user: current_user).order_by_date.ids, - bucket_ids: BacklogBucket.for_project(@project).order_alphabetically.ids, + sprint_ids: availability.candidate_sprints.map(&:id), + bucket_ids: availability.candidate_buckets.map(&:id), current_user: ), layout: false) @@ -369,7 +370,7 @@ def load_collection_work_packages # this project, in the submitted order: silently dropping a member would # break the client's optimistic block. Nil when any id does not resolve. def find_collection_work_packages(ids) - found = WorkPackage.visible.where(project: @project, id: ids).index_by { |wp| wp.id.to_s } + found = WorkPackage.visible.where(project: @project, id: ids).includes(:type, :status).index_by { |wp| wp.id.to_s } ordered = ids.map { |id| found[id.to_s] } ordered.any?(&:nil?) ? nil : ordered diff --git a/modules/backlogs/app/models/backlog_bucket.rb b/modules/backlogs/app/models/backlog_bucket.rb index 99fe65b818b5..ac40649489bd 100644 --- a/modules/backlogs/app/models/backlog_bucket.rb +++ b/modules/backlogs/app/models/backlog_bucket.rb @@ -47,7 +47,7 @@ class BacklogBucket < ApplicationRecord scope :order_alphabetically, -> { order(:name) } - scopes :visible + scopes :visible, :assignable validates :name, :project, presence: true diff --git a/modules/backlogs/app/models/backlog_buckets/scopes/assignable.rb b/modules/backlogs/app/models/backlog_buckets/scopes/assignable.rb new file mode 100644 index 000000000000..a03d36a37f7b --- /dev/null +++ b/modules/backlogs/app/models/backlog_buckets/scopes/assignable.rb @@ -0,0 +1,39 @@ +# 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 BacklogBuckets::Scopes::Assignable + extend ActiveSupport::Concern + + class_methods do + def assignable(project:, user: User.current) + for_project(project).visible(user) + end + end +end diff --git a/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb b/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb index ca7bfc7f75ca..6bc473a18a31 100644 --- a/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb +++ b/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb @@ -80,6 +80,12 @@ def call(list_type: nil, list_id: nil, prev_id: nil) # rubocop:disable Metrics/A [work_package.id, Backlogs::Target.for_work_package(work_package)] end + preflight = Backlogs::WorkPackages::DestinationAvailability.new(project: batch_project, user:, work_packages: []) + return unavailable_target_failure unless preflight.manage_permission? && preflight.candidate?(target) + + # Each call needs fresh under-lock state, including when this instance is reused. + @destination_availability = nil + move_batch_in_transaction(target, prev_id, list_type:, list_id:) end @@ -277,7 +283,7 @@ def append_anchor_changed?(placement, target) # changed since the controller loaded it, which is exactly what this check # under the lock exists to catch. def revalidate_destination(target) - return unavailable_target_failure unless target_available?(target) + return unavailable_target_failure unless destination_availability.manage_permission? && target_available?(target) refused = destination_availability.refusing(target) refused_members_failure(refused) if refused.any? @@ -294,7 +300,7 @@ def destination_availability @destination_availability ||= Backlogs::WorkPackages::DestinationAvailability.new( project: batch_project, user:, - work_packages: WorkPackage.where(id: work_packages.map(&:id)).to_a + work_packages: WorkPackage.where(id: work_packages.map(&:id)).includes(:status).to_a ) end diff --git a/modules/backlogs/app/services/backlogs/work_packages/destination_availability.rb b/modules/backlogs/app/services/backlogs/work_packages/destination_availability.rb index 5dddc5b24bc7..ea55d77efa8d 100644 --- a/modules/backlogs/app/services/backlogs/work_packages/destination_availability.rb +++ b/modules/backlogs/app/services/backlogs/work_packages/destination_availability.rb @@ -37,73 +37,51 @@ def initialize(project:, user:, work_packages:) @work_packages = work_packages end - def permitted?(target) - user.allowed_in_project?(:manage_sprint_items, project) && - candidate?(target) && - work_packages.all? { |work_package| free?(work_package) || current_target(work_package) == target } + def manage_permission? + return @manage_permission if defined?(@manage_permission) + + @manage_permission = user.allowed_in_project?(:manage_sprint_items, project) end - def sprints - sprint_candidates.select { |sprint| offered?(Backlogs::Target.for(sprint)) } + def candidate_sprints + @candidate_sprints ||= Sprint.assignable(project:, user:).order_by_date.to_a end - def buckets - bucket_candidates.select { |bucket| offered?(Backlogs::Target.for(bucket)) } + def candidate_buckets + @candidate_buckets ||= BacklogBucket.assignable(project:, user:).to_a end - def inbox? - offered?(Backlogs::Target::InboxId) + def candidate?(target) + candidate_targets.include?(target) end - # The members that cannot enter the target: a work package frozen by its - # status stays where it is, so only its own destination accepts it. def refusing(target) - work_packages.reject { |work_package| free?(work_package) || current_target(work_package) == target } + work_packages.reject { |work_package| accepts?(work_package, target) } end - # Target candidacy alone, without the per-member arm of permitted?: the - # batch move reports which members refused rather than collapsing the whole - # batch into one anonymous failure. - def candidate?(target) - case target - in Backlogs::Target::SprintId - sprint_candidates.any? { |sprint| sprint.id == target.list_id } - in Backlogs::Target::BucketId - bucket_candidates.any? { |bucket| bucket.id == target.list_id } - in Backlogs::Target::InboxId - true - else - false - end - end + def sprints = offered(candidate_sprints) + def buckets = offered(candidate_buckets) private - def offered?(target) - permitted?(target) && work_packages.any? { |work_package| current_target(work_package) != target } + def candidate_targets + @candidate_targets ||= Set.new( + [Backlogs::Target::InboxId] + + (candidate_sprints + candidate_buckets).map { |container| Backlogs::Target.for(container) } + ) end - def sprint_candidates - @sprint_candidates ||= Sprint.assignable(project:, user:).order_by_date.to_a + def accepts?(work_package, target) + !work_package.readonly_status? || Backlogs::Target.for_work_package(work_package) == target end - def bucket_candidates - @bucket_candidates ||= BacklogBucket.for_project(project).order_alphabetically.to_a - end + def offered(containers) + return [] unless manage_permission? - def free?(work_package) - readonly_status_ids.exclude?(work_package.status_id) - end - - def readonly_status_ids - @readonly_status_ids ||= if Status.can_readonly? - Status.where(id: work_packages.map(&:status_id), is_readonly: true).ids - else - [] - end - end - - def current_target(work_package) - Backlogs::Target.for_work_package(work_package) + containers.select do |container| + target = Backlogs::Target.for(container) + refusing(target).empty? && + work_packages.any? { |wp| Backlogs::Target.for_work_package(wp) != target } + end end end diff --git a/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_spec.rb b/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_spec.rb index 97f134787ee4..85af42f9d34f 100644 --- a/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_spec.rb +++ b/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_spec.rb @@ -57,6 +57,27 @@ def sprint_order sprint.work_packages_for(project).pluck(:id) end + it "rejects a foreign destination before taking any movement locks" do + foreign = create(:sprint, project: create(:project)) + allow(OpenProject::Mutex).to receive(:with_advisory_lock_transaction).and_call_original + + result = service([bucket_wp1]).call(list_type: "sprint", list_id: foreign.id.to_s) + + expect(result).to be_failure + expect(OpenProject::Mutex).not_to have_received(:with_advisory_lock_transaction) + end + + it "rejects a user without movement permission before taking locks" do + unauthorized = create(:user, member_with_permissions: { project => %i[view_work_packages view_sprints] }) + allow(OpenProject::Mutex).to receive(:with_advisory_lock_transaction).and_call_original + + result = described_class.new(user: unauthorized, work_packages: [bucket_wp1]) + .call(list_type: "inbox") + + expect(result).to be_failure + expect(OpenProject::Mutex).not_to have_received(:with_advisory_lock_transaction) + end + it "moves a cross-list batch as one contiguous block after the predecessor" do result = service([bucket_wp1, sprint_wp3]) .call(list_type: "sprint", list_id: sprint.id.to_s, prev_id: sprint_wp1.id.to_s) diff --git a/modules/backlogs/spec/services/backlogs/work_packages/destination_availability_spec.rb b/modules/backlogs/spec/services/backlogs/work_packages/destination_availability_spec.rb index 989e31740e51..30892c96c2ae 100644 --- a/modules/backlogs/spec/services/backlogs/work_packages/destination_availability_spec.rb +++ b/modules/backlogs/spec/services/backlogs/work_packages/destination_availability_spec.rb @@ -66,7 +66,7 @@ def availability(work_packages, for_user: user) expect(result.sprints).to eq [earlier_sprint, later_sprint] expect(result.buckets).to eq [alpha_bucket, zulu_bucket] - expect(result.inbox?).to be true + expect(result.refusing(Backlogs::Target::InboxId)).to be_empty end it "intersects a confined member's current list with a free member", with_ee: %i[readonly_work_packages] do @@ -77,7 +77,7 @@ def availability(work_packages, for_user: user) expect(result.sprints).to eq [earlier_sprint] expect(result.buckets).to be_empty - expect(result.inbox?).to be false + expect(result.refusing(Backlogs::Target::InboxId)).not_to be_empty end it "offers nothing when confined members are in different lists", with_ee: %i[readonly_work_packages] do @@ -88,7 +88,7 @@ def availability(work_packages, for_user: user) expect(result.sprints).to be_empty expect(result.buckets).to be_empty - expect(result.inbox?).to be false + expect(result.refusing(Backlogs::Target::InboxId)).not_to be_empty end it "omits an all-members-current option without revoking positional reuse" do @@ -98,16 +98,16 @@ def availability(work_packages, for_user: user) result = availability(work_packages) expect(result.sprints).to eq [later_sprint] - expect(result.permitted?(current_target)).to be true + expect(result.candidate?(current_target)).to be true + expect(result.refusing(current_target)).to be_empty end it "loads read-only status identity in one query for the whole batch", with_ee: %i[readonly_work_packages] do work_packages = create_list(:work_package, 5, project:, type:, status: readonly_status) - work_packages.each(&:reload) - recorder = ActiveRecord::QueryRecorder.new do - availability(work_packages).permitted?(Backlogs::Target::InboxId) + loaded = WorkPackage.where(id: work_packages.map(&:id)).includes(:status).to_a + expect(availability(loaded).refusing(Backlogs::Target::InboxId)).to be_empty end status_queries = recorder.log.grep(/FROM "statuses"/) @@ -119,10 +119,30 @@ def availability(work_packages, for_user: user) persisted_readonly_status.update_column(:is_readonly, true) work_package = create(:work_package, project:, type:, status: persisted_readonly_status) - expect(availability([work_package]).permitted?(Backlogs::Target.for(alpha_bucket))).to be true + expect(availability([work_package]).refusing(Backlogs::Target.for(alpha_bucket))).to be_empty end end + it "keeps the invoking member's current destination in the candidate universe" do + member = create(:work_package, project:, type:, sprint: earlier_sprint) + result = availability([member]) + + expect(result.candidate_sprints).to eq [earlier_sprint, later_sprint] + expect(result.candidate_buckets).to eq [alpha_bucket, zulu_bucket] + expect(result.sprints).to eq [later_sprint] + end + + it "checks management permission once while offering both destination kinds" do + member = create(:work_package, project:, type:) + result = availability([member]) + allow(user).to receive(:allowed_in_project?).and_call_original + + result.sprints + result.buckets + + expect(user).to have_received(:allowed_in_project?).with(:manage_sprint_items, project).once + end + describe "authoritative candidates" do let(:work_package) { create(:work_package, project:, type:) } @@ -130,14 +150,14 @@ def availability(work_packages, for_user: user) result = availability([work_package]) expect(result.sprints).not_to include(completed_sprint) - expect(result.permitted?(Backlogs::Target.for(completed_sprint))).to be false + expect(result.candidate?(Backlogs::Target.for(completed_sprint))).to be false end it "rejects buckets outside the batch project" do result = availability([work_package]) expect(result.buckets).not_to include(foreign_bucket) - expect(result.permitted?(Backlogs::Target.for(foreign_bucket))).to be false + expect(result.candidate?(Backlogs::Target.for(foreign_bucket))).to be false end it "rejects every destination without manage_sprint_items permission" do @@ -148,10 +168,7 @@ def availability(work_packages, for_user: user) expect(result.sprints).to be_empty expect(result.buckets).to be_empty - expect(result.inbox?).to be false - expect(result.permitted?(Backlogs::Target.for(earlier_sprint))).to be false - expect(result.permitted?(Backlogs::Target.for(alpha_bucket))).to be false - expect(result.permitted?(Backlogs::Target::InboxId)).to be false + expect(result.manage_permission?).to be false end end end From 645fc6d3dcfb97d572582b80818f5c7fb5a1c338 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 9 Sep 2026 18:48:50 +0100 Subject: [PATCH 22/27] Separate batch movement lock acquisition Extracts ordered transaction-scoped lock acquisition while retaining service-owned validation and placement. Moves target lock identity and class-level wire types beside their target representations. --- .../move_to_bucket_dialog_component.rb | 2 +- .../move_to_sprint_dialog_component.rb | 2 +- .../work_package_card_menu_component.rb | 4 +- .../backlogs/app/models/backlogs/target.rb | 12 ++- .../work_packages/batch_move_locks.rb | 80 ++++++++++++++++++ .../work_packages/batch_update_service.rb | 82 ++----------------- .../batch_update_service_spec.rb | 6 +- 7 files changed, 103 insertions(+), 85 deletions(-) create mode 100644 modules/backlogs/app/services/backlogs/work_packages/batch_move_locks.rb diff --git a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.rb b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.rb index 28f824cb0ff7..3f88deb5a753 100644 --- a/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.rb +++ b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.rb @@ -50,7 +50,7 @@ def initialize(work_packages:, buckets:, move_action:) private def destination_list_type - Backlogs::Target::BucketId.new(nil).list_type + Backlogs::Target::BucketId.list_type end end end diff --git a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.rb b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.rb index 3e7ebf6751aa..39f38bceaafa 100644 --- a/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.rb +++ b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.rb @@ -50,7 +50,7 @@ def initialize(work_packages:, sprints:, move_action:) private def destination_list_type - Backlogs::Target::SprintId.new(nil).list_type + Backlogs::Target::SprintId.list_type 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 5f785c75ec03..56e2bf1b916d 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 @@ -108,11 +108,11 @@ def inbox_list_type end def sprint_list_type - Backlogs::Target::SprintId[nil].list_type + Backlogs::Target::SprintId.list_type end def bucket_list_type - Backlogs::Target::BucketId[nil].list_type + Backlogs::Target::BucketId.list_type end def destination_data(type, ids) diff --git a/modules/backlogs/app/models/backlogs/target.rb b/modules/backlogs/app/models/backlogs/target.rb index f0983db39c7a..c2e3f19ec446 100644 --- a/modules/backlogs/app/models/backlogs/target.rb +++ b/modules/backlogs/app/models/backlogs/target.rb @@ -41,7 +41,10 @@ def name = I18n.t(:label_inbox) end.new SprintId = Data.define(:id) do - def list_type = "sprint" + def self.list_type = "sprint" + delegate :list_type, to: :class + + def lock_record = Sprint.find_by(id:) def list_id = id @@ -57,7 +60,10 @@ def container_for(project) = Sprint.for_project(project).visible.find_by(id:) end BucketId = Data.define(:id) do - def list_type = "backlog_bucket" + def self.list_type = "backlog_bucket" + delegate :list_type, to: :class + + def lock_record = BacklogBucket.find_by(id:) def list_id = id @@ -75,6 +81,8 @@ def container_for(project) = BacklogBucket.for_project(project).visible.find_by( InboxId = Data.define do def list_type = "inbox" + def lock_record = nil + def list_id = nil def to_list_params = { list_type: } diff --git a/modules/backlogs/app/services/backlogs/work_packages/batch_move_locks.rb b/modules/backlogs/app/services/backlogs/work_packages/batch_move_locks.rb new file mode 100644 index 000000000000..61c5eafab5e2 --- /dev/null +++ b/modules/backlogs/app/services/backlogs/work_packages/batch_move_locks.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. +#++ + +class Backlogs::WorkPackages::BatchMoveLocks + def initialize(project:, source_targets:, target:) + @project = project + @source_targets = source_targets + @destination = target.lock_record + end + + # Source locks prevent moving members out after FinishService enumerates them. + # Ordering every source and target by [class, id] prevents deadlocks between + # batches with inverse source/target pairs. + def acquire_lifecycle + records = @source_targets.uniq.map(&:lock_record) + [@destination] + ordered = records.compact.uniq { |record| lifecycle_identity(record) }.sort_by { |record| lifecycle_identity(record) } + acquire_ordered(ordered) + end + + def acquire_placement(target:, prev_id:) + return if prev_id.present? && target != Backlogs::Target::InboxId + + suffix = ["backlogs_batch_update_destination", target.list_type, target.list_id].compact.join("_") + # rubocop:disable-next Lint/EmptyBlock -- transaction-scoped lock outlives this block + OpenProject::Mutex.with_advisory_lock_transaction(@project, suffix) {} + end + + def acquire_members(work_packages:, anchor:) + acquire_ordered((work_packages + [anchor]).compact.uniq.sort_by(&:id)) + end + + def lock_destination_row + @destination&.lock! + rescue ActiveRecord::RecordNotFound + nil + end + + private + + def lifecycle_identity(record) + [record.class.name, record.id] + end + + # Transaction-scoped locks survive these empty blocks. The gem forgets its + # per-thread lock entry, so inner services may request it again; PostgreSQL + # grants the transaction its already-held lock without waiting. + def acquire_ordered(records) + records.each do |record| + # rubocop:disable-next Lint/EmptyBlock -- transaction-scoped lock outlives this block + OpenProject::Mutex.with_advisory_lock_transaction(record) {} + end + end +end diff --git a/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb b/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb index 6bc473a18a31..12229fd92fb5 100644 --- a/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb +++ b/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb @@ -109,16 +109,18 @@ def move_batch_in_transaction(target, prev_id, list_type:, list_id:) end def move_batch(target, prev_id, list_type:, list_id:) - destination = raw_destination(target) - acquire_ordered_locks(ordered_lifecycle_records(destination)) - acquire_placement_serialization_lock(target, prev_id) + locks = Backlogs::WorkPackages::BatchMoveLocks.new( + project: batch_project, source_targets: @batch_source_targets.values, target: + ) + locks.acquire_lifecycle + locks.acquire_placement(target:, prev_id:) placement = resolve_placement(target, prev_id) return placement if placement.is_a?(ServiceResult) - acquire_ordered_locks(lock_entries(placement.anchor)) + locks.acquire_members(work_packages:, anchor: placement.anchor) return stale_batch_failure unless cohort_intact? - lock_destination_row!(destination) + locks.lock_destination_row destination_failure = revalidate_destination(target) return destination_failure if destination_failure @@ -156,76 +158,6 @@ def move_members(placement, list_type:, list_id:) # rubocop:disable Metrics/AbcS call end - # Sprint lifecycle services serialize on the Sprint model mutex before - # enumerating and moving their work packages, so a batch has to join every - # source lifecycle as well as the target's: locking only the target lets a - # batch move a member out after FinishService has enumerated it. Sorted by - # class and id, so two batches with inverse source/target pairs cannot - # deadlock. - def ordered_lifecycle_records(destination) - source_records = @batch_source_targets.values.uniq.filter_map { |target| raw_destination(target) } - - (source_records + [destination]) - .compact - .uniq { |record| lifecycle_lock_identity(record) } - .sort_by { |record| lifecycle_lock_identity(record) } - end - - def lifecycle_lock_identity(record) - [record.class.name, record.id] - end - - # Inbox has no lifecycle mutex. Even distinct explicit anchors share its - # position scope, so their positions must not shift between read and insert. - # Unanchored placement also needs serialization when the target is empty. - def acquire_placement_serialization_lock(target, prev_id) - return if prev_id.present? && target != Backlogs::Target::InboxId - - suffix = ["backlogs_batch_update_destination", target.list_type, target.list_id].compact.join("_") - # rubocop:disable-next Lint/EmptyBlock -- the lock outlives the block; see acquire_ordered_locks - OpenProject::Mutex.with_advisory_lock_transaction(batch_project, suffix) {} - end - - # Ascending id order, so two overlapping batches request the same lock - # sequence and neither waits on the other while holding one. - def lock_entries(anchor) - (work_packages + [anchor]).compact.uniq.sort_by(&:id) - end - - # Transaction-scoped locks outlive their block until the enclosing - # transaction ends, so each one is taken with an empty block in one flat - # sequence. The gem's per-thread lock stack forgets the lock at block exit, - # so the inner services re-request theirs; Postgres grants a lock the - # session already holds without waiting. - def acquire_ordered_locks(entries) - entries.each do |entry| - # rubocop:disable-next Lint/EmptyBlock -- the lock outlives the block; see the comment above - OpenProject::Mutex.with_advisory_lock_transaction(entry) {} - end - end - - # Unscoped by policy so completion, deletion and reassignment all resolve - # to the same advisory identity. - def raw_destination(target) - case target - in Backlogs::Target::SprintId - Sprint.find_by(id: target.list_id) - in Backlogs::Target::BucketId - BacklogBucket.find_by(id: target.list_id) - in Backlogs::Target::InboxId - nil - end - end - - # lock! reloads under FOR UPDATE, so a concurrent completion, deletion or - # reassignment commits before the availability query runs. Inbox has no - # destination row; its placement is serialized by the advisory lock alone. - def lock_destination_row!(destination) - destination&.lock! - rescue ActiveRecord::RecordNotFound - nil - end - # The anchor is scoped to the batch project because the acts_as_list scope # includes project_id: in a shared sprint another project's work package # would pass a container-only comparison, yet be unresolvable for diff --git a/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_spec.rb b/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_spec.rb index 85af42f9d34f..ea427fbef45a 100644 --- a/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_spec.rb +++ b/modules/backlogs/spec/services/backlogs/work_packages/batch_update_service_spec.rb @@ -431,10 +431,8 @@ def work_package_lock_ids(locked) batch = service([bucket_wp1]) lock_names = [] - allow(batch).to receive(:raw_destination).and_wrap_original do |method, target| - destination = method.call(target) - destination.is_a?(Sprint) && destination.id == sprint.id ? concrete_sprint : destination - end + allow(Sprint).to receive(:find_by).and_call_original + allow(Sprint).to receive(:find_by).with(id: sprint.id).and_return(concrete_sprint) allow(OpenProject::Mutex) .to receive(:with_advisory_lock) .and_wrap_original do |method, resource_class, lock_name, *args, &block| From a60652c62fc3b38d64ff11e6d791a0b71c33fbad Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 9 Sep 2026 18:49:49 +0100 Subject: [PATCH 23/27] Show progress for destination moves Uses the shared progress wrapper for direct destinations and dialog loading. Preserves cleanup and async-dialog rejection behavior while including HTTP status in non-stream request errors. --- .../controllers/async-dialog.controller.ts | 9 +++------ .../dynamic/sortable-lists.controller.spec.ts | 18 ++++++++++++++++++ .../dynamic/sortable-lists.controller.ts | 4 ++-- .../stimulus/helpers/request-helpers.spec.ts | 3 ++- .../src/stimulus/helpers/request-helpers.ts | 2 +- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/frontend/src/stimulus/controllers/async-dialog.controller.ts b/frontend/src/stimulus/controllers/async-dialog.controller.ts index e116f6a5260c..32e70be9f66a 100644 --- a/frontend/src/stimulus/controllers/async-dialog.controller.ts +++ b/frontend/src/stimulus/controllers/async-dialog.controller.ts @@ -28,8 +28,7 @@ import { Controller } from '@hotwired/stimulus'; import { FetchRequest } from '@rails/request.js'; -import { performTurboStreamRequest } from 'core-stimulus/helpers/request-helpers'; -import { TurboHelpers } from 'core-turbo/helpers'; +import { performTurboStreamRequest, withProgressBar } from 'core-stimulus/helpers/request-helpers'; export default class AsyncDialogController extends Controller { static values = { disableDuringLoad: { type: Boolean, default: true } }; @@ -77,16 +76,14 @@ export default class AsyncDialogController extends Controller { const body = form ? new FormData(form) : undefined; this.setLoading(true); - TurboHelpers.showProgressBar(); try { - await performTurboStreamRequest(new FetchRequest(method, url, { + await withProgressBar(performTurboStreamRequest(new FetchRequest(method, url, { body, responseKind: 'turbo-stream', - })); + }))); } finally { this.setLoading(false); - TurboHelpers.hideProgressBar(); } } diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts index 490e41a2f899..37a9af707852 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts @@ -58,6 +58,7 @@ import { attachClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/clos import type { monitorForElements as monitorForElementsFn } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { waitFor } from '@testing-library/dom'; import { type Mock, type MockInstance } from 'vitest'; +import { TurboHelpers } from 'core-turbo/helpers'; import { LiveRegionElement } from '@primer/live-region-element'; import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers'; import type SortableListsControllerType from './sortable-lists.controller'; @@ -1280,6 +1281,23 @@ describe('Sortable lists controller', () => { return element.hasAttribute('data-batch-selected'); } + it.each([200, 422, 500])('cleans up destination progress after HTTP %s', async (status) => { + const show = vi.spyOn(TurboHelpers, 'showProgressBar').mockImplementation(() => undefined); + const hide = vi.spyOn(TurboHelpers, 'hideProgressBar').mockImplementation(() => undefined); + const { root, items } = renderSelectableRoot({ collectionMoveUrl: '/batch/move' }); + await ctx.nextFrame(); + let finish!:(response:Response) => void; + fetchMock.mockReturnValueOnce(new Promise((resolve) => { finish = resolve; })); + selectItem(items[0]); + destinationController(root).moveToDestination(items[0], destination); + expect(show).toHaveBeenCalledOnce(); + expect(hide).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()); + finish(new Response('', { status, headers: { 'Content-Type': 'text/vnd.turbo-stream.html' } })); + await vi.waitFor(() => expect(hide).toHaveBeenCalledOnce()); + expect(root.hasAttribute('data-sortable-lists-busy')).toBe(false); + }); + it('submits the selected scope in document order without optimistic or positional fields', async () => { fetchMock.mockResolvedValueOnce(new Response('', { headers: { 'Content-Type': 'text/vnd.turbo-stream.html' }, diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index aa15e9f141e4..ea84adb78ba7 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -36,7 +36,7 @@ import { announce } from '@primer/live-region-element'; import { debugLog } from 'core-app/shared/helpers/debug_output'; import { OPToastEvent } from 'core-app/shared/components/toaster/toast-event'; import { flipMove } from 'core-stimulus/helpers/flip-helper'; -import { performTurboStreamRequest } from 'core-stimulus/helpers/request-helpers'; +import { performTurboStreamRequest, withProgressBar } from 'core-stimulus/helpers/request-helpers'; import { parseTemplate } from 'url-template'; import { buildMoveFormData, @@ -714,7 +714,7 @@ export default class SortableListsController extends Controller imp this.startMoveRequest(); try { - await performTurboStreamRequest(request); + await withProgressBar(performTurboStreamRequest(request)); } catch (error) { // Only a request that never produced a stream lands here — a rejection // streams its own flash. Without the toast the busy state would simply diff --git a/frontend/src/stimulus/helpers/request-helpers.spec.ts b/frontend/src/stimulus/helpers/request-helpers.spec.ts index c9daf0cb851a..a77be9bea8f3 100644 --- a/frontend/src/stimulus/helpers/request-helpers.spec.ts +++ b/frontend/src/stimulus/helpers/request-helpers.spec.ts @@ -72,11 +72,12 @@ describe('performTurboStreamRequest', () => { ok: true, unprocessableEntity: false, isTurboStream: false, + statusCode: 500, renderTurboStream, } as unknown as FetchResponse; await expect(performTurboStreamRequest(requestFor(response))) - .rejects.toThrow('Response is not a Turbo Stream'); + .rejects.toMatchObject({ name: 'FetchRequestError', _errorCode: 500, message: 'Response is not a Turbo Stream' }); expect(renderTurboStream).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/stimulus/helpers/request-helpers.ts b/frontend/src/stimulus/helpers/request-helpers.ts index a07f311e827b..98663cda838e 100644 --- a/frontend/src/stimulus/helpers/request-helpers.ts +++ b/frontend/src/stimulus/helpers/request-helpers.ts @@ -40,7 +40,7 @@ export async function performTurboStreamRequest(request:FetchRequest):Promise Date: Wed, 9 Sep 2026 18:56:45 +0100 Subject: [PATCH 24/27] Share collection validation and move feedback Shares ordered ID validation and member/batch result handling. Tests rendered dialog forms and association query counts, and removes unused builder and persistence helpers. Preserves singular feedback and uses explicit translation keys. --- .../batch_move_params_contract.rb | 28 +---- .../work_packages/collection_ids_contract.rb | 64 +++++++++++ .../backlogs/work_packages_controller.rb | 106 ++++++++---------- .../collection_ids_contract_spec.rb | 61 ++++++++++ .../backlogs/work_packages_controller_spec.rb | 68 ++++++----- .../backlogs/spec/support/pages/backlog.rb | 16 --- 6 files changed, 212 insertions(+), 131 deletions(-) create mode 100644 modules/backlogs/app/contracts/backlogs/work_packages/collection_ids_contract.rb create mode 100644 modules/backlogs/spec/contracts/backlogs/work_packages/collection_ids_contract_spec.rb diff --git a/modules/backlogs/app/contracts/backlogs/work_packages/batch_move_params_contract.rb b/modules/backlogs/app/contracts/backlogs/work_packages/batch_move_params_contract.rb index 5a714e049f6d..5ea7b6b3c22a 100644 --- a/modules/backlogs/app/contracts/backlogs/work_packages/batch_move_params_contract.rb +++ b/modules/backlogs/app/contracts/backlogs/work_packages/batch_move_params_contract.rb @@ -30,38 +30,12 @@ module Backlogs module WorkPackages - class BatchMoveParamsContract < ::ParamsContract - validate :ids_distinct_and_present - validate :batch_within_cap + class BatchMoveParamsContract < CollectionIdsContract validate :target_resolvable validate :predecessor_well_formed - # BaseContract#errors returns model.errors whenever the model responds - # to it, and project does: without this override, validating the - # contract would clear and repopulate the live project's own error bag. - def errors - @errors ||= ActiveModel::Errors.new(self) - end - private - def ids - Array(params[:ids]).map(&:to_s) - end - - def ids_distinct_and_present - return unless ids.empty? || ids.any?(&:blank?) || ids.uniq.length != ids.length - - errors.add(:base, I18n.t("backlogs.work_packages.move_collection.invalid_ids")) - end - - def batch_within_cap - return if ids.length <= BatchUpdateService::MAX_BATCH_SIZE - - errors.add(:base, I18n.t("backlogs.work_packages.move_collection.too_many_work_packages", - max: BatchUpdateService::MAX_BATCH_SIZE)) - end - def target_resolvable return if Backlogs::Target.from_list(params[:list_type], params[:list_id]) diff --git a/modules/backlogs/app/contracts/backlogs/work_packages/collection_ids_contract.rb b/modules/backlogs/app/contracts/backlogs/work_packages/collection_ids_contract.rb new file mode 100644 index 000000000000..d9e9b3068ac6 --- /dev/null +++ b/modules/backlogs/app/contracts/backlogs/work_packages/collection_ids_contract.rb @@ -0,0 +1,64 @@ +# 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 Backlogs + module WorkPackages + class CollectionIdsContract < ::ParamsContract + validate :ids_distinct_and_present + validate :batch_within_cap + + # BaseContract#errors returns model.errors whenever the model responds + # to it, and project does: without this override, validating the + # contract would clear and repopulate the live project's own error bag. + def errors + @errors ||= ActiveModel::Errors.new(self) + end + + private + + def ids + Array(params[:ids]).map(&:to_s) + end + + def ids_distinct_and_present + return unless ids.empty? || ids.any?(&:blank?) || ids.uniq.length != ids.length + + errors.add(:base, I18n.t("backlogs.work_packages.move_collection.invalid_ids")) + end + + def batch_within_cap + return if ids.length <= BatchUpdateService::MAX_BATCH_SIZE + + errors.add(:base, I18n.t("backlogs.work_packages.move_collection.too_many_work_packages", + max: BatchUpdateService::MAX_BATCH_SIZE)) + end + end + end +end diff --git a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb index cf408835624a..bd64f4bb23bb 100644 --- a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb +++ b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb @@ -61,10 +61,12 @@ def add_existing_dialog container: ) elsif target - render_error_flash_message_via_turbo_stream(message: t(".target_not_found")) + render_error_flash_message_via_turbo_stream( + message: I18n.t("backlogs.work_packages.add_existing_dialog.target_not_found") + ) respond_with_turbo_streams(status: :not_found) else - render_error_flash_message_via_turbo_stream(message: t(".invalid_target")) + render_error_flash_message_via_turbo_stream(message: I18n.t("backlogs.work_packages.add_existing_dialog.invalid_target")) respond_with_turbo_streams(status: :unprocessable_entity) end end @@ -74,9 +76,11 @@ def move_to_sprint_dialog return if performed? sprints = destination_availability(work_packages).sprints - return render_move_collection_error(t(".no_available_destinations")) if sprints.empty? + if sprints.empty? + return render_move_collection_error(I18n.t("backlogs.work_packages.move_to_sprint_dialog.no_available_destinations")) + end - respond_with_dialog build_move_to_sprint_dialog( + respond_with_dialog Backlogs::MoveToSprintDialogComponent.new( work_packages:, sprints:, move_action: move_collection_path @@ -88,9 +92,11 @@ def move_to_bucket_dialog return if performed? buckets = destination_availability(work_packages).buckets - return render_move_collection_error(t(".no_available_destinations")) if buckets.empty? + if buckets.empty? + return render_move_collection_error(I18n.t("backlogs.work_packages.move_to_bucket_dialog.no_available_destinations")) + end - respond_with_dialog build_move_to_bucket_dialog( + respond_with_dialog Backlogs::MoveToBucketDialogComponent.new( work_packages:, buckets:, move_action: move_collection_path @@ -139,7 +145,9 @@ def move_collection # rubocop:disable Metrics/AbcSize return render_move_collection_error(contract.errors.full_messages.join(" ")) unless contract.valid? work_packages = find_collection_work_packages(move_collection_params[:ids]) - return render_move_collection_error(t(".work_packages_not_found")) unless work_packages + unless work_packages + return render_move_collection_error(I18n.t("backlogs.work_packages.move_collection.work_packages_not_found")) + end # Snapshot before the call: move_after reloads mid-method and destroys # dirty tracking, exactly as the member action's comment explains. @@ -169,8 +177,7 @@ def render_update_collection_turbo_streams(call) WORK_PACKAGE_MOVED_EVENT, detail: { work_package_ids: call.result.map(&:id) } ) - invisible_feedback_rendered = render_invisible_after_move_batch_flash(call.result) - render_collection_move_announcement(call.result) unless optimistic_move? || invisible_feedback_rendered + render_collection_move_feedback(call.result) else render_error_flash_message_via_turbo_stream( message: I18n.t(:notice_unsuccessful_update_with_reason, reason: batch_failure_reason(call)) @@ -180,6 +187,15 @@ def render_update_collection_turbo_streams(call) respond_with_turbo_streams(status: call) end + def render_collection_move_feedback(results) + invisible = invisible_after_move(results) + if invisible.any? + render_invisible_after_move_batch_flash(invisible) + elsif !optimistic_move? + render_collection_move_announcement(results) + end + end + # A member failure is reported with the member: the batch's own message # is empty then, and the flash would not say which work package refused. def batch_failure_reason(call) @@ -189,8 +205,8 @@ def batch_failure_reason(call) work_package = failed.result return failed.message unless work_package.is_a?(WorkPackage) - t("backlogs.work_packages.move_collection.member_failed", - work_package: work_package.to_fs(:caption), reason: failed.message) + I18n.t("backlogs.work_packages.move_collection.member_failed", + work_package: work_package.to_fs(:caption), reason: failed.message) end def optimistic_same_list_batch_move?(call, source_targets) @@ -198,21 +214,20 @@ def optimistic_same_list_batch_move?(call, source_targets) destination = Backlogs::Target.for_work_package(call.result.first) call.result.all? { |wp| source_targets[wp.id] == destination } && - requested_block_honored?(call.result) + requested_block_honored?(call.result, move_arguments: move_collection_params) end - # The batch form of requested_anchor_honored?: the first member sits where - # the request anchored it and every further member directly below its - # predecessor, which is the client's optimistic block. - def requested_block_honored?(results) # rubocop:disable Metrics/AbcSize - return false unless move_collection_params.key?(:prev_id) + # The first member occupies the requested anchor and any further members + # follow contiguously. This check serves both member and batch moves. + def requested_block_honored?(results, move_arguments:) + return false unless move_arguments.key?(:prev_id) # One anchor query; the rest of the block is checked in memory. # BatchUpdateService reloads every moved member before returning and # the members share one target scope, so adjacent positions prove # adjacency. A gap from elsewhere fails this check falsely, degrading # to the full frame reload rather than skipping a needed one. - prev_id = move_collection_params[:prev_id].presence + prev_id = move_arguments[:prev_id].presence first = results.first anchor_honored = prev_id ? first.higher_item&.id == prev_id.to_i : first.higher_item.nil? @@ -246,21 +261,16 @@ def render_update_turbo_streams(call) end def render_invisible_after_move_flash(work_package) - return unless work_package_invisible_after_move?(work_package) - - render_flash_message_via_turbo_stream( - message: I18n.t(:notice_work_package_invisible_after_move, count: 1, backlog: target_list_name(work_package)) - ) + invisible = invisible_after_move([work_package]) + render_invisible_after_move_batch_flash(invisible) if invisible.any? end - # A member's own type or status can hide it independently of its - # list-mates, so the first member cannot answer this for the batch. - def render_invisible_after_move_batch_flash(results) # rubocop:disable Naming/PredicateMethod - invisible = results.select { |wp| work_package_invisible_after_move?(wp) } - return false if invisible.empty? + def invisible_after_move(results) + results.select { |wp| work_package_invisible_after_move?(wp) } + end + def render_invisible_after_move_batch_flash(invisible) render_flash_message_via_turbo_stream(message: invisible_after_move_batch_message(invisible)) - true end def invisible_after_move_batch_message(invisible) @@ -309,8 +319,8 @@ def render_collection_move_announcement(work_packages) end def collection_move_announcement_message(work_packages, first:, total:) - t( - ".moved_announcement", + I18n.t( + "backlogs.work_packages.move_collection.moved_announcement", count: work_packages.size, list: target_list_name(work_packages.first), first: first + 1, @@ -339,8 +349,7 @@ def target_list_name(work_package) end def load_work_package - @work_packages = WorkPackage.visible.where(project: @project).order_by_position - @work_package = @work_packages.find(params.expect(:id)) + @work_package = WorkPackage.visible.where(project: @project).find(params.expect(:id)) end # The dialogs validate the id list alone: they open before a destination @@ -350,20 +359,11 @@ def load_work_package def load_collection_work_packages ids = move_collection_params[:ids] - # Before the lookup: an oversized id list must not reach the database. - if ids.length > Backlogs::WorkPackages::BatchUpdateService::MAX_BATCH_SIZE - return render_move_collection_error( - t("backlogs.work_packages.move_collection.too_many_work_packages", - max: Backlogs::WorkPackages::BatchUpdateService::MAX_BATCH_SIZE) - ) - end - - if ids.any?(&:blank?) || ids.uniq.length != ids.length - return render_move_collection_error(t("backlogs.work_packages.move_collection.invalid_ids")) - end + contract = Backlogs::WorkPackages::CollectionIdsContract.new(@project, current_user, params: { ids: }) + return render_move_collection_error(contract.errors.full_messages.join(" ")) unless contract.valid? find_collection_work_packages(ids) || - render_move_collection_error(t("backlogs.work_packages.move_collection.work_packages_not_found")) + render_move_collection_error(I18n.t("backlogs.work_packages.move_collection.work_packages_not_found")) end # Every submitted id must resolve to a distinct, visible work package of @@ -391,14 +391,6 @@ def destination_availability(work_packages) ) end - def build_move_to_sprint_dialog(**args) - Backlogs::MoveToSprintDialogComponent.new(**args) - end - - def build_move_to_bucket_dialog(**args) - Backlogs::MoveToBucketDialogComponent.new(**args) - end - # params.expect guarantees a present, non-empty array of scalar ids; the # optional placement and target fields go through permit instead. def move_collection_params @@ -452,14 +444,8 @@ def optimistic_same_list_move?(call, source_target) # so they reconcile via reload. def requested_anchor_honored?(work_package) return false if move_params[:position].present? - return false unless move_params.key?(:prev_id) - prev_id = move_params[:prev_id].presence - if prev_id - work_package.higher_item&.id == prev_id.to_i - else - work_package.higher_item.nil? - end + requested_block_honored?([work_package], move_arguments: move_params) end # Kept out of move_params: the service's keyword args reject it. diff --git a/modules/backlogs/spec/contracts/backlogs/work_packages/collection_ids_contract_spec.rb b/modules/backlogs/spec/contracts/backlogs/work_packages/collection_ids_contract_spec.rb new file mode 100644 index 000000000000..fd0884c0e37d --- /dev/null +++ b/modules/backlogs/spec/contracts/backlogs/work_packages/collection_ids_contract_spec.rb @@ -0,0 +1,61 @@ +# 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" + +RSpec.describe Backlogs::WorkPackages::CollectionIdsContract, type: :model do + shared_let(:project) { create(:project) } + shared_let(:user) { create(:user) } + + it "accepts the full cap without requiring a destination" do + ids = Array.new(Backlogs::WorkPackages::BatchUpdateService::MAX_BATCH_SIZE) { |i| (i + 1).to_s } + contract = described_class.new(project, user, params: { ids: }) + expect(contract).to be_valid + end + + [[], [""], %w[1 1]].each do |ids| + it "rejects invalid membership of size #{ids.size}" do + contract = described_class.new(project, user, params: { ids: }) + expect(contract).not_to be_valid + expect(contract.errors).not_to be_empty + expect(project.errors).to be_empty + end + end + + it "rejects one more than the cap" do + ids = Array.new(Backlogs::WorkPackages::BatchUpdateService::MAX_BATCH_SIZE + 1) { |i| (i + 1).to_s } + contract = described_class.new(project, user, params: { ids: }) + + expect(contract).not_to be_valid + expect(contract.errors[:base]).to include(I18n.t("backlogs.work_packages.move_collection.too_many_work_packages", + max: Backlogs::WorkPackages::BatchUpdateService::MAX_BATCH_SIZE)) + expect(project.errors).to be_empty + end +end diff --git a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb index a2086201aaf4..ea294509fb89 100644 --- a/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb +++ b/modules/backlogs/spec/controllers/backlogs/work_packages_controller_spec.rb @@ -1152,21 +1152,39 @@ def turbo_stream_template(action:) subject(:response) { post :move_to_sprint_dialog, params:, format: :turbo_stream } - before do - allow(controller).to receive(:build_move_to_sprint_dialog).and_return(Object.new) - allow(controller).to receive(:respond_with_dialog) { controller.head :ok } + it "renders the ordered collection and authoritative sprints" do + response + + expect(response).to have_turbo_stream action: "dialog" + document = Nokogiri::HTML.fragment(response.body) + expect(document.css('input[name="ids[]"]').pluck("value")) + .to eq [second.id.to_s, first.id.to_s] + expect(document.css("select option").pluck("value")) + .to eq [available_sprint.id.to_s] + expect(document.css('input[name="prev_id"]')).to be_empty + expect(document.css("form").pluck("action")) + .to include(move_project_backlogs_work_packages_path(project)) end - it "passes the exact ordered collection and authoritative sprints to the dialog" do - response + context "with several distinct card types and statuses" do + let!(:stories) do + Array.new(5) do |index| + create(:work_package, project:, + type: create(:type, name: "Dialog type #{index}"), + status: create(:status, name: "Dialog status #{index}")) + end + end + let(:ids) { stories.map(&:id).reverse } - expect(controller) - .to have_received(:build_move_to_sprint_dialog) - .with( - work_packages: [second, first], - sprints: [available_sprint], - move_action: move_project_backlogs_work_packages_path(project) - ) + it "loads card type and policy status in one query each" do + recorder = ActiveRecord::QueryRecorder.new { response } + + expect(response).to have_turbo_stream action: "dialog" + expect(recorder.log.grep(/FROM "types"/).size).to eq 1 + expect(recorder.log.grep(/FROM "statuses"/).size).to eq 1 + document = Nokogiri::HTML.fragment(response.body) + expect(document.css('input[name="ids[]"]').pluck("value")).to eq ids.map(&:to_s) + end end shared_examples "rejects invalid dialog ids" do @@ -1174,7 +1192,6 @@ def turbo_stream_template(action:) expect(response).to have_http_status :unprocessable_entity expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" expect(response).not_to have_turbo_stream action: "dialog" - expect(controller).not_to have_received(:build_move_to_sprint_dialog) end end @@ -1229,7 +1246,6 @@ def turbo_stream_template(action:) expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" expect(body).to include(I18n.t("backlogs.work_packages.move_to_sprint_dialog.no_available_destinations")) expect(response).not_to have_turbo_stream action: "dialog" - expect(controller).not_to have_received(:build_move_to_sprint_dialog) end end end @@ -1242,21 +1258,18 @@ def turbo_stream_template(action:) subject(:response) { post :move_to_bucket_dialog, params:, format: :turbo_stream } - before do - allow(controller).to receive(:build_move_to_bucket_dialog).and_return(Object.new) - allow(controller).to receive(:respond_with_dialog) { controller.head :ok } - end - - it "passes the exact ordered collection and authoritative buckets to the dialog" do + it "renders the ordered collection and authoritative buckets" do response - expect(controller) - .to have_received(:build_move_to_bucket_dialog) - .with( - work_packages: [second, first], - buckets: [available_bucket], - move_action: move_project_backlogs_work_packages_path(project) - ) + expect(response).to have_turbo_stream action: "dialog" + document = Nokogiri::HTML.fragment(response.body) + expect(document.css('input[name="ids[]"]').pluck("value")) + .to eq [second.id.to_s, first.id.to_s] + expect(document.css("select option").pluck("value")) + .to eq [available_bucket.id.to_s] + expect(document.css('input[name="prev_id"]')).to be_empty + expect(document.css("form").pluck("action")) + .to include(move_project_backlogs_work_packages_path(project)) end context "when every bucket destination is omitted" do @@ -1269,7 +1282,6 @@ def turbo_stream_template(action:) expect(response).to have_turbo_stream action: "flash", target: "op-primer-flash-component" expect(body).to include(I18n.t("backlogs.work_packages.move_to_bucket_dialog.no_available_destinations")) expect(response).not_to have_turbo_stream action: "dialog" - expect(controller).not_to have_received(:build_move_to_bucket_dialog) end end end diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb index 8555b5bd08c9..227a370c151c 100644 --- a/modules/backlogs/spec/support/pages/backlog.rb +++ b/modules/backlogs/spec/support/pages/backlog.rb @@ -852,22 +852,6 @@ def expect_polite_announcement(message) end.to eq(message) end - def expect_persisted_sprint_order(sprint, *work_packages) - wait_for { sprint.work_packages_for(project).pluck(:id) } - .to eq(work_packages.map(&:id)) - end - - def expect_persisted_bucket_order(bucket, *work_packages) - wait_for { WorkPackage.where(backlog_bucket: bucket).order_by_position.pluck(:id) } - .to eq(work_packages.map(&:id)) - end - - def expect_persisted_inbox_order(*work_packages) - wait_for do - WorkPackage.where(project:, sprint_id: nil, backlog_bucket_id: nil).order_by_position.pluck(:id) - end.to eq(work_packages.map(&:id)) - end - # The shared description every selected card's `aria-describedby` points # at. Rendered once, permanently `hidden` — screen readers still reach it # through the reference despite that — so `visible: :all` is required. From 35d06e35821506bc12bc7b191d9493d3fc4a58f1 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 9 Sep 2026 19:07:25 +0100 Subject: [PATCH 25/27] Cover ordered destination batch outcomes Adds selected bucket and inbox moves and a shrinking Shift range through the sprint dialog. Uses contextual fixtures, rendered persistence and announcements, and checks the divider in the same menu opening. --- .../batch_destination_menu_spec.rb | 472 +++++++++++------- 1 file changed, 287 insertions(+), 185 deletions(-) diff --git a/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb b/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb index 748fab3b50e7..8c98b90e7f7e 100644 --- a/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb +++ b/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb @@ -50,209 +50,311 @@ create(:user, member_with_roles: { project => manage_sprint_items_role }) end - it "moves a selected cross-list batch in live document order and announces its appended range" do - source_sprint = create(:sprint, project:, name: "Source sprint") - destination_sprint = create(:sprint, project:, name: "Destination sprint") - bucket = create(:backlog_bucket, project:, name: "Source bucket") - destination_story = create(:work_package, project:, type:, sprint: destination_sprint, position: 1) - bucket_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 1) - sprint_story = create(:work_package, project:, type:, sprint: source_sprint, position: 1) - backlogs_page.visit! - - backlogs_page.select_cards(sprint_story, bucket_story) - backlogs_page.expect_selected_cards_in_order(bucket_story, sprint_story) - - backlogs_page.open_destination_dialog(sprint_story, "Move to sprint") - backlogs_page.expect_destination_dialog( - "Move to sprint", - work_packages: [bucket_story, sprint_story] - ) - backlogs_page.submit_destination_dialog( - "Move to sprint", - field_label: Sprint.human_model_name, - option: destination_sprint.name - ) - - backlogs_page.expect_sprint_items_in_order( - destination_sprint, - items: [destination_story, bucket_story, sprint_story] - ) - backlogs_page.expect_polite_announcement( - I18n.t( - "backlogs.work_packages.move_collection.moved_announcement", - count: 2, - list: destination_sprint.name, - first: 2, - last: 3, - total: 3 + context "with selected members in different source lists" do + let!(:source_sprint) { create(:sprint, project:, name: "Source sprint") } + let!(:destination_sprint) { create(:sprint, project:, name: "Destination sprint") } + let!(:bucket) { create(:backlog_bucket, project:, name: "Source bucket") } + let!(:destination_story) { create(:work_package, project:, type:, sprint: destination_sprint) } + let!(:bucket_story) { create(:work_package, project:, type:, backlog_bucket: bucket) } + let!(:sprint_story) { create(:work_package, project:, type:, sprint: source_sprint) } + + it "moves a selected cross-list batch in live document order and announces its appended range" do + backlogs_page.visit! + + backlogs_page.select_cards(sprint_story, bucket_story) + backlogs_page.expect_selected_cards_in_order(bucket_story, sprint_story) + + backlogs_page.open_destination_dialog(sprint_story, "Move to sprint") + backlogs_page.expect_destination_dialog( + "Move to sprint", + work_packages: [bucket_story, sprint_story] ) - ) - backlogs_page.expect_no_selected_cards - - backlogs_page.visit! - backlogs_page.expect_sprint_items_in_order( - destination_sprint, - items: [destination_story, bucket_story, sprint_story] - ) + backlogs_page.submit_destination_dialog( + "Move to sprint", + field_label: Sprint.human_model_name, + option: destination_sprint.name + ) + + backlogs_page.expect_sprint_items_in_order( + destination_sprint, + items: [destination_story, bucket_story, sprint_story] + ) + backlogs_page.expect_polite_announcement( + I18n.t( + "backlogs.work_packages.move_collection.moved_announcement", + count: 2, + list: destination_sprint.name, + first: 2, + last: 3, + total: 3 + ) + ) + backlogs_page.expect_no_selected_cards + + backlogs_page.visit! + backlogs_page.expect_sprint_items_in_order( + destination_sprint, + items: [destination_story, bucket_story, sprint_story] + ) + end end - it "replaces the old selection when an unselected card invokes a destination action" do - first_sprint = create(:sprint, project:, name: "First sprint") - second_sprint = create(:sprint, project:, name: "Second sprint") - bucket = create(:backlog_bucket, project:, name: "Source bucket") - selected_bucket_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 1) - selected_sprint_story = create(:work_package, project:, type:, sprint: first_sprint, position: 1) - invoker = create(:work_package, project:, type:, sprint: second_sprint, position: 1) - backlogs_page.visit! - - backlogs_page.select_cards(selected_sprint_story, selected_bucket_story) - backlogs_page.expect_selected_cards_in_order(selected_bucket_story, selected_sprint_story) - - backlogs_page.move_to_backlog_inbox(invoker) - - backlogs_page.expect_inbox_items_in_order(items: [invoker]) - backlogs_page.expect_bucket_items_in_order(bucket, items: [selected_bucket_story]) - backlogs_page.expect_sprint_items_in_order(first_sprint, items: [selected_sprint_story]) - backlogs_page.expect_no_selected_cards - - backlogs_page.visit! - backlogs_page.expect_inbox_items_in_order(items: [invoker]) - backlogs_page.expect_bucket_items_in_order(bucket, items: [selected_bucket_story]) - backlogs_page.expect_sprint_items_in_order(first_sprint, items: [selected_sprint_story]) + context "with an unselected destination invoker" do + let!(:first_sprint) { create(:sprint, project:, name: "First sprint") } + let!(:second_sprint) { create(:sprint, project:, name: "Second sprint") } + let!(:bucket) { create(:backlog_bucket, project:, name: "Source bucket") } + let!(:selected_bucket_story) { create(:work_package, project:, type:, backlog_bucket: bucket) } + let!(:selected_sprint_story) { create(:work_package, project:, type:, sprint: first_sprint) } + let!(:invoker) { create(:work_package, project:, type:, sprint: second_sprint) } + + it "replaces the old selection when an unselected card invokes a destination action" do + backlogs_page.visit! + + backlogs_page.select_cards(selected_sprint_story, selected_bucket_story) + backlogs_page.expect_selected_cards_in_order(selected_bucket_story, selected_sprint_story) + + backlogs_page.move_to_backlog_inbox(invoker) + + backlogs_page.expect_inbox_items_in_order(items: [invoker]) + backlogs_page.expect_bucket_items_in_order(bucket, items: [selected_bucket_story]) + backlogs_page.expect_sprint_items_in_order(first_sprint, items: [selected_sprint_story]) + backlogs_page.expect_no_selected_cards + + backlogs_page.visit! + backlogs_page.expect_inbox_items_in_order(items: [invoker]) + backlogs_page.expect_bucket_items_in_order(bucket, items: [selected_bucket_story]) + backlogs_page.expect_sprint_items_in_order(first_sprint, items: [selected_sprint_story]) + end end - it "offers a partly occupied destination and gathers the whole batch at its end" do - destination_sprint = create(:sprint, project:, name: "Destination sprint") - bucket = create(:backlog_bucket, project:, name: "Source bucket") - destination_story = create(:work_package, project:, type:, sprint: destination_sprint, position: 1) - selected_destination_story = create(:work_package, project:, type:, sprint: destination_sprint, position: 2) - bucket_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 1) - backlogs_page.visit! - - backlogs_page.select_cards(selected_destination_story, bucket_story) - backlogs_page.expect_selected_cards_in_order(bucket_story, selected_destination_story) - backlogs_page.expect_work_package_action(selected_destination_story, "Move to sprint") - - backlogs_page.open_destination_dialog(selected_destination_story, "Move to sprint") - backlogs_page.expect_destination_dialog_options( - "Move to sprint", - field_label: Sprint.human_model_name, - options: [destination_sprint.name] - ) - backlogs_page.submit_destination_dialog( - "Move to sprint", - field_label: Sprint.human_model_name, - option: destination_sprint.name - ) - - backlogs_page.expect_sprint_items_in_order( - destination_sprint, - items: [destination_story, bucket_story, selected_destination_story] - ) - - backlogs_page.visit! - backlogs_page.expect_sprint_items_in_order( - destination_sprint, - items: [destination_story, bucket_story, selected_destination_story] - ) + context "with a partly occupied destination" do + let!(:destination_sprint) { create(:sprint, project:, name: "Destination sprint") } + let!(:bucket) { create(:backlog_bucket, project:, name: "Source bucket") } + let!(:destination_story) { create(:work_package, project:, type:, sprint: destination_sprint, position: 1) } + let!(:selected_destination_story) { create(:work_package, project:, type:, sprint: destination_sprint, position: 2) } + let!(:bucket_story) { create(:work_package, project:, type:, backlog_bucket: bucket) } + + it "offers a partly occupied destination and gathers the whole batch at its end" do + backlogs_page.visit! + + backlogs_page.select_cards(selected_destination_story, bucket_story) + backlogs_page.expect_selected_cards_in_order(bucket_story, selected_destination_story) + backlogs_page.expect_work_package_action(selected_destination_story, "Move to sprint") + + backlogs_page.open_destination_dialog(selected_destination_story, "Move to sprint") + backlogs_page.expect_destination_dialog_options( + "Move to sprint", + field_label: Sprint.human_model_name, + options: [destination_sprint.name] + ) + backlogs_page.submit_destination_dialog( + "Move to sprint", + field_label: Sprint.human_model_name, + option: destination_sprint.name + ) + + backlogs_page.expect_sprint_items_in_order( + destination_sprint, + items: [destination_story, bucket_story, selected_destination_story] + ) + + backlogs_page.visit! + backlogs_page.expect_sprint_items_in_order( + destination_sprint, + items: [destination_story, bucket_story, selected_destination_story] + ) + end end - it "intersects destinations for confined selections and offers none when their lists conflict" do - first_sprint = create(:sprint, project:, name: "First sprint") - second_sprint = create(:sprint, project:, name: "Second sprint") - bucket = create(:backlog_bucket, project:, name: "Source bucket") - readonly_status = create(:status, is_readonly: true) - confined_first = create(:work_package, project:, type:, sprint: first_sprint, status: readonly_status) - confined_second = create(:work_package, project:, type:, sprint: second_sprint, status: readonly_status) - free_story = create(:work_package, project:, type:, backlog_bucket: bucket) - backlogs_page.visit! - - backlogs_page.select_cards(confined_first, free_story) - backlogs_page.expect_destination_actions( - confined_first, - present: ["Move to sprint"], - absent: ["Move to backlog bucket", "Move to backlog inbox"] - ) - backlogs_page.open_destination_dialog(confined_first, "Move to sprint") - backlogs_page.expect_destination_dialog_options( - "Move to sprint", - field_label: Sprint.human_model_name, - options: [first_sprint.name] - ) - backlogs_page.cancel_destination_dialog("Move to sprint") - backlogs_page.clear_card_selection(confined_first) - - backlogs_page.select_cards(confined_first, confined_second) - backlogs_page.expect_destination_actions( - confined_first, - present: [], - absent: ["Move to sprint", "Move to backlog bucket", "Move to backlog inbox"] - ) - backlogs_page.within_work_package_menu(confined_first) do |menu| - expect(menu).to have_no_css("li[role=separator]", visible: :visible) + context "with confined members" do + let!(:first_sprint) { create(:sprint, project:, name: "First sprint") } + let!(:second_sprint) { create(:sprint, project:, name: "Second sprint") } + let!(:bucket) { create(:backlog_bucket, project:, name: "Source bucket") } + let!(:readonly_status) { create(:status, is_readonly: true) } + let!(:confined_first) { create(:work_package, project:, type:, sprint: first_sprint, status: readonly_status) } + let!(:confined_second) { create(:work_package, project:, type:, sprint: second_sprint, status: readonly_status) } + let!(:free_story) { create(:work_package, project:, type:, backlog_bucket: bucket) } + + it "intersects destinations for confined selections and offers none when their lists conflict" do + backlogs_page.visit! + + backlogs_page.select_cards(confined_first, free_story) + backlogs_page.expect_destination_actions( + confined_first, + present: ["Move to sprint"], + absent: ["Move to backlog bucket", "Move to backlog inbox"] + ) + backlogs_page.open_destination_dialog(confined_first, "Move to sprint") + backlogs_page.expect_destination_dialog_options( + "Move to sprint", + field_label: Sprint.human_model_name, + options: [first_sprint.name] + ) + backlogs_page.cancel_destination_dialog("Move to sprint") + backlogs_page.clear_card_selection(confined_first) + + backlogs_page.select_cards(confined_first, confined_second) + backlogs_page.within_work_package_menu(confined_first) do |menu| + ["Move to sprint", "Move to backlog bucket", "Move to backlog inbox"].each do |label| + expect(menu).to have_no_selector(:menuitem, text: label, exact_text: true) + end + expect(menu).to have_no_css('[data-sortable-lists--item-target="moveDivider"]', visible: :visible) + end end end - it "omits Move to position from a multi-card action scope" do - sprint = create(:sprint, project:, name: "Sprint") - create(:backlog_bucket, project:, name: "Destination bucket") - first_story = create(:work_package, project:, type:, sprint:, position: 1) - second_story = create(:work_package, project:, type:, sprint:, position: 2) - backlogs_page.visit! + context "with a multi-card action scope" do + let!(:sprint) { create(:sprint, project:, name: "Sprint") } + let!(:destination_bucket) { create(:backlog_bucket, project:, name: "Destination bucket") } + let!(:first_story) { create(:work_package, project:, type:, sprint:, position: 1) } + let!(:second_story) { create(:work_package, project:, type:, sprint:, position: 2) } + + it "omits Move to position from a multi-card action scope" do + backlogs_page.visit! - backlogs_page.select_cards(first_story, second_story) + backlogs_page.select_cards(first_story, second_story) - backlogs_page.expect_no_work_package_action(first_story, "Move to position") - backlogs_page.expect_work_package_action(first_story, "Move to backlog bucket") + backlogs_page.expect_no_work_package_action(first_story, "Move to position") + backlogs_page.expect_work_package_action(first_story, "Move to backlog bucket") + end end - it "shows feedback without an empty modal when the last destination disappears before loading" do - sprint = create(:sprint, project:, name: "Only sprint") - bucket = create(:backlog_bucket, project:, name: "Source bucket") - first_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 1) - second_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 2) - backlogs_page.visit! + context "when the last destination disappears" do + let!(:sprint) { create(:sprint, project:, name: "Only sprint") } + let!(:bucket) { create(:backlog_bucket, project:, name: "Source bucket") } + let!(:first_story) { create(:work_package, project:, type:, backlog_bucket: bucket, position: 1) } + let!(:second_story) { create(:work_package, project:, type:, backlog_bucket: bucket, position: 2) } + + it "shows feedback without an empty modal when the last destination disappears before loading" do + backlogs_page.visit! + + backlogs_page.select_cards(first_story, second_story) + backlogs_page.invoke_destination_action_after_menu_load(first_story, "Move to sprint") do + sprint.update_columns(status: "completed") + end - backlogs_page.select_cards(first_story, second_story) - backlogs_page.invoke_destination_action_after_menu_load(first_story, "Move to sprint") do - sprint.update_columns(status: "completed") + backlogs_page.expect_move_error( + I18n.t("backlogs.work_packages.move_to_sprint_dialog.no_available_destinations") + ) + backlogs_page.expect_no_destination_dialog + backlogs_page.expect_selected_cards_in_order(first_story, second_story) + backlogs_page.expect_bucket_items_in_order(bucket, items: [first_story, second_story]) end + end + + context "when a batch member refuses the destination" do + let!(:sprint) { create(:sprint, project:, name: "Destination sprint") } + let!(:bucket) { create(:backlog_bucket, project:, name: "Source bucket") } + let!(:first_story) { create(:work_package, project:, type:, backlog_bucket: bucket, position: 1) } + let!(:second_story) { create(:work_package, project:, type:, backlog_bucket: bucket, position: 2) } + + it "rejects the complete batch atomically and retains its selection" do + backlogs_page.visit! + + backlogs_page.select_cards(first_story, second_story) + backlogs_page.open_destination_dialog(first_story, "Move to sprint") + + second_story.update_columns(status_id: create(:status, is_readonly: true).id) + backlogs_page.submit_destination_dialog( + "Move to sprint", + field_label: Sprint.human_model_name, + option: sprint.name, + frame_reload: false + ) - backlogs_page.expect_move_error( - I18n.t("backlogs.work_packages.move_to_sprint_dialog.no_available_destinations") - ) - backlogs_page.expect_no_destination_dialog - backlogs_page.expect_selected_cards_in_order(first_story, second_story) - backlogs_page.expect_bucket_items_in_order(bucket, items: [first_story, second_story]) + backlogs_page.expect_move_error( + I18n.t("backlogs.work_packages.move_collection.member_failed", + work_package: second_story.reload.to_fs(:caption), + reason: I18n.t("backlogs.work_packages.batch_update_service.unavailable_target")) + ) + backlogs_page.expect_bucket_items_in_order(bucket, items: [first_story, second_story]) + backlogs_page.expect_selected_cards_in_order(first_story, second_story) + + backlogs_page.visit! + backlogs_page.expect_bucket_items_in_order(bucket, items: [first_story, second_story]) + end end - it "rejects the complete batch atomically and retains its selection" do - sprint = create(:sprint, project:, name: "Destination sprint") - bucket = create(:backlog_bucket, project:, name: "Source bucket") - first_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 1) - second_story = create(:work_package, project:, type:, backlog_bucket: bucket, position: 2) - backlogs_page.visit! - - backlogs_page.select_cards(first_story, second_story) - backlogs_page.open_destination_dialog(first_story, "Move to sprint") - - second_story.update_columns(status_id: create(:status, is_readonly: true).id) - backlogs_page.submit_destination_dialog( - "Move to sprint", - field_label: Sprint.human_model_name, - option: sprint.name, - frame_reload: false - ) - - backlogs_page.expect_move_error( - I18n.t("backlogs.work_packages.move_collection.member_failed", - work_package: second_story.reload.to_fs(:caption), - reason: I18n.t("backlogs.work_packages.batch_update_service.unavailable_target")) - ) - backlogs_page.expect_bucket_items_in_order(bucket, items: [first_story, second_story]) - backlogs_page.expect_selected_cards_in_order(first_story, second_story) - - backlogs_page.visit! - backlogs_page.expect_bucket_items_in_order(bucket, items: [first_story, second_story]) + context "with a selected batch moving to a backlog bucket" do + let!(:sprint) { create(:sprint, project:) } + let!(:bucket) { create(:backlog_bucket, project:) } + let!(:existing) { create(:work_package, project:, type:, backlog_bucket: bucket) } + let!(:first) { create(:work_package, project:, type:, sprint:, position: 1) } + let!(:second) { create(:work_package, project:, type:, sprint:, position: 2) } + + it "appends the ordered batch and announces its range" do + backlogs_page.visit! + backlogs_page.select_cards(first, second) + backlogs_page.open_destination_dialog(first, "Move to backlog bucket") + backlogs_page.expect_destination_dialog("Move to backlog bucket", work_packages: [first, second]) + backlogs_page.submit_destination_dialog( + "Move to backlog bucket", field_label: BacklogBucket.human_attribute_name(:name), option: bucket.name + ) + + backlogs_page.expect_bucket_items_in_order(bucket, items: [existing, first, second]) + backlogs_page.expect_polite_announcement( + I18n.t("backlogs.work_packages.move_collection.moved_announcement", + count: 2, list: bucket.name, first: 2, last: 3, total: 3) + ) + backlogs_page.expect_no_selected_cards + backlogs_page.visit! + backlogs_page.expect_bucket_items_in_order(bucket, items: [existing, first, second]) + end + end + + context "with a selected batch moving to the inbox" do + let!(:sprint) { create(:sprint, project:) } + let!(:existing) { create(:work_package, project:, type:) } + let!(:first) { create(:work_package, project:, type:, sprint:, position: 1) } + let!(:second) { create(:work_package, project:, type:, sprint:, position: 2) } + + it "appends the whole selection and announces its range" do + backlogs_page.visit! + backlogs_page.select_cards(first, second) + backlogs_page.move_to_backlog_inbox(first) + + backlogs_page.expect_inbox_items_in_order(items: [existing, first, second]) + backlogs_page.expect_polite_announcement( + I18n.t("backlogs.work_packages.move_collection.moved_announcement", + count: 2, list: I18n.t(:label_inbox), first: 2, last: 3, total: 3) + ) + backlogs_page.expect_no_selected_cards + backlogs_page.visit! + backlogs_page.expect_inbox_items_in_order(items: [existing, first, second]) + end + end + + context "with additive selection followed by a shrinking Shift range" do + let!(:source) { create(:sprint, project:, name: "Source sprint") } + let!(:destination) { create(:sprint, project:, name: "Destination sprint") } + let!(:existing) { create(:work_package, project:, type:, sprint: destination, subject: "X") } + let!(:stories) do + %w[A B C D E].map.with_index(1) do |subject, position| + create(:work_package, project:, type:, sprint: source, subject:, position:) + end + end + + it "submits the current range while preserving the earlier additive member" do + a, b, c, d, e = stories + backlogs_page.visit! + backlogs_page.toggle_card(a) + backlogs_page.toggle_card(c) + backlogs_page.extend_selection_to(e) + backlogs_page.expect_selected_cards_in_order(a, c, d, e) + backlogs_page.extend_selection_to(d) + backlogs_page.expect_selected_cards_in_order(a, c, d) + backlogs_page.open_destination_dialog(c, "Move to sprint") + backlogs_page.expect_destination_dialog("Move to sprint", work_packages: [a, c, d]) + backlogs_page.submit_destination_dialog( + "Move to sprint", field_label: Sprint.human_model_name, option: destination.name + ) + + backlogs_page.expect_sprint_items_in_order(destination, items: [existing, a, c, d]) + backlogs_page.expect_sprint_items_in_order(source, items: [b, e]) + backlogs_page.expect_no_selected_cards + backlogs_page.visit! + backlogs_page.expect_sprint_items_in_order(destination, items: [existing, a, c, d]) + backlogs_page.expect_sprint_items_in_order(source, items: [b, e]) + end end end From bfdb13a7a1282761c02be3cbb0f68fbf2e784088 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Thu, 10 Sep 2026 13:38:43 +0100 Subject: [PATCH 26/27] Render selected work packages as a semantic list Uses OpPrimer::ListComponent to expose each selected work package as a list item while preserving the heading and selection order. --- .../selected_work_packages_component.html.erb | 26 +++++++++++-------- .../selected_work_packages_component_spec.rb | 8 ++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/modules/backlogs/app/components/backlogs/selected_work_packages_component.html.erb b/modules/backlogs/app/components/backlogs/selected_work_packages_component.html.erb index 871f1b2344cf..d6a91cb0bbbb 100644 --- a/modules/backlogs/app/components/backlogs/selected_work_packages_component.html.erb +++ b/modules/backlogs/app/components/backlogs/selected_work_packages_component.html.erb @@ -40,16 +40,20 @@ See COPYRIGHT and LICENSE files for more details. ) { t(".label", count: work_packages.size) } %> - <% work_packages.each do |work_package| %> - <%= - render( - WorkPackages::InfoLineComponent.new( - work_package:, - show_subject: true, - show_status: false, - my: 1 - ) - ) - %> + <%= render(OpPrimer::ListComponent.new(ml: 0, tag: :ol)) do |list| %> + <% work_packages.each do |work_package| %> + <% list.with_item do %> + <%= + render( + WorkPackages::InfoLineComponent.new( + work_package:, + show_subject: true, + show_status: false, + my: 1 + ) + ) + %> + <% end %> + <% end %> <% end %> <% end %> diff --git a/modules/backlogs/spec/components/backlogs/selected_work_packages_component_spec.rb b/modules/backlogs/spec/components/backlogs/selected_work_packages_component_spec.rb index 32c3635ff3c9..621351ff5bef 100644 --- a/modules/backlogs/spec/components/backlogs/selected_work_packages_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/selected_work_packages_component_spec.rb @@ -62,6 +62,14 @@ def render_component(work_packages: [feature, epic]) expect(page).to have_link(epic.formatted_id, href: "/work_packages/#{epic.id}") end + it "renders the selection as a semantic list", :aggregate_failures do + render_component + + expect(page).to have_list_item count: 2 + expect(page).to have_list_item position: 1, text: feature.subject + expect(page).to have_list_item position: 2, text: epic.subject + end + it "heads the box with the selected count on the description element" do render_component From cb32f2494d1b87ca8feed38871c63d4f7fec83a2 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Tue, 15 Sep 2026 17:48:44 +0100 Subject: [PATCH 27/27] Reconcile destination branch with batch review Removes the unused root enumeration wrapper after its orchestrator counterpart was removed. Retains the complexity allowance needed by the destination preflight added above the transaction helper. --- .../controllers/dynamic/sortable-lists.controller.ts | 5 ----- .../services/backlogs/work_packages/batch_update_service.rb | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts index ea84adb78ba7..f962c25b3808 100644 --- a/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts @@ -244,11 +244,6 @@ export default class SortableListsController extends Controller imp } } - // Live ordered membership, for AGILE-278's batch move. - selectedItems():SelectionItem[] { - return this.selection?.selectedItems() ?? []; - } - actionScopeFor(itemElement:HTMLElement):ActionScope { return this.selection?.actionScopeFor(itemElement) ?? { kind: 'refused', items: [] }; } diff --git a/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb b/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb index 12229fd92fb5..3324bfc6977a 100644 --- a/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb +++ b/modules/backlogs/app/services/backlogs/work_packages/batch_update_service.rb @@ -58,7 +58,7 @@ def initialize(user:, work_packages:) def initial_prev_id = anchor ? anchor.id.to_s : "" end - def call(list_type: nil, list_id: nil, prev_id: nil) # rubocop:disable Metrics/AbcSize + def call(list_type: nil, list_id: nil, prev_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity return empty_batch_failure if work_packages.empty? contract = Backlogs::WorkPackages::BatchMoveParamsContract.new(