Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions modules/backend/behaviors/ListController.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ public function makeList($definition = null)
'showTree',
'treeExpanded',
'customViewPath',
'sortable',
];

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

/*
* Drag-and-drop reordering - requires the model to use the Sortable trait.
*/
if (!empty($listConfig->sortable)) {
if (!in_array(\Winter\Storm\Database\Traits\Sortable::class, class_uses_recursive($model))) {
throw new ApplicationException(sprintf(
'To use "sortable" on a list, the model "%s" must use the %s trait.',
get_class($model),
\Winter\Storm\Database\Traits\Sortable::class
));
}

/*
* Drag-and-drop reordering presents every record in a single fixed order, so it
* cannot coexist with features that show a partial or re-ordered view. Reject those
* combinations up front rather than silently producing a wrong order.
*/
$toolbar = $listConfig->toolbar ?? null;
$conflicts = array_keys(array_filter([
'toolbar search' => is_array($toolbar) && !empty($toolbar['search']),
'filter' => $listConfig->filter ?? null,
'recordsPerPage' => $listConfig->recordsPerPage ?? null,
'defaultSort' => $listConfig->defaultSort ?? null,
]));
if ($conflicts) {
throw new ApplicationException(sprintf(
'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.',
implode(', ', $conflicts)
));
}

$widget->bindEvent('list.reorder', function ($ids, $orders) use ($model) {
$model->setSortableOrder($ids, $orders);
});
}

$widget->bindEvent('list.extendColumnsBefore', function () use ($widget) {
$this->controller->listExtendColumnsBefore($widget);
});
Expand Down
136 changes: 136 additions & 0 deletions modules/backend/behaviors/RelationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Form as FormHelper;
use Backend\Classes\ControllerBehavior;
use Winter\Storm\Database\Model;
use Winter\Storm\Database\Models\DeferredBinding;
use ApplicationException;

/**
Expand Down Expand Up @@ -562,6 +563,76 @@ public function relationGetSessionKey($force = false)
return $this->sessionKey = FormHelper::getSessionKey();
}

/**
* Present a sortable relation's records in their stored order while operating in
* deferred mode.
*
* When the relation is deferred, withDeferred() builds the query in "orphan" mode and
* cannot order by (or even surface) the pivot sort column. The sort order therefore has
* to be resolved in PHP from the deferred_bindings pivot_data (which overrides any
* already-committed pivot rows), written onto each record's in-memory pivot, and the
* collection re-sorted. This keeps the Lists widget completely relation-agnostic - it
* just reads the pivot[sort_order] path as usual.
*/
protected function applyDeferredRelationOrder($records)
{
if (!$this->deferredBinding || !$this->model->isSortableRelation($this->relationName)) {
return $records;
}

$column = $this->model->getRelationSortOrderColumn($this->relationName);
$sessionKey = $this->relationGetSessionKey();
$map = [];

/*
* Committed pivot rows (none when the parent record does not yet exist).
*/
if ($this->model->exists) {
$relation = $this->model->{$this->relationName}();
$query = Db::table($relation->getTable())
->where($relation->getForeignPivotKeyName(), $this->model->getKey());

// Constrain morphToMany pivots by the parent morph type.
if (method_exists($relation, 'getMorphType') && method_exists($relation, 'getMorphClass')) {
$query->where($relation->getMorphType(), $relation->getMorphClass());
}

$map = $query
->pluck($column, $relation->getRelatedPivotKeyName())
->map(function ($value) {
return (int) $value;
})
->all();
}

/*
* Deferred "bind" rows override the committed order.
*/
$bindings = DeferredBinding::where('master_type', get_class($this->model))
->where('master_field', $this->relationName)
->where('session_key', $sessionKey)
->where('is_bind', 1)
->get();

foreach ($bindings as $binding) {
$pivotData = $binding->pivot_data ?: [];
if (array_key_exists($column, $pivotData)) {
$map[$binding->slave_id] = (int) $pivotData[$column];
}
}

foreach ($records as $record) {
if (array_key_exists($record->getKey(), $map)) {
$record->pivot->{$column} = $map[$record->getKey()];
}
}

return $records->sortBy(function ($record) use ($column) {
// Deferred records not yet assigned an order sort to the end.
return $record->pivot->{$column} ?? PHP_INT_MAX;
})->values();
Comment thread
LukeTowers marked this conversation as resolved.
}

//
// Widgets
//
Expand Down Expand Up @@ -709,8 +780,62 @@ protected function makeViewWidget()
$config->noRecordsMessage = $emptyMessage;
}

/*
* Drag-and-drop reordering - requires the parent model to use the
* HasSortableRelations trait and to declare this relation in $sortableRelations.
*/
$sortable = $this->getConfig('view[sortable]', false);
if ($sortable) {
if (
!in_array(\Winter\Storm\Database\Traits\HasSortableRelations::class, class_uses_recursive($this->model))
|| !$this->model->isSortableRelation($this->relationName)
) {
throw new ApplicationException(sprintf(
'To use "sortable" on the "%s" relation, the model "%s" must use the %s trait and declare the relation in $sortableRelations.',
$this->relationName,
get_class($this->model),
\Winter\Storm\Database\Traits\HasSortableRelations::class
));
}

/*
* Drag-and-drop reordering presents every record in a single fixed order, so it
* cannot coexist with features that show a partial or re-ordered view. Reject
* those combinations up front rather than silently producing a wrong order.
*/
$conflicts = array_keys(array_filter([
'showSearch' => $this->getConfig('view[showSearch]'),
'filter' => $this->getConfig('view[filter]'),
'recordsPerPage' => $this->getConfig('view[recordsPerPage]'),
'defaultSort' => $this->getConfig('view[defaultSort]'),
]));
if ($conflicts) {
throw new ApplicationException(sprintf(
'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.',
$this->relationName,
implode(', ', $conflicts)
));
}

$config->sortable = true;
}

$widget = $this->makeWidget('Backend\Widgets\Lists', $config);

/*
* Persist reordering and, in deferred mode, present records in their dragged order.
*/
if ($sortable) {
$widget->bindEvent('list.reorder', function ($ids, $orders) {
$sessionKey = $this->deferredBinding ? $this->relationGetSessionKey() : null;
$this->model->setRelationOrder($this->relationName, $ids, $orders, $sessionKey);
});

$widget->bindEvent('list.extendRecords', function ($records) {
return $this->applyDeferredRelationOrder($records);
});
}

/*
* Apply defined constraints
*/
Expand Down Expand Up @@ -756,6 +881,17 @@ protected function makeViewWidget()
|| $this->relationType === 'morphedByMany'
) {
$this->relationObject->setQuery($query->getQuery());

/*
* In deferred mode withDeferred() builds the query in "orphan" mode with no
* pivot join, so the relation's pivot-based order clause is invalid SQL.
* Sortable relations are ordered in PHP instead (applyDeferredRelationOrder()).
*/
if ($sessionKey && $this->getConfig('view[sortable]', false)) {
$query->reorder();
$this->relationObject->reorder();
}

return $this->relationObject;
}
});
Expand Down
1 change: 1 addition & 0 deletions modules/backend/lang/en/lang.php
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@
'loading' => 'Loading...',
'setup_title' => 'List setup',
'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.',
'sort_drag_title' => 'Drag to reorder',
'records_per_page' => 'Records per page',
'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.',
'check' => 'Check',
Expand Down
3 changes: 2 additions & 1 deletion modules/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"monaco-editor": "^0.34.1",
"constrained-editor-plugin": "^1.3.0",
"vue": "^3.2.45",
"@simonwep/pickr": "^1.8.2"
"@simonwep/pickr": "^1.8.2",
"sortablejs": "1.15.7"
},
"devDependencies": {
"eslint": "^8.6.0",
Expand Down
48 changes: 48 additions & 0 deletions modules/backend/tests/fixtures/models/SortableFixture.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace Backend\Tests\Fixtures\Models;

use Illuminate\Support\Facades\Schema;
use Winter\Storm\Database\Model;
use Winter\Storm\Database\Traits\Sortable;

/**
* Self-contained Sortable model fixture for list reordering tests.
*
* Owns its own table so the backend test suite has no dependency on any plugin.
*/
class SortableFixture extends Model
{
use Sortable;

public $table = 'backend_test_sortable_fixtures';

protected $guarded = [];

public $timestamps = false;

/**
* Create the backing table if it does not already exist.
*/
public static function migrateUp(): void
{
if (Schema::hasTable('backend_test_sortable_fixtures')) {
return;
}

Schema::create('backend_test_sortable_fixtures', function ($table) {
$table->increments('id');
$table->string('name')->nullable();
$table->string('label')->nullable();
$table->integer('sort_order')->nullable();
});
}

/**
* Drop the backing table.
*/
public static function migrateDown(): void
{
Schema::dropIfExists('backend_test_sortable_fixtures');
}
}
Loading
Loading