|
6 | 6 | use Form as FormHelper; |
7 | 7 | use Backend\Classes\ControllerBehavior; |
8 | 8 | use Winter\Storm\Database\Model; |
| 9 | +use Winter\Storm\Database\Models\DeferredBinding; |
9 | 10 | use ApplicationException; |
10 | 11 |
|
11 | 12 | /** |
@@ -562,6 +563,76 @@ public function relationGetSessionKey($force = false) |
562 | 563 | return $this->sessionKey = FormHelper::getSessionKey(); |
563 | 564 | } |
564 | 565 |
|
| 566 | + /** |
| 567 | + * Present a sortable relation's records in their stored order while operating in |
| 568 | + * deferred mode. |
| 569 | + * |
| 570 | + * When the relation is deferred, withDeferred() builds the query in "orphan" mode and |
| 571 | + * cannot order by (or even surface) the pivot sort column. The sort order therefore has |
| 572 | + * to be resolved in PHP from the deferred_bindings pivot_data (which overrides any |
| 573 | + * already-committed pivot rows), written onto each record's in-memory pivot, and the |
| 574 | + * collection re-sorted. This keeps the Lists widget completely relation-agnostic - it |
| 575 | + * just reads the pivot[sort_order] path as usual. |
| 576 | + */ |
| 577 | + protected function applyDeferredRelationOrder($records) |
| 578 | + { |
| 579 | + if (!$this->deferredBinding || !$this->model->isSortableRelation($this->relationName)) { |
| 580 | + return $records; |
| 581 | + } |
| 582 | + |
| 583 | + $column = $this->model->getRelationSortOrderColumn($this->relationName); |
| 584 | + $sessionKey = $this->relationGetSessionKey(); |
| 585 | + $map = []; |
| 586 | + |
| 587 | + /* |
| 588 | + * Committed pivot rows (none when the parent record does not yet exist). |
| 589 | + */ |
| 590 | + if ($this->model->exists) { |
| 591 | + $relation = $this->model->{$this->relationName}(); |
| 592 | + $query = Db::table($relation->getTable()) |
| 593 | + ->where($relation->getForeignPivotKeyName(), $this->model->getKey()); |
| 594 | + |
| 595 | + // Constrain morphToMany pivots by the parent morph type. |
| 596 | + if (method_exists($relation, 'getMorphType') && method_exists($relation, 'getMorphClass')) { |
| 597 | + $query->where($relation->getMorphType(), $relation->getMorphClass()); |
| 598 | + } |
| 599 | + |
| 600 | + $map = $query |
| 601 | + ->pluck($column, $relation->getRelatedPivotKeyName()) |
| 602 | + ->map(function ($value) { |
| 603 | + return (int) $value; |
| 604 | + }) |
| 605 | + ->all(); |
| 606 | + } |
| 607 | + |
| 608 | + /* |
| 609 | + * Deferred "bind" rows override the committed order. |
| 610 | + */ |
| 611 | + $bindings = DeferredBinding::where('master_type', get_class($this->model)) |
| 612 | + ->where('master_field', $this->relationName) |
| 613 | + ->where('session_key', $sessionKey) |
| 614 | + ->where('is_bind', 1) |
| 615 | + ->get(); |
| 616 | + |
| 617 | + foreach ($bindings as $binding) { |
| 618 | + $pivotData = $binding->pivot_data ?: []; |
| 619 | + if (array_key_exists($column, $pivotData)) { |
| 620 | + $map[$binding->slave_id] = (int) $pivotData[$column]; |
| 621 | + } |
| 622 | + } |
| 623 | + |
| 624 | + foreach ($records as $record) { |
| 625 | + if (array_key_exists($record->getKey(), $map)) { |
| 626 | + $record->pivot->{$column} = $map[$record->getKey()]; |
| 627 | + } |
| 628 | + } |
| 629 | + |
| 630 | + return $records->sortBy(function ($record) use ($column) { |
| 631 | + // Deferred records not yet assigned an order sort to the end. |
| 632 | + return $record->pivot->{$column} ?? PHP_INT_MAX; |
| 633 | + })->values(); |
| 634 | + } |
| 635 | + |
565 | 636 | // |
566 | 637 | // Widgets |
567 | 638 | // |
@@ -709,8 +780,62 @@ protected function makeViewWidget() |
709 | 780 | $config->noRecordsMessage = $emptyMessage; |
710 | 781 | } |
711 | 782 |
|
| 783 | + /* |
| 784 | + * Drag-and-drop reordering - requires the parent model to use the |
| 785 | + * HasSortableRelations trait and to declare this relation in $sortableRelations. |
| 786 | + */ |
| 787 | + $sortable = $this->getConfig('view[sortable]', false); |
| 788 | + if ($sortable) { |
| 789 | + if ( |
| 790 | + !in_array(\Winter\Storm\Database\Traits\HasSortableRelations::class, class_uses_recursive($this->model)) |
| 791 | + || !$this->model->isSortableRelation($this->relationName) |
| 792 | + ) { |
| 793 | + throw new ApplicationException(sprintf( |
| 794 | + 'To use "sortable" on the "%s" relation, the model "%s" must use the %s trait and declare the relation in $sortableRelations.', |
| 795 | + $this->relationName, |
| 796 | + get_class($this->model), |
| 797 | + \Winter\Storm\Database\Traits\HasSortableRelations::class |
| 798 | + )); |
| 799 | + } |
| 800 | + |
| 801 | + /* |
| 802 | + * Drag-and-drop reordering presents every record in a single fixed order, so it |
| 803 | + * cannot coexist with features that show a partial or re-ordered view. Reject |
| 804 | + * those combinations up front rather than silently producing a wrong order. |
| 805 | + */ |
| 806 | + $conflicts = array_keys(array_filter([ |
| 807 | + 'showSearch' => $this->getConfig('view[showSearch]'), |
| 808 | + 'filter' => $this->getConfig('view[filter]'), |
| 809 | + 'recordsPerPage' => $this->getConfig('view[recordsPerPage]'), |
| 810 | + 'defaultSort' => $this->getConfig('view[defaultSort]'), |
| 811 | + ])); |
| 812 | + if ($conflicts) { |
| 813 | + throw new ApplicationException(sprintf( |
| 814 | + 'The "%s" relation cannot combine "sortable" with: %s. Drag-and-drop reordering requires the whole relation in a fixed order. Remove these options, or use the ReorderController for a dedicated reordering page.', |
| 815 | + $this->relationName, |
| 816 | + implode(', ', $conflicts) |
| 817 | + )); |
| 818 | + } |
| 819 | + |
| 820 | + $config->sortable = true; |
| 821 | + } |
| 822 | + |
712 | 823 | $widget = $this->makeWidget('Backend\Widgets\Lists', $config); |
713 | 824 |
|
| 825 | + /* |
| 826 | + * Persist reordering and, in deferred mode, present records in their dragged order. |
| 827 | + */ |
| 828 | + if ($sortable) { |
| 829 | + $widget->bindEvent('list.reorder', function ($ids, $orders) { |
| 830 | + $sessionKey = $this->deferredBinding ? $this->relationGetSessionKey() : null; |
| 831 | + $this->model->setRelationOrder($this->relationName, $ids, $orders, $sessionKey); |
| 832 | + }); |
| 833 | + |
| 834 | + $widget->bindEvent('list.extendRecords', function ($records) { |
| 835 | + return $this->applyDeferredRelationOrder($records); |
| 836 | + }); |
| 837 | + } |
| 838 | + |
714 | 839 | /* |
715 | 840 | * Apply defined constraints |
716 | 841 | */ |
@@ -756,6 +881,17 @@ protected function makeViewWidget() |
756 | 881 | || $this->relationType === 'morphedByMany' |
757 | 882 | ) { |
758 | 883 | $this->relationObject->setQuery($query->getQuery()); |
| 884 | + |
| 885 | + /* |
| 886 | + * In deferred mode withDeferred() builds the query in "orphan" mode with no |
| 887 | + * pivot join, so the relation's pivot-based order clause is invalid SQL. |
| 888 | + * Sortable relations are ordered in PHP instead (applyDeferredRelationOrder()). |
| 889 | + */ |
| 890 | + if ($sessionKey && $this->getConfig('view[sortable]', false)) { |
| 891 | + $query->reorder(); |
| 892 | + $this->relationObject->reorder(); |
| 893 | + } |
| 894 | + |
759 | 895 | return $this->relationObject; |
760 | 896 | } |
761 | 897 | }); |
|
0 commit comments