Skip to content

Commit ef6fdb0

Browse files
committed
fix: correct top-drop position to stay at top (#110)
Dragging a card to the very top of a column made it snap to the bottom. All other drop positions worked. Root cause: Filament's shared x-sortable directive wraps SortableJS and re-inserts the dragged node after items[newDraggableIndex - 1] to work around filamentphp/filament#17402. That branch is skipped when newDraggableIndex === 0, so SortableJS's stale DOM leaks through on top drops. flowforge's handleSortableEnd then read neighbors from event.to.sortable.toArray(), which was still in the old order — producing a payload of (afterCardId = old previous, beforeCardId = null), which the backend correctly interprets as "append to bottom". Fix: extend the insertBefore workaround to newDraggableIndex === 0, then derive neighbors from the normalized DOM (querySelectorAll scoped to parentNode) rather than sortable.toArray(). The backend position math was already correct — confirmed by the new feature test that calls moveCard with a realistic multi-card column and asserts the moved card ends up at the top. Closes #110
1 parent 99a4f41 commit ef6fdb0

4 files changed

Lines changed: 62 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## Unreleased
9+
10+
### Fixed
11+
12+
- Dragging a card to the top of a column no longer snaps it to the bottom. Extended the SortableJS top-drop workaround so the DOM is normalized before neighbors are read, and switched to reading them from the DOM directly rather than the stale internal sortable array (#110)
13+
814
## v4.0.9 - 2026-04-10
915

1016
### Fixed

resources/dist/flowforge.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

resources/js/flowforge.js

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,35 +26,49 @@ export default function flowforge({state}) {
2626
},
2727

2828
handleSortableEnd(event) {
29-
const newOrder = event.to.sortable.toArray();
30-
let cardId = event.item.getAttribute('x-sortable-item');
29+
const draggedNode = event.item;
30+
const parentNode = event.to;
31+
const newDraggableIndex = event.newDraggableIndex;
32+
33+
// Filament's shared x-sortable directive re-inserts the dragged node
34+
// after items[newDraggableIndex - 1] to work around filamentphp/filament#17402,
35+
// but that branch is skipped when newDraggableIndex === 0 — leaving the
36+
// DOM stale on top drops. Extend the workaround so the top position is
37+
// normalized before we read neighbors.
38+
if (newDraggableIndex === 0) {
39+
const firstItem = parentNode.querySelector(':scope > [x-sortable-item]');
40+
if (firstItem && firstItem !== draggedNode) {
41+
parentNode.insertBefore(draggedNode, firstItem);
42+
}
43+
}
3144

32-
// Fallback to data-card-id if x-sortable-item is missing (edge case safety)
45+
let cardId = draggedNode.getAttribute('x-sortable-item')
46+
|| draggedNode.getAttribute('data-card-id');
3347
if (!cardId) {
34-
cardId = event.item.getAttribute('data-card-id');
35-
if (!cardId) {
36-
console.error('Flowforge: Could not determine card ID for move operation');
37-
return;
38-
}
48+
console.error('Flowforge: Could not determine card ID for move operation');
49+
return;
3950
}
4051

41-
const targetColumn = event.to.getAttribute('data-column-id');
52+
const targetColumn = parentNode.getAttribute('data-column-id');
4253
if (!targetColumn) {
4354
console.error('Flowforge: Target column ID is missing');
4455
return;
4556
}
4657

47-
const cardElement = event.item;
48-
49-
this.setCardState(cardElement, true);
58+
// Derive neighbors from the normalized DOM rather than sortable.toArray(),
59+
// which can return stale order when SortableJS leaves the DOM out of sync.
60+
const items = parentNode.querySelectorAll(':scope > [x-sortable-item]');
61+
const index = Array.prototype.indexOf.call(items, draggedNode);
62+
const afterCardId = index > 0 ? items[index - 1].getAttribute('x-sortable-item') : null;
63+
const beforeCardId = index >= 0 && index < items.length - 1
64+
? items[index + 1].getAttribute('x-sortable-item')
65+
: null;
5066

51-
const cardIndex = newOrder.indexOf(cardId);
52-
const afterCardId = cardIndex > 0 ? newOrder[cardIndex - 1] : null;
53-
const beforeCardId = cardIndex < newOrder.length - 1 ? newOrder[cardIndex + 1] : null;
67+
this.setCardState(draggedNode, true);
5468

5569
this.$wire.moveCard(cardId, targetColumn, afterCardId, beforeCardId)
56-
.then(() => this.setCardState(cardElement, false))
57-
.catch(() => this.setCardState(cardElement, false));
70+
.then(() => this.setCardState(draggedNode, false))
71+
.catch(() => this.setCardState(draggedNode, false));
5872
},
5973

6074
setCardState(cardElement, disabled) {

tests/Feature/LivewireBoardTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@
6666
->and((float) $movedTask->order_position)->toBeLessThan(65535);
6767
});
6868

69+
test('dragging last card to top places it above first card (regression for #110)', function () {
70+
$first = Task::factory()->inProgress()->withPosition('65535.0000000000')->create(['title' => 'First']);
71+
$second = Task::factory()->inProgress()->withPosition('131070.0000000000')->create(['title' => 'Second']);
72+
$third = Task::factory()->inProgress()->withPosition('196605.0000000000')->create(['title' => 'Third']);
73+
$last = Task::factory()->inProgress()->withPosition('262140.0000000000')->create(['title' => 'Last']);
74+
75+
Livewire::test(TestBoard::class)
76+
->call('moveCard', (string) $last->id, 'in_progress', null, (string) $first->id);
77+
78+
$movedTask = $last->fresh();
79+
expect((float) $movedTask->order_position)->toBeLessThan((float) $first->order_position);
80+
81+
$orderedIds = Task::where('status', 'in_progress')
82+
->orderBy('order_position')
83+
->pluck('id')
84+
->map(fn ($id) => (string) $id)
85+
->toArray();
86+
87+
expect($orderedIds[0])->toBe((string) $last->id)
88+
->and($orderedIds[1])->toBe((string) $first->id)
89+
->and($orderedIds[2])->toBe((string) $second->id)
90+
->and($orderedIds[3])->toBe((string) $third->id);
91+
});
92+
6993
test('moves card to bottom of column', function () {
7094
$existingTask = Task::factory()->inProgress()->withPosition('65535.0000000000')->create();
7195
$taskToMove = Task::factory()->todo()->withPosition('65535.0000000000')->create();

0 commit comments

Comments
 (0)