diff --git a/modules/backend/behaviors/ListController.php b/modules/backend/behaviors/ListController.php index 292a2da686..a66138a0f3 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,42 @@ public function makeList($definition = null) */ $widget = $this->makeWidget(\Backend\Widgets\Lists::class, $columnConfig); + /* + * Drag-and-drop reordering - requires the model to use the Sortable trait. + */ + if (!empty($listConfig->sortable)) { + if (!in_array(\Winter\Storm\Database\Traits\Sortable::class, class_uses_recursive($model))) { + throw new ApplicationException(sprintf( + 'To use "sortable" on a list, the model "%s" must use the %s trait.', + get_class($model), + \Winter\Storm\Database\Traits\Sortable::class + )); + } + + /* + * Drag-and-drop reordering presents every record in a single fixed order, so it + * cannot coexist with features that show a partial or re-ordered view. Reject those + * combinations up front rather than silently producing a wrong order. + */ + $toolbar = $listConfig->toolbar ?? null; + $conflicts = array_keys(array_filter([ + 'toolbar search' => is_array($toolbar) && !empty($toolbar['search']), + 'filter' => $listConfig->filter ?? null, + 'recordsPerPage' => $listConfig->recordsPerPage ?? null, + 'defaultSort' => $listConfig->defaultSort ?? null, + ])); + if ($conflicts) { + throw new ApplicationException(sprintf( + 'A "sortable" list cannot also use: %s. Drag-and-drop reordering requires the whole list in a fixed order. Remove these options, or use the ReorderController for a dedicated reordering page.', + implode(', ', $conflicts) + )); + } + + $widget->bindEvent('list.reorder', function ($ids, $orders) use ($model) { + $model->setSortableOrder($ids, $orders); + }); + } + $widget->bindEvent('list.extendColumnsBefore', function () use ($widget) { $this->controller->listExtendColumnsBefore($widget); }); diff --git a/modules/backend/behaviors/RelationController.php b/modules/backend/behaviors/RelationController.php index 293051679c..ee51977856 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,76 @@ public function relationGetSessionKey($force = false) return $this->sessionKey = FormHelper::getSessionKey(); } + /** + * Present a sortable relation's records in their stored order while operating in + * deferred mode. + * + * When the relation is deferred, withDeferred() builds the query in "orphan" mode and + * cannot order by (or even surface) the pivot sort column. The sort order therefore has + * to be resolved in PHP from the deferred_bindings pivot_data (which overrides any + * already-committed pivot rows), written onto each record's in-memory pivot, and the + * collection re-sorted. This keeps the Lists widget completely relation-agnostic - it + * just reads the pivot[sort_order] path as usual. + */ + protected function applyDeferredRelationOrder($records) + { + if (!$this->deferredBinding || !$this->model->isSortableRelation($this->relationName)) { + return $records; + } + + $column = $this->model->getRelationSortOrderColumn($this->relationName); + $sessionKey = $this->relationGetSessionKey(); + $map = []; + + /* + * Committed pivot rows (none when the parent record does not yet exist). + */ + if ($this->model->exists) { + $relation = $this->model->{$this->relationName}(); + $query = Db::table($relation->getTable()) + ->where($relation->getForeignPivotKeyName(), $this->model->getKey()); + + // Constrain morphToMany pivots by the parent morph type. + if (method_exists($relation, 'getMorphType') && method_exists($relation, 'getMorphClass')) { + $query->where($relation->getMorphType(), $relation->getMorphClass()); + } + + $map = $query + ->pluck($column, $relation->getRelatedPivotKeyName()) + ->map(function ($value) { + return (int) $value; + }) + ->all(); + } + + /* + * Deferred "bind" rows override the committed order. + */ + $bindings = DeferredBinding::where('master_type', get_class($this->model)) + ->where('master_field', $this->relationName) + ->where('session_key', $sessionKey) + ->where('is_bind', 1) + ->get(); + + foreach ($bindings as $binding) { + $pivotData = $binding->pivot_data ?: []; + if (array_key_exists($column, $pivotData)) { + $map[$binding->slave_id] = (int) $pivotData[$column]; + } + } + + foreach ($records as $record) { + if (array_key_exists($record->getKey(), $map)) { + $record->pivot->{$column} = $map[$record->getKey()]; + } + } + + return $records->sortBy(function ($record) use ($column) { + // Deferred records not yet assigned an order sort to the end. + return $record->pivot->{$column} ?? PHP_INT_MAX; + })->values(); + } + // // Widgets // @@ -709,8 +780,62 @@ protected function makeViewWidget() $config->noRecordsMessage = $emptyMessage; } + /* + * Drag-and-drop reordering - requires the parent model to use the + * HasSortableRelations trait and to declare this relation in $sortableRelations. + */ + $sortable = $this->getConfig('view[sortable]', false); + if ($sortable) { + if ( + !in_array(\Winter\Storm\Database\Traits\HasSortableRelations::class, class_uses_recursive($this->model)) + || !$this->model->isSortableRelation($this->relationName) + ) { + throw new ApplicationException(sprintf( + 'To use "sortable" on the "%s" relation, the model "%s" must use the %s trait and declare the relation in $sortableRelations.', + $this->relationName, + get_class($this->model), + \Winter\Storm\Database\Traits\HasSortableRelations::class + )); + } + + /* + * Drag-and-drop reordering presents every record in a single fixed order, so it + * cannot coexist with features that show a partial or re-ordered view. Reject + * those combinations up front rather than silently producing a wrong order. + */ + $conflicts = array_keys(array_filter([ + 'showSearch' => $this->getConfig('view[showSearch]'), + 'filter' => $this->getConfig('view[filter]'), + 'recordsPerPage' => $this->getConfig('view[recordsPerPage]'), + 'defaultSort' => $this->getConfig('view[defaultSort]'), + ])); + if ($conflicts) { + throw new ApplicationException(sprintf( + 'The "%s" relation cannot combine "sortable" with: %s. Drag-and-drop reordering requires the whole relation in a fixed order. Remove these options, or use the ReorderController for a dedicated reordering page.', + $this->relationName, + implode(', ', $conflicts) + )); + } + + $config->sortable = true; + } + $widget = $this->makeWidget('Backend\Widgets\Lists', $config); + /* + * Persist reordering and, in deferred mode, present records in their dragged order. + */ + if ($sortable) { + $widget->bindEvent('list.reorder', function ($ids, $orders) { + $sessionKey = $this->deferredBinding ? $this->relationGetSessionKey() : null; + $this->model->setRelationOrder($this->relationName, $ids, $orders, $sessionKey); + }); + + $widget->bindEvent('list.extendRecords', function ($records) { + return $this->applyDeferredRelationOrder($records); + }); + } + /* * Apply defined constraints */ @@ -756,6 +881,17 @@ protected function makeViewWidget() || $this->relationType === 'morphedByMany' ) { $this->relationObject->setQuery($query->getQuery()); + + /* + * In deferred mode withDeferred() builds the query in "orphan" mode with no + * pivot join, so the relation's pivot-based order clause is invalid SQL. + * Sortable relations are ordered in PHP instead (applyDeferredRelationOrder()). + */ + if ($sessionKey && $this->getConfig('view[sortable]', false)) { + $query->reorder(); + $this->relationObject->reorder(); + } + return $this->relationObject; } }); 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..003f8b032c 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..02918e278a --- /dev/null +++ b/modules/backend/tests/widgets/ListsSortableTest.php @@ -0,0 +1,136 @@ +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, + '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 testOnReorderGeneratesSequentialOrdersServerSide() + { + $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]; + }); + + // 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'); + $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]]); + + $this->expectException(ApplicationException::class); + $list->onReorder(); + } + + public function testOnReorderThrowsWhenNotSortable() + { + $list = $this->makeList(['sortable' => false]); + $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 2b3aa59b80..536e04349d 100644 --- a/modules/backend/widgets/Lists.php +++ b/modules/backend/widgets/Lists.php @@ -103,6 +103,13 @@ 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 bool|string Display pagination when limiting records per page. */ @@ -224,6 +231,7 @@ public function init() 'treeExpanded', 'showPagination', 'customViewPath', + 'sortable', ]); /* @@ -237,6 +245,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 +268,12 @@ 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/dist/winter.list.sortable.js', 'core'); + $this->addCss('css/winter.list.sortable.css', 'core'); + } } /** @@ -281,6 +304,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 +382,57 @@ 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 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() + { + if (!$this->sortable) { + throw new ApplicationException('Reordering is not enabled for this list.'); + } + + $ids = post('record_ids'); + + if (!is_array($ids) || !count($ids)) { + return; + } + + /* + * Security: only permit reordering records that are visible within the current + * query scope. This prevents a crafted request from reordering arbitrary records. + */ + $allowed = array_flip(array_map('strval', $this->prepareQuery()->pluck($this->model->getQualifiedKeyName())->all())); + foreach ($ids as $id) { + 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 + * 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(); + } + /** * Event handler for switching the page number. */ @@ -1037,6 +1113,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 +1228,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..f6d8fa510c --- /dev/null +++ b/modules/backend/widgets/lists/assets/css/winter.list.sortable.css @@ -0,0 +1,50 @@ +/* + * 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; +} + +/* 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 tr.rowlink td.list-cell-sort-handle.nolink:active { + cursor: grabbing; +} + +.control-list .list-sort-handle { + display: inline-block; + color: #666; + line-height: 1; + text-decoration: none; + cursor: inherit; +} + +.control-list tr:hover .list-sort-handle { + color: #333; +} + +.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/dist/winter.list.sortable.js b/modules/backend/widgets/lists/assets/js/dist/winter.list.sortable.js new file mode 100644 index 0000000000..6aa84580fb --- /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;o 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 ($) { + "use strict"; + + 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); + + // 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(); + } + }); + + var reorderInFlight = false; + + 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 (event) { + // Nothing changed if the row was dropped back in its original position. + if (event.oldIndex === event.newIndex) { + return; + } + + // Ignore further drops until the current reorder has been persisted and the + // list re-rendered, so overlapping requests can't race and persist a stale order. + if (reorderInFlight) { + return; + } + reorderInFlight = true; + + // 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: collectIds(tbody) } + }).always(function () { + reorderInFlight = false; + if (indicator) { + indicator.hide(); + } + }); + } + }); + } + + 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..6b84ede816 100644 --- a/modules/backend/widgets/lists/partials/_list_body_row.php +++ b/modules/backend/widgets/lists/partials/_list_body_row.php @@ -3,7 +3,9 @@ $childRecords = $showTree ? $record->getChildren() : null; $treeLevelClass = $showTree ? 'list-tree-level-'.$treeLevel : ''; ?> - +makePartial('list_body_checkbox', ['record' => $record]) ?> @@ -16,6 +18,12 @@ ]) ?> + + + + $column): ?> + + $column): ?> sortable): ?>
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 @@ + +