Skip to content

Commit 252f42c

Browse files
LukeTowersclaude
andcommitted
Add inline drag-and-drop sorting for lists and relations
Implements inline reordering of records directly in a List widget and in RelationController relation lists, closing a longstanding request (#1472). Requires the companion Storm changes (wintercms/storm#235). Lists widget: - New `sortable` / `sortOrderColumn` config. When enabled, a drag-handle column is shown, column-header sorting is disabled (every column is forced non-sortable so `getSortColumn()` returns false and the model / relation's own order is preserved), and pagination is disabled so every record is shown in order. - `onReorder()` AJAX handler validates that submitted record ids are within the current query scope, then fires a `list.reorder` event. - `getRecordSortOrder()` reads the sort value from a record via the configured column path (e.g. `sort_order` or `pivot[sort_order]`). ListController: - `sortable: true` in the list config validates the model uses the Sortable trait and binds `list.reorder` to `setSortableOrder()`. RelationController: - `view[sortable]: true` validates the parent uses HasSortableRelations and declares the relation, then binds `list.reorder` to `setRelationOrder()` (passing the session key in deferred mode). - In deferred mode `withDeferred()` builds the query in orphan mode with no pivot join, so the pivot order clause is stripped and the records are ordered in PHP from `deferred_bindings.pivot_data` (overlaid on any committed pivot rows) via a `list.extendRecords` binding. The Lists widget stays relation-agnostic. Assets: minimal, additive SortableJS initializer (the existing jQuery list widget is untouched) plus styles; SortableJS is vendored at `js/lib/sortable.min.js` and added to package.json. Tests: ListsSortableTest covers the sortable widget config, drag-handle column, `getRecordSortOrder` (direct and pivot paths), and `onReorder` event firing + query-scope validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 851434a commit 252f42c

12 files changed

Lines changed: 574 additions & 3 deletions

File tree

modules/backend/behaviors/ListController.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ public function makeList($definition = null)
153153
'showTree',
154154
'treeExpanded',
155155
'customViewPath',
156+
'sortable',
156157
];
157158

158159
foreach ($configFieldsToTransfer as $field) {
@@ -166,6 +167,25 @@ public function makeList($definition = null)
166167
*/
167168
$widget = $this->makeWidget(\Backend\Widgets\Lists::class, $columnConfig);
168169

170+
/*
171+
* Drag-and-drop reordering - requires the model to use the Sortable trait.
172+
*/
173+
if (!empty($listConfig->sortable)) {
174+
if (!in_array(\Winter\Storm\Database\Traits\Sortable::class, class_uses_recursive($model))) {
175+
throw new ApplicationException(sprintf(
176+
'To use "sortable" on a list, the model "%s" must use the %s trait.',
177+
get_class($model),
178+
\Winter\Storm\Database\Traits\Sortable::class
179+
));
180+
}
181+
182+
$widget->sortOrderColumn = $model->getSortOrderColumn();
183+
184+
$widget->bindEvent('list.reorder', function ($ids, $orders) use ($model) {
185+
$model->setSortableOrder($ids, $orders);
186+
});
187+
}
188+
169189
$widget->bindEvent('list.extendColumnsBefore', function () use ($widget) {
170190
$this->controller->listExtendColumnsBefore($widget);
171191
});

modules/backend/behaviors/RelationController.php

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Form as FormHelper;
77
use Backend\Classes\ControllerBehavior;
88
use Winter\Storm\Database\Model;
9+
use Winter\Storm\Database\Models\DeferredBinding;
910
use ApplicationException;
1011

1112
/**
@@ -562,6 +563,75 @@ public function relationGetSessionKey($force = false)
562563
return $this->sessionKey = FormHelper::getSessionKey();
563564
}
564565

566+
/**
567+
* Present a sortable relation's records in their stored order while operating in
568+
* deferred mode.
569+
*
570+
* When the relation is deferred, withDeferred() builds the query in "orphan" mode and
571+
* cannot order by (or even surface) the pivot sort column. The sort order therefore has
572+
* to be resolved in PHP from the deferred_bindings pivot_data (which overrides any
573+
* already-committed pivot rows), written onto each record's in-memory pivot, and the
574+
* collection re-sorted. This keeps the Lists widget completely relation-agnostic - it
575+
* just reads the pivot[sort_order] path as usual.
576+
*/
577+
protected function applyDeferredRelationOrder($records)
578+
{
579+
if (!$this->deferredBinding || !$this->model->isSortableRelation($this->relationName)) {
580+
return $records;
581+
}
582+
583+
$column = $this->model->getRelationSortOrderColumn($this->relationName);
584+
$sessionKey = $this->relationGetSessionKey();
585+
$map = [];
586+
587+
/*
588+
* Committed pivot rows (none when the parent record does not yet exist).
589+
*/
590+
if ($this->model->exists) {
591+
$relation = $this->model->{$this->relationName}();
592+
$query = Db::table($relation->getTable())
593+
->where($relation->getForeignPivotKeyName(), $this->model->getKey());
594+
595+
// Constrain morphToMany pivots by the parent morph type.
596+
if (method_exists($relation, 'getMorphType') && method_exists($relation, 'getMorphClass')) {
597+
$query->where($relation->getMorphType(), $relation->getMorphClass());
598+
}
599+
600+
$map = $query
601+
->pluck($column, $relation->getRelatedPivotKeyName())
602+
->map(function ($value) {
603+
return (int) $value;
604+
})
605+
->all();
606+
}
607+
608+
/*
609+
* Deferred "bind" rows override the committed order.
610+
*/
611+
$bindings = DeferredBinding::where('master_type', get_class($this->model))
612+
->where('master_field', $this->relationName)
613+
->where('session_key', $sessionKey)
614+
->where('is_bind', 1)
615+
->get();
616+
617+
foreach ($bindings as $binding) {
618+
$pivotData = $binding->pivot_data ?: [];
619+
if (array_key_exists($column, $pivotData)) {
620+
$map[$binding->slave_id] = (int) $pivotData[$column];
621+
}
622+
}
623+
624+
foreach ($records as $record) {
625+
if ($record->pivot && array_key_exists($record->getKey(), $map)) {
626+
$record->pivot->{$column} = $map[$record->getKey()];
627+
}
628+
}
629+
630+
return $records->sortBy(function ($record) use ($column) {
631+
return $record->pivot->{$column} ?? PHP_INT_MAX;
632+
})->values();
633+
}
634+
565635
//
566636
// Widgets
567637
//
@@ -709,8 +779,44 @@ protected function makeViewWidget()
709779
$config->noRecordsMessage = $emptyMessage;
710780
}
711781

782+
/*
783+
* Drag-and-drop reordering - requires the parent model to use the
784+
* HasSortableRelations trait and to declare this relation in $sortableRelations.
785+
*/
786+
$sortable = $this->getConfig('view[sortable]', false);
787+
if ($sortable) {
788+
if (
789+
!in_array(\Winter\Storm\Database\Traits\HasSortableRelations::class, class_uses_recursive($this->model))
790+
|| !$this->model->isSortableRelation($this->relationName)
791+
) {
792+
throw new ApplicationException(sprintf(
793+
'To use "sortable" on the "%s" relation, the model "%s" must use the %s trait and declare the relation in $sortableRelations.',
794+
$this->relationName,
795+
get_class($this->model),
796+
\Winter\Storm\Database\Traits\HasSortableRelations::class
797+
));
798+
}
799+
800+
$config->sortable = true;
801+
$config->sortOrderColumn = 'pivot[' . $this->model->getRelationSortOrderColumn($this->relationName) . ']';
802+
}
803+
712804
$widget = $this->makeWidget('Backend\Widgets\Lists', $config);
713805

806+
/*
807+
* Persist reordering and, in deferred mode, present records in their dragged order.
808+
*/
809+
if ($sortable) {
810+
$widget->bindEvent('list.reorder', function ($ids, $orders) {
811+
$sessionKey = $this->deferredBinding ? $this->relationGetSessionKey() : null;
812+
$this->model->setRelationOrder($this->relationName, $ids, $orders, $sessionKey);
813+
});
814+
815+
$widget->bindEvent('list.extendRecords', function ($records) {
816+
return $this->applyDeferredRelationOrder($records);
817+
});
818+
}
819+
714820
/*
715821
* Apply defined constraints
716822
*/
@@ -756,6 +862,17 @@ protected function makeViewWidget()
756862
|| $this->relationType === 'morphedByMany'
757863
) {
758864
$this->relationObject->setQuery($query->getQuery());
865+
866+
/*
867+
* In deferred mode withDeferred() builds the query in "orphan" mode with no
868+
* pivot join, so the relation's pivot-based order clause is invalid SQL.
869+
* Sortable relations are ordered in PHP instead (applyDeferredRelationOrder()).
870+
*/
871+
if ($sessionKey && $this->getConfig('view[sortable]', false)) {
872+
$query->reorder();
873+
$this->relationObject->reorder();
874+
}
875+
759876
return $this->relationObject;
760877
}
761878
});

modules/backend/lang/en/lang.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@
236236
'loading' => 'Loading...',
237237
'setup_title' => 'List setup',
238238
'setup_help' => 'Use checkboxes to select columns you want to see in the list. You can change position of columns by dragging them up or down.',
239+
'sort_drag_title' => 'Drag to reorder',
239240
'records_per_page' => 'Records per page',
240241
'records_per_page_help' => 'Select the number of records per page to display. Please note that high number of records on a single page can reduce performance.',
241242
'check' => 'Check',

modules/backend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"monaco-editor": "^0.34.1",
3030
"constrained-editor-plugin": "^1.3.0",
3131
"vue": "^3.2.45",
32-
"@simonwep/pickr": "^1.8.2"
32+
"@simonwep/pickr": "^1.8.2",
33+
"sortablejs": "^1.15.7"
3334
},
3435
"devDependencies": {
3536
"eslint": "^8.6.0",
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php
2+
3+
namespace Backend\Tests\Widgets;
4+
5+
use System\Tests\Bootstrap\PluginTestCase;
6+
use Winter\Storm\Exception\ApplicationException;
7+
use Backend\Tests\Fixtures\Models\UserFixture;
8+
use Backend\Widgets\Lists;
9+
use Illuminate\Http\Request as HttpRequest;
10+
use Illuminate\Support\Facades\Schema;
11+
use Winter\Test\Models\Attribute;
12+
13+
class ListsSortableTest extends PluginTestCase
14+
{
15+
public function setUp(): void
16+
{
17+
parent::setUp();
18+
19+
if (!Schema::hasTable('winter_test_attributes')) {
20+
Schema::create('winter_test_attributes', function ($table) {
21+
$table->increments('id');
22+
$table->string('type')->nullable();
23+
$table->string('name')->nullable();
24+
$table->string('label')->nullable();
25+
$table->string('code')->nullable();
26+
$table->boolean('is_default')->default(false);
27+
$table->integer('sort_order')->nullable();
28+
$table->timestamps();
29+
});
30+
}
31+
32+
$this->actingAs((new UserFixture)->asSuperUser());
33+
}
34+
35+
protected function makeList(array $overrides = []): Lists
36+
{
37+
return new Lists(null, array_merge([
38+
'model' => new Attribute,
39+
'alias' => 'testlist',
40+
'arrayName' => 'array',
41+
'sortable' => true,
42+
'sortOrderColumn' => 'sort_order',
43+
'columns' => [
44+
'name' => ['type' => 'text', 'label' => 'Name'],
45+
'label' => ['type' => 'text', 'label' => 'Label'],
46+
],
47+
], $overrides));
48+
}
49+
50+
protected function seedAttributes(): array
51+
{
52+
$records = [];
53+
foreach (['Alpha', 'Bravo', 'Charlie'] as $i => $name) {
54+
$records[] = Attribute::create([
55+
'type' => 'general.type',
56+
'name' => strtolower($name),
57+
'label' => $name,
58+
'sort_order' => $i + 1,
59+
]);
60+
}
61+
return $records;
62+
}
63+
64+
protected function postRequest(array $data): void
65+
{
66+
$request = HttpRequest::create('/', 'POST', $data);
67+
$this->app->instance('request', $request);
68+
\Request::swap($request);
69+
}
70+
71+
public function testSortableDisablesPaginationAndColumnSorting()
72+
{
73+
$list = $this->makeList();
74+
$list->render();
75+
76+
$this->assertFalse($list->showPagination);
77+
// With every column forced non-sortable, no sort column is resolved.
78+
$this->assertFalse($list->getSortColumn());
79+
80+
foreach ($list->getColumns() as $column) {
81+
$this->assertFalse($column->sortable, "Column {$column->columnName} should not be sortable");
82+
}
83+
}
84+
85+
public function testSortableAddsDragHandleToColumnTotal()
86+
{
87+
$sortable = $this->makeList();
88+
$plain = $this->makeList(['sortable' => false]);
89+
90+
$method = new \ReflectionMethod(Lists::class, 'getTotalColumns');
91+
$method->setAccessible(true);
92+
93+
$this->assertSame(
94+
$method->invoke($plain) + 1,
95+
$method->invoke($sortable),
96+
'Sortable list should reserve one extra column for the drag handle'
97+
);
98+
}
99+
100+
public function testGetRecordSortOrderReadsDirectColumn()
101+
{
102+
$list = $this->makeList();
103+
$record = new Attribute(['sort_order' => 7]);
104+
105+
$this->assertSame(7, (int) $list->getRecordSortOrder($record));
106+
}
107+
108+
public function testGetRecordSortOrderReadsPivotPath()
109+
{
110+
$list = $this->makeList(['sortOrderColumn' => 'pivot[sort_order]']);
111+
112+
$record = new \stdClass();
113+
$record->pivot = new \stdClass();
114+
$record->pivot->sort_order = 4;
115+
116+
$this->assertSame(4, (int) $list->getRecordSortOrder($record));
117+
}
118+
119+
public function testGetRecordSortOrderReturnsNullWhenMissing()
120+
{
121+
$list = $this->makeList(['sortOrderColumn' => 'pivot[sort_order]']);
122+
$record = new Attribute(['sort_order' => 7]); // no pivot relation
123+
124+
$this->assertNull($list->getRecordSortOrder($record));
125+
}
126+
127+
public function testOnReorderFiresEventWithIdsAndOrders()
128+
{
129+
$records = $this->seedAttributes();
130+
$ids = [$records[2]->id, $records[0]->id, $records[1]->id];
131+
132+
$list = $this->makeList();
133+
134+
$captured = null;
135+
$list->bindEvent('list.reorder', function ($eventIds, $eventOrders) use (&$captured) {
136+
$captured = [$eventIds, $eventOrders];
137+
});
138+
139+
$this->postRequest(['record_ids' => $ids, 'sort_orders' => [1, 2, 3]]);
140+
$list->onReorder();
141+
142+
$this->assertNotNull($captured, 'list.reorder event should have fired');
143+
$this->assertSame(array_map('strval', $ids), array_map('strval', $captured[0]));
144+
$this->assertSame([1, 2, 3], $captured[1]);
145+
}
146+
147+
public function testOnReorderRejectsRecordsOutsideQueryScope()
148+
{
149+
$records = $this->seedAttributes();
150+
151+
$list = $this->makeList();
152+
153+
// 99999 is not a seeded record id.
154+
$this->postRequest(['record_ids' => [$records[0]->id, 99999], 'sort_orders' => [1, 2]]);
155+
156+
$this->expectException(ApplicationException::class);
157+
$list->onReorder();
158+
}
159+
160+
public function testOnReorderThrowsWhenNotSortable()
161+
{
162+
$list = $this->makeList(['sortable' => false]);
163+
$this->postRequest(['record_ids' => [1], 'sort_orders' => [1]]);
164+
165+
$this->expectException(ApplicationException::class);
166+
$list->onReorder();
167+
}
168+
}

0 commit comments

Comments
 (0)