Skip to content

Commit 1f451cf

Browse files
committed
Keep truncated lists on the stream reload path
A same-list inbox drag was always answered with a 204 (no frame reload), even when the target list's visible window is truncated (Backlogs::InboxComponent renders only a head/tail slice plus a show-more marker row). Trusting the client's optimistic reorder there left the truncated window stale: the moved row could stay visible when it should now hide behind the marker, or a newly-eligible row would never appear. sortable-lists/list-dom.ts gains hasTruncationMarkerRow, checking for the existing sortablePreviousItemIdAttribute marker on a rows container. sortable-lists.controller.ts's handleDrop uses it to decide whether a move is optimistic; buildMoveFormData now only appends the optimistic param when true, so a truncated target list falls back to the same full turbo-stream reload menu moves already use, while the optimistic DOM reorder still happens client-side for responsiveness. Adds a Selenium feature spec covering a same-list drag into a stubbed-small truncated inbox, verifying the visible window and the marker's collapsed-item id are both recomputed after the drag. Also hardens resolveRowsContainer against an invalid rowsContainerSelector value: querySelector can throw a SyntaxError on a malformed selector, which previously propagated instead of falling back to the list element like every other unmatched-selector case. Wraps the lookup in try/catch, warning and returning the list element on failure.
1 parent c385c14 commit 1f451cf

8 files changed

Lines changed: 169 additions & 13 deletions

File tree

frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ describe('Sortable lists controller', () => {
118118
return { root, sourceList, targetList, firstSourceItem: sourceList.querySelector<HTMLElement>('[data-sortable-lists--item-id-value="1"]')! };
119119
}
120120

121+
function truncationMarkerRow(previousItemId = 'hidden-item'):HTMLLIElement {
122+
const row = document.createElement('li');
123+
row.setAttribute('data-sortable-lists-prev-item-id', previousItemId);
124+
return row;
125+
}
126+
121127
function renderScrollableFixture(values = '') {
122128
fixture.innerHTML = `
123129
<div data-controller="sortable-lists" ${values}>
@@ -291,6 +297,29 @@ describe('Sortable lists controller', () => {
291297
expect(options.body.get('prev_id')).toEqual('5');
292298
});
293299

300+
it('marks the move optimistic when the target list has no truncation marker row', async () => {
301+
const { targetList, firstSourceItem } = renderFixture();
302+
303+
await ctx.nextFrame();
304+
await dropCurrentItemOnList(firstSourceItem, targetList);
305+
306+
const options = fetchMock.mock.lastCall?.[1] as { body:FormData };
307+
308+
expect(options.body.get('optimistic')).toEqual('true');
309+
});
310+
311+
it('does not mark the move optimistic when the target list has a truncation marker row', async () => {
312+
const { targetList, firstSourceItem } = renderFixture();
313+
targetList.append(truncationMarkerRow());
314+
315+
await ctx.nextFrame();
316+
await dropCurrentItemOnList(firstSourceItem, targetList);
317+
318+
const options = fetchMock.mock.lastCall?.[1] as { body:FormData };
319+
320+
expect(options.body.get('optimistic')).toBeNull();
321+
});
322+
294323
it('resolves the move url from the template matching the item type', async () => {
295324
const { targetList, firstSourceItem } = renderFixture({
296325
moveUrlTemplates: { work_package: '/projects/demo/backlogs/work_packages/{id}/move' },

frontend/src/stimulus/controllers/dynamic/sortable-lists.controller.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
} from './sortable-lists/drag-and-drop';
4747
import {
4848
captureRowPositions,
49+
hasTruncationMarkerRow,
4950
reorderRows,
5051
resolveSourceRow,
5152
restoreRowPositions,
@@ -150,15 +151,18 @@ export default class SortableListsController extends Controller<HTMLElement> {
150151
return;
151152
}
152153

153-
// Move the row optimistically, then persist. A same-list move is answered
154-
// with 204 and this DOM order is final; a cross-list move gets a
155-
// turbo-stream frame reload that reconciles the list. A failure rolls the
156-
// row back to where it started.
154+
// Move the row optimistically, then persist. A same-list move into a
155+
// non-truncated list is answered with 204 and this DOM order is final; a
156+
// cross-list move, or a same-list move into a truncated list (whose
157+
// visible window is server-computed), gets a turbo-stream frame reload
158+
// that reconciles the list. A failure rolls the row back to where it
159+
// started.
157160
const rows = [sourceRow];
158161
const rollback = captureRowPositions(rows);
159162
reorderRows({ rows, container: intent.rowsContainer, previousItemId: intent.previousItemId });
160163

161-
const result = await this.moveItem({ intent, moveUrl });
164+
const optimistic = !hasTruncationMarkerRow(intent.rowsContainer);
165+
const result = await this.moveItem({ intent, moveUrl, optimistic });
162166

163167
if (result.ok) {
164168
// After moveItem's finally: moving is false again, so listeners resumed
@@ -213,9 +217,17 @@ export default class SortableListsController extends Controller<HTMLElement> {
213217
return /^[a-z][a-z\d+\-.]*:/i.test(moveUrl) ? url.toString() : `${url.pathname}${url.search}${url.hash}`;
214218
}
215219

216-
private async moveItem({ intent, moveUrl }:{ intent:DropIntent; moveUrl:string }):Promise<MoveResult> {
220+
private async moveItem({
221+
intent,
222+
moveUrl,
223+
optimistic,
224+
}:{
225+
intent:DropIntent;
226+
moveUrl:string;
227+
optimistic:boolean;
228+
}):Promise<MoveResult> {
217229
const request = new FetchRequest('put', moveUrl, {
218-
body: buildMoveFormData({ intent, positionMode: this.positionMode }),
230+
body: buildMoveFormData({ intent, positionMode: this.positionMode, optimistic }),
219231
responseKind: 'turbo-stream',
220232
});
221233

frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.spec.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ describe('sortable lists drag and drop helpers', () => {
204204

205205
describe('buildMoveFormData', () => {
206206
it('builds a relative payload from the intent', () => {
207-
const data = buildMoveFormData({ intent: intentFixture(), positionMode: 'relative' });
207+
const data = buildMoveFormData({ intent: intentFixture(), positionMode: 'relative', optimistic: true });
208208
expect(data.get('list_type')).toBe('sprint');
209209
expect(data.get('list_id')).toBe('42');
210210
expect(data.get('prev_id')).toBe('a');
@@ -216,18 +216,28 @@ describe('sortable lists drag and drop helpers', () => {
216216
const data = buildMoveFormData({
217217
intent: intentFixture({ listId: null, previousItemId: null }),
218218
positionMode: 'relative',
219+
optimistic: true,
219220
});
220221
expect(data.get('list_id')).toBe('');
221222
expect(data.get('prev_id')).toBe('');
222223
});
223224

224225
it('builds an absolute payload with a computed position', () => {
225-
const data = buildMoveFormData({ intent: intentFixture({ previousItemId: 'a' }), positionMode: 'absolute' });
226+
const data = buildMoveFormData({
227+
intent: intentFixture({ previousItemId: 'a' }),
228+
positionMode: 'absolute',
229+
optimistic: true,
230+
});
226231
expect(data.get('target_id')).toBe('42');
227232
expect(data.get('position')).toBe('2');
228233
expect(data.get('optimistic')).toBe('true');
229234
expect(data.get('prev_id')).toBeNull();
230235
});
236+
237+
it('omits the optimistic param when the move is not optimistic', () => {
238+
const data = buildMoveFormData({ intent: intentFixture(), positionMode: 'relative', optimistic: false });
239+
expect(data.get('optimistic')).toBeNull();
240+
});
231241
});
232242

233243
// The drop target Pragmatic DnD reports is always the item controller's own

frontend/src/stimulus/controllers/dynamic/sortable-lists/drag-and-drop.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,17 +116,24 @@ export function sortableListData({
116116
export type SortablePositionMode = 'relative'|'absolute';
117117

118118
// One builder for both payload shapes so call sites hand over the whole drop
119-
// intent; every sortable-lists move is optimistic (the row has already been
120-
// reordered in the DOM), which the `optimistic` param signals to the server.
119+
// intent; the row has always already been reordered in the DOM, but the
120+
// server should only treat that reorder as final when `optimistic` is true —
121+
// truncated target lists opt out because the server must re-render the
122+
// visible window (which rows show, the truncation marker's metadata) rather
123+
// than trust the client's optimistic DOM order.
121124
export function buildMoveFormData({
122125
intent,
123126
positionMode,
127+
optimistic,
124128
}:{
125129
intent:DropIntent;
126130
positionMode:SortablePositionMode;
131+
optimistic:boolean;
127132
}):FormData {
128133
const data = new FormData();
129-
data.append('optimistic', 'true');
134+
if (optimistic) {
135+
data.append('optimistic', 'true');
136+
}
130137

131138
if (positionMode === 'absolute') {
132139
data.append('target_id', intent.listData.listId ?? '');

frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@
2626
// See COPYRIGHT and LICENSE files for more details.
2727
//++
2828

29+
import { vi } from 'vitest';
30+
2931
import {
3032
captureRowPositions,
33+
hasTruncationMarkerRow,
3134
reorderRows,
3235
resolveItemElement,
3336
resolveItemPosition,
@@ -202,6 +205,36 @@ describe('sortable lists DOM helpers', () => {
202205
list.setAttribute('data-sortable-lists--list-rows-container-selector-value', ':scope > ul');
203206
expect(resolveRowsContainer(list)).toBe(list);
204207
});
208+
209+
it('falls back to the list element and warns when the selector value is invalid', () => {
210+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
211+
const list = document.createElement('div');
212+
list.setAttribute('data-sortable-lists--list-rows-container-selector-value', '[[');
213+
214+
expect(resolveRowsContainer(list)).toBe(list);
215+
expect(warnSpy).toHaveBeenCalledTimes(1);
216+
const message = warnSpy.mock.calls[0]?.[0] as string;
217+
expect(message).toContain('data-sortable-lists--list-rows-container-selector-value');
218+
expect(message).toContain('[[');
219+
220+
warnSpy.mockRestore();
221+
});
222+
});
223+
224+
describe('hasTruncationMarkerRow', () => {
225+
it('returns false for a container with only item rows', () => {
226+
const container = listElement();
227+
container.append(itemRow('1'), itemRow('2'));
228+
229+
expect(hasTruncationMarkerRow(container)).toBe(false);
230+
});
231+
232+
it('returns true when the container holds a truncation marker row', () => {
233+
const container = listElement();
234+
container.append(itemRow('1'), showMoreRow(), itemRow('2'));
235+
236+
expect(hasTruncationMarkerRow(container)).toBe(true);
237+
});
205238
});
206239

207240
describe('resolveRow', () => {

frontend/src/stimulus/controllers/dynamic/sortable-lists/list-dom.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,21 @@ export function resolveRowsContainer(list:HTMLElement):HTMLElement {
5151
return list;
5252
}
5353

54-
return list.querySelector<HTMLElement>(selector) ?? list;
54+
try {
55+
return list.querySelector<HTMLElement>(selector) ?? list;
56+
} catch (error) {
57+
console.warn(`Invalid ${rowsContainerSelectorAttribute} selector "${selector}" on`, list, error);
58+
return list;
59+
}
60+
}
61+
62+
// Whether a rows container holds a truncated list's "show more" marker row
63+
// (data-sortable-lists-prev-item-id on a non-item row). A truncated list's
64+
// visible window (which rows show, the marker's collapsed-item metadata) is
65+
// server-computed from the full ordering, so an optimistic client-side
66+
// reorder cannot be the final word there — the server must re-render it.
67+
export function hasTruncationMarkerRow(container:Element):boolean {
68+
return container.querySelector(`[${sortablePreviousItemIdAttribute}]`) !== null;
5569
}
5670

5771
// A row is the direct child of the rows container that contains the element.

modules/backlogs/spec/features/work_packages/drag_in_inbox_spec.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,46 @@
141141
end
142142
end
143143

144+
context "when the inbox is truncated" do
145+
# tail_size = [TRUNCATE_MIDDLE / 5, 1].max = 1, so with TRUNCATE_MIDDLE
146+
# stubbed to 2, the visible window is first(2) + last(1); the truncate
147+
# threshold (TRUNCATE_MIDDLE + tail_size * 2 = 4) stays below the 5 work
148+
# packages the outer example group already sets up, so the inbox truncates.
149+
before do
150+
stub_const("Backlogs::InboxComponent::TRUNCATE_MIDDLE", 2)
151+
end
152+
153+
it "keeps the server-rendered truncation window consistent after a same-list drag" do
154+
backlogs_page.visit!
155+
156+
backlogs_page.expect_work_packages_in_inbox_in_order(work_packages: [inbox_wp1, inbox_wp2])
157+
backlogs_page.expect_inbox_item(inbox_wp5)
158+
backlogs_page.expect_no_inbox_item(inbox_wp3)
159+
backlogs_page.expect_no_inbox_item(inbox_wp4)
160+
backlogs_page.expect_inbox_show_more
161+
expect(backlogs_page.inbox_truncation_marker_previous_item_id).to eq(inbox_wp4.id.to_s)
162+
163+
# Same list, but the drop target (the inbox) is truncated: the server
164+
# must recompute the visible window rather than trust the optimistic
165+
# client-side reorder, so this move settles via a turbo-stream frame
166+
# reload rather than the 204/sortable-lists:moved path a plain same-list
167+
# drag takes. `cross_list: true` steers drag_work_package to wait on
168+
# that frame reload (see sortable-lists.controller.ts's `optimistic`
169+
# gate and Backlogs::InboxComponent's TRUNCATE_MIDDLE window).
170+
backlogs_page
171+
.drag_work_package(inbox_wp1, before: inbox_wp5, cross_list: true)
172+
173+
# inbox_wp1 moved behind inbox_wp4 (the item the marker names), pushing
174+
# it out of the visible head and pulling inbox_wp3 into view instead.
175+
backlogs_page.expect_work_packages_in_inbox_in_order(work_packages: [inbox_wp2, inbox_wp3])
176+
backlogs_page.expect_inbox_item(inbox_wp5)
177+
backlogs_page.expect_no_inbox_item(inbox_wp1)
178+
backlogs_page.expect_no_inbox_item(inbox_wp4)
179+
backlogs_page.expect_inbox_show_more
180+
expect(backlogs_page.inbox_truncation_marker_previous_item_id).to eq(inbox_wp1.id.to_s)
181+
end
182+
end
183+
144184
context "when lacking the permission to manage sprint items" do
145185
current_user do
146186
create(:user,

modules/backlogs/spec/support/pages/backlog.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,17 @@ def click_inbox_show_more
167167
wait_for_backlogs_network_idle
168168
end
169169

170+
# The truncation marker row's data-sortable-lists-prev-item-id names the
171+
# last work package collapsed behind the "show more" row (the DOM contract
172+
# sortable-lists relies on to anchor drops next to a hidden block). Reading
173+
# it back lets specs assert the server recomputed the truncated window
174+
# rather than trusting a stale client-side reorder.
175+
def inbox_truncation_marker_previous_item_id
176+
within_backlog_inbox do
177+
find(".op-work-package-card-list--show-more-row", visible: :all)["data-sortable-lists-prev-item-id"]
178+
end
179+
end
180+
170181
def expect_work_packages_in_inbox_in_order(work_packages: [])
171182
within_backlog_inbox do
172183
expect_work_packages_in_order work_packages:

0 commit comments

Comments
 (0)