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..32e70be9f66a 100644
--- a/frontend/src/stimulus/controllers/async-dialog.controller.ts
+++ b/frontend/src/stimulus/controllers/async-dialog.controller.ts
@@ -26,82 +26,89 @@
// See COPYRIGHT and LICENSE files for more details.
//++
-import { ApplicationController } from 'stimulus-use';
-import { renderStreamMessage } from '@hotwired/turbo';
-import { TurboHelpers } from 'core-turbo/helpers';
+import { Controller } from '@hotwired/stimulus';
+import { FetchRequest } from '@rails/request.js';
+import { performTurboStreamRequest, withProgressBar } from 'core-stimulus/helpers/request-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');
- }
- 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'));
- }
+ 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;
- return response.text();
- }).then((html) => {
- renderStreamMessage(html);
- }).finally(() => {
- if (this.disableDuringLoadValue) {
- this.loading = false;
- (this.element as HTMLElement).removeAttribute('aria-disabled');
- }
- TurboHelpers.hideProgressBar();
- });
+ const method = form?.method ?? this.method;
+ const url = urlOverride ?? form?.action ?? this.href;
+ const body = form ? new FormData(form) : undefined;
+
+ this.setLoading(true);
+
+ try {
+ await withProgressBar(performTurboStreamRequest(new FetchRequest(method, url, {
+ body,
+ responseKind: 'turbo-stream',
+ })));
+ } finally {
+ this.setLoading(false);
+ }
}
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');
+ }
+ }
+ }
}
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..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';
@@ -1240,6 +1241,248 @@ 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.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' },
+ 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();
@@ -1437,6 +1680,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], { ctrlKey: 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();
@@ -1987,17 +2245,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.controller.ts b/frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts
index bc24e635be14..f962c25b3808 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, withProgressBar } from 'core-stimulus/helpers/request-helpers';
import { parseTemplate } from 'url-template';
import {
buildMoveFormData,
@@ -62,12 +63,15 @@ 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 { scopeIds, type ActionScope } from './sortable-lists/action-scope';
import { itemIdentity, orderedItemElements } from './sortable-lists/selection';
type CleanupFn = () => void;
@@ -120,8 +124,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),
@@ -235,6 +244,40 @@ export default class SortableListsController extends Controller imp
}
}
+ 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: [] };
+ }
+
+ // 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[] {
+ 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.
@@ -465,6 +508,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
@@ -516,6 +582,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;
}
@@ -611,20 +681,46 @@ 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 {
+ 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
+ // 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 {
@@ -779,7 +875,7 @@ export default class SortableListsController extends Controller imp
},
);
- this.setBusy(true);
+ this.startMoveRequest();
try {
const response = await request.perform();
@@ -794,7 +890,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/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 600f4d84b726..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,6 +55,7 @@ import {
type MoveAvailability,
type MoveDirection,
} from './list-dom';
+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.
@@ -102,9 +103,16 @@ 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[];
+ 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 1fa208a97d2d..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,6 +58,7 @@ import type { ActionEvent } from '@hotwired/stimulus';
import type ItemControllerType from './item.controller';
import type { SortableListsRoot } from './drag-and-drop';
import type { DestinationIdentity } from './list-dom';
+import type { ActionScope } from './action-scope';
describe('Sortable lists item controller', () => {
let draggable:typeof draggableFn;
@@ -104,8 +105,13 @@ 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(() => []),
+ 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(),
@@ -1135,8 +1141,13 @@ 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(() => []),
+ 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(),
@@ -1228,8 +1239,13 @@ 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(() => []),
+ 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(),
@@ -1363,13 +1379,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);
@@ -1384,11 +1400,24 @@ 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 };
}
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.
@@ -1404,15 +1433,73 @@ 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(() => []),
+ moveToDestination: 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('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'));
@@ -1429,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'));
@@ -1441,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'));
@@ -1456,20 +1545,172 @@ 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);
});
+ 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.querySelector('anchored-position')!.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).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 () => {
+ 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'],
+ ['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) => {
+ 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.
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);
@@ -1481,6 +1722,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);
@@ -1496,6 +1738,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);
@@ -1508,6 +1751,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');
@@ -1532,7 +1776,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
@@ -1545,6 +1789,113 @@ 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 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');
+ 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;
@@ -1638,8 +1989,13 @@ 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(() => []),
+ moveToDestination: vi.fn(),
moveInDirection: vi.fn(),
moveAvailability: vi.fn(() => null),
+ ownerListElementOf: vi.fn(() => null),
ownerRowsContainer: vi.fn(() => null),
freezeDragBatch: vi.fn(() => 1),
markDragBatch,
@@ -1666,8 +2022,13 @@ 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(() => []),
+ 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 80e7f2e281d5..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,19 +52,23 @@ import {
} from './drag-and-drop';
import {
isMoveDirection,
+ parseDestinationCandidates,
isOrderableItem,
itemMobility,
resolveItemExternalUrl,
resolveItemLabel,
sortableItemSelector,
+ type DestinationIdentity,
} from './list-dom';
import { webLinkHref } from './external-data';
import { renderDragPreview } from './preview';
+import { scopeIds } from './action-scope';
+import { refreshMenuAvailability } from './menu-availability';
type CleanupFn = () => void;
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 +93,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;
@@ -106,6 +111,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
@@ -113,8 +119,10 @@ 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') {
- this.refreshMoveMenuAvailability();
+ if (this.hasMenuElement
+ && event.target === this.menuElement.popoverElement
+ && (event as ToggleEvent).newState === 'open') {
+ this.refreshActionAvailability();
}
};
@@ -125,6 +133,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.
@@ -135,24 +144,30 @@ 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
- // 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.scheduleAvailabilityRefresh();
+ }
+
+ destinationItemTargetConnected():void {
+ 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 {
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;
}
@@ -162,6 +177,43 @@ export default class ItemController extends Controller implements R
}
}
+ prepareDialog(event:CustomEvent<{ form:HTMLFormElement|null }>):void {
+ 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();
+ 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.menuItemActionable(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 {
@@ -463,81 +515,33 @@ export default class ItemController extends Controller implements R
this.dropIndicatorElement = undefined;
}
- private refreshMoveMenuAvailability():void {
+ private refreshActionAvailability():void {
+ this.refreshToken = undefined;
const root = this.root;
- if (!root || !this.hasMenuElement) {
- 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) {
+ if (!root || !this.hasMenuElement || (this.destinationItemTargets.length === 0 && this.moveItemTargets.length === 0)) {
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);
- }
-
- 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),
+ });
}
- // 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.hasAttribute('hidden')) {
- divider.removeAttribute('hidden');
- return;
- }
-
- sibling = sibling.nextElementSibling;
- }
-
- divider.setAttribute('hidden', 'hidden');
+ private menuItemActionable(item:EventTarget|null):item is HTMLElement {
+ return this.hasMenuElement && item instanceof HTMLElement
+ && !this.menuElement.isItemDisabled(item) && !this.menuElement.isItemHidden(item);
}
- // 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);
- }
+ private destinationCandidates(item:HTMLElement):DestinationIdentity[] {
+ 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 7fe83a4f7a2d..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,11 +26,15 @@
// See COPYRIGHT and LICENSE files for more details.
//++
+import { environment } from '../../../../environments/environment';
+
import {
+ parseDestinationCandidates,
captureRowPositions,
isOrderableItem,
itemAcceptsDestination,
itemMobility,
+ permittedDestinations,
reorderRows,
sortableItemMobilityAttribute,
resolveDirectionalPreviousItemId,
@@ -43,6 +47,7 @@ import {
restoreRowPositions,
rowOf,
rowsRemainAt,
+ sameDestination,
} from './list-dom';
describe('sortable lists DOM helpers', () => {
@@ -455,6 +460,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');
@@ -683,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 d85962b6045c..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;
}
@@ -142,6 +169,24 @@ export function itemAcceptsDestination(
}
}
+export function permittedDestinations({
+ items,
+ candidates,
+ ownerDestinationOf,
+}:{
+ items:HTMLElement[];
+ candidates:DestinationIdentity[];
+ ownerDestinationOf:(item:HTMLElement) => DestinationIdentity|null;
+}):DestinationIdentity[] {
+ if (items.length === 0) {
+ return [];
+ }
+
+ return candidates.filter((target) => (
+ items.every((item) => itemAcceptsDestination(item, target, ownerDestinationOf))
+ ));
+}
+
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..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,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 './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
@@ -75,8 +76,13 @@ 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(() => []),
+ 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/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 7436950072f0..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,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 './action-scope';
vi.mock('@atlaskit/pragmatic-drag-and-drop-auto-scroll/element', () => ({
autoScrollForElements: vi.fn(() => vi.fn()),
@@ -70,8 +71,13 @@ 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(() => []),
+ 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..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,10 +60,6 @@ export interface SelectionHost {
ownerRowsContainer(itemElement:HTMLElement):HTMLElement|null;
}
-export type ActionScope =
- | { kind:'batch'; items:HTMLElement[] }
- | { kind:'refused'; items:[] };
-
// 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.
@@ -400,6 +397,10 @@ export class SelectionOrchestrator {
}
event.preventDefault();
+ if (this.host.busy) {
+ return;
+ }
+
escapesClearedBySelection.add(event);
this.selection.clear();
this.renderSelection('selection');
diff --git a/frontend/src/stimulus/helpers/request-helpers.spec.ts b/frontend/src/stimulus/helpers/request-helpers.spec.ts
index 0fd3d0427112..a77be9bea8f3 100644
--- a/frontend/src/stimulus/helpers/request-helpers.spec.ts
+++ b/frontend/src/stimulus/helpers/request-helpers.spec.ts
@@ -26,10 +26,61 @@
// 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,
+ statusCode: 500,
+ renderTurboStream,
+ } as unknown as FetchResponse;
+
+ await expect(performTurboStreamRequest(requestFor(response)))
+ .rejects.toMatchObject({ name: 'FetchRequestError', _errorCode: 500, message: '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..98663cda838e 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 FetchRequestError(response.statusCode, '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');
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..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,26 +27,31 @@ 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
- available_buckets = buckets
- bucket_list_type = list_type
+<%= 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 %>
+ <%=
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|
+ 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)
@@ -54,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(show_divider: true) do %>
+ <%=
component_collection do |buttons|
buttons.with_component(Primer::Beta::Button.new(data: { close_dialog_id: DIALOG_ID })) do
t(:button_cancel)
@@ -68,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_bucket_dialog_component.rb b/modules/backlogs/app/components/backlogs/move_to_bucket_dialog_component.rb
index 14046c5bccbf..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
@@ -35,23 +35,22 @@ 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
- Backlogs::Target::BucketId.new(nil).list_type
+ def destination_list_type
+ Backlogs::Target::BucketId.list_type
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 29698a64638d..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,32 +27,53 @@ 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
- available_sprints = sprints
- sprint_list_type = list_type
+<%= 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 %>
+ <%=
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|
+ 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
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(show_divider: true) do %>
+ <%=
component_collection do |buttons|
buttons.with_component(Primer::Beta::Button.new(data: { close_dialog_id: DIALOG_ID })) do
t(:button_cancel)
@@ -64,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.rb b/modules/backlogs/app/components/backlogs/move_to_sprint_dialog_component.rb
index bf0efcf387f2..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
@@ -35,23 +35,22 @@ 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
- Backlogs::Target::SprintId.new(nil).list_type
+ def destination_list_type
+ Backlogs::Target::SprintId.list_type
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..d6a91cb0bbbb
--- /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::Beta::Heading.new(
+ tag: :h2,
+ id: description_id,
+ font_size: :small,
+ font_weight: :bold,
+ mb: 2
+ )
+ ) { t(".label", count: work_packages.size) }
+ %>
+
+ <%= 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/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..4552d03a4067
--- /dev/null
+++ b/modules/backlogs/app/components/backlogs/selected_work_packages_component.rb
@@ -0,0 +1,49 @@
+# 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 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
+
+ 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/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..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,8 +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, []).merge(
+ action: "click->sortable-lists--item#moveToDestination:prevent"
+ )
) do |item|
item.with_leading_visual_icon(icon: :inbox)
end
@@ -86,8 +87,16 @@ 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),
- content_arguments: { data: { controller: "async-dialog" } }
+ tag: :button,
+ href: move_to_bucket_dialog_project_backlogs_work_packages_path(project),
+ form_arguments: { method: :post },
+ content_arguments: {
+ data: {
+ controller: "async-dialog",
+ action: "async-dialog:beforeLoad->sortable-lists--item#prepareDialog"
+ }
+ },
+ data: destination_data(bucket_list_type, bucket_ids)
) do |item|
item.with_leading_visual_icon(icon: :package)
end
@@ -97,8 +106,16 @@ 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),
- content_arguments: { data: { controller: "async-dialog" } }
+ tag: :button,
+ href: move_to_sprint_dialog_project_backlogs_work_packages_path(project),
+ form_arguments: { method: :post },
+ content_arguments: {
+ data: {
+ controller: "async-dialog",
+ action: "async-dialog:beforeLoad->sortable-lists--item#prepareDialog"
+ }
+ },
+ data: destination_data(sprint_list_type, sprint_ids)
) do |item|
item.with_leading_visual_icon(icon: :zap)
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..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
@@ -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,26 @@ 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.list_type
+ end
+
+ def bucket_list_type
+ Backlogs::Target::BucketId.list_type
+ end
+
+ def destination_data(type, ids)
+ 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",
+ sortable_lists_destinations: candidates.to_json
+ }
+ end
end
end
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 385097cff9ac..bd64f4bb23bb 100644
--- a/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb
+++ b/modules/backlogs/app/controllers/backlogs/work_packages_controller.rb
@@ -36,15 +36,16 @@ 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
+ availability = destination_availability([@work_package])
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: availability.candidate_sprints.map(&:id),
+ bucket_ids: availability.candidate_buckets.map(&:id),
current_user:
),
layout: false)
@@ -60,27 +61,45 @@ 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
def move_to_sprint_dialog
+ work_packages = load_collection_work_packages
+ return if performed?
+
+ sprints = destination_availability(work_packages).sprints
+ if sprints.empty?
+ return render_move_collection_error(I18n.t("backlogs.work_packages.move_to_sprint_dialog.no_available_destinations"))
+ end
+
respond_with_dialog Backlogs::MoveToSprintDialogComponent.new(
- work_package: @work_package,
- project: @project,
- move_action: move_path
+ work_packages:,
+ sprints:,
+ move_action: move_collection_path
)
end
def move_to_bucket_dialog
+ work_packages = load_collection_work_packages
+ return if performed?
+
+ buckets = destination_availability(work_packages).buckets
+ if buckets.empty?
+ return render_move_collection_error(I18n.t("backlogs.work_packages.move_to_bucket_dialog.no_available_destinations"))
+ end
+
respond_with_dialog Backlogs::MoveToBucketDialogComponent.new(
- work_package: @work_package,
- project: @project,
- move_action: move_path
+ work_packages:,
+ buckets:,
+ move_action: move_collection_path
)
end
@@ -126,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.
@@ -156,7 +177,7 @@ 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)
+ 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))
@@ -166,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)
@@ -175,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)
@@ -184,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?
@@ -232,19 +261,15 @@ 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)
- invisible = results.select { |wp| work_package_invisible_after_move?(wp) }
- return 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))
end
@@ -272,7 +297,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,
@@ -281,6 +306,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:)
+ I18n.t(
+ "backlogs.work_packages.move_collection.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
@@ -301,15 +349,28 @@ 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
+ # 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]
+
+ 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(I18n.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.
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
@@ -322,6 +383,14 @@ 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
+
# 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,19 +400,8 @@ def move_collection_params
end
end
- 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)
+ 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
@@ -386,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/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/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 29d9d4869496..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(
@@ -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
@@ -103,17 +109,20 @@ 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)
- return unavailable_target_failure unless target_available?(target)
+ locks.lock_destination_row
+ destination_failure = revalidate_destination(target)
+ return destination_failure if destination_failure
anchor_failure = revalidate_anchor(placement, target)
return anchor_failure if anchor_failure
@@ -149,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
@@ -251,34 +190,50 @@ 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
- # 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 destination_availability.manage_permission? && 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)).includes(:status).to_a
+ )
end
def last_non_batch_member(target)
@@ -319,6 +274,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..ea55d77efa8d
--- /dev/null
+++ b/modules/backlogs/app/services/backlogs/work_packages/destination_availability.rb
@@ -0,0 +1,87 @@
+# 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 manage_permission?
+ return @manage_permission if defined?(@manage_permission)
+
+ @manage_permission = user.allowed_in_project?(:manage_sprint_items, project)
+ end
+
+ def candidate_sprints
+ @candidate_sprints ||= Sprint.assignable(project:, user:).order_by_date.to_a
+ end
+
+ def candidate_buckets
+ @candidate_buckets ||= BacklogBucket.assignable(project:, user:).to_a
+ end
+
+ def candidate?(target)
+ candidate_targets.include?(target)
+ end
+
+ def refusing(target)
+ work_packages.reject { |work_package| accepts?(work_package, target) }
+ end
+
+ def sprints = offered(candidate_sprints)
+ def buckets = offered(candidate_buckets)
+
+ private
+
+ def candidate_targets
+ @candidate_targets ||= Set.new(
+ [Backlogs::Target::InboxId] +
+ (candidate_sprints + candidate_buckets).map { |container| Backlogs::Target.for(container) }
+ )
+ end
+
+ def accepts?(work_package, target)
+ !work_package.readonly_status? || Backlogs::Target.for_work_package(work_package) == target
+ end
+
+ def offered(containers)
+ return [] unless manage_permission?
+
+ 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/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/config/locales/en.yml b/modules/backlogs/config/locales/en.yml
index 3dce5e68e083..a23915ee5d6a 100644
--- a/modules/backlogs/config/locales/en.yml
+++ b/modules/backlogs/config/locales/en.yml
@@ -208,6 +208,12 @@ en:
rebuild: "Rebuild"
rebuild_positions: "Rebuild positions"
remaining_hours: "Remaining work"
+
+ selected_work_packages_component:
+ label:
+ one: "1 selected work package"
+ other: "%{count} selected work packages"
+
sharing: "Sprint sharing"
show_burndown_chart: "Burndown chart"
@@ -295,9 +301,16 @@ 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}"
+ 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/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/components/backlogs/move_to_bucket_dialog_component_spec.rb b/modules/backlogs/spec/components/backlogs/move_to_bucket_dialog_component_spec.rb
index 6af17413abea..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
@@ -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,30 @@ 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(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")
- 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
+ it "describes the bucket select by the selection heading" do
+ render_component
- expect(page).to have_element(:form, action: /all=true/)
- end
+ label = page.find_css("##{described_class::SELECTION_LABEL_ID}").first
+ 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
+ )
end
it "renders Cancel and Move buttons" do
@@ -74,42 +84,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..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
@@ -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,30 @@ 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(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")
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
+ it "describes the sprint select by the selection heading" do
+ render_component
- expect(page).to have_css("form[action*='all=true']", visible: :all)
- end
+ label = page.find_css("##{described_class::SELECTION_LABEL_ID}").first
+ 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
+ )
end
it "renders Cancel and Save buttons" do
@@ -74,65 +84,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/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..621351ff5bef
--- /dev/null
+++ b/modules/backlogs/spec/components/backlogs/selected_work_packages_component_spec.rb
@@ -0,0 +1,87 @@
+# 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", count: 2))
+ 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
+
+ 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
+
+ expect(page).to have_css(
+ "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
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..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
@@ -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
@@ -273,11 +291,25 @@ def render_component(work_package: self.work_package, open_sprints_exist: true,
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(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_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
@@ -288,9 +320,9 @@ 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/)
+ expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_sprint\z/)
end
end
@@ -298,20 +330,34 @@ 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/)
+ expect(page).to have_element(:button, 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/)
+ 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
@@ -319,7 +365,7 @@ def render_component(work_package: self.work_package, open_sprints_exist: true,
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
@@ -337,20 +383,35 @@ 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
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(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_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
@@ -361,9 +422,9 @@ 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/)
+ expect(page).to have_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/)
end
end
@@ -371,16 +432,16 @@ 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/)
+ expect(page).to have_element(:button, 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/)
+ expect(page).to have_no_element(:button, id: /\Awork_package_#{work_package.id}_menu_move_to_backlog_bucket\z/)
end
end
@@ -392,12 +453,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(: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
it "keeps the positional moves within its own list", :aggregate_failures do
@@ -435,7 +495,7 @@ def render_component(work_package: self.work_package, open_sprints_exist: true,
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
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 308ef477d023..ea294509fb89 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
@@ -1167,191 +1143,145 @@ 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 }
+
+ 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
- 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}"))
+ 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 }
- it "does not include the other sprint" do
- subject
-
- expect(body).not_to include(%(value="#{other_sprint.id}"))
- end
- 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 "loads card type and policy status in one query each" do
+ recorder = ActiveRecord::QueryRecorder.new { response }
- 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}"))
+ 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
- context "when all=true is in params" do
- let(:params) { { project_id: project.id, id: work_package.id, all: "true" } }
-
- it "embeds the all query in the dialog form action URL" do
- expect(body).to include("all=true")
+ 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"
end
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 a blank id" do
+ let(:ids) { [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) }
+ context "with duplicate ids" do
+ let(:ids) { [second.id, second.id] }
- it "responds with 404" do
- expect(response).to have_http_status :not_found
- end
+ it_behaves_like "rejects invalid dialog ids"
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"
+ context "with an id from another project" do
+ let(:foreign_project) do
+ create(:project, name: "Foreign dialog project", identifier: "foreign-dialog-project")
end
-
- it "includes the project buckets as list_id options" do
- subject
-
- displayed_buckets.each do |bucket|
- expect(body).to include(%(value="#{bucket.id}"))
- end
+ 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 "does not include buckets from other projects" do
- subject
-
- expect(body).not_to include(%(value="#{other_bucket.id}"))
- end
+ it_behaves_like "rejects invalid dialog ids"
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"
+ 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 "excludes the current bucket from the options" do
- subject
-
- expect(body).not_to include(%(value="#{current_bucket.id}"))
+ let(:invisible_project) do
+ create(:project, public: false, name: "Invisible dialog project", identifier: "invisible-dialog-project")
end
-
- it "includes the other project buckets" do
- displayed_buckets.each do |bucket|
- expect(body).to include(%(value="#{bucket.id}"))
- end
+ 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_behaves_like "rejects invalid dialog ids"
end
- context "when all=true is in params" do
- let(:params) { { project_id: project.id, id: work_package.id, all: "true" } }
+ 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 "embeds the all query in the dialog form action URL" do
- expect(body).to include("all=true")
+ 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"
end
end
+ 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] }) }
-
- it "responds with 403" do
- expect(response).to have_http_status :forbidden
- end
+ 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] } }
+
+ subject(:response) { post :move_to_bucket_dialog, params:, format: :turbo_stream }
+
+ it "renders the ordered collection and authoritative buckets" 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_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 "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"
end
end
end
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)
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..8c98b90e7f7e
--- /dev/null
+++ b/modules/backlogs/spec/features/work_packages/batch_destination_menu_spec.rb
@@ -0,0 +1,360 @@
+# 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
+
+ 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.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
+
+ 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
+
+ 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
+
+ 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
+
+ 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.expect_no_work_package_action(first_story, "Move to position")
+ backlogs_page.expect_work_package_action(first_story, "Move to backlog bucket")
+ end
+ end
+
+ 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.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_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
+
+ 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
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
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_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)
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..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
@@ -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)
@@ -410,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|
@@ -452,6 +471,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..30892c96c2ae
--- /dev/null
+++ b/modules/backlogs/spec/services/backlogs/work_packages/destination_availability_spec.rb
@@ -0,0 +1,174 @@
+# 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.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
+ 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.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
+ 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.refusing(Backlogs::Target::InboxId)).not_to be_empty
+ 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.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)
+ recorder = ActiveRecord::QueryRecorder.new do
+ 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"/)
+ 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]).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:) }
+
+ it "rejects completed or otherwise unassignable sprints" do
+ result = availability([work_package])
+
+ expect(result.sprints).not_to include(completed_sprint)
+ 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.candidate?(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.manage_permission?).to be false
+ end
+ end
+end
diff --git a/modules/backlogs/spec/support/pages/backlog.rb b/modules/backlogs/spec/support/pages/backlog.rb
index ecbce1b28d67..227a370c151c 100644
--- a/modules/backlogs/spec/support/pages/backlog.rb
+++ b/modules/backlogs/spec/support/pages/backlog.rb
@@ -743,6 +743,115 @@ 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
+ 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
+ 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
+
# 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.