From e4743a8f949f90589412a0ab039ddf5cb531c92b Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Thu, 18 Jun 2026 19:06:10 -0600 Subject: [PATCH 1/8] 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 (wintercms/winter#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) --- modules/backend/behaviors/ListController.php | 20 +++ .../backend/behaviors/RelationController.php | 117 +++++++++++++ modules/backend/lang/en/lang.php | 1 + modules/backend/package.json | 3 +- .../tests/fixtures/models/SortableFixture.php | 48 ++++++ .../tests/widgets/ListsSortableTest.php | 162 ++++++++++++++++++ modules/backend/widgets/Lists.php | 130 ++++++++++++++ .../lists/assets/css/winter.list.sortable.css | 41 +++++ .../lists/assets/js/lib/sortable.min.js | 2 + .../lists/assets/js/winter.list.sortable.js | 71 ++++++++ .../backend/widgets/lists/partials/_list.php | 7 +- .../widgets/lists/partials/_list_body_row.php | 13 +- .../widgets/lists/partials/_list_head_row.php | 4 + 13 files changed, 616 insertions(+), 3 deletions(-) create mode 100644 modules/backend/tests/fixtures/models/SortableFixture.php create mode 100644 modules/backend/tests/widgets/ListsSortableTest.php create mode 100644 modules/backend/widgets/lists/assets/css/winter.list.sortable.css create mode 100644 modules/backend/widgets/lists/assets/js/lib/sortable.min.js create mode 100644 modules/backend/widgets/lists/assets/js/winter.list.sortable.js diff --git a/modules/backend/behaviors/ListController.php b/modules/backend/behaviors/ListController.php index 292a2da686..1cc6541b88 100644 --- a/modules/backend/behaviors/ListController.php +++ b/modules/backend/behaviors/ListController.php @@ -153,6 +153,7 @@ public function makeList($definition = null) 'showTree', 'treeExpanded', 'customViewPath', + 'sortable', ]; foreach ($configFieldsToTransfer as $field) { @@ -166,6 +167,25 @@ 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 + )); + } + + $widget->sortOrderColumn = $model->getSortOrderColumn(); + + $widget->bindEvent('list.reorder', function ($ids, $orders) use ($model) { + $model->setSortableOrder($ids, $orders); + }); + } + $widget->bindEvent('list.extendColumnsBefore', function () use ($widget) { $this->controller->listExtendColumnsBefore($widget); }); diff --git a/modules/backend/behaviors/RelationController.php b/modules/backend/behaviors/RelationController.php index 293051679c..1b08ca7734 100644 --- a/modules/backend/behaviors/RelationController.php +++ b/modules/backend/behaviors/RelationController.php @@ -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; /** @@ -562,6 +563,75 @@ 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 ($record->pivot && array_key_exists($record->getKey(), $map)) { + $record->pivot->{$column} = $map[$record->getKey()]; + } + } + + return $records->sortBy(function ($record) use ($column) { + return $record->pivot->{$column} ?? PHP_INT_MAX; + })->values(); + } + // // Widgets // @@ -709,8 +779,44 @@ 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 + )); + } + + $config->sortable = true; + $config->sortOrderColumn = 'pivot[' . $this->model->getRelationSortOrderColumn($this->relationName) . ']'; + } + $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 */ @@ -756,6 +862,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; } }); diff --git a/modules/backend/lang/en/lang.php b/modules/backend/lang/en/lang.php index d56c5f5153..38983c276e 100644 --- a/modules/backend/lang/en/lang.php +++ b/modules/backend/lang/en/lang.php @@ -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', diff --git a/modules/backend/package.json b/modules/backend/package.json index 63c3651237..35f4492707 100644 --- a/modules/backend/package.json +++ b/modules/backend/package.json @@ -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", diff --git a/modules/backend/tests/fixtures/models/SortableFixture.php b/modules/backend/tests/fixtures/models/SortableFixture.php new file mode 100644 index 0000000000..d2811aba43 --- /dev/null +++ b/modules/backend/tests/fixtures/models/SortableFixture.php @@ -0,0 +1,48 @@ +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'); + } +} diff --git a/modules/backend/tests/widgets/ListsSortableTest.php b/modules/backend/tests/widgets/ListsSortableTest.php new file mode 100644 index 0000000000..8687b3f877 --- /dev/null +++ b/modules/backend/tests/widgets/ListsSortableTest.php @@ -0,0 +1,162 @@ +actingAs((new UserFixture)->asSuperUser()); + } + + public function tearDown(): void + { + SortableFixture::migrateDown(); + + parent::tearDown(); + } + + protected function makeList(array $overrides = []): Lists + { + return new Lists(null, array_merge([ + 'model' => new SortableFixture, + 'alias' => 'testlist', + 'arrayName' => 'array', + 'sortable' => true, + 'sortOrderColumn' => 'sort_order', + 'columns' => [ + 'name' => ['type' => 'text', 'label' => 'Name'], + 'label' => ['type' => 'text', 'label' => 'Label'], + ], + ], $overrides)); + } + + protected function seedRecords(): array + { + $records = []; + foreach (['Alpha', 'Bravo', 'Charlie'] as $i => $name) { + $records[] = SortableFixture::create([ + 'name' => strtolower($name), + 'label' => $name, + 'sort_order' => $i + 1, + ]); + } + return $records; + } + + protected function postRequest(array $data): void + { + $request = HttpRequest::create('/', 'POST', $data); + $this->app->instance('request', $request); + \Request::swap($request); + } + + public function testSortableDisablesPaginationAndColumnSorting() + { + $list = $this->makeList(); + $list->render(); + + $this->assertFalse($list->showPagination); + // With every column forced non-sortable, no sort column is resolved. + $this->assertFalse($list->getSortColumn()); + + foreach ($list->getColumns() as $column) { + $this->assertFalse($column->sortable, "Column {$column->columnName} should not be sortable"); + } + } + + public function testSortableAddsDragHandleToColumnTotal() + { + $sortable = $this->makeList(); + $plain = $this->makeList(['sortable' => false]); + + $method = new \ReflectionMethod(Lists::class, 'getTotalColumns'); + $method->setAccessible(true); + + $this->assertSame( + $method->invoke($plain) + 1, + $method->invoke($sortable), + 'Sortable list should reserve one extra column for the drag handle' + ); + } + + public function testGetRecordSortOrderReadsDirectColumn() + { + $list = $this->makeList(); + $record = new SortableFixture(['sort_order' => 7]); + + $this->assertSame(7, (int) $list->getRecordSortOrder($record)); + } + + public function testGetRecordSortOrderReadsPivotPath() + { + $list = $this->makeList(['sortOrderColumn' => 'pivot[sort_order]']); + + $record = new \stdClass(); + $record->pivot = new \stdClass(); + $record->pivot->sort_order = 4; + + $this->assertSame(4, (int) $list->getRecordSortOrder($record)); + } + + public function testGetRecordSortOrderReturnsNullWhenMissing() + { + $list = $this->makeList(['sortOrderColumn' => 'pivot[sort_order]']); + $record = new SortableFixture(['sort_order' => 7]); // no pivot relation + + $this->assertNull($list->getRecordSortOrder($record)); + } + + public function testOnReorderFiresEventWithIdsAndOrders() + { + $records = $this->seedRecords(); + $ids = [$records[2]->id, $records[0]->id, $records[1]->id]; + + $list = $this->makeList(); + + $captured = null; + $list->bindEvent('list.reorder', function ($eventIds, $eventOrders) use (&$captured) { + $captured = [$eventIds, $eventOrders]; + }); + + $this->postRequest(['record_ids' => $ids, 'sort_orders' => [1, 2, 3]]); + $list->onReorder(); + + $this->assertNotNull($captured, 'list.reorder event should have fired'); + $this->assertSame(array_map('strval', $ids), array_map('strval', $captured[0])); + $this->assertSame([1, 2, 3], $captured[1]); + } + + public function testOnReorderRejectsRecordsOutsideQueryScope() + { + $records = $this->seedRecords(); + + $list = $this->makeList(); + + // 99999 is not a seeded record id. + $this->postRequest(['record_ids' => [$records[0]->id, 99999], 'sort_orders' => [1, 2]]); + + $this->expectException(ApplicationException::class); + $list->onReorder(); + } + + public function testOnReorderThrowsWhenNotSortable() + { + $list = $this->makeList(['sortable' => false]); + $this->postRequest(['record_ids' => [1], 'sort_orders' => [1]]); + + $this->expectException(ApplicationException::class); + $list->onReorder(); + } +} diff --git a/modules/backend/widgets/Lists.php b/modules/backend/widgets/Lists.php index 2b3aa59b80..004b448430 100644 --- a/modules/backend/widgets/Lists.php +++ b/modules/backend/widgets/Lists.php @@ -103,6 +103,20 @@ class Lists extends WidgetBase */ public $treeExpanded = false; + /** + * @var bool Enable drag-and-drop reordering of records. Requires the model to use the + * Sortable trait (model lists) or HasSortableRelations (relation lists). When enabled, + * column header sorting and pagination are disabled and a drag handle column is shown. + */ + public $sortable = false; + + /** + * @var string|null Column path used to read the sort order value from each record, + * e.g. "sort_order" (model lists) or "pivot[sort_order]" (relation lists). Set by the + * controlling behavior; the widget itself has no knowledge of relations or parent models. + */ + public $sortOrderColumn = null; + /** * @var bool|string Display pagination when limiting records per page. */ @@ -224,6 +238,8 @@ public function init() 'treeExpanded', 'showPagination', 'customViewPath', + 'sortable', + 'sortOrderColumn', ]); /* @@ -237,6 +253,15 @@ public function init() $this->showPagination = $this->recordsPerPage && $this->recordsPerPage > 0; } + /* + * Drag-and-drop reordering shows every record in its stored order. Disable column + * header sorting and pagination so the model/relation order is always presented. + */ + if ($this->sortable) { + $this->showSorting = false; + $this->showPagination = false; + } + if ($this->customViewPath) { $this->prependViewPath($this->customViewPath); } @@ -251,6 +276,13 @@ public function init() protected function loadAssets() { $this->addJs('js/winter.list.js', 'core'); + + // loadAssets() runs before init()/fillFromConfig(), so read the raw config value. + if ($this->getConfig('sortable', false)) { + $this->addJs('js/lib/sortable.min.js', 'core'); + $this->addJs('js/winter.list.sortable.js', 'core'); + $this->addCss('css/winter.list.sortable.css', 'core'); + } } /** @@ -281,6 +313,8 @@ public function prepareVars() $this->vars['sortDirection'] = $this->sortDirection; $this->vars['showTree'] = $this->showTree; $this->vars['treeLevel'] = 0; + $this->vars['sortable'] = $this->sortable; + $this->vars['reorderHandler'] = $this->sortable ? $this->getEventHandler('onReorder') : null; if ($this->showPagination) { $this->vars['pageCurrent'] = $records->currentPage(); @@ -357,6 +391,86 @@ public function onRefresh() return ['#'.$this->getId() => $this->makePartial('list')]; } + /** + * Event handler for drag-and-drop reordering of records. + * + * Receives the record ids in their new order and the original sort order values + * (reassigned 1:1 to the new positions), validates that all ids are within the + * current query scope, then fires the `list.reorder` event for behaviors to persist. + */ + public function onReorder() + { + if (!$this->sortable) { + throw new ApplicationException('Reordering is not enabled for this list.'); + } + + $ids = post('record_ids'); + $orders = post('sort_orders'); + + if (!is_array($ids) || !count($ids)) { + return; + } + + $orders = array_map('intval', (array) $orders); + + if (count($ids) !== count($orders)) { + throw new ApplicationException('Mismatched record ids and sort orders.'); + } + + /* + * Security: only permit reordering records that are visible within the current + * query scope. This prevents a crafted request from reordering arbitrary records. + */ + $allowed = array_map('strval', $this->prepareQuery()->pluck($this->model->getQualifiedKeyName())->all()); + foreach ($ids as $id) { + if (!in_array((string) $id, $allowed, true)) { + throw new ApplicationException('One or more records are not available for reordering.'); + } + } + + /** + * @event backend.list.reorder + * Called when records are reordered via drag-and-drop. Receives the record ids in + * their new order and the sort order values to assign to each. + * + * $listWidget->bindEvent('list.reorder', function ($ids, $orders) { + * $model->setSortableOrder($ids, $orders); + * }); + * + */ + $this->fireSystemEvent('backend.list.reorder', [$ids, $orders]); + + return $this->onRefresh(); + } + + /** + * Reads the sort order value from a record using the configured sortOrderColumn path + * (e.g. "sort_order" or "pivot[sort_order]"). Returns null when not available. + */ + public function getRecordSortOrder($record) + { + if (!$this->sortOrderColumn) { + return null; + } + + $value = $record; + foreach (HtmlHelper::nameToArray($this->sortOrderColumn) as $part) { + if (is_array($value)) { + $value = $value[$part] ?? null; + } elseif (is_object($value)) { + $value = $value->{$part} ?? null; + } else { + return null; + } + + if ($value === null) { + return null; + } + } + + return $value; + } + /** * Event handler for switching the page number. */ @@ -1037,6 +1151,18 @@ protected function defineListColumns() $this->allColumns = array_merge($orderedDefinitions, $this->allColumns); } + /* + * When drag-and-drop reordering is enabled, disable sorting on every column so + * getSortColumn() returns false. This keeps the widget from applying its own + * orderBy() and preserves the model/relation's stored sort order (it also stops + * RelationController from clearing the relation order when a sort column is set). + */ + if ($this->sortable) { + foreach ($this->allColumns as $column) { + $column->sortable = false; + } + } + return $this->allColumns; } @@ -1140,6 +1266,10 @@ protected function getTotalColumns() $total++; } + if ($this->sortable) { + $total++; + } + return $total; } diff --git a/modules/backend/widgets/lists/assets/css/winter.list.sortable.css b/modules/backend/widgets/lists/assets/css/winter.list.sortable.css new file mode 100644 index 0000000000..57f58f4479 --- /dev/null +++ b/modules/backend/widgets/lists/assets/css/winter.list.sortable.css @@ -0,0 +1,41 @@ +/* + * Drag-and-drop reordering styles for the list widget. + */ +.control-list .list-sort-handle-column, +.control-list .list-cell-sort-handle { + width: 28px; + text-align: center; + padding-left: 4px; + padding-right: 4px; +} + +.control-list .list-sort-handle { + cursor: move; + cursor: grab; + color: #ccc; + display: inline-block; + line-height: 1; + text-decoration: none; +} + +.control-list tr:hover .list-sort-handle { + color: #999; +} + +.control-list .list-sort-handle:active { + cursor: grabbing; +} + +.control-list .list-sortable-ghost { + opacity: 0.5; + background: #f0f7fd; +} + +.control-list .list-sortable-chosen { + background: #f7f9fa; +} + +.control-list[data-sortable="true"] tbody tr { + /* Avoid text selection while dragging rows */ + user-select: none; +} diff --git a/modules/backend/widgets/lists/assets/js/lib/sortable.min.js b/modules/backend/widgets/lists/assets/js/lib/sortable.min.js new file mode 100644 index 0000000000..8148b9dda2 --- /dev/null +++ b/modules/backend/widgets/lists/assets/js/lib/sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.7 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function o(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function m(t){return t.host&&t!==document&&t.host.nodeType&&t.host!==t?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&g(t,e)||o&&t===n)return t}while(t!==n&&(t=m(t)))}return null}var v,b=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(b," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(b," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function D(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function E(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function jt(t){$&&$.parentNode[K]._isOutsideThisEl(t.target)}function Ht(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Rt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Ht.supportPointer&&"PointerEvent"in window&&(!u||d),emptyInsertThreshold:5};for(n in G.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Xt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&Pt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?f(t,"pointerdown",this._onTapStart):(f(t,"mousedown",this._onTapStart),f(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(f(t,"dragover",this),f(t,"dragenter",this)),_t.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,N())}function Lt(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Kt(t){t.draggable=!1}function Wt(){Ot=!1}function zt(t){return setTimeout(t,0)}function Gt(t){return clearTimeout(t)}Ht.prototype={constructor:Ht,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(bt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,$):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Mt.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Mt.push(o)}}(o),!$&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||nt===l)){if(rt=j(l),lt=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return Z({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),q("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return Z({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),q("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!$&&n.parentNode===r&&(o=X(n),tt=r,Q=($=n).parentNode,et=$.nextSibling,nt=n,ct=a.group,dt={target:Ht.dragged=$,clientX:(e||t).clientX,clientY:(e||t).clientY},gt=dt.clientX-o.left,mt=dt.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,$.style["will-change"]="all",o=function(){q("delayEnded",i,{evt:t}),Ht.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!c&&i.nativeDraggable&&($.draggable=!0),i._triggerDragStart(t,e),Z({sortable:i,name:"choose",originalEvent:t}),k($,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){E($,t.trim(),Kt)}),f(l,"dragover",Ft),f(l,"mousemove",Ft),f(l,"touchmove",Ft),a.supportPointer?(f(l,"pointerup",i._onDrop),this.nativeDraggable||f(l,"pointercancel",i._onDrop)):(f(l,"mouseup",i._onDrop),f(l,"touchend",i._onDrop),f(l,"touchcancel",i._onDrop)),c&&this.nativeDraggable&&(this.options.touchStartThreshold=4,$.draggable=!0),q("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():Ht.eventCanceled?this._onDrop():(a.supportPointer?(f(l,"pointerup",i._disableDelayedDrag),f(l,"pointercancel",i._disableDelayedDrag)):(f(l,"mouseup",i._disableDelayedDrag),f(l,"touchend",i._disableDelayedDrag),f(l,"touchcancel",i._disableDelayedDrag)),f(l,"mousemove",i._delayedDragTouchMoveHandler),f(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&f(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){$&&Kt($),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?f(document,"pointermove",this._onTouchMove):f(document,e?"touchmove":"mousemove",this._onTouchMove):(f($,"dragend",this),f(tt,"dragstart",this._onDragStart));try{document.selection?zt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Et=!1,tt&&$?(q("dragStarted",this,{evt:e}),this.nativeDraggable&&f(document,"dragover",jt),n=this.options,t||k($,n.dragClass,!1),k($,n.ghostClass,!0),Ht.active=this,t&&this._appendGhost(),Z({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(ht){this._lastX=ht.clientX,this._lastY=ht.clientY,Yt();for(var t=document.elementFromPoint(ht.clientX,ht.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(ht.clientX,ht.clientY))!==e;)e=t;if($.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:ht.clientX,clientY:ht.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=m(t=e));Bt()}},_onTouchMove:function(t){if(dt){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=J&&D(J,!0),a=J&&r&&r.a,l=J&&r&&r.d,e=Nt&&Dt&&S(Dt),a=(i.clientX-dt.clientX+o.x)/(a||1)+(e?e[0]-xt[0]:0)/(a||1),l=(i.clientY-dt.clientY+o.y)/(l||1)+(e?e[1]-xt[1]:0)/(l||1);if(!Ht.active&&!Et){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,J),e?t.clientX<_.left-10||t.clientY using the + * per-row drag handle. On drop the new record order is posted to the list's reorder handler, + * which persists the order and re-renders the list. + * + * The record ids are collected in their new DOM order and assigned sequential 1..N sort + * order values. Because sortable lists show every record (pagination is disabled), a simple + * positional numbering is unambiguous and also handles freshly added (deferred) records that + * do not yet have a stored sort order. + */ ++function ($) { + "use strict"; + + if (typeof window.Sortable === 'undefined') { + return; + } + + function collectIds(tbody) { + return Array.prototype.map.call( + tbody.querySelectorAll('tr[data-record-id]'), + function (tr) { + return tr.getAttribute('data-record-id'); + } + ); + } + + function initList(listEl) { + var tbody = listEl.querySelector('tbody'); + if (!tbody || tbody.wnListSortable) { + return; + } + + var handler = listEl.getAttribute('data-reorder-handler'); + if (!handler) { + return; + } + + var $list = $(listEl); + + tbody.wnListSortable = window.Sortable.create(tbody, { + handle: '.list-sort-handle', + draggable: 'tr', + filter: '.no-data', + animation: 150, + ghostClass: 'list-sortable-ghost', + chosenClass: 'list-sortable-chosen', + onEnd: function () { + var ids = collectIds(tbody); + var orders = ids.map(function (id, index) { + return index + 1; + }); + $list.request(handler, { + data: { record_ids: ids, sort_orders: orders } + }); + } + }); + } + + function initAll() { + var lists = document.querySelectorAll('[data-control="listwidget"][data-sortable="true"]'); + Array.prototype.forEach.call(lists, initList); + } + + $(document).ready(initAll); + + // Re-initialise after the list partial is replaced by an AJAX update (e.g. onRefresh). + $(document).on('render', initAll); +}(window.jQuery); diff --git a/modules/backend/widgets/lists/partials/_list.php b/modules/backend/widgets/lists/partials/_list.php index 5746bde2eb..525f0a7fe5 100644 --- a/modules/backend/widgets/lists/partials/_list.php +++ b/modules/backend/widgets/lists/partials/_list.php @@ -1,4 +1,9 @@ -
+
+ data-sortable="true" + data-reorder-handler="" + +> diff --git a/modules/backend/widgets/lists/partials/_list_body_row.php b/modules/backend/widgets/lists/partials/_list_body_row.php index 8341f1c4ff..7e4617bad1 100644 --- a/modules/backend/widgets/lists/partials/_list_body_row.php +++ b/modules/backend/widgets/lists/partials/_list_body_row.php @@ -3,7 +3,12 @@ $childRecords = $showTree ? $record->getChildren() : null; $treeLevelClass = $showTree ? 'list-tree-level-'.$treeLevel : ''; ?> - + + data-record-sort-order="getRecordSortOrder($record)) ?>" + +> makePartial('list_body_checkbox', ['record' => $record]) ?> @@ -16,6 +21,12 @@ ]) ?> + + + + $column): ?> + + $column): ?> sortable): ?> From 690e90522332d65717e2c3d2a280c48955a2f231 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Fri, 19 Jun 2026 01:01:14 -0600 Subject: [PATCH 6/8] Build SortableJS via Mix, harden onReorder, gate sortable lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Build the reordering bundle from the `sortablejs` npm dependency via Mix (winter.mix.js → js/dist/winter.list.sortable.js) instead of committing a vendored copy of the library; pin `sortablejs` to an exact version. Removes the vendored js/lib/sortable.min.js and the hand-written plain script. - onReorder() now assigns the 1..N sort orders server-side from the submitted record ids (the list always reorders by position), so client-supplied order values are no longer trusted; the scope check uses an O(N) lookup. - The JS posts only the record ids and skips the request entirely when a row is dropped back into its original position. - Reject combining `sortable` with search, filtering, pagination, or an explicit defaultSort (in both ListController and RelationController) with a clear error pointing at the ReorderController, since drag-reordering needs the whole list in a fixed order. See wintercms/winter#1492 for committing a lockfile so asset rebuilds are reproducible (the broader dependency-drift concern surfaced here). Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/backend/behaviors/ListController.php | 19 ++++++++ .../backend/behaviors/RelationController.php | 19 ++++++++ modules/backend/package.json | 2 +- .../tests/widgets/ListsSortableTest.php | 10 +++-- modules/backend/widgets/Lists.php | 26 ++++++----- .../assets/js/dist/winter.list.sortable.js | 1 + .../lists/assets/js/lib/sortable.min.js | 2 - .../js/{ => src}/winter.list.sortable.js | 43 ++++++++----------- modules/backend/winter.mix.js | 6 +++ 9 files changed, 83 insertions(+), 45 deletions(-) create mode 100644 modules/backend/widgets/lists/assets/js/dist/winter.list.sortable.js delete mode 100644 modules/backend/widgets/lists/assets/js/lib/sortable.min.js rename modules/backend/widgets/lists/assets/js/{ => src}/winter.list.sortable.js (65%) diff --git a/modules/backend/behaviors/ListController.php b/modules/backend/behaviors/ListController.php index 1cc6541b88..c14ee5768f 100644 --- a/modules/backend/behaviors/ListController.php +++ b/modules/backend/behaviors/ListController.php @@ -179,6 +179,25 @@ public function makeList($definition = null) )); } + /* + * 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->sortOrderColumn = $model->getSortOrderColumn(); $widget->bindEvent('list.reorder', function ($ids, $orders) use ($model) { diff --git a/modules/backend/behaviors/RelationController.php b/modules/backend/behaviors/RelationController.php index 1b08ca7734..57dae9cd0a 100644 --- a/modules/backend/behaviors/RelationController.php +++ b/modules/backend/behaviors/RelationController.php @@ -797,6 +797,25 @@ protected function makeViewWidget() )); } + /* + * 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; $config->sortOrderColumn = 'pivot[' . $this->model->getRelationSortOrderColumn($this->relationName) . ']'; } diff --git a/modules/backend/package.json b/modules/backend/package.json index 35f4492707..003f8b032c 100644 --- a/modules/backend/package.json +++ b/modules/backend/package.json @@ -30,7 +30,7 @@ "constrained-editor-plugin": "^1.3.0", "vue": "^3.2.45", "@simonwep/pickr": "^1.8.2", - "sortablejs": "^1.15.7" + "sortablejs": "1.15.7" }, "devDependencies": { "eslint": "^8.6.0", diff --git a/modules/backend/tests/widgets/ListsSortableTest.php b/modules/backend/tests/widgets/ListsSortableTest.php index 8687b3f877..c5289deeb9 100644 --- a/modules/backend/tests/widgets/ListsSortableTest.php +++ b/modules/backend/tests/widgets/ListsSortableTest.php @@ -118,7 +118,7 @@ public function testGetRecordSortOrderReturnsNullWhenMissing() $this->assertNull($list->getRecordSortOrder($record)); } - public function testOnReorderFiresEventWithIdsAndOrders() + public function testOnReorderGeneratesSequentialOrdersServerSide() { $records = $this->seedRecords(); $ids = [$records[2]->id, $records[0]->id, $records[1]->id]; @@ -130,7 +130,9 @@ public function testOnReorderFiresEventWithIdsAndOrders() $captured = [$eventIds, $eventOrders]; }); - $this->postRequest(['record_ids' => $ids, 'sort_orders' => [1, 2, 3]]); + // The client sends only the record ids in their new order; the server assigns the + // sort order values 1..N by position. + $this->postRequest(['record_ids' => $ids]); $list->onReorder(); $this->assertNotNull($captured, 'list.reorder event should have fired'); @@ -145,7 +147,7 @@ public function testOnReorderRejectsRecordsOutsideQueryScope() $list = $this->makeList(); // 99999 is not a seeded record id. - $this->postRequest(['record_ids' => [$records[0]->id, 99999], 'sort_orders' => [1, 2]]); + $this->postRequest(['record_ids' => [$records[0]->id, 99999]]); $this->expectException(ApplicationException::class); $list->onReorder(); @@ -154,7 +156,7 @@ public function testOnReorderRejectsRecordsOutsideQueryScope() public function testOnReorderThrowsWhenNotSortable() { $list = $this->makeList(['sortable' => false]); - $this->postRequest(['record_ids' => [1], 'sort_orders' => [1]]); + $this->postRequest(['record_ids' => [1]]); $this->expectException(ApplicationException::class); $list->onReorder(); diff --git a/modules/backend/widgets/Lists.php b/modules/backend/widgets/Lists.php index 004b448430..1d7c3b7cb4 100644 --- a/modules/backend/widgets/Lists.php +++ b/modules/backend/widgets/Lists.php @@ -279,8 +279,7 @@ protected function loadAssets() // loadAssets() runs before init()/fillFromConfig(), so read the raw config value. if ($this->getConfig('sortable', false)) { - $this->addJs('js/lib/sortable.min.js', 'core'); - $this->addJs('js/winter.list.sortable.js', 'core'); + $this->addJs('js/dist/winter.list.sortable.js', 'core'); $this->addCss('css/winter.list.sortable.css', 'core'); } } @@ -394,9 +393,9 @@ public function onRefresh() /** * Event handler for drag-and-drop reordering of records. * - * Receives the record ids in their new order and the original sort order values - * (reassigned 1:1 to the new positions), validates that all ids are within the - * current query scope, then fires the `list.reorder` event for behaviors to persist. + * Receives the record ids in their new order and validates that all ids are within the + * current query scope, then fires the `list.reorder` event with sequential 1..N sort + * order values (assigned server-side by position) for behaviors to persist. */ public function onReorder() { @@ -405,29 +404,28 @@ public function onReorder() } $ids = post('record_ids'); - $orders = post('sort_orders'); if (!is_array($ids) || !count($ids)) { return; } - $orders = array_map('intval', (array) $orders); - - if (count($ids) !== count($orders)) { - throw new ApplicationException('Mismatched record ids and sort orders.'); - } - /* * Security: only permit reordering records that are visible within the current * query scope. This prevents a crafted request from reordering arbitrary records. */ - $allowed = array_map('strval', $this->prepareQuery()->pluck($this->model->getQualifiedKeyName())->all()); + $allowed = array_flip(array_map('strval', $this->prepareQuery()->pluck($this->model->getQualifiedKeyName())->all())); foreach ($ids as $id) { - if (!in_array((string) $id, $allowed, true)) { + if (!isset($allowed[(string) $id])) { throw new ApplicationException('One or more records are not available for reordering.'); } } + /* + * Sort orders are assigned server-side by position; the list always reorders + * positionally, so we never trust client-supplied order values. + */ + $orders = range(1, count($ids)); + /** * @event backend.list.reorder * Called when records are reordered via drag-and-drop. Receives the record ids in diff --git a/modules/backend/widgets/lists/assets/js/dist/winter.list.sortable.js b/modules/backend/widgets/lists/assets/js/dist/winter.list.sortable.js new file mode 100644 index 0000000000..228c652230 --- /dev/null +++ b/modules/backend/widgets/lists/assets/js/dist/winter.list.sortable.js @@ -0,0 +1 @@ +"use strict";(self.webpackChunk_wintercms_wn_backend_module=self.webpackChunk_wintercms_wn_backend_module||[]).push([[700],{901:function(){function t(t,e,n){return(e=function(t){var e=function(t,e){if("object"!=typeof t||!t)return t;var n=t[Symbol.toPrimitive];if(void 0!==n){var o=n.call(t,e||"default");if("object"!=typeof o)return o;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===e?String:Number)(t)}(t,"string");return"symbol"==typeof e?e:e+""}(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function e(){return e=Object.assign?Object.assign.bind():function(t){for(var e=1;e"===e[0]&&(e=e.substring(1)),t)try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return!1}return!1}}function v(t){return t.host&&t!==document&&t.host.nodeType&&t.host!==t?t.host:t.parentNode}function m(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"===e[0]?t.parentNode===n&&g(t,e):g(t,e))||o&&t===n)return t;if(t===n)break}while(t=v(t))}return null}var b,y=/\s+/g;function w(t,e,n){if(t&&e)if(t.classList)t.classList[n?"add":"remove"](e);else{var o=(" "+t.className+" ").replace(y," ").replace(" "+e+" "," ");t.className=(o+(n?" "+e:"")).replace(y," ")}}function E(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];e in o||-1!==e.indexOf("webkit")||(e="-webkit-"+e),o[e]=n+("string"==typeof n?"":"px")}}function _(t,e){var n="";if("string"==typeof t)n=t;else do{var o=E(t,"transform");o&&"none"!==o&&(n=o+" "+n)}while(!e&&(t=t.parentNode));var r=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return r&&new r(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),r=0,i=o.length;if(n)for(;r=i:r<=i))return o;if(o===S())break;o=P(o,!1)}return!1}function x(t,e,n,o){for(var r=0,i=0,a=t.children;i2&&void 0!==arguments[2]?arguments[2]:{},r=n.evt,i=function(t,e){if(null==t)return{};var n,o,r=function(t,e){if(null==t)return{};var n={};for(var o in t)if({}.hasOwnProperty.call(t,o)){if(-1!==e.indexOf(o))continue;n[o]=t[o]}return n}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(o=0;o=o&&"none"===n[xt]||i&&"none"===n[xt]&&s+c>o)?"vertical":"horizontal"},Pt=function(t){function e(t,n){return function(o,r,i,a){var l=o.options.group.name&&r.options.group.name&&o.options.group.name===r.options.group.name;if(null==t&&(n||l))return!0;if(null==t||!1===t)return!1;if(n&&"clone"===t)return t;if("function"==typeof t)return e(t(o,r,i,a),n)(o,r,i,a);var s=(n?o:r).options.group.name;return!0===t||"string"==typeof t&&t===s||t.join&&t.indexOf(s)>-1}}var n={},o=t.group;o&&"object"==r(o)||(o={name:o}),n.name=o.name,n.checkPull=e(o.pull,!0),n.checkPut=e(o.put),n.revertClone=o.revertClone,t.group=n},It=function(){!At&&V&&E(V,"display","none")},Nt=function(){!At&&V&&E(V,"display","")};Tt&&!d&&document.addEventListener("click",function(t){if(bt)return t.preventDefault(),t.stopPropagation&&t.stopPropagation(),t.stopImmediatePropagation&&t.stopImmediatePropagation(),bt=!1,!1},!0);var kt=function(t){if(G){t=t.touches?t.touches[0]:t;var e=(r=t.clientX,i=t.clientY,yt.some(function(t){var e=t[R].options.emptyInsertThreshold;if(e&&!O(t)){var n=T(t),o=r>=n.left-e&&r<=n.right+e,l=i>=n.top-e&&i<=n.bottom+e;return o&&l?a=t:void 0}}),a);if(e){var n={};for(var o in t)t.hasOwnProperty(o)&&(n[o]=t[o]);n.target=n.rootEl=e,n.preventDefault=void 0,n.stopPropagation=void 0,e[R]._onDragOver(n)}}var r,i,a},Xt=function(t){G&&G.parentNode[R]._isOutsideThisEl(t.target)};function Yt(t,n){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=n=e({},n),t[R]=this;var o={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Mt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Yt.supportPointer&&"PointerEvent"in window&&(!c||u),emptyInsertThreshold:5};for(var r in j.initializePlugins(this,t,o),o)!(r in n)&&(n[r]=o[r]);for(var i in Pt(n),this)"_"===i.charAt(0)&&"function"==typeof this[i]&&(this[i]=this[i].bind(this));this.nativeDraggable=!n.forceFallback&&Ot,this.nativeDraggable&&(this.options.touchStartThreshold=1),n.supportPointer?f(t,"pointerdown",this._onTapStart):(f(t,"mousedown",this._onTapStart),f(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(f(t,"dragover",this),f(t,"dragenter",this)),yt.push(this.el),n.store&&n.store.get&&this.sort(n.store.get(this)||[]),e(this,B())}function Rt(t,e,n,o,r,i,s,c){var u,d,h=t[R],f=h.options.onMove;return!window.CustomEvent||a||l?(u=document.createEvent("Event")).initEvent("move",!0,!0):u=new CustomEvent("move",{bubbles:!0,cancelable:!0}),u.to=e,u.from=t,u.dragged=n,u.draggedRect=o,u.related=r||e,u.relatedRect=i||T(e),u.willInsertAfter=c,u.originalEvent=s,t.dispatchEvent(u),f&&(d=f.call(h,u,s)),d}function Bt(t){t.draggable=!1}function Ft(){Dt=!1}function Lt(t){for(var e=t.tagName+t.className+t.src+t.href+t.textContent,n=e.length,o=0;n--;)o+=e.charCodeAt(n);return o.toString(36)}function jt(t){return setTimeout(t,0)}function Ht(t){return clearTimeout(t)}Yt.prototype={constructor:Yt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(ft=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,G):this.options.direction},_onTapStart:function(t){if(t.cancelable){var e=this,n=this.el,o=this.options,r=o.preventOnFilter,i=t.type,a=t.touches&&t.touches[0]||t.pointerType&&"touch"===t.pointerType&&t,l=(a||t).target,s=t.target.shadowRoot&&(t.path&&t.path[0]||t.composedPath&&t.composedPath()[0])||l,u=o.filter;if(function(t){St.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&St.push(o)}}(n),!G&&!(/mousedown|pointerdown/.test(i)&&0!==t.button||o.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!c||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=m(l,o.draggable,n,!1))&&l.animated||K===l)){if(tt=A(l),nt=A(l,o.draggable),"function"==typeof u){if(u.call(this,t,l,this))return q({sortable:e,rootEl:s,name:"filter",targetEl:l,toEl:n,fromEl:n}),z("filter",e,{evt:t}),void(r&&t.preventDefault())}else if(u&&(u=u.split(",").some(function(o){if(o=m(s,o.trim(),n,!1))return q({sortable:e,rootEl:o,name:"filter",targetEl:l,fromEl:n,toEl:n}),z("filter",e,{evt:t}),!0})))return void(r&&t.preventDefault());o.handle&&!m(s,o.handle,n,!1)||this._prepareDragStart(t,a,l)}}},_prepareDragStart:function(t,e,n){var o,r=this,i=r.el,c=r.options,u=i.ownerDocument;if(n&&!G&&n.parentNode===i){var d=T(n);if(Q=i,U=(G=n).parentNode,Z=G.nextSibling,K=n,rt=c.group,Yt.dragged=G,at={target:G,clientX:(e||t).clientX,clientY:(e||t).clientY},ut=at.clientX-d.left,dt=at.clientY-d.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,G.style["will-change"]="all",o=function(){z("delayEnded",r,{evt:t}),Yt.eventCanceled?r._onDrop():(r._disableDelayedDragEvents(),!s&&r.nativeDraggable&&(G.draggable=!0),r._triggerDragStart(t,e),q({sortable:r,name:"choose",originalEvent:t}),w(G,c.chosenClass,!0))},c.ignore.split(",").forEach(function(t){D(G,t.trim(),Bt)}),f(u,"dragover",kt),f(u,"mousemove",kt),f(u,"touchmove",kt),c.supportPointer?(f(u,"pointerup",r._onDrop),!this.nativeDraggable&&f(u,"pointercancel",r._onDrop)):(f(u,"mouseup",r._onDrop),f(u,"touchend",r._onDrop),f(u,"touchcancel",r._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,G.draggable=!0),z("delayStart",this,{evt:t}),!c.delay||c.delayOnTouchOnly&&!e||this.nativeDraggable&&(l||a))o();else{if(Yt.eventCanceled)return void this._onDrop();c.supportPointer?(f(u,"pointerup",r._disableDelayedDrag),f(u,"pointercancel",r._disableDelayedDrag)):(f(u,"mouseup",r._disableDelayedDrag),f(u,"touchend",r._disableDelayedDrag),f(u,"touchcancel",r._disableDelayedDrag)),f(u,"mousemove",r._delayedDragTouchMoveHandler),f(u,"touchmove",r._delayedDragTouchMoveHandler),c.supportPointer&&f(u,"pointermove",r._delayedDragTouchMoveHandler),r._dragStartTimer=setTimeout(o,c.delay)}}},_delayedDragTouchMoveHandler:function(t){var e=t.touches?t.touches[0]:t;Math.max(Math.abs(e.clientX-this._lastX),Math.abs(e.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){G&&Bt(G),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?f(document,"pointermove",this._onTouchMove):f(document,e?"touchmove":"mousemove",this._onTouchMove):(f(G,"dragend",this),f(Q,"dragstart",this._onDragStart));try{document.selection?jt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){if(mt=!1,Q&&G){z("dragStarted",this,{evt:e}),this.nativeDraggable&&f(document,"dragover",Xt);var n=this.options;!t&&w(G,n.dragClass,!1),w(G,n.ghostClass,!0),Yt.active=this,t&&this._appendGhost(),q({sortable:this,name:"start",originalEvent:e})}else this._nulling()},_emulateDragOver:function(){if(lt){this._lastX=lt.clientX,this._lastY=lt.clientY,It();for(var t=document.elementFromPoint(lt.clientX,lt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(lt.clientX,lt.clientY))!==e;)e=t;if(G.parentNode[R]._isOutsideThisEl(t),e)do{if(e[R]){if(e[R]._onDragOver({clientX:lt.clientX,clientY:lt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}t=e}while(e=v(e));Nt()}},_onTouchMove:function(t){if(at){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,r=t.touches?t.touches[0]:t,i=V&&_(V,!0),a=V&&i&&i.a,l=V&&i&&i.d,s=Ct&&vt&&M(vt),c=(r.clientX-at.clientX+o.x)/(a||1)+(s?s[0]-_t[0]:0)/(a||1),u=(r.clientY-at.clientY+o.y)/(l||1)+(s?s[1]-_t[1]:0)/(l||1);if(!Yt.active&&!mt){if(n&&Math.max(Math.abs(r.clientX-this._lastX),Math.abs(r.clientY-this._lastY))r.right+i||t.clientY>o.bottom&&t.clientX>o.left:t.clientY>r.bottom+i||t.clientX>o.right&&t.clientY>o.top}(t,i,this)&&!v.animated){if(v===G)return W(!1);if(v&&a===t.target&&(l=v),l&&(n=T(l)),!1!==Rt(Q,a,G,e,l,n,t,!!l))return H(),v&&v.nextSibling?a.insertBefore(G,v.nextSibling):a.appendChild(G),U=a,K(),W(!0)}else if(v&&function(t,e,n){var o=T(x(n.el,0,n.options,!0)),r=Y(n.el,n.options,V),i=10;return e?t.clientXu+c*i/2:sd-gt)return-pt}else if(s>u+c*(1-r)/2&&sd-c*i/2))return s>u+c/2?1:-1;return 0}(t,l,n,i,M?1:s.swapThreshold,null==s.invertedSwapThreshold?s.swapThreshold:s.invertedSwapThreshold,Et,ft===l),0!==y){var X=A(G);do{X-=y,D=U.children[X]}while(D&&("none"===E(D,"display")||D===V))}if(0===y||D===l)return W(!1);ft=l,pt=y;var B=l.nextElementSibling,F=!1,L=Rt(Q,a,G,e,l,n,t,F=1===y);if(!1!==L)return 1!==L&&-1!==L||(F=1===L),Dt=!0,setTimeout(Ft,30),H(),F&&!B?a.appendChild(G):l.parentNode.insertBefore(G,F?B:l),I&&k(I,0,N-I.scrollTop),U=G.parentNode,void 0===_||Et||(gt=Math.abs(_-T(l)[P])),K(),W(!0)}if(a.contains(G))return W(!1)}return!1}function j(s,c){z(s,p,o({evt:t,isOwner:d,axis:i?"vertical":"horizontal",revert:r,dragRect:e,targetRect:n,canSort:h,fromSortable:f,target:l,completed:W,onMove:function(n,o){return Rt(Q,a,G,e,n,T(n),t,o)},changed:K},c))}function H(){j("dragOverAnimationCapture"),p.captureAnimationState(),p!==f&&f.captureAnimationState()}function W(e){return j("dragOverCompleted",{insertion:e}),e&&(d?u._hideClone():u._showClone(p),p!==f&&(w(G,it?it.options.ghostClass:u.options.ghostClass,!1),w(G,s.ghostClass,!0)),it!==p&&p!==Yt.active?it=p:p===Yt.active&&it&&(it=null),f===p&&(p._ignoreWhileAnimating=l),p.animateAll(function(){j("dragOverAnimationComplete"),p._ignoreWhileAnimating=null}),p!==f&&(f.animateAll(),f._ignoreWhileAnimating=null)),(l===G&&!G.animated||l===a&&!l.animated)&&(ft=null),s.dragoverBubble||t.rootEl||l===document||(G.parentNode[R]._isOutsideThisEl(t.target),!e&&kt(t)),!s.dragoverBubble&&t.stopPropagation&&t.stopPropagation(),g=!0}function K(){et=A(G),ot=A(G,s.draggable),q({sortable:p,name:"change",toEl:a,newIndex:et,newDraggableIndex:ot,originalEvent:t})}},_ignoreWhileAnimating:null,_offMoveEvents:function(){p(document,"mousemove",this._onTouchMove),p(document,"touchmove",this._onTouchMove),p(document,"pointermove",this._onTouchMove),p(document,"dragover",kt),p(document,"mousemove",kt),p(document,"touchmove",kt)},_offUpEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._onDrop),p(t,"touchend",this._onDrop),p(t,"pointerup",this._onDrop),p(t,"pointercancel",this._onDrop),p(t,"touchcancel",this._onDrop),p(document,"selectstart",this)},_onDrop:function(t){var e=this.el,n=this.options;et=A(G),ot=A(G,n.draggable),z("drop",this,{evt:t}),U=G&&G.parentNode,et=A(G),ot=A(G,n.draggable),Yt.eventCanceled||(mt=!1,Et=!1,wt=!1,clearInterval(this._loopId),clearTimeout(this._dragStartTimer),Ht(this.cloneId),Ht(this._dragStartId),this.nativeDraggable&&(p(document,"drop",this),p(e,"dragstart",this._onDragStart)),this._offMoveEvents(),this._offUpEvents(),c&&E(document.body,"user-select",""),E(G,"transform",""),t&&(ht&&(t.cancelable&&t.preventDefault(),!n.dropBubble&&t.stopPropagation()),V&&V.parentNode&&V.parentNode.removeChild(V),(Q===U||it&&"clone"!==it.lastPutMode)&&$&&$.parentNode&&$.parentNode.removeChild($),G&&(this.nativeDraggable&&p(G,"dragend",this),Bt(G),G.style["will-change"]="",ht&&!mt&&w(G,it?it.options.ghostClass:this.options.ghostClass,!1),w(G,this.options.chosenClass,!1),q({sortable:this,name:"unchoose",toEl:U,newIndex:null,newDraggableIndex:null,originalEvent:t}),Q!==U?(et>=0&&(q({rootEl:U,name:"add",toEl:U,fromEl:Q,originalEvent:t}),q({sortable:this,name:"remove",toEl:U,originalEvent:t}),q({rootEl:U,name:"sort",toEl:U,fromEl:Q,originalEvent:t}),q({sortable:this,name:"sort",toEl:U,originalEvent:t})),it&&it.save()):et!==tt&&et>=0&&(q({sortable:this,name:"update",toEl:U,originalEvent:t}),q({sortable:this,name:"sort",toEl:U,originalEvent:t})),Yt.active&&(null!=et&&-1!==et||(et=tt,ot=nt),q({sortable:this,name:"end",toEl:U,originalEvent:t}),this.save())))),this._nulling()},_nulling:function(){z("nulling",this),Q=G=U=V=Z=$=K=J=at=lt=ht=et=ot=tt=nt=ft=pt=it=rt=Yt.dragged=Yt.ghost=Yt.clone=Yt.active=null;var t=this.el;St.forEach(function(e){t.contains(e)&&(e.checked=!0)}),St.length=st=ct=0},handleEvent:function(t){switch(t.type){case"drop":case"dragend":this._onDrop(t);break;case"dragenter":case"dragover":G&&(this._onDragOver(t),function(t){t.dataTransfer&&(t.dataTransfer.dropEffect="move");t.cancelable&&t.preventDefault()}(t));break;case"selectstart":t.preventDefault()}},toArray:function(){for(var t,e=[],n=this.el.children,o=0,r=n.length,i=this.options;ot.length)&&(e=t.length);for(var n=0,o=Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function m(t){return t.host&&t!==document&&t.host.nodeType&&t.host!==t?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&g(t,e)||o&&t===n)return t}while(t!==n&&(t=m(t)))}return null}var v,b=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(b," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(b," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function D(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function E(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function jt(t){$&&$.parentNode[K]._isOutsideThisEl(t.target)}function Ht(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Rt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Ht.supportPointer&&"PointerEvent"in window&&(!u||d),emptyInsertThreshold:5};for(n in G.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Xt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&Pt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?f(t,"pointerdown",this._onTapStart):(f(t,"mousedown",this._onTapStart),f(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(f(t,"dragover",this),f(t,"dragenter",this)),_t.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,N())}function Lt(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Kt(t){t.draggable=!1}function Wt(){Ot=!1}function zt(t){return setTimeout(t,0)}function Gt(t){return clearTimeout(t)}Ht.prototype={constructor:Ht,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(bt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,$):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Mt.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Mt.push(o)}}(o),!$&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||nt===l)){if(rt=j(l),lt=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return Z({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),q("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return Z({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),q("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!$&&n.parentNode===r&&(o=X(n),tt=r,Q=($=n).parentNode,et=$.nextSibling,nt=n,ct=a.group,dt={target:Ht.dragged=$,clientX:(e||t).clientX,clientY:(e||t).clientY},gt=dt.clientX-o.left,mt=dt.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,$.style["will-change"]="all",o=function(){q("delayEnded",i,{evt:t}),Ht.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!c&&i.nativeDraggable&&($.draggable=!0),i._triggerDragStart(t,e),Z({sortable:i,name:"choose",originalEvent:t}),k($,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){E($,t.trim(),Kt)}),f(l,"dragover",Ft),f(l,"mousemove",Ft),f(l,"touchmove",Ft),a.supportPointer?(f(l,"pointerup",i._onDrop),this.nativeDraggable||f(l,"pointercancel",i._onDrop)):(f(l,"mouseup",i._onDrop),f(l,"touchend",i._onDrop),f(l,"touchcancel",i._onDrop)),c&&this.nativeDraggable&&(this.options.touchStartThreshold=4,$.draggable=!0),q("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():Ht.eventCanceled?this._onDrop():(a.supportPointer?(f(l,"pointerup",i._disableDelayedDrag),f(l,"pointercancel",i._disableDelayedDrag)):(f(l,"mouseup",i._disableDelayedDrag),f(l,"touchend",i._disableDelayedDrag),f(l,"touchcancel",i._disableDelayedDrag)),f(l,"mousemove",i._delayedDragTouchMoveHandler),f(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&f(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){$&&Kt($),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?f(document,"pointermove",this._onTouchMove):f(document,e?"touchmove":"mousemove",this._onTouchMove):(f($,"dragend",this),f(tt,"dragstart",this._onDragStart));try{document.selection?zt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Et=!1,tt&&$?(q("dragStarted",this,{evt:e}),this.nativeDraggable&&f(document,"dragover",jt),n=this.options,t||k($,n.dragClass,!1),k($,n.ghostClass,!0),Ht.active=this,t&&this._appendGhost(),Z({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(ht){this._lastX=ht.clientX,this._lastY=ht.clientY,Yt();for(var t=document.elementFromPoint(ht.clientX,ht.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(ht.clientX,ht.clientY))!==e;)e=t;if($.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:ht.clientX,clientY:ht.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=m(t=e));Bt()}},_onTouchMove:function(t){if(dt){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=J&&D(J,!0),a=J&&r&&r.a,l=J&&r&&r.d,e=Nt&&Dt&&S(Dt),a=(i.clientX-dt.clientX+o.x)/(a||1)+(e?e[0]-xt[0]:0)/(a||1),l=(i.clientY-dt.clientY+o.y)/(l||1)+(e?e[1]-xt[1]:0)/(l||1);if(!Ht.active&&!Et){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,J),e?t.clientX<_.left-10||t.clientY using the - * per-row drag handle. On drop the new record order is posted to the list's reorder handler, - * which persists the order and re-renders the list. - * - * The record ids are collected in their new DOM order and assigned sequential 1..N sort - * order values. Because sortable lists show every record (pagination is disabled), a simple - * positional numbering is unambiguous and also handles freshly added (deferred) records that - * do not yet have a stored sort order. + * Additive enhancement on top of the existing list widget. When a list is rendered with + * `data-sortable="true"`, SortableJS is initialised on its using the per-row drag + * handle cell. On drop, the record ids in their new DOM order are posted to the list's reorder + * handler, which assigns the sort order values server-side (by position) and re-renders. */ -+function ($) { +(function ($) { "use strict"; - if (typeof window.Sortable === 'undefined') { - return; - } - function collectIds(tbody) { return Array.prototype.map.call( tbody.querySelectorAll('tr[data-record-id]'), @@ -51,28 +44,30 @@ } }); - tbody.wnListSortable = window.Sortable.create(tbody, { + tbody.wnListSortable = Sortable.create(tbody, { handle: '.list-cell-sort-handle', draggable: 'tr', filter: '.no-data', animation: 150, ghostClass: 'list-sortable-ghost', chosenClass: 'list-sortable-chosen', - onEnd: function () { - var ids = collectIds(tbody); - var orders = ids.map(function (id, index) { - return index + 1; - }); + onEnd: function (event) { + // Nothing changed if the row was dropped back in its original position. + if (event.oldIndex === event.newIndex) { + return; + } - // The request is fired programmatically (not from a [data-request] - // element), so show the stripe load indicator manually for feedback. + // The request is fired programmatically (not from a [data-request] element), + // so show the stripe load indicator manually for feedback. var indicator = ($.wn && $.wn.stripeLoadIndicator) || ($.oc && $.oc.stripeLoadIndicator); if (indicator) { indicator.show(); } + // Only the record ids (in their new order) are sent; the server assigns the + // sort order values by position. $list.request(handler, { - data: { record_ids: ids, sort_orders: orders } + data: { record_ids: collectIds(tbody) } }).always(function () { if (indicator) { indicator.hide(); @@ -91,4 +86,4 @@ // Re-initialise after the list partial is replaced by an AJAX update (e.g. onRefresh). $(document).on('render', initAll); -}(window.jQuery); +})(window.jQuery); diff --git a/modules/backend/winter.mix.js b/modules/backend/winter.mix.js index accadafdb9..8cbf862f58 100644 --- a/modules/backend/winter.mix.js +++ b/modules/backend/winter.mix.js @@ -49,6 +49,12 @@ mix './formwidgets/sensitive/assets/js/dist/sensitive.js', ) + // Compile the list widget drag-and-drop reordering bundle (includes SortableJS) + .js( + './widgets/lists/assets/js/src/winter.list.sortable.js', + './widgets/lists/assets/js/dist/winter.list.sortable.js', + ) + // Compile pages .js( './assets/ui/js/pages/Preferences.js', From 13f8f86c4680bacd4e77de3b62765df657d464e2 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Fri, 19 Jun 2026 01:13:38 -0600 Subject: [PATCH 7/8] Guard against overlapping reorder requests; drop dead pivot guards - Ignore further drops while a reorder request is in flight, so rapid successive drags can't race and persist a stale order (the response that lands last would otherwise win). - Remove the `$record->pivot` null guards in applyDeferredRelationOrder: records from a sortable belongsToMany/morphToMany/morphedByMany relation always carry a hydrated pivot, verified in both new-parent and existing-parent deferred/orphan modes, so the guards were unreachable. Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/backend/behaviors/RelationController.php | 3 ++- .../lists/assets/js/dist/winter.list.sortable.js | 2 +- .../lists/assets/js/src/winter.list.sortable.js | 10 ++++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/modules/backend/behaviors/RelationController.php b/modules/backend/behaviors/RelationController.php index 57dae9cd0a..ccea302af0 100644 --- a/modules/backend/behaviors/RelationController.php +++ b/modules/backend/behaviors/RelationController.php @@ -622,12 +622,13 @@ protected function applyDeferredRelationOrder($records) } foreach ($records as $record) { - if ($record->pivot && array_key_exists($record->getKey(), $map)) { + 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(); } diff --git a/modules/backend/widgets/lists/assets/js/dist/winter.list.sortable.js b/modules/backend/widgets/lists/assets/js/dist/winter.list.sortable.js index 228c652230..6aa84580fb 100644 --- a/modules/backend/widgets/lists/assets/js/dist/winter.list.sortable.js +++ b/modules/backend/widgets/lists/assets/js/dist/winter.list.sortable.js @@ -1 +1 @@ -"use strict";(self.webpackChunk_wintercms_wn_backend_module=self.webpackChunk_wintercms_wn_backend_module||[]).push([[700],{901:function(){function t(t,e,n){return(e=function(t){var e=function(t,e){if("object"!=typeof t||!t)return t;var n=t[Symbol.toPrimitive];if(void 0!==n){var o=n.call(t,e||"default");if("object"!=typeof o)return o;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===e?String:Number)(t)}(t,"string");return"symbol"==typeof e?e:e+""}(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function e(){return e=Object.assign?Object.assign.bind():function(t){for(var e=1;e"===e[0]&&(e=e.substring(1)),t)try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return!1}return!1}}function v(t){return t.host&&t!==document&&t.host.nodeType&&t.host!==t?t.host:t.parentNode}function m(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"===e[0]?t.parentNode===n&&g(t,e):g(t,e))||o&&t===n)return t;if(t===n)break}while(t=v(t))}return null}var b,y=/\s+/g;function w(t,e,n){if(t&&e)if(t.classList)t.classList[n?"add":"remove"](e);else{var o=(" "+t.className+" ").replace(y," ").replace(" "+e+" "," ");t.className=(o+(n?" "+e:"")).replace(y," ")}}function E(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];e in o||-1!==e.indexOf("webkit")||(e="-webkit-"+e),o[e]=n+("string"==typeof n?"":"px")}}function _(t,e){var n="";if("string"==typeof t)n=t;else do{var o=E(t,"transform");o&&"none"!==o&&(n=o+" "+n)}while(!e&&(t=t.parentNode));var r=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return r&&new r(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),r=0,i=o.length;if(n)for(;r=i:r<=i))return o;if(o===S())break;o=P(o,!1)}return!1}function x(t,e,n,o){for(var r=0,i=0,a=t.children;i2&&void 0!==arguments[2]?arguments[2]:{},r=n.evt,i=function(t,e){if(null==t)return{};var n,o,r=function(t,e){if(null==t)return{};var n={};for(var o in t)if({}.hasOwnProperty.call(t,o)){if(-1!==e.indexOf(o))continue;n[o]=t[o]}return n}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(o=0;o=o&&"none"===n[xt]||i&&"none"===n[xt]&&s+c>o)?"vertical":"horizontal"},Pt=function(t){function e(t,n){return function(o,r,i,a){var l=o.options.group.name&&r.options.group.name&&o.options.group.name===r.options.group.name;if(null==t&&(n||l))return!0;if(null==t||!1===t)return!1;if(n&&"clone"===t)return t;if("function"==typeof t)return e(t(o,r,i,a),n)(o,r,i,a);var s=(n?o:r).options.group.name;return!0===t||"string"==typeof t&&t===s||t.join&&t.indexOf(s)>-1}}var n={},o=t.group;o&&"object"==r(o)||(o={name:o}),n.name=o.name,n.checkPull=e(o.pull,!0),n.checkPut=e(o.put),n.revertClone=o.revertClone,t.group=n},It=function(){!At&&V&&E(V,"display","none")},Nt=function(){!At&&V&&E(V,"display","")};Tt&&!d&&document.addEventListener("click",function(t){if(bt)return t.preventDefault(),t.stopPropagation&&t.stopPropagation(),t.stopImmediatePropagation&&t.stopImmediatePropagation(),bt=!1,!1},!0);var kt=function(t){if(G){t=t.touches?t.touches[0]:t;var e=(r=t.clientX,i=t.clientY,yt.some(function(t){var e=t[R].options.emptyInsertThreshold;if(e&&!O(t)){var n=T(t),o=r>=n.left-e&&r<=n.right+e,l=i>=n.top-e&&i<=n.bottom+e;return o&&l?a=t:void 0}}),a);if(e){var n={};for(var o in t)t.hasOwnProperty(o)&&(n[o]=t[o]);n.target=n.rootEl=e,n.preventDefault=void 0,n.stopPropagation=void 0,e[R]._onDragOver(n)}}var r,i,a},Xt=function(t){G&&G.parentNode[R]._isOutsideThisEl(t.target)};function Yt(t,n){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=n=e({},n),t[R]=this;var o={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Mt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Yt.supportPointer&&"PointerEvent"in window&&(!c||u),emptyInsertThreshold:5};for(var r in j.initializePlugins(this,t,o),o)!(r in n)&&(n[r]=o[r]);for(var i in Pt(n),this)"_"===i.charAt(0)&&"function"==typeof this[i]&&(this[i]=this[i].bind(this));this.nativeDraggable=!n.forceFallback&&Ot,this.nativeDraggable&&(this.options.touchStartThreshold=1),n.supportPointer?f(t,"pointerdown",this._onTapStart):(f(t,"mousedown",this._onTapStart),f(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(f(t,"dragover",this),f(t,"dragenter",this)),yt.push(this.el),n.store&&n.store.get&&this.sort(n.store.get(this)||[]),e(this,B())}function Rt(t,e,n,o,r,i,s,c){var u,d,h=t[R],f=h.options.onMove;return!window.CustomEvent||a||l?(u=document.createEvent("Event")).initEvent("move",!0,!0):u=new CustomEvent("move",{bubbles:!0,cancelable:!0}),u.to=e,u.from=t,u.dragged=n,u.draggedRect=o,u.related=r||e,u.relatedRect=i||T(e),u.willInsertAfter=c,u.originalEvent=s,t.dispatchEvent(u),f&&(d=f.call(h,u,s)),d}function Bt(t){t.draggable=!1}function Ft(){Dt=!1}function Lt(t){for(var e=t.tagName+t.className+t.src+t.href+t.textContent,n=e.length,o=0;n--;)o+=e.charCodeAt(n);return o.toString(36)}function jt(t){return setTimeout(t,0)}function Ht(t){return clearTimeout(t)}Yt.prototype={constructor:Yt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(ft=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,G):this.options.direction},_onTapStart:function(t){if(t.cancelable){var e=this,n=this.el,o=this.options,r=o.preventOnFilter,i=t.type,a=t.touches&&t.touches[0]||t.pointerType&&"touch"===t.pointerType&&t,l=(a||t).target,s=t.target.shadowRoot&&(t.path&&t.path[0]||t.composedPath&&t.composedPath()[0])||l,u=o.filter;if(function(t){St.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&St.push(o)}}(n),!G&&!(/mousedown|pointerdown/.test(i)&&0!==t.button||o.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!c||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=m(l,o.draggable,n,!1))&&l.animated||K===l)){if(tt=A(l),nt=A(l,o.draggable),"function"==typeof u){if(u.call(this,t,l,this))return q({sortable:e,rootEl:s,name:"filter",targetEl:l,toEl:n,fromEl:n}),z("filter",e,{evt:t}),void(r&&t.preventDefault())}else if(u&&(u=u.split(",").some(function(o){if(o=m(s,o.trim(),n,!1))return q({sortable:e,rootEl:o,name:"filter",targetEl:l,fromEl:n,toEl:n}),z("filter",e,{evt:t}),!0})))return void(r&&t.preventDefault());o.handle&&!m(s,o.handle,n,!1)||this._prepareDragStart(t,a,l)}}},_prepareDragStart:function(t,e,n){var o,r=this,i=r.el,c=r.options,u=i.ownerDocument;if(n&&!G&&n.parentNode===i){var d=T(n);if(Q=i,U=(G=n).parentNode,Z=G.nextSibling,K=n,rt=c.group,Yt.dragged=G,at={target:G,clientX:(e||t).clientX,clientY:(e||t).clientY},ut=at.clientX-d.left,dt=at.clientY-d.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,G.style["will-change"]="all",o=function(){z("delayEnded",r,{evt:t}),Yt.eventCanceled?r._onDrop():(r._disableDelayedDragEvents(),!s&&r.nativeDraggable&&(G.draggable=!0),r._triggerDragStart(t,e),q({sortable:r,name:"choose",originalEvent:t}),w(G,c.chosenClass,!0))},c.ignore.split(",").forEach(function(t){D(G,t.trim(),Bt)}),f(u,"dragover",kt),f(u,"mousemove",kt),f(u,"touchmove",kt),c.supportPointer?(f(u,"pointerup",r._onDrop),!this.nativeDraggable&&f(u,"pointercancel",r._onDrop)):(f(u,"mouseup",r._onDrop),f(u,"touchend",r._onDrop),f(u,"touchcancel",r._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,G.draggable=!0),z("delayStart",this,{evt:t}),!c.delay||c.delayOnTouchOnly&&!e||this.nativeDraggable&&(l||a))o();else{if(Yt.eventCanceled)return void this._onDrop();c.supportPointer?(f(u,"pointerup",r._disableDelayedDrag),f(u,"pointercancel",r._disableDelayedDrag)):(f(u,"mouseup",r._disableDelayedDrag),f(u,"touchend",r._disableDelayedDrag),f(u,"touchcancel",r._disableDelayedDrag)),f(u,"mousemove",r._delayedDragTouchMoveHandler),f(u,"touchmove",r._delayedDragTouchMoveHandler),c.supportPointer&&f(u,"pointermove",r._delayedDragTouchMoveHandler),r._dragStartTimer=setTimeout(o,c.delay)}}},_delayedDragTouchMoveHandler:function(t){var e=t.touches?t.touches[0]:t;Math.max(Math.abs(e.clientX-this._lastX),Math.abs(e.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){G&&Bt(G),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?f(document,"pointermove",this._onTouchMove):f(document,e?"touchmove":"mousemove",this._onTouchMove):(f(G,"dragend",this),f(Q,"dragstart",this._onDragStart));try{document.selection?jt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){if(mt=!1,Q&&G){z("dragStarted",this,{evt:e}),this.nativeDraggable&&f(document,"dragover",Xt);var n=this.options;!t&&w(G,n.dragClass,!1),w(G,n.ghostClass,!0),Yt.active=this,t&&this._appendGhost(),q({sortable:this,name:"start",originalEvent:e})}else this._nulling()},_emulateDragOver:function(){if(lt){this._lastX=lt.clientX,this._lastY=lt.clientY,It();for(var t=document.elementFromPoint(lt.clientX,lt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(lt.clientX,lt.clientY))!==e;)e=t;if(G.parentNode[R]._isOutsideThisEl(t),e)do{if(e[R]){if(e[R]._onDragOver({clientX:lt.clientX,clientY:lt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}t=e}while(e=v(e));Nt()}},_onTouchMove:function(t){if(at){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,r=t.touches?t.touches[0]:t,i=V&&_(V,!0),a=V&&i&&i.a,l=V&&i&&i.d,s=Ct&&vt&&M(vt),c=(r.clientX-at.clientX+o.x)/(a||1)+(s?s[0]-_t[0]:0)/(a||1),u=(r.clientY-at.clientY+o.y)/(l||1)+(s?s[1]-_t[1]:0)/(l||1);if(!Yt.active&&!mt){if(n&&Math.max(Math.abs(r.clientX-this._lastX),Math.abs(r.clientY-this._lastY))r.right+i||t.clientY>o.bottom&&t.clientX>o.left:t.clientY>r.bottom+i||t.clientX>o.right&&t.clientY>o.top}(t,i,this)&&!v.animated){if(v===G)return W(!1);if(v&&a===t.target&&(l=v),l&&(n=T(l)),!1!==Rt(Q,a,G,e,l,n,t,!!l))return H(),v&&v.nextSibling?a.insertBefore(G,v.nextSibling):a.appendChild(G),U=a,K(),W(!0)}else if(v&&function(t,e,n){var o=T(x(n.el,0,n.options,!0)),r=Y(n.el,n.options,V),i=10;return e?t.clientXu+c*i/2:sd-gt)return-pt}else if(s>u+c*(1-r)/2&&sd-c*i/2))return s>u+c/2?1:-1;return 0}(t,l,n,i,M?1:s.swapThreshold,null==s.invertedSwapThreshold?s.swapThreshold:s.invertedSwapThreshold,Et,ft===l),0!==y){var X=A(G);do{X-=y,D=U.children[X]}while(D&&("none"===E(D,"display")||D===V))}if(0===y||D===l)return W(!1);ft=l,pt=y;var B=l.nextElementSibling,F=!1,L=Rt(Q,a,G,e,l,n,t,F=1===y);if(!1!==L)return 1!==L&&-1!==L||(F=1===L),Dt=!0,setTimeout(Ft,30),H(),F&&!B?a.appendChild(G):l.parentNode.insertBefore(G,F?B:l),I&&k(I,0,N-I.scrollTop),U=G.parentNode,void 0===_||Et||(gt=Math.abs(_-T(l)[P])),K(),W(!0)}if(a.contains(G))return W(!1)}return!1}function j(s,c){z(s,p,o({evt:t,isOwner:d,axis:i?"vertical":"horizontal",revert:r,dragRect:e,targetRect:n,canSort:h,fromSortable:f,target:l,completed:W,onMove:function(n,o){return Rt(Q,a,G,e,n,T(n),t,o)},changed:K},c))}function H(){j("dragOverAnimationCapture"),p.captureAnimationState(),p!==f&&f.captureAnimationState()}function W(e){return j("dragOverCompleted",{insertion:e}),e&&(d?u._hideClone():u._showClone(p),p!==f&&(w(G,it?it.options.ghostClass:u.options.ghostClass,!1),w(G,s.ghostClass,!0)),it!==p&&p!==Yt.active?it=p:p===Yt.active&&it&&(it=null),f===p&&(p._ignoreWhileAnimating=l),p.animateAll(function(){j("dragOverAnimationComplete"),p._ignoreWhileAnimating=null}),p!==f&&(f.animateAll(),f._ignoreWhileAnimating=null)),(l===G&&!G.animated||l===a&&!l.animated)&&(ft=null),s.dragoverBubble||t.rootEl||l===document||(G.parentNode[R]._isOutsideThisEl(t.target),!e&&kt(t)),!s.dragoverBubble&&t.stopPropagation&&t.stopPropagation(),g=!0}function K(){et=A(G),ot=A(G,s.draggable),q({sortable:p,name:"change",toEl:a,newIndex:et,newDraggableIndex:ot,originalEvent:t})}},_ignoreWhileAnimating:null,_offMoveEvents:function(){p(document,"mousemove",this._onTouchMove),p(document,"touchmove",this._onTouchMove),p(document,"pointermove",this._onTouchMove),p(document,"dragover",kt),p(document,"mousemove",kt),p(document,"touchmove",kt)},_offUpEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._onDrop),p(t,"touchend",this._onDrop),p(t,"pointerup",this._onDrop),p(t,"pointercancel",this._onDrop),p(t,"touchcancel",this._onDrop),p(document,"selectstart",this)},_onDrop:function(t){var e=this.el,n=this.options;et=A(G),ot=A(G,n.draggable),z("drop",this,{evt:t}),U=G&&G.parentNode,et=A(G),ot=A(G,n.draggable),Yt.eventCanceled||(mt=!1,Et=!1,wt=!1,clearInterval(this._loopId),clearTimeout(this._dragStartTimer),Ht(this.cloneId),Ht(this._dragStartId),this.nativeDraggable&&(p(document,"drop",this),p(e,"dragstart",this._onDragStart)),this._offMoveEvents(),this._offUpEvents(),c&&E(document.body,"user-select",""),E(G,"transform",""),t&&(ht&&(t.cancelable&&t.preventDefault(),!n.dropBubble&&t.stopPropagation()),V&&V.parentNode&&V.parentNode.removeChild(V),(Q===U||it&&"clone"!==it.lastPutMode)&&$&&$.parentNode&&$.parentNode.removeChild($),G&&(this.nativeDraggable&&p(G,"dragend",this),Bt(G),G.style["will-change"]="",ht&&!mt&&w(G,it?it.options.ghostClass:this.options.ghostClass,!1),w(G,this.options.chosenClass,!1),q({sortable:this,name:"unchoose",toEl:U,newIndex:null,newDraggableIndex:null,originalEvent:t}),Q!==U?(et>=0&&(q({rootEl:U,name:"add",toEl:U,fromEl:Q,originalEvent:t}),q({sortable:this,name:"remove",toEl:U,originalEvent:t}),q({rootEl:U,name:"sort",toEl:U,fromEl:Q,originalEvent:t}),q({sortable:this,name:"sort",toEl:U,originalEvent:t})),it&&it.save()):et!==tt&&et>=0&&(q({sortable:this,name:"update",toEl:U,originalEvent:t}),q({sortable:this,name:"sort",toEl:U,originalEvent:t})),Yt.active&&(null!=et&&-1!==et||(et=tt,ot=nt),q({sortable:this,name:"end",toEl:U,originalEvent:t}),this.save())))),this._nulling()},_nulling:function(){z("nulling",this),Q=G=U=V=Z=$=K=J=at=lt=ht=et=ot=tt=nt=ft=pt=it=rt=Yt.dragged=Yt.ghost=Yt.clone=Yt.active=null;var t=this.el;St.forEach(function(e){t.contains(e)&&(e.checked=!0)}),St.length=st=ct=0},handleEvent:function(t){switch(t.type){case"drop":case"dragend":this._onDrop(t);break;case"dragenter":case"dragover":G&&(this._onDragOver(t),function(t){t.dataTransfer&&(t.dataTransfer.dropEffect="move");t.cancelable&&t.preventDefault()}(t));break;case"selectstart":t.preventDefault()}},toArray:function(){for(var t,e=[],n=this.el.children,o=0,r=n.length,i=this.options;o"===e[0]&&(e=e.substring(1)),t)try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return!1}return!1}}function v(t){return t.host&&t!==document&&t.host.nodeType&&t.host!==t?t.host:t.parentNode}function m(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"===e[0]?t.parentNode===n&&g(t,e):g(t,e))||o&&t===n)return t;if(t===n)break}while(t=v(t))}return null}var b,y=/\s+/g;function w(t,e,n){if(t&&e)if(t.classList)t.classList[n?"add":"remove"](e);else{var o=(" "+t.className+" ").replace(y," ").replace(" "+e+" "," ");t.className=(o+(n?" "+e:"")).replace(y," ")}}function E(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];e in o||-1!==e.indexOf("webkit")||(e="-webkit-"+e),o[e]=n+("string"==typeof n?"":"px")}}function _(t,e){var n="";if("string"==typeof t)n=t;else do{var o=E(t,"transform");o&&"none"!==o&&(n=o+" "+n)}while(!e&&(t=t.parentNode));var r=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return r&&new r(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),r=0,i=o.length;if(n)for(;r=i:r<=i))return o;if(o===S())break;o=P(o,!1)}return!1}function x(t,e,n,o){for(var r=0,i=0,a=t.children;i2&&void 0!==arguments[2]?arguments[2]:{},r=n.evt,i=function(t,e){if(null==t)return{};var n,o,r=function(t,e){if(null==t)return{};var n={};for(var o in t)if({}.hasOwnProperty.call(t,o)){if(-1!==e.indexOf(o))continue;n[o]=t[o]}return n}(t,e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);for(o=0;o=o&&"none"===n[xt]||i&&"none"===n[xt]&&s+c>o)?"vertical":"horizontal"},Pt=function(t){function e(t,n){return function(o,r,i,a){var l=o.options.group.name&&r.options.group.name&&o.options.group.name===r.options.group.name;if(null==t&&(n||l))return!0;if(null==t||!1===t)return!1;if(n&&"clone"===t)return t;if("function"==typeof t)return e(t(o,r,i,a),n)(o,r,i,a);var s=(n?o:r).options.group.name;return!0===t||"string"==typeof t&&t===s||t.join&&t.indexOf(s)>-1}}var n={},o=t.group;o&&"object"==r(o)||(o={name:o}),n.name=o.name,n.checkPull=e(o.pull,!0),n.checkPut=e(o.put),n.revertClone=o.revertClone,t.group=n},It=function(){!At&&V&&E(V,"display","none")},Nt=function(){!At&&V&&E(V,"display","")};Tt&&!d&&document.addEventListener("click",function(t){if(bt)return t.preventDefault(),t.stopPropagation&&t.stopPropagation(),t.stopImmediatePropagation&&t.stopImmediatePropagation(),bt=!1,!1},!0);var kt=function(t){if(G){t=t.touches?t.touches[0]:t;var e=(r=t.clientX,i=t.clientY,yt.some(function(t){var e=t[R].options.emptyInsertThreshold;if(e&&!O(t)){var n=T(t),o=r>=n.left-e&&r<=n.right+e,l=i>=n.top-e&&i<=n.bottom+e;return o&&l?a=t:void 0}}),a);if(e){var n={};for(var o in t)t.hasOwnProperty(o)&&(n[o]=t[o]);n.target=n.rootEl=e,n.preventDefault=void 0,n.stopPropagation=void 0,e[R]._onDragOver(n)}}var r,i,a},Xt=function(t){G&&G.parentNode[R]._isOutsideThisEl(t.target)};function Yt(t,n){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=n=e({},n),t[R]=this;var o={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Mt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Yt.supportPointer&&"PointerEvent"in window&&(!c||u),emptyInsertThreshold:5};for(var r in j.initializePlugins(this,t,o),o)!(r in n)&&(n[r]=o[r]);for(var i in Pt(n),this)"_"===i.charAt(0)&&"function"==typeof this[i]&&(this[i]=this[i].bind(this));this.nativeDraggable=!n.forceFallback&&Ot,this.nativeDraggable&&(this.options.touchStartThreshold=1),n.supportPointer?f(t,"pointerdown",this._onTapStart):(f(t,"mousedown",this._onTapStart),f(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(f(t,"dragover",this),f(t,"dragenter",this)),yt.push(this.el),n.store&&n.store.get&&this.sort(n.store.get(this)||[]),e(this,B())}function Rt(t,e,n,o,r,i,s,c){var u,d,h=t[R],f=h.options.onMove;return!window.CustomEvent||a||l?(u=document.createEvent("Event")).initEvent("move",!0,!0):u=new CustomEvent("move",{bubbles:!0,cancelable:!0}),u.to=e,u.from=t,u.dragged=n,u.draggedRect=o,u.related=r||e,u.relatedRect=i||T(e),u.willInsertAfter=c,u.originalEvent=s,t.dispatchEvent(u),f&&(d=f.call(h,u,s)),d}function Bt(t){t.draggable=!1}function Ft(){Dt=!1}function Lt(t){for(var e=t.tagName+t.className+t.src+t.href+t.textContent,n=e.length,o=0;n--;)o+=e.charCodeAt(n);return o.toString(36)}function jt(t){return setTimeout(t,0)}function Ht(t){return clearTimeout(t)}Yt.prototype={constructor:Yt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(ft=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,G):this.options.direction},_onTapStart:function(t){if(t.cancelable){var e=this,n=this.el,o=this.options,r=o.preventOnFilter,i=t.type,a=t.touches&&t.touches[0]||t.pointerType&&"touch"===t.pointerType&&t,l=(a||t).target,s=t.target.shadowRoot&&(t.path&&t.path[0]||t.composedPath&&t.composedPath()[0])||l,u=o.filter;if(function(t){St.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&St.push(o)}}(n),!G&&!(/mousedown|pointerdown/.test(i)&&0!==t.button||o.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!c||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=m(l,o.draggable,n,!1))&&l.animated||K===l)){if(tt=A(l),nt=A(l,o.draggable),"function"==typeof u){if(u.call(this,t,l,this))return q({sortable:e,rootEl:s,name:"filter",targetEl:l,toEl:n,fromEl:n}),z("filter",e,{evt:t}),void(r&&t.preventDefault())}else if(u&&(u=u.split(",").some(function(o){if(o=m(s,o.trim(),n,!1))return q({sortable:e,rootEl:o,name:"filter",targetEl:l,fromEl:n,toEl:n}),z("filter",e,{evt:t}),!0})))return void(r&&t.preventDefault());o.handle&&!m(s,o.handle,n,!1)||this._prepareDragStart(t,a,l)}}},_prepareDragStart:function(t,e,n){var o,r=this,i=r.el,c=r.options,u=i.ownerDocument;if(n&&!G&&n.parentNode===i){var d=T(n);if(Q=i,U=(G=n).parentNode,Z=G.nextSibling,K=n,rt=c.group,Yt.dragged=G,at={target:G,clientX:(e||t).clientX,clientY:(e||t).clientY},ut=at.clientX-d.left,dt=at.clientY-d.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,G.style["will-change"]="all",o=function(){z("delayEnded",r,{evt:t}),Yt.eventCanceled?r._onDrop():(r._disableDelayedDragEvents(),!s&&r.nativeDraggable&&(G.draggable=!0),r._triggerDragStart(t,e),q({sortable:r,name:"choose",originalEvent:t}),w(G,c.chosenClass,!0))},c.ignore.split(",").forEach(function(t){D(G,t.trim(),Bt)}),f(u,"dragover",kt),f(u,"mousemove",kt),f(u,"touchmove",kt),c.supportPointer?(f(u,"pointerup",r._onDrop),!this.nativeDraggable&&f(u,"pointercancel",r._onDrop)):(f(u,"mouseup",r._onDrop),f(u,"touchend",r._onDrop),f(u,"touchcancel",r._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,G.draggable=!0),z("delayStart",this,{evt:t}),!c.delay||c.delayOnTouchOnly&&!e||this.nativeDraggable&&(l||a))o();else{if(Yt.eventCanceled)return void this._onDrop();c.supportPointer?(f(u,"pointerup",r._disableDelayedDrag),f(u,"pointercancel",r._disableDelayedDrag)):(f(u,"mouseup",r._disableDelayedDrag),f(u,"touchend",r._disableDelayedDrag),f(u,"touchcancel",r._disableDelayedDrag)),f(u,"mousemove",r._delayedDragTouchMoveHandler),f(u,"touchmove",r._delayedDragTouchMoveHandler),c.supportPointer&&f(u,"pointermove",r._delayedDragTouchMoveHandler),r._dragStartTimer=setTimeout(o,c.delay)}}},_delayedDragTouchMoveHandler:function(t){var e=t.touches?t.touches[0]:t;Math.max(Math.abs(e.clientX-this._lastX),Math.abs(e.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){G&&Bt(G),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?f(document,"pointermove",this._onTouchMove):f(document,e?"touchmove":"mousemove",this._onTouchMove):(f(G,"dragend",this),f(Q,"dragstart",this._onDragStart));try{document.selection?jt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){if(mt=!1,Q&&G){z("dragStarted",this,{evt:e}),this.nativeDraggable&&f(document,"dragover",Xt);var n=this.options;!t&&w(G,n.dragClass,!1),w(G,n.ghostClass,!0),Yt.active=this,t&&this._appendGhost(),q({sortable:this,name:"start",originalEvent:e})}else this._nulling()},_emulateDragOver:function(){if(lt){this._lastX=lt.clientX,this._lastY=lt.clientY,It();for(var t=document.elementFromPoint(lt.clientX,lt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(lt.clientX,lt.clientY))!==e;)e=t;if(G.parentNode[R]._isOutsideThisEl(t),e)do{if(e[R]){if(e[R]._onDragOver({clientX:lt.clientX,clientY:lt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}t=e}while(e=v(e));Nt()}},_onTouchMove:function(t){if(at){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,r=t.touches?t.touches[0]:t,i=V&&_(V,!0),a=V&&i&&i.a,l=V&&i&&i.d,s=Ct&&vt&&M(vt),c=(r.clientX-at.clientX+o.x)/(a||1)+(s?s[0]-_t[0]:0)/(a||1),u=(r.clientY-at.clientY+o.y)/(l||1)+(s?s[1]-_t[1]:0)/(l||1);if(!Yt.active&&!mt){if(n&&Math.max(Math.abs(r.clientX-this._lastX),Math.abs(r.clientY-this._lastY))r.right+i||t.clientY>o.bottom&&t.clientX>o.left:t.clientY>r.bottom+i||t.clientX>o.right&&t.clientY>o.top}(t,i,this)&&!v.animated){if(v===G)return W(!1);if(v&&a===t.target&&(l=v),l&&(n=T(l)),!1!==Rt(Q,a,G,e,l,n,t,!!l))return H(),v&&v.nextSibling?a.insertBefore(G,v.nextSibling):a.appendChild(G),U=a,K(),W(!0)}else if(v&&function(t,e,n){var o=T(x(n.el,0,n.options,!0)),r=Y(n.el,n.options,V),i=10;return e?t.clientXu+c*i/2:sd-gt)return-pt}else if(s>u+c*(1-r)/2&&sd-c*i/2))return s>u+c/2?1:-1;return 0}(t,l,n,i,M?1:s.swapThreshold,null==s.invertedSwapThreshold?s.swapThreshold:s.invertedSwapThreshold,Et,ft===l),0!==y){var X=A(G);do{X-=y,D=U.children[X]}while(D&&("none"===E(D,"display")||D===V))}if(0===y||D===l)return W(!1);ft=l,pt=y;var B=l.nextElementSibling,F=!1,L=Rt(Q,a,G,e,l,n,t,F=1===y);if(!1!==L)return 1!==L&&-1!==L||(F=1===L),Dt=!0,setTimeout(Ft,30),H(),F&&!B?a.appendChild(G):l.parentNode.insertBefore(G,F?B:l),I&&k(I,0,N-I.scrollTop),U=G.parentNode,void 0===_||Et||(gt=Math.abs(_-T(l)[P])),K(),W(!0)}if(a.contains(G))return W(!1)}return!1}function j(s,c){z(s,p,o({evt:t,isOwner:d,axis:i?"vertical":"horizontal",revert:r,dragRect:e,targetRect:n,canSort:h,fromSortable:f,target:l,completed:W,onMove:function(n,o){return Rt(Q,a,G,e,n,T(n),t,o)},changed:K},c))}function H(){j("dragOverAnimationCapture"),p.captureAnimationState(),p!==f&&f.captureAnimationState()}function W(e){return j("dragOverCompleted",{insertion:e}),e&&(d?u._hideClone():u._showClone(p),p!==f&&(w(G,it?it.options.ghostClass:u.options.ghostClass,!1),w(G,s.ghostClass,!0)),it!==p&&p!==Yt.active?it=p:p===Yt.active&&it&&(it=null),f===p&&(p._ignoreWhileAnimating=l),p.animateAll(function(){j("dragOverAnimationComplete"),p._ignoreWhileAnimating=null}),p!==f&&(f.animateAll(),f._ignoreWhileAnimating=null)),(l===G&&!G.animated||l===a&&!l.animated)&&(ft=null),s.dragoverBubble||t.rootEl||l===document||(G.parentNode[R]._isOutsideThisEl(t.target),!e&&kt(t)),!s.dragoverBubble&&t.stopPropagation&&t.stopPropagation(),g=!0}function K(){et=A(G),ot=A(G,s.draggable),q({sortable:p,name:"change",toEl:a,newIndex:et,newDraggableIndex:ot,originalEvent:t})}},_ignoreWhileAnimating:null,_offMoveEvents:function(){p(document,"mousemove",this._onTouchMove),p(document,"touchmove",this._onTouchMove),p(document,"pointermove",this._onTouchMove),p(document,"dragover",kt),p(document,"mousemove",kt),p(document,"touchmove",kt)},_offUpEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._onDrop),p(t,"touchend",this._onDrop),p(t,"pointerup",this._onDrop),p(t,"pointercancel",this._onDrop),p(t,"touchcancel",this._onDrop),p(document,"selectstart",this)},_onDrop:function(t){var e=this.el,n=this.options;et=A(G),ot=A(G,n.draggable),z("drop",this,{evt:t}),U=G&&G.parentNode,et=A(G),ot=A(G,n.draggable),Yt.eventCanceled||(mt=!1,Et=!1,wt=!1,clearInterval(this._loopId),clearTimeout(this._dragStartTimer),Ht(this.cloneId),Ht(this._dragStartId),this.nativeDraggable&&(p(document,"drop",this),p(e,"dragstart",this._onDragStart)),this._offMoveEvents(),this._offUpEvents(),c&&E(document.body,"user-select",""),E(G,"transform",""),t&&(ht&&(t.cancelable&&t.preventDefault(),!n.dropBubble&&t.stopPropagation()),V&&V.parentNode&&V.parentNode.removeChild(V),(Q===U||it&&"clone"!==it.lastPutMode)&&$&&$.parentNode&&$.parentNode.removeChild($),G&&(this.nativeDraggable&&p(G,"dragend",this),Bt(G),G.style["will-change"]="",ht&&!mt&&w(G,it?it.options.ghostClass:this.options.ghostClass,!1),w(G,this.options.chosenClass,!1),q({sortable:this,name:"unchoose",toEl:U,newIndex:null,newDraggableIndex:null,originalEvent:t}),Q!==U?(et>=0&&(q({rootEl:U,name:"add",toEl:U,fromEl:Q,originalEvent:t}),q({sortable:this,name:"remove",toEl:U,originalEvent:t}),q({rootEl:U,name:"sort",toEl:U,fromEl:Q,originalEvent:t}),q({sortable:this,name:"sort",toEl:U,originalEvent:t})),it&&it.save()):et!==tt&&et>=0&&(q({sortable:this,name:"update",toEl:U,originalEvent:t}),q({sortable:this,name:"sort",toEl:U,originalEvent:t})),Yt.active&&(null!=et&&-1!==et||(et=tt,ot=nt),q({sortable:this,name:"end",toEl:U,originalEvent:t}),this.save())))),this._nulling()},_nulling:function(){z("nulling",this),Q=G=U=V=Z=$=K=J=at=lt=ht=et=ot=tt=nt=ft=pt=it=rt=Yt.dragged=Yt.ghost=Yt.clone=Yt.active=null;var t=this.el;St.forEach(function(e){t.contains(e)&&(e.checked=!0)}),St.length=st=ct=0},handleEvent:function(t){switch(t.type){case"drop":case"dragend":this._onDrop(t);break;case"dragenter":case"dragover":G&&(this._onDragOver(t),function(t){t.dataTransfer&&(t.dataTransfer.dropEffect="move");t.cancelable&&t.preventDefault()}(t));break;case"selectstart":t.preventDefault()}},toArray:function(){for(var t,e=[],n=this.el.children,o=0,r=n.length,i=this.options;o Date: Fri, 19 Jun 2026 01:19:22 -0600 Subject: [PATCH 8/8] Remove now-dead sortOrderColumn / getRecordSortOrder plumbing Since the reorder request sends only record ids (the server assigns the order by position), nothing on the client reads a per-row sort-order value anymore. The `data-record-sort-order` row attribute, the widget's `getRecordSortOrder()` method, and its `sortOrderColumn` property (set by both behaviors but never read) are all dead, so they're removed along with their tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- modules/backend/behaviors/ListController.php | 2 -- .../backend/behaviors/RelationController.php | 1 - .../tests/widgets/ListsSortableTest.php | 28 --------------- modules/backend/widgets/Lists.php | 36 ------------------- .../widgets/lists/partials/_list_body_row.php | 3 -- 5 files changed, 70 deletions(-) diff --git a/modules/backend/behaviors/ListController.php b/modules/backend/behaviors/ListController.php index c14ee5768f..a66138a0f3 100644 --- a/modules/backend/behaviors/ListController.php +++ b/modules/backend/behaviors/ListController.php @@ -198,8 +198,6 @@ public function makeList($definition = null) )); } - $widget->sortOrderColumn = $model->getSortOrderColumn(); - $widget->bindEvent('list.reorder', function ($ids, $orders) use ($model) { $model->setSortableOrder($ids, $orders); }); diff --git a/modules/backend/behaviors/RelationController.php b/modules/backend/behaviors/RelationController.php index ccea302af0..ee51977856 100644 --- a/modules/backend/behaviors/RelationController.php +++ b/modules/backend/behaviors/RelationController.php @@ -818,7 +818,6 @@ protected function makeViewWidget() } $config->sortable = true; - $config->sortOrderColumn = 'pivot[' . $this->model->getRelationSortOrderColumn($this->relationName) . ']'; } $widget = $this->makeWidget('Backend\Widgets\Lists', $config); diff --git a/modules/backend/tests/widgets/ListsSortableTest.php b/modules/backend/tests/widgets/ListsSortableTest.php index c5289deeb9..02918e278a 100644 --- a/modules/backend/tests/widgets/ListsSortableTest.php +++ b/modules/backend/tests/widgets/ListsSortableTest.php @@ -34,7 +34,6 @@ protected function makeList(array $overrides = []): Lists 'alias' => 'testlist', 'arrayName' => 'array', 'sortable' => true, - 'sortOrderColumn' => 'sort_order', 'columns' => [ 'name' => ['type' => 'text', 'label' => 'Name'], 'label' => ['type' => 'text', 'label' => 'Label'], @@ -91,33 +90,6 @@ public function testSortableAddsDragHandleToColumnTotal() ); } - public function testGetRecordSortOrderReadsDirectColumn() - { - $list = $this->makeList(); - $record = new SortableFixture(['sort_order' => 7]); - - $this->assertSame(7, (int) $list->getRecordSortOrder($record)); - } - - public function testGetRecordSortOrderReadsPivotPath() - { - $list = $this->makeList(['sortOrderColumn' => 'pivot[sort_order]']); - - $record = new \stdClass(); - $record->pivot = new \stdClass(); - $record->pivot->sort_order = 4; - - $this->assertSame(4, (int) $list->getRecordSortOrder($record)); - } - - public function testGetRecordSortOrderReturnsNullWhenMissing() - { - $list = $this->makeList(['sortOrderColumn' => 'pivot[sort_order]']); - $record = new SortableFixture(['sort_order' => 7]); // no pivot relation - - $this->assertNull($list->getRecordSortOrder($record)); - } - public function testOnReorderGeneratesSequentialOrdersServerSide() { $records = $this->seedRecords(); diff --git a/modules/backend/widgets/Lists.php b/modules/backend/widgets/Lists.php index 1d7c3b7cb4..536e04349d 100644 --- a/modules/backend/widgets/Lists.php +++ b/modules/backend/widgets/Lists.php @@ -110,13 +110,6 @@ class Lists extends WidgetBase */ public $sortable = false; - /** - * @var string|null Column path used to read the sort order value from each record, - * e.g. "sort_order" (model lists) or "pivot[sort_order]" (relation lists). Set by the - * controlling behavior; the widget itself has no knowledge of relations or parent models. - */ - public $sortOrderColumn = null; - /** * @var bool|string Display pagination when limiting records per page. */ @@ -239,7 +232,6 @@ public function init() 'showPagination', 'customViewPath', 'sortable', - 'sortOrderColumn', ]); /* @@ -441,34 +433,6 @@ public function onReorder() return $this->onRefresh(); } - /** - * Reads the sort order value from a record using the configured sortOrderColumn path - * (e.g. "sort_order" or "pivot[sort_order]"). Returns null when not available. - */ - public function getRecordSortOrder($record) - { - if (!$this->sortOrderColumn) { - return null; - } - - $value = $record; - foreach (HtmlHelper::nameToArray($this->sortOrderColumn) as $part) { - if (is_array($value)) { - $value = $value[$part] ?? null; - } elseif (is_object($value)) { - $value = $value->{$part} ?? null; - } else { - return null; - } - - if ($value === null) { - return null; - } - } - - return $value; - } - /** * Event handler for switching the page number. */ diff --git a/modules/backend/widgets/lists/partials/_list_body_row.php b/modules/backend/widgets/lists/partials/_list_body_row.php index 0156252dc0..6b84ede816 100644 --- a/modules/backend/widgets/lists/partials/_list_body_row.php +++ b/modules/backend/widgets/lists/partials/_list_body_row.php @@ -5,9 +5,6 @@ ?> - data-record-sort-order="getRecordSortOrder($record)) ?>" - > makePartial('list_body_checkbox', ['record' => $record]) ?>
diff --git a/modules/backend/widgets/lists/partials/_list_head_row.php b/modules/backend/widgets/lists/partials/_list_head_row.php index f440feb1eb..a538d57ce1 100644 --- a/modules/backend/widgets/lists/partials/_list_head_row.php +++ b/modules/backend/widgets/lists/partials/_list_head_row.php @@ -14,6 +14,10 @@ + + Date: Thu, 18 Jun 2026 22:55:19 -0600 Subject: [PATCH 2/8] Use stripe load indicator when firing the request to reorder records --- .../widgets/lists/assets/js/winter.list.sortable.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/modules/backend/widgets/lists/assets/js/winter.list.sortable.js b/modules/backend/widgets/lists/assets/js/winter.list.sortable.js index b6f45ed8a4..6c165c61ec 100644 --- a/modules/backend/widgets/lists/assets/js/winter.list.sortable.js +++ b/modules/backend/widgets/lists/assets/js/winter.list.sortable.js @@ -52,8 +52,20 @@ var orders = ids.map(function (id, index) { return index + 1; }); + + // The request is fired programmatically (not from a [data-request] + // element), so show the stripe load indicator manually for feedback. + var indicator = ($.wn && $.wn.stripeLoadIndicator) || ($.oc && $.oc.stripeLoadIndicator); + if (indicator) { + indicator.show(); + } + $list.request(handler, { data: { record_ids: ids, sort_orders: orders } + }).always(function () { + if (indicator) { + indicator.hide(); + } }); } }); From ce9818f21d5429c28d0e755d67a8a4e59ce22d22 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Thu, 18 Jun 2026 23:04:14 -0600 Subject: [PATCH 3/8] Don't flag the form as changed when reordering relation records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SortableJS dispatches a native `change` event on the list root (the tbody) while an item is being dragged. Inside a form (e.g. a RelationController relation list), that event bubbled up to the form's change monitor and flagged it as having unsaved changes, triggering the "Changes you made may not be saved" prompt on reload — even though the new order is persisted immediately via AJAX. Stop the synthetic change event (whose target is the tbody itself) from bubbling past the list. Real form-field changes (target = input / select / textarea, e.g. the row checkboxes) are left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../widgets/lists/assets/js/winter.list.sortable.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/modules/backend/widgets/lists/assets/js/winter.list.sortable.js b/modules/backend/widgets/lists/assets/js/winter.list.sortable.js index 6c165c61ec..e750420f7f 100644 --- a/modules/backend/widgets/lists/assets/js/winter.list.sortable.js +++ b/modules/backend/widgets/lists/assets/js/winter.list.sortable.js @@ -40,6 +40,17 @@ var $list = $(listEl); + // SortableJS dispatches a native "change" event on the list root (the tbody) while + // an item is being dragged. Stop it bubbling to the surrounding form's change monitor + // so reordering — which persists immediately — does not flag the form as having + // unsaved changes. Real form-field changes (target = input/select/textarea) are left + // untouched. + tbody.addEventListener('change', function (event) { + if (event.target === tbody) { + event.stopPropagation(); + } + }); + tbody.wnListSortable = window.Sortable.create(tbody, { handle: '.list-sort-handle', draggable: 'tr', From fea679ef41647843d63960f0b9fae600710bd4f6 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Thu, 18 Jun 2026 23:13:28 -0600 Subject: [PATCH 4/8] Make the whole drag-handle cell the reorder target The drag handle was only as large as its icon (~11x14px), making it an awkward target. Use the handle cell itself as the SortableJS handle and put the grab cursor on the cell, so the entire cell is grabbable. The row height is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lists/assets/css/winter.list.sortable.css | 20 +++++++++++-------- .../lists/assets/js/winter.list.sortable.js | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/modules/backend/widgets/lists/assets/css/winter.list.sortable.css b/modules/backend/widgets/lists/assets/css/winter.list.sortable.css index 57f58f4479..361d5bec2a 100644 --- a/modules/backend/widgets/lists/assets/css/winter.list.sortable.css +++ b/modules/backend/widgets/lists/assets/css/winter.list.sortable.css @@ -5,27 +5,31 @@ .control-list .list-cell-sort-handle { width: 28px; text-align: center; - padding-left: 4px; - padding-right: 4px; } -.control-list .list-sort-handle { +/* The whole handle cell is the drag target (SortableJS handle is the cell), + so the grab affordance and hit area cover the entire cell, not just the icon. */ +.control-list .list-cell-sort-handle { cursor: move; cursor: grab; - color: #ccc; +} + +.control-list .list-cell-sort-handle:active { + cursor: grabbing; +} + +.control-list .list-sort-handle { display: inline-block; + color: #ccc; line-height: 1; text-decoration: none; + cursor: inherit; } .control-list tr:hover .list-sort-handle { color: #999; } -.control-list .list-sort-handle:active { - cursor: grabbing; -} - .control-list .list-sortable-ghost { opacity: 0.5; background: #f0f7fd; diff --git a/modules/backend/widgets/lists/assets/js/winter.list.sortable.js b/modules/backend/widgets/lists/assets/js/winter.list.sortable.js index e750420f7f..d02b5a8527 100644 --- a/modules/backend/widgets/lists/assets/js/winter.list.sortable.js +++ b/modules/backend/widgets/lists/assets/js/winter.list.sortable.js @@ -52,7 +52,7 @@ }); tbody.wnListSortable = window.Sortable.create(tbody, { - handle: '.list-sort-handle', + handle: '.list-cell-sort-handle', draggable: 'tr', filter: '.no-data', animation: 150, From af483ea2365c73222ad71c1deea74b98c08505f5 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Thu, 18 Jun 2026 23:48:27 -0600 Subject: [PATCH 5/8] Refine the list drag handle: non-link element, visible icon, grab cursor - Use a for the drag handle instead of . The handle isn't a navigation target, and an anchor is natively draggable (which also fights custom cursors); a span avoids both. - Set the handle icon colour explicitly (#666, #333 on row hover). The anchor previously inherited the list's link colour; the span needs it set so the icon stays as visible as before. - Override rowlink's `tr.rowlink td.nolink { cursor: auto }` rule so the grab cursor shows across the whole handle cell on lists whose rows are clickable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lists/assets/css/winter.list.sortable.css | 17 +++++++++++------ .../widgets/lists/partials/_list_body_row.php | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/modules/backend/widgets/lists/assets/css/winter.list.sortable.css b/modules/backend/widgets/lists/assets/css/winter.list.sortable.css index 361d5bec2a..f6d8fa510c 100644 --- a/modules/backend/widgets/lists/assets/css/winter.list.sortable.css +++ b/modules/backend/widgets/lists/assets/css/winter.list.sortable.css @@ -7,27 +7,32 @@ text-align: center; } -/* The whole handle cell is the drag target (SortableJS handle is the cell), - so the grab affordance and hit area cover the entire cell, not just the icon. */ -.control-list .list-cell-sort-handle { +/* The whole handle cell is the drag target (SortableJS handle is the cell), so + the grab affordance and hit area cover the entire cell, not just the icon. + The second selector is specific enough to override rowlink's + "tr.rowlink td.nolink { cursor: auto }" rule, which would otherwise reset the + cursor on this (intentionally) non-link cell when the list rows are clickable. */ +.control-list .list-cell-sort-handle, +.control-list tr.rowlink td.list-cell-sort-handle.nolink { cursor: move; cursor: grab; } -.control-list .list-cell-sort-handle:active { +.control-list .list-cell-sort-handle:active, +.control-list tr.rowlink td.list-cell-sort-handle.nolink:active { cursor: grabbing; } .control-list .list-sort-handle { display: inline-block; - color: #ccc; + color: #666; line-height: 1; text-decoration: none; cursor: inherit; } .control-list tr:hover .list-sort-handle { - color: #999; + color: #333; } .control-list .list-sortable-ghost { diff --git a/modules/backend/widgets/lists/partials/_list_body_row.php b/modules/backend/widgets/lists/partials/_list_body_row.php index 7e4617bad1..0156252dc0 100644 --- a/modules/backend/widgets/lists/partials/_list_body_row.php +++ b/modules/backend/widgets/lists/partials/_list_body_row.php @@ -23,7 +23,7 @@