Skip to content

Commit 66e600f

Browse files
authored
Enhance HasSortableRelations for inline drag-and-drop sorting (#235)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Related: wintercms/winter#1491, wintercms/docs#260, wintercms/winter#1472
1 parent 989e5b4 commit 66e600f

3 files changed

Lines changed: 376 additions & 38 deletions

File tree

src/Database/Traits/HasSortableRelations.php

Lines changed: 100 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
use Exception;
44

5-
use Winter\Sorm\Database\Model;
5+
use Winter\Storm\Database\Model;
6+
use Winter\Storm\Database\Models\DeferredBinding;
67

78
/**
89
* HasSortableRelations trait
@@ -17,7 +18,7 @@
1718
*
1819
* To set orders:
1920
*
20-
* $model->setSortableRelationOrder($relationName, $recordIds, $recordOrders);
21+
* $model->setRelationOrder($relationName, $recordIds, $recordOrders);
2122
*
2223
*/
2324
trait HasSortableRelations
@@ -41,8 +42,20 @@ public function initializeHasSortableRelations() : void
4142
if (array_key_exists($relationName, $sortableRelations)) {
4243
$column = $this->getRelationSortOrderColumn($relationName);
4344

45+
// If the records were attached with an explicit sort order (e.g. when committing
46+
// deferred bindings that already carry a pivot sort order), keep it - otherwise
47+
// auto-append the records to the end of the relation. $data is the list of pivot
48+
// insert rows, one per attached record.
49+
if (is_array($data)) {
50+
foreach ($data as $row) {
51+
if (is_array($row) && isset($row[$column])) {
52+
return;
53+
}
54+
}
55+
}
56+
4457
foreach ($attached as $id) {
45-
$this->updateRelationOrder($relationName, $id, $column);
58+
$this->updateRelationOrder($relationName, $id, $column, null);
4659
}
4760
}
4861
});
@@ -52,39 +65,62 @@ public function initializeHasSortableRelations() : void
5265
if (array_key_exists($relationName, $sortableRelations)) {
5366
$column = $this->getRelationSortOrderColumn($relationName);
5467

55-
$this->updateRelationOrder($relationName, $relatedModel->getKey(), $column);
68+
// No order - auto-append to the end of the relation.
69+
$this->updateRelationOrder($relationName, $relatedModel->getKey(), $column, null);
5670
}
5771
});
5872

5973
foreach ($sortableRelations as $relationName => $column) {
6074
$relationType = $this->getRelationType($relationName);
61-
if (!in_array($relationType, ['belongsToMany', 'morphToMany'])) {
75+
if (!in_array($relationType, ['belongsToMany', 'morphToMany', 'morphedByMany'])) {
6276
continue;
6377
}
6478
$definition = $this->getRelationDefinition($relationName);
65-
$pivot = array_wrap(array_get($definition, 'pivot', []));
6679

80+
// Make sure the sort order column is available as pivot data.
81+
$pivot = array_wrap(array_get($definition, 'pivot', []));
6782
if (!in_array($column, $pivot)) {
68-
// Make sure the sort order column is available as pivot data.
6983
$pivot[] = $column;
7084
$definition['pivot'] = $pivot;
71-
$this->$relationType[$relationName] = $definition;
7285
}
86+
87+
// Auto-add an ordering clause so the relation is returned in sort order by default.
88+
// Qualify with the pivot table name to avoid colliding with a column of the same
89+
// name on the related model (which would silently order by the wrong column).
90+
if (!array_key_exists('order', $definition)) {
91+
$pivotTable = array_get($definition, 'table');
92+
$definition['order'] = ($pivotTable ? $pivotTable . '.' : '') . $column . ' asc';
93+
}
94+
95+
$this->$relationType[$relationName] = $definition;
7396
}
7497
}
7598

7699
/**
77-
* Set the sort order of records to the specified orders. If the orders is
78-
* undefined, the record identifier is used.
100+
* Set the sort order of records to the specified orders. If the orders are
101+
* undefined, a sequential 1..N order is assigned in the given id order.
102+
*
103+
* When a $sessionKey is provided the relation is operating in deferred mode:
104+
* the sort order is written to the pivot_data of the matching deferred_bindings
105+
* records instead of the pivot table. The caller (e.g. RelationController) is
106+
* responsible for passing the sessionKey only when in deferred mode.
79107
*/
80-
public function setRelationOrder(string $relationName, string|int|array $itemIds, array $itemOrders = []) : void
81-
{
108+
public function setRelationOrder(
109+
string $relationName,
110+
string|int|array $itemIds,
111+
array $itemOrders = [],
112+
?string $sessionKey = null
113+
) : void {
82114
if (!is_array($itemIds)) {
83115
$itemIds = [$itemIds];
84116
}
85117

118+
if (empty($itemIds)) {
119+
return;
120+
}
121+
86122
if (empty($itemOrders)) {
87-
$itemOrders = $itemIds;
123+
$itemOrders = range(1, count($itemIds));
88124
}
89125

90126
if (count($itemIds) != count($itemOrders)) {
@@ -93,21 +129,56 @@ public function setRelationOrder(string $relationName, string|int|array $itemIds
93129

94130
$column = $this->getRelationSortOrderColumn($relationName);
95131

132+
/*
133+
* Deferred mode - update pivot_data on the deferred_bindings records.
134+
* Batch the lookup into a single query, then save each binding (each row
135+
* carries its own JSON pivot_data so individual saves are required).
136+
*/
137+
if ($sessionKey) {
138+
$bindings = DeferredBinding::where('master_type', get_class($this))
139+
->where('master_field', $relationName)
140+
->whereIn('slave_id', $itemIds)
141+
->where('session_key', $sessionKey)
142+
->where('is_bind', 1)
143+
->get()
144+
->keyBy('slave_id');
145+
146+
foreach ($itemIds as $index => $id) {
147+
if ($binding = $bindings->get($id)) {
148+
$pivotData = $binding->pivot_data ?: [];
149+
$pivotData[$column] = (int) $itemOrders[$index];
150+
$binding->pivot_data = $pivotData;
151+
$binding->save();
152+
}
153+
}
154+
155+
return;
156+
}
157+
96158
foreach ($itemIds as $index => $id) {
97-
$order = (int)$itemOrders[$index];
98-
$this->updateRelationOrder($relationName, $id, $column, $order);
159+
// Pass the explicit order (which may legitimately be 0) so it is not mistaken
160+
// for the "auto-append" case.
161+
$this->updateRelationOrder($relationName, $id, $column, (int) $itemOrders[$index]);
99162
}
100163
}
101164

102165
/**
103-
* Update relation record sort_order.
166+
* Update relation record sort_order. A null $order auto-appends the record to the end
167+
* of the relation; an explicit integer (including 0) is written as-is.
104168
*/
105-
protected function updateRelationOrder(string $relationName, string|int|Model $id, string $column, int $order = 0) : void
169+
protected function updateRelationOrder(string $relationName, string|int|Model $id, string $column, ?int $order = null) : void
106170
{
107171
$relation = $this->{$relationName}();
108172

109-
if (!$order) {
110-
$order = $relation->count();
173+
if ($order === null) {
174+
// Append to the end of the relation. For pivot-based relations use the pivot
175+
// query directly so a defined "order" clause on the relation cannot interfere
176+
// with the aggregate, and so non-contiguous sort orders are handled correctly.
177+
if (method_exists($relation, 'newPivotQuery')) {
178+
$order = ((int) $relation->newPivotQuery()->max($column)) + 1;
179+
} else {
180+
$order = $relation->count() + 1;
181+
}
111182
}
112183
if (method_exists($relation, 'updateExistingPivot')) {
113184
$relation->updateExistingPivot($id, [ $column => (int)$order ]);
@@ -123,7 +194,15 @@ protected function updateRelationOrder(string $relationName, string|int|Model $i
123194
}
124195

125196
/**
126-
* Get the name of the "sort_order" column.
197+
* Returns true if the given relation is configured as a sortable relation.
198+
*/
199+
public function isSortableRelation(string $relationName) : bool
200+
{
201+
return array_key_exists($relationName, $this->getSortableRelations());
202+
}
203+
204+
/**
205+
* Get the name of the "sort_order" column for the given relation.
127206
*/
128207
public function getRelationSortOrderColumn(string $relationName) : string
129208
{
@@ -133,7 +212,7 @@ public function getRelationSortOrderColumn(string $relationName) : string
133212
/**
134213
* Return all configured sortable relations.
135214
*/
136-
protected function getSortableRelations() : array
215+
public function getSortableRelations() : array
137216
{
138217
if (property_exists($this, 'sortableRelations')) {
139218
return $this->sortableRelations;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace Winter\Storm\Tests\Database\Fixtures;
4+
5+
use Illuminate\Database\Schema\Builder;
6+
use Winter\Storm\Database\Model;
7+
use Winter\Storm\Database\Traits\HasSortableRelations;
8+
9+
class SortableArticle extends Model
10+
{
11+
use MigratesForTesting;
12+
use HasSortableRelations;
13+
14+
/**
15+
* @var string The database table used by the model.
16+
*/
17+
public $table = 'database_tester_sortable_articles';
18+
19+
/**
20+
* @var array Guarded fields
21+
*/
22+
protected $guarded = [];
23+
24+
/**
25+
* @var array Sortable relations and their sort order pivot column.
26+
*/
27+
public $sortableRelations = [
28+
'authors' => 'sort_order',
29+
];
30+
31+
/**
32+
* @var array Relations
33+
*/
34+
public $belongsToMany = [
35+
'authors' => [
36+
Author::class,
37+
'table' => 'database_tester_sortable_article_author',
38+
'key' => 'article_id',
39+
'otherKey' => 'author_id',
40+
],
41+
];
42+
43+
public static function migrateUp(Builder $builder): void
44+
{
45+
if ($builder->hasTable('database_tester_sortable_articles')) {
46+
return;
47+
}
48+
49+
$builder->create('database_tester_sortable_articles', function ($table) {
50+
$table->engine = 'InnoDB';
51+
$table->increments('id');
52+
$table->string('title')->nullable();
53+
$table->timestamps();
54+
});
55+
56+
$builder->create('database_tester_sortable_article_author', function ($table) {
57+
$table->engine = 'InnoDB';
58+
$table->integer('article_id')->unsigned();
59+
$table->integer('author_id')->unsigned();
60+
$table->integer('sort_order')->default(0);
61+
$table->primary(['article_id', 'author_id']);
62+
});
63+
}
64+
65+
public static function migrateDown(Builder $builder): void
66+
{
67+
if (!$builder->hasTable('database_tester_sortable_articles')) {
68+
return;
69+
}
70+
71+
$builder->dropIfExists('database_tester_sortable_article_author');
72+
$builder->dropIfExists('database_tester_sortable_articles');
73+
}
74+
}

0 commit comments

Comments
 (0)