Skip to content

Commit 8cd68a5

Browse files
authored
Add inline drag-and-drop sorting for lists and relations (#1491)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Closes: wintercms/winter#1472 Related: wintercms/storm#235, wintercms/docs#260
1 parent edd0cd8 commit 8cd68a5

14 files changed

Lines changed: 627 additions & 3 deletions

File tree

behaviors/ListController.php

Lines changed: 37 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,42 @@ 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+
/*
183+
* Drag-and-drop reordering presents every record in a single fixed order, so it
184+
* cannot coexist with features that show a partial or re-ordered view. Reject those
185+
* combinations up front rather than silently producing a wrong order.
186+
*/
187+
$toolbar = $listConfig->toolbar ?? null;
188+
$conflicts = array_keys(array_filter([
189+
'toolbar search' => is_array($toolbar) && !empty($toolbar['search']),
190+
'filter' => $listConfig->filter ?? null,
191+
'recordsPerPage' => $listConfig->recordsPerPage ?? null,
192+
'defaultSort' => $listConfig->defaultSort ?? null,
193+
]));
194+
if ($conflicts) {
195+
throw new ApplicationException(sprintf(
196+
'A "sortable" list cannot also use: %s. Drag-and-drop reordering requires the whole list in a fixed order. Remove these options, or use the ReorderController for a dedicated reordering page.',
197+
implode(', ', $conflicts)
198+
));
199+
}
200+
201+
$widget->bindEvent('list.reorder', function ($ids, $orders) use ($model) {
202+
$model->setSortableOrder($ids, $orders);
203+
});
204+
}
205+
169206
$widget->bindEvent('list.extendColumnsBefore', function () use ($widget) {
170207
$this->controller->listExtendColumnsBefore($widget);
171208
});

behaviors/RelationController.php

Lines changed: 136 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,76 @@ 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 (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+
// Deferred records not yet assigned an order sort to the end.
632+
return $record->pivot->{$column} ?? PHP_INT_MAX;
633+
})->values();
634+
}
635+
565636
//
566637
// Widgets
567638
//
@@ -709,8 +780,62 @@ protected function makeViewWidget()
709780
$config->noRecordsMessage = $emptyMessage;
710781
}
711782

783+
/*
784+
* Drag-and-drop reordering - requires the parent model to use the
785+
* HasSortableRelations trait and to declare this relation in $sortableRelations.
786+
*/
787+
$sortable = $this->getConfig('view[sortable]', false);
788+
if ($sortable) {
789+
if (
790+
!in_array(\Winter\Storm\Database\Traits\HasSortableRelations::class, class_uses_recursive($this->model))
791+
|| !$this->model->isSortableRelation($this->relationName)
792+
) {
793+
throw new ApplicationException(sprintf(
794+
'To use "sortable" on the "%s" relation, the model "%s" must use the %s trait and declare the relation in $sortableRelations.',
795+
$this->relationName,
796+
get_class($this->model),
797+
\Winter\Storm\Database\Traits\HasSortableRelations::class
798+
));
799+
}
800+
801+
/*
802+
* Drag-and-drop reordering presents every record in a single fixed order, so it
803+
* cannot coexist with features that show a partial or re-ordered view. Reject
804+
* those combinations up front rather than silently producing a wrong order.
805+
*/
806+
$conflicts = array_keys(array_filter([
807+
'showSearch' => $this->getConfig('view[showSearch]'),
808+
'filter' => $this->getConfig('view[filter]'),
809+
'recordsPerPage' => $this->getConfig('view[recordsPerPage]'),
810+
'defaultSort' => $this->getConfig('view[defaultSort]'),
811+
]));
812+
if ($conflicts) {
813+
throw new ApplicationException(sprintf(
814+
'The "%s" relation cannot combine "sortable" with: %s. Drag-and-drop reordering requires the whole relation in a fixed order. Remove these options, or use the ReorderController for a dedicated reordering page.',
815+
$this->relationName,
816+
implode(', ', $conflicts)
817+
));
818+
}
819+
820+
$config->sortable = true;
821+
}
822+
712823
$widget = $this->makeWidget('Backend\Widgets\Lists', $config);
713824

825+
/*
826+
* Persist reordering and, in deferred mode, present records in their dragged order.
827+
*/
828+
if ($sortable) {
829+
$widget->bindEvent('list.reorder', function ($ids, $orders) {
830+
$sessionKey = $this->deferredBinding ? $this->relationGetSessionKey() : null;
831+
$this->model->setRelationOrder($this->relationName, $ids, $orders, $sessionKey);
832+
});
833+
834+
$widget->bindEvent('list.extendRecords', function ($records) {
835+
return $this->applyDeferredRelationOrder($records);
836+
});
837+
}
838+
714839
/*
715840
* Apply defined constraints
716841
*/
@@ -756,6 +881,17 @@ protected function makeViewWidget()
756881
|| $this->relationType === 'morphedByMany'
757882
) {
758883
$this->relationObject->setQuery($query->getQuery());
884+
885+
/*
886+
* In deferred mode withDeferred() builds the query in "orphan" mode with no
887+
* pivot join, so the relation's pivot-based order clause is invalid SQL.
888+
* Sortable relations are ordered in PHP instead (applyDeferredRelationOrder()).
889+
*/
890+
if ($sessionKey && $this->getConfig('view[sortable]', false)) {
891+
$query->reorder();
892+
$this->relationObject->reorder();
893+
}
894+
759895
return $this->relationObject;
760896
}
761897
});

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',

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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace Backend\Tests\Fixtures\Models;
4+
5+
use Illuminate\Support\Facades\Schema;
6+
use Winter\Storm\Database\Model;
7+
use Winter\Storm\Database\Traits\Sortable;
8+
9+
/**
10+
* Self-contained Sortable model fixture for list reordering tests.
11+
*
12+
* Owns its own table so the backend test suite has no dependency on any plugin.
13+
*/
14+
class SortableFixture extends Model
15+
{
16+
use Sortable;
17+
18+
public $table = 'backend_test_sortable_fixtures';
19+
20+
protected $guarded = [];
21+
22+
public $timestamps = false;
23+
24+
/**
25+
* Create the backing table if it does not already exist.
26+
*/
27+
public static function migrateUp(): void
28+
{
29+
if (Schema::hasTable('backend_test_sortable_fixtures')) {
30+
return;
31+
}
32+
33+
Schema::create('backend_test_sortable_fixtures', function ($table) {
34+
$table->increments('id');
35+
$table->string('name')->nullable();
36+
$table->string('label')->nullable();
37+
$table->integer('sort_order')->nullable();
38+
});
39+
}
40+
41+
/**
42+
* Drop the backing table.
43+
*/
44+
public static function migrateDown(): void
45+
{
46+
Schema::dropIfExists('backend_test_sortable_fixtures');
47+
}
48+
}

0 commit comments

Comments
 (0)