Skip to content

Drag-and-Drop Sorting for Lists & RelationControllers #1472

@LukeTowers

Description

@LukeTowers

Context

Winter CMS has no built-in way to reorder records inline within a List widget or a RelationController list. The ReorderController exists as a standalone page, but there's no way to drag-and-drop sort rows directly in a list — particularly in relation lists embedded in forms. This has been a longstanding feature request (octobercms/october#3010).

Prior art:

  • October CMS (shipped) — Created a ListStructure widget extending Lists, a SortableRelation trait with pivotSortable config in relation definitions, and uses SortableJS library. The SortableRelation trait auto-configures pivot columns and ordering on boot.
  • wintercms/winter#553 — Merges ReorderController into ListController for inline sorting. Handles Sortable and NestedTree models. Patches list.sortable.js for <tr> support. Does NOT address RelationController.
  • octobercms/october#5298 — Original proposal: separate ReorderRelationController behavior + inline list sorting. Later evolved into the shipped ListStructure approach.
  • wintercms/storm#94 — Added HasSortableRelations trait to Storm. Provides auto-attach ordering, setRelationOrder(), pivot column injection. Needs enhancements for order clause injection, deferred bindings, and isSortableRelation().

Key decision: build on existing HasSortableRelations trait rather than creating a new SortableRelation trait. The existing trait (storm/src/Database/Traits/HasSortableRelations.php) already provides auto-attach ordering, setRelationOrder(), pivot column injection, and non-pivot relation support. It needs enhancements for: auto-ordering query results, deferred binding support, and an isSortableRelation() method.

Key decision: use SortableJS instead of patching the existing HTML5 DnD list.sortable.js. HTML5 drag-and-drop on <tr> elements has known Firefox/Safari quirks that caused recurring issues in prior attempts (Winter PR #553, October PR #5298). SortableJS is a mature, well-tested library with native table row support. No changes to list.sortable.js — ReorderController continues using it unchanged.

Design Principles

  1. Inline-first — Reorder directly in the list, no popup/modal.
  2. Zero config where possible$sortableRelations on the model + view[sortable]: true is all a developer needs.
  3. Use SortableJS — Proven cross-browser DnD library, no patches to existing list.sortable.js.
  4. No new widget/behavior classes — Extend Lists, ListController, RelationController.
  5. Build on HasSortableRelations — Enhance the existing Storm trait, don't duplicate it.
  6. Deferred binding support — Reordering works on unsaved parent records via existing pivot_data infrastructure.

Architecture

Developer config                    Auto-detected from model traits
─────────────────                   ────────────────────────────────
Model:                              Sortable → model list reorder
  $sortableRelations = [...]        HasSortableRelations → relation reorder
Relation YAML:
  view[sortable]: true
List YAML:
  sortable: true
        │
        ▼
┌──────────────────────────────────────────────────────────┐
│  Behavior layer (event listeners)                         │
│                                                           │
│  ListController.makeList():                               │
│    binds list.reorder → model.setSortableOrder()          │
│                                                           │
│  RelationController.makeViewWidget():                     │
│    sets sortOrderColumn = 'pivot.sort_order'              │
│    binds list.reorder → model.setRelationOrder()          │
│    passes sessionKey for deferred binding support         │
│    (setRelationOrder handles both cases internally)       │
├──────────────────────────────────────────────────────────┤
│  Lists widget                                             │
│  - Auto-injects drag handle column                        │
│  - Forces ORDER BY sort_order ASC                         │
│  - Disables column header sorting + pagination            │
│  - onReorder() handler (aliased per widget instance)      │
│  - Fires list.reorder event → behavior handles it         │
├──────────────────────────────────────────────────────────┤
│  winter.list.js (Snowboard plugin, npm sortablejs via Mix) │
│  - Checkboxes, drag-scroll (existing, rewritten)          │
│  - SortableJS on <tbody> with handle option (new)         │
│  - On sort end: POSTs record_ids[] + sort_orders[]        │
│  - ajaxUpdate listener re-inits after list refresh        │
└──────────────────────────────────────────────────────────┘

Implementation

1. Enhance HasSortableRelations trait (Winter Storm)

File: src/Database/Traits/HasSortableRelations.php (existing — enhance, don't replace)

Fixes:

  • Fix typo on line 5: Winter\Sorm\Database\ModelWinter\Storm\Database\Model

New method — isSortableRelation():

public function isSortableRelation(string $relationName): bool
{
    return array_key_exists($relationName, $this->getSortableRelations());
}

Enhance existing setRelationOrder() — add deferred binding support + batch queries:

Add an optional $sessionKey parameter. When a sessionKey is provided, update pivot_data on deferred_bindings records instead of pivot table rows. The caller (RelationController) is responsible for passing the sessionKey only when the relation is operating in deferred mode — the trait doesn't need to infer this from $this->exists.

public function setRelationOrder(
    string $relationName,
    string|int|array $itemIds,
    array $itemOrders = [],
    ?string $sessionKey = null
): void {
    if (!is_array($itemIds)) {
        $itemIds = [$itemIds];
    }

    if (empty($itemOrders)) {
        $itemOrders = range(1, count($itemIds));
    }

    // ... existing count validation ...

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

    // If sessionKey provided, we're in deferred mode — update deferred_bindings
    if ($sessionKey) {
        // Batch SELECT (1 query) then individual saves (N queries)
        // Individual saves are necessary because each row has different JSON pivot_data
        $bindings = DeferredBinding::where('master_type', get_class($this))
            ->where('master_field', $relationName)
            ->whereIn('slave_id', $itemIds)
            ->where('session_key', $sessionKey)
            ->where('is_bind', 1)
            ->get()
            ->keyBy('slave_id');

        foreach ($itemIds as $index => $id) {
            $binding = $bindings->get($id);
            if ($binding) {
                $pivotData = $binding->pivot_data ?: [];
                $pivotData[$column] = (int) $itemOrders[$index];
                $binding->pivot_data = $pivotData;
                $binding->save();
            }
        }
        return;
    }

    // No sessionKey — update directly via existing updateRelationOrder() loop
    // (matches Sortable::setSortableOrder() pattern — simple and proven)
    foreach ($itemIds as $index => $id) {
        $order = (int) $itemOrders[$index];
        $this->updateRelationOrder($relationName, $id, $column, $order);
    }
}

The direct update path uses the existing updateRelationOrder() loop — same N-query pattern as Sortable::setSortableOrder(). For typical reorder operations (5-20 records), this is fine. A CASE WHEN batch optimization could be added later if profiling shows it's needed (the NestedTree trait already uses this pattern at NestedTree.php:1039).

The deferred binding path batches the SELECT into 1 query via whereIn + keyBy (was N individual first() calls).

Enhancement to initializeHasSortableRelations() — auto-add order clause:

The existing init adds the pivot column but does NOT add ordering. Add order clause injection to the loop at lines 59-73:

foreach ($sortableRelations as $relationName => $column) {
    $relationType = $this->getRelationType($relationName);
    if (!in_array($relationType, ['belongsToMany', 'morphToMany'])) {
        continue;
    }
    $definition = $this->getRelationDefinition($relationName);

    // Auto-add pivot column (existing code)
    $pivot = array_wrap(array_get($definition, 'pivot', []));
    if (!in_array($column, $pivot)) {
        $pivot[] = $column;
        $definition['pivot'] = $pivot;
    }

    // Auto-add order clause (new)
    if (!array_key_exists('order', $definition)) {
        $definition['order'] = $column . ' asc';
    }

    $this->$relationType[$relationName] = $definition;
}

Enhancement to getSortableRelations() — make public:

Change from protected to public so behaviors can call it.

How IDs/orders arrays work:

The JS captures the original sort_orders from data-record-sort-order attributes before the drag. After the drag, it collects record_ids in the new DOM order. The two arrays are the same length and map 1:1:

Before drag:   Author A (sort=1), Author B (sort=2), Author C (sort=3)
User drags C to position 1.
After drag DOM: Author C, Author A, Author B

JS sends:
  record_ids:  [C, A, B]     ← new DOM order
  sort_orders: [1, 2, 3]     ← original sort values, now reassigned

Result: C gets sort=1, A gets sort=2, B gets sort=3

2. Lists widget (modules/backend/widgets/Lists.php)

New properties:

Property Type Purpose
$sortable bool Enable drag-and-drop reordering (developer config)
$sortOrderColumn string|null Column path for reading sort order from records (set by behaviors)

$sortOrderColumn holds a simple column path like sort_order (for model sorting) or pivot.sort_order (for relation sorting). The widget reads the sort value from each record using this path — it has no knowledge of relations or parent models.

init() changes:

  • Add 'sortable' to the fillFromConfig() array
  • When $sortable is true: disable showSorting (column header clicks), disable showPagination, set recordsPerPage to a very high number or disable pagination entirely
  • loadAssets(): Change from loading js/winter.list.js to js/build/winter.list.js (the compiled Snowboard plugin bundle, which includes SortableJS). Loaded unconditionally — the plugin reads data-sortable to decide whether to init sorting.

defineListColumns() changes:

  • When $sortable is true: prepend a _sort_handle column using built-in partial _list_sort_handle.php. Column has sortable: false, clickable: false, width: 20px.

prepareQuery() changes:

  • When $sortable is true: skip the widget's own orderBy() entirely. The model's global scope (from Sortable trait) or the relation's order clause (from HasSortableRelations trait) already provides the correct ordering.

prepareVars() changes:

  • Pass $sortable to template vars.

New onReorder() AJAX handler:

  • Receives record_ids[] and sort_orders[] from JS POST
  • Validates record_ids are not empty, casts sort_orders to integers
  • Security: validates that submitted record_ids exist within the current query scope (re-runs prepareQuery() with a whereIn check)
  • Fires the list.reorder local event with ($ids, $orders) — behaviors listen for this
  • Returns $this->onRefresh() to re-render the list

Why on the widget? AJAX handlers on widgets are scoped via the widget alias (e.g. listPrimary::onReorder). This prevents conflicts when multiple list instances exist on the same page. The JS reads data-reorder-handler (set via $this->getEventHandler('onReorder')) to call the correct aliased handler.

New getRecordSortOrder($record) method:

  • Reads $this->sortOrderColumn to determine where the value lives:
    • pivot.sort_order$record->pivot->sort_order
    • sort_order$record->sort_order

Scope: flat sorting only. Tree-based reordering (NestedTree) remains the domain of ReorderController.

3. List partials

_list.php changes:

<div class="control-list list-scrollable"
     data-control="listwidget"
     <?php if ($sortable): ?>
         data-sortable="true"
         data-reorder-handler="<?= e($this->getEventHandler('onReorder')) ?>"
     <?php endif ?>
>

The single data-control="listwidget" triggers Snowboard WidgetHandler auto-initialization. The data-sortable and data-reorder-handler attributes are read by the plugin's dataConfig via the defaults() mapping — data-sortable maps to config.get('sortable') and data-reorder-handler maps to config.get('reorderHandler') (automatic camelCase conversion per HTML5 dataset spec).

_list_body_row.php changes:

  • Add data-record-id="<?= $record->getKey() ?>" to <tr> (always — useful for other features too).
  • When $sortable: add data-record-sort-order="<?= $this->getRecordSortOrder($record) ?>".
  • Do NOT add draggable="true" — SortableJS manages this internally.

New _list_sort_handle.php:

<div class="list-sort-handle" title="Drag to reorder">☰</div>

4. ListController (modules/backend/behaviors/ListController.php)

makeList() changes:

  • Add 'sortable' to $configFieldsToTransfer array (lines 142-156).
  • When sortable is truthy in the transferred config:
    • Set $widget->sortOrderColumn = $model->getSortOrderColumn() — tells the Lists widget where to read sort order values
    • Validate that the model uses the Sortable trait — throw SystemException if not
  • Bind to the widget's list.reorder event:
    $widget->bindEvent('list.reorder', function ($ids, $orders) use ($model) {
        $model->setSortableOrder($ids, $orders);
    });

5. RelationController (modules/backend/behaviors/RelationController.php)

makeViewWidget() changes:

  • Read $sortable = $this->getConfig('view[sortable]', false) (follows existing pattern used for view[showSetup], view[showSorting], etc.)
  • When sortable:
    • Set $config->sortable = true
    • Set $config->sortOrderColumn = 'pivot.' . $this->model->getRelationSortOrderColumn($this->relationName) — tells the Lists widget where to read sort order values from each record
    • Validate that the parent model uses HasSortableRelations trait and $this->relationName is in getSortableRelations() — throw SystemException if not
  • Bind to the view widget's list.reorder event:
    $widget->bindEvent('list.reorder', function ($ids, $orders) {
        $sessionKey = $this->deferredBinding
            ? $this->relationGetSessionKey()
            : null;
    
        $this->model->setRelationOrder(
            $this->relationName,
            $ids,
            $orders,
            $sessionKey
        );
    });

Note: sessionKey is only passed when $this->deferredBinding is true (set by !$this->model->exists || $this->getConfig('deferredBinding')). This correctly handles both new records and existing records with explicit deferredBinding: true config.

6. JavaScript — Rewrite winter.list.js as Snowboard plugin

Merge the existing winter.list.js (checkbox handling, drag-scroll) and new sortable functionality into a single Snowboard plugin. This replaces the jQuery plugin entirely, solves the data-control conflict (one plugin owns listwidget), and follows the pattern set by ColorPicker, CodeEditor, etc.

Add SortableJS via npm — add sortablejs to modules/backend/package.json dependencies (same level as vue and monaco-editor).

New source file: modules/backend/widgets/lists/assets/js/src/winter.list.js

import Sortable from 'sortablejs';

((Snowboard, $) => {
    class ListWidget extends Snowboard.PluginBase {
        construct(element) {
            this.element = element;
            this.config = this.snowboard.dataConfig(this, element);
            this.head = element.querySelector('thead');
            this.body = element.querySelector('tbody');
            this.lastChecked = null;

            // Store instance on jQuery data for backward compat lookups
            $(element).data('oc.listwidget', this);

            this.initDragScroll();
            this.initCheckboxes();

            if (this.config.get('sortable')) {
                this.initSortable();
            }
        }

        defaults() {
            return {
                sortable: false,
                reorderHandler: null,
                scrollClassContainer: null,
            };
        }

        listens() {
            return {
                ajaxUpdate: 'onAjaxUpdate',
            };
        }

        // -- Drag-scroll (existing functionality) --

        initDragScroll() {
            $(this.element).dragScroll({
                scrollClassContainer: this.config.get('scrollClassContainer')
                    || this.element.parentElement,
                scrollSelector: 'thead',
                dragSelector: 'thead',
            });
        }

        // -- Checkboxes (existing functionality) --

        initCheckboxes() {
            if (!this.head || !this.body) return;

            // Mark initially-checked rows
            this.body.querySelectorAll('.list-checkbox input[type="checkbox"]:checked')
                .forEach(cb => cb.closest('tr')?.classList.add('active'));

            // Select-all in header
            this.head.addEventListener('change', (e) => {
                if (!e.target.matches('.list-checkbox input[type="checkbox"]')) return;
                const checked = e.target.checked;
                this.body.querySelectorAll('.list-checkbox input[type="checkbox"]')
                    .forEach(cb => { cb.checked = checked; });
                this.body.querySelectorAll('tr')
                    .forEach(tr => tr.classList.toggle('active', checked));
            });

            // Individual checkbox in body
            this.body.addEventListener('change', (e) => {
                if (!e.target.matches('.list-checkbox input[type="checkbox"]')) return;
                if (e.target.checked) {
                    e.target.closest('tr')?.classList.add('active');
                } else {
                    this.head.querySelectorAll('.list-checkbox input[type="checkbox"]')
                        .forEach(cb => { cb.checked = false; });
                    e.target.closest('tr')?.classList.remove('active');
                }
            });

            // Shift+click range selection
            this.body.addEventListener('click', (e) => {
                const target = e.target.closest('.list-checkbox input[type="checkbox"]');
                if (!target) return;

                if (this.lastChecked && e.shiftKey) {
                    const checkboxes = Array.from(
                        this.body.querySelectorAll('.list-checkbox input[type="checkbox"]')
                    );
                    const start = checkboxes.indexOf(target);
                    const end = checkboxes.indexOf(this.lastChecked);
                    const [from, to] = [Math.min(start, end), Math.max(start, end)];

                    checkboxes.slice(from, to + 1).forEach(cb => {
                        cb.checked = target.checked;
                        cb.dispatchEvent(new Event('change', { bubbles: true }));
                    });
                }
                this.lastChecked = target;
            });
        }

        getChecked() {
            return Array.from(
                this.body.querySelectorAll('.list-checkbox input[type="checkbox"]:checked')
            ).map(cb => cb.value);
        }

        toggleChecked(el) {
            // winter.relation.js passes [el] (array) — unwrap it
            if (Array.isArray(el)) el = el[0];
            const cb = el.closest('tr')?.querySelector('.list-checkbox input[type="checkbox"]');
            if (cb) {
                cb.checked = !cb.checked;
                cb.dispatchEvent(new Event('change', { bubbles: true }));
            }
        }

        // -- Sortable (new functionality) --

        initSortable() {
            if (!this.body) return;

            // Destroy previous instance if re-initializing after AJAX refresh
            if (this.sortable) {
                this.sortable.destroy();
            }

            // Capture sort orders from data attributes
            this.initialOrders = Array.from(
                this.body.querySelectorAll('tr[data-record-sort-order]')
            ).map(tr => parseInt(tr.dataset.recordSortOrder, 10));

            this.sortable = Sortable.create(this.body, {
                handle: '.list-sort-handle',
                animation: 150,
                ghostClass: 'list-sortable-ghost',
                onEnd: () => this.processReorder(),
            });
        }

        processReorder() {
            const recordIds = Array.from(
                this.body.querySelectorAll('tr[data-record-id]')
            ).map(tr => tr.dataset.recordId);

            this.snowboard.request(this.element, this.config.get('reorderHandler'), {
                data: {
                    record_ids: recordIds,
                    sort_orders: this.initialOrders,
                },
            });
        }

        // -- Lifecycle --

        onAjaxUpdate(element) {
            if (this.element !== element && !this.element.contains(element)) return;

            // Re-bind to new DOM after list refresh
            this.head = this.element.querySelector('thead');
            this.body = this.element.querySelector('tbody');
            this.initCheckboxes();

            if (this.config.get('sortable')) {
                this.initSortable();
            }
        }

        destruct() {
            if (this.sortable) {
                this.sortable.destroy();
            }
            super.destruct();
        }
    }

    Snowboard.addPlugin('backend.widget.list', ListWidget);
    Snowboard['backend.ui.widgethandler']().register('listwidget', 'backend.widget.list');

    // -- Backward-compat shims --
    // $.fn.listWidget() is called in 28+ toolbar templates via
    // $('#listId').listWidget('getChecked') and in winter.relation.js via
    // .listWidget('toggleChecked', [el]). Must be preserved.

    function getPluginInstance(element) {
        const listEl = element.closest('[data-control="listwidget"]');
        if (!listEl) return null;
        return $(listEl).data('oc.listwidget') || null;
    }

    $.fn.listWidget = function (option, ...args) {
        let result;
        this.each(function () {
            const instance = getPluginInstance(this);

            // No args or object arg = initialization call — no-op,
            // Snowboard WidgetHandler handles init automatically
            if (option === undefined || typeof option === 'object') {
                return;
            }

            // String arg = method call on the Snowboard plugin instance
            if (instance && typeof option === 'string' && typeof instance[option] === 'function') {
                result = instance[option](...args);
                if (result !== undefined) return false; // break .each() like the original
            }
        });
        return result !== undefined ? result : this;
    };

    $.fn.listWidget.Constructor = {}; // stub for any code referencing .Constructor

    if ($.wn === undefined) $.wn = {};
    if ($.oc === undefined) $.oc = $.wn;

    $.wn.listToggleChecked = function (el) {
        const instance = getPluginInstance(el);
        if (instance) instance.toggleChecked(el);
    };

    $.wn.listGetChecked = function (el) {
        const instance = getPluginInstance(el);
        return instance ? instance.getChecked() : [];
    };
})(window.Snowboard, window.jQuery);

Mix entry in modules/backend/winter.mix.js:

mix.js(
    'widgets/lists/assets/js/src/winter.list.js',
    'widgets/lists/assets/js/build/winter.list.js'
);

Asset loading in Lists.php:

protected function loadAssets()
{
    $this->addJs('js/build/winter.list.js', 'core');
}

SortableJS is bundled into the compiled output only when import Sortable is present — tree-shaking keeps the non-sortable path lightweight if Mix/webpack is configured for it, but since all list instances share the same bundle, the library is included once regardless.

Old winter.list.js is replaced — the compiled js/build/winter.list.js takes over the data-control="listwidget" registration. The old file at js/winter.list.js is removed.

No changes to list.sortable.js — ReorderController continues using it unchanged.

7. CSS (modules/backend/widgets/lists/assets/less/ or inline)

  • .list-sort-handle { cursor: move; color: #999; user-select: none; } — drag grip cursor
  • .list-sortable-ghost { opacity: 0.4; background: #f0f0f0; } — ghost class during drag
  • When sortable: column headers should not show sort indicators

8. Documentation (docs/)

Update docs/database/traits.md — Add new section "HasSortableRelations" after the existing "Sortable" section:

## HasSortableRelations

Sorted relations will store a sort order value in the pivot table of a `belongsToMany`,
`morphToMany`, or `morphedByMany` relation. To add sorting to a relation, apply the
`Winter\Storm\Database\Traits\HasSortableRelations` trait and define a `$sortableRelations`
property mapping relation names to their sort order column.

    class Article extends Model
    {
        use \Winter\Storm\Database\Traits\HasSortableRelations;

        public $sortableRelations = ['authors' => 'sort_order'];

        public $morphToMany = [
            'authors' => [
                Author::class,
                'table' => 'article_author',
            ],
        ];
    }

The trait automatically adds the column to the pivot data, orders results by it, and
assigns a sort order to newly attached records.

Use `setRelationOrder` to programmatically reorder:

    $article->setRelationOrder('authors', $arrayOfIds);

Update docs/backend/lists.md — Add "Sorting records" section after existing sorting configuration docs:

## Sorting records

Lists can support drag-and-drop reordering when the underlying model uses the
`Winter\Storm\Database\Traits\Sortable` trait. Add `sortable: true` to the list
configuration to enable inline sorting.

    # config_list.yaml
    sortable: true

When enabled, a drag handle column is automatically added to the list, column
header sorting is disabled, and pagination is disabled (all records are shown).
Drag-and-drop reordering persists to the model's `sort_order` column via AJAX.

Update docs/backend/relations.md — Add "Sorting relations" section to the belongsToMany/morphToMany documentation:

### Sorting relations

Relations can support drag-and-drop reordering when the parent model uses the
`HasSortableRelations` trait with the relation defined in `$sortableRelations`. Add
`sortable: true` to the view configuration.

    # config_relation.yaml
    authors:
        label: Authors
        view:
            list: $/path/to/columns.yaml
            sortable: true

Update docs/backend/reorder.md — Add a note at the top pointing to the new inline sorting:

> **NOTE:** For inline drag-and-drop sorting within a list, see [Lists: Sorting records](lists#sorting-records)
> and [Relations: Sorting relations](relations#sorting-relations). The ReorderController
> documented below provides a dedicated standalone page for reordering, which is useful
> for models with deep tree structures.

9. Test plugin (plugins/winter/test)

Demonstrate both features using existing models and relations.

Model list sorting — Categories

The Category model already uses Sortable + SimpleTree and has a sort_order column. Currently uses the standalone ReorderController page. Add inline sorting to the list as an alternative.

Changes:

  • controllers/trees/config_list_categories.yaml — add sortable: true

Relation list sorting — Post → Tags

The Post → Tags belongsToMany relation is the best candidate. Needs a pivot sort_order column. Note: controllers/posts/config_relation.yaml currently has no tags config block — a complete relation definition must be added.

Changes:

  • New migration (updates/v2.2.0/add_sort_order_to_posts_tags.php) — adds sort_order integer column (default 0) to winter_test_posts_tags pivot table
  • updates/version.yaml — add version entry
  • models/Post.php — add HasSortableRelations trait, add $sortableRelations = ['tags' => 'sort_order']
  • controllers/posts/config_relation.yaml — add complete tags relation config block with sortable: true under view

This gives developers a working reference implementation for both use cases in the test plugin.

Files to create/modify

Winter Storm (vendor/winter/storm/ or storm repo)

File Change
src/Database/Traits/HasSortableRelations.php Fix typo, add isSortableRelation(), add deferred binding support to setRelationOrder(), auto-add order clause, make getSortableRelations() public

Winter CMS Backend (modules/backend/)

File Change
widgets/Lists.php Add $sortable + $sortOrderColumn properties, add sortable to fillFromConfig(), auto-inject handle column, skip ordering when sortable, disable pagination when sortable, onReorder() handler with scope validation, getRecordSortOrder()
behaviors/ListController.php Add sortable to $configFieldsToTransfer, set sortOrderColumn, validate Sortable trait, bind list.reorder event
behaviors/RelationController.php Read view[sortable] config, pass sortable + sortOrderColumn to widget, validate HasSortableRelations, bind list.reorder event with sessionKey
widgets/lists/partials/_list.php Add data-sortable and data-reorder-handler attributes
widgets/lists/partials/_list_body_row.php Add data-record-id (always), data-record-sort-order (when sortable)
widgets/lists/partials/_list_sort_handle.php New — drag handle column partial
widgets/lists/assets/js/winter.list.js Removed — replaced by compiled Snowboard plugin
widgets/lists/assets/js/src/winter.list.js New — Snowboard plugin source (ListWidget: checkboxes + drag-scroll + sortable)
widgets/lists/assets/js/build/winter.list.js Built — compiled bundle (ListWidget + SortableJS)
widgets/lists/assets/css/ or less/ Styles for drag handle, ghost class
package.json Add sortablejs to dependencies; add jest, @babel/core, @babel/preset-env, babel-plugin-module-resolver to devDependencies; add test script
winter.mix.js Add Mix entry for winter.list.js compilation
tests/js/jest.config.js New — Jest configuration (mirrors system module pattern)
.babelrc New — Babel config for test transpilation
tests/js/cases/widgets/ListWidget.test.js New — JS tests for ListWidget Snowboard plugin

Documentation (docs/)

File Change
database/traits.md Add HasSortableRelations section (usage, configuration, methods)
backend/lists.md Add "Sorting records" section
backend/relations.md Add "Sorting relations" section
backend/reorder.md Add note pointing to inline sorting as alternative

Test plugin (plugins/winter/test/)

File Change
updates/v2.2.0/add_sort_order_to_posts_tags.php New — migration adding sort_order to pivot
updates/version.yaml Add version entry
models/Post.php Add HasSortableRelations trait, add $sortableRelations property for tags
controllers/posts/config_relation.yaml Add complete tags relation config block with sortable: true under view
controllers/trees/config_list_categories.yaml Add sortable: true

Developer experience (usage)

Sortable model list

// Model — just add the trait (must have sort_order column)
class Category extends Model {
    use \Winter\Storm\Database\Traits\Sortable;
}
# config_list.yaml
sortable: true

Sortable relation list

// Parent model — add trait + declare sortable relations
class Post extends Model {
    use \Winter\Storm\Database\Traits\HasSortableRelations;

    public $sortableRelations = ['tags' => 'sort_order'];

    public $belongsToMany = [
        'tags' => [
            Tag::class,
            'table' => 'posts_tags',
        ],
    ];
}
# config_relation.yaml
tags:
    label: Tags
    view:
        list: $/path/to/columns.yaml
        toolbarButtons: link|unlink
        sortable: true

That's it. No custom JS, no custom partials, no AJAX handlers, no column config for drag handles.

Tests

Unit tests (Winter Storm — HasSortableRelations trait)

File: tests/Database/Traits/HasSortableRelationsTest.php

  • testGetSortableRelationsFromProperty — Model with $sortableRelations property → getSortableRelations() returns it.
  • testIsSortableRelation — Returns true for configured relations, false for others.
  • testGetRelationSortOrderColumn — Returns the configured column name, defaults to sort_order.
  • testInitAutoAddsPivotAndOrder — After init, the relation definition has the sort column in pivot array and order clause added.
  • testSetRelationOrder — Updates pivot sort_order for given IDs. Verify DB values.
  • testSetRelationOrderWithAutoRange — Pass empty orders → uses 1..N range.
  • testSetRelationOrderWithDeferred — Pass sessionKey on unsaved model → updates pivot_data on deferred_bindings records.
  • testAutoAttachSortOrder — Attach a new record → gets max(sort_order) + 1 equivalent.
  • testAutoAttachSortOrderOnEmptyRelation — First attach on empty relation → sort_order = 1.

Unit tests (Backend — ListController + RelationController)

File: tests/Backend/Behaviors/ListControllerSortableTest.php

  • testMakeListPassesSortableConfigsortable: true in config → Lists widget has $sortable = true.
  • testSortableDisablesColumnSortingAndPagination — When sortable, showSorting and showPagination are false.
  • testSortableAutoInjectsHandleColumn — The _sort_handle column is prepended.
  • testOnReorderSimple — POST record_ids + sort_orders → calls setSortableOrder, list refreshes.
  • testOnReorderValidatesModel — Model without Sortable trait + sortable: true → exception.
  • testOnReorderValidatesRecordScope — Submitted IDs not in query scope → rejected.

File: tests/Backend/Behaviors/RelationControllerSortableTest.php

  • testMakeViewWidgetPassesSortableConfigview[sortable]: true → Lists widget gets $sortable = true and $sortOrderColumn = 'pivot.sort_order'.
  • testOnReorderRelation — Drag reorder → list.reorder event fires → setRelationOrder called.
  • testOnReorderRelationWithDeferredBinding — Reorder on unsaved parent → deferred_bindings pivot_data updated.
  • testOnReorderRelationValidatesModel — Model without HasSortableRelations trait → exception.

JavaScript tests (Jest — ListWidget Snowboard plugin)

Infrastructure: Bootstrap Jest in the backend module, mirroring the system module's setup. Add jest, @babel/core, @babel/preset-env, babel-plugin-module-resolver to modules/backend/package.json devDependencies. Add "test": "jest --config tests/js/jest.config.js tests/js/cases" script. Create tests/js/jest.config.js and .babelrc. Reuse the system module's FakeDom helper for DOM simulation.

File: modules/backend/tests/js/cases/widgets/ListWidget.test.js

Initialization:

  • test plugin registers on data-control=listwidget — WidgetHandler finds elements, Snowboard plugin instantiated.
  • test sortable not initialized without data-sortable — Plugin constructs but this.sortable is null.
  • test sortable initialized when data-sortable=true — SortableJS instance created on tbody.
  • test dataConfig reads reorderHandlerdata-reorder-handler="list::onReorder"config.get('reorderHandler') returns correct value.

Checkboxes (existing behavior, now tested):

  • test header checkbox toggles all body checkboxes — Check header → all body checkboxes checked + rows get active class.
  • test uncheck header clears all — Uncheck header → all body checkboxes unchecked + active removed.
  • test individual checkbox toggles active class — Check one → row gets active; uncheck → removed.
  • test shift+click selects range — Click checkbox A, shift+click checkbox C → A through C all checked.
  • test getChecked returns checked values — Check specific rows → getChecked() returns their values.
  • test toggleChecked flips state — Call toggleChecked(el) → checkbox state inverts, change event fires.

Sortable:

  • test initSortable captures initial orders — Rows with data-record-sort-orderthis.initialOrders populated correctly.
  • test processReorder collects record IDs in DOM order — After simulated reorder, processReorder() sends correct record_ids and sort_orders arrays.
  • test processReorder uses snowboard request — Verify this.snowboard.request() is called (not jQuery AJAX) with correct handler name and data.

Lifecycle:

  • test onAjaxUpdate re-initializes after refresh — Replace innerHTML, fire ajaxUpdate → plugin re-binds to new thead/tbody, SortableJS re-created.
  • test onAjaxUpdate ignores unrelated elements — Fire ajaxUpdate for a different element → plugin does not re-initialize.
  • test destruct destroys sortable instance — Call destruct()sortable.destroy() called, no dangling references.

Backward-compat shims:

  • test $.fn.listWidget no-arg call is no-op$('#list').listWidget() does not throw, returns jQuery object for chaining.
  • test $.fn.listWidget sets oc.listwidget data key — After any call, $('#list').data('oc.listwidget') is truthy (the Snowboard plugin instance).
  • test $.fn.listWidget getChecked delegates to plugin$('#list').listWidget('getChecked') returns same result as instance.getChecked().
  • test $.fn.listWidget toggleChecked delegates to plugin$('#list').listWidget('toggleChecked', el) calls through to plugin instance.
  • test $.wn.listToggleChecked delegates to plugin — Global helper finds correct instance and calls toggleChecked().
  • test $.wn.listGetChecked delegates to plugin — Global helper finds correct instance and calls getChecked().

Browser tests (manual or Playwright)

  1. Drag a row in a sortable model list → order persists after page reload.
  2. Drag a row in a sortable relation list → order persists after form reload.
  3. Add a record to a sortable relation → appears at the end.
  4. Drag reorder on a new (unsaved) record's relation → save → order persists.
  5. Drag handle cursor shows move.
  6. Column header click-to-sort is disabled when sortable.
  7. Pagination is disabled when sortable (all records shown).
  8. Test in Chrome, Firefox, Safari.

Verification (end-to-end)

  1. Model sorting: List with sortable: true on Sortable model. Drag rows. Verify sort_order updates in DB. Refresh page — order persists.
  2. Relation sorting (saved): Relation with view[sortable]: true. Add records. Drag to reorder. Verify pivot sort_order. Reload form — order persists.
  3. Relation sorting (unsaved/deferred): Create new parent record. Add relations. Drag to reorder. Save parent. Verify pivot sort_order matches drag order.
  4. Auto-attach ordering: Add a new record to a relation. Verify it gets appropriate sort_order automatically.
  5. Frontend display: Verify $model->relatedRecords returns in sort_order on frontend templates.
  6. Security: POST fabricated record_ids to onReorder() → rejected if IDs are outside query scope.
  7. Run tests: composer test in storm repo and CMS repo — all pass.

Reference: files examined during planning

Winter Storm (src/Database/)

  • Traits/HasSortableRelations.phpExisting trait (144 lines). setRelationOrder(), getRelationSortOrderColumn(), getSortableRelations() (protected), auto-adds pivot column. Missing: isSortableRelation(), order clause injection, deferred binding support. Has typo on line 5.
  • Traits/Sortable.phpsetSortableOrder($ids, $orders), getSortOrderColumn(). 80 lines.
  • Relations/Concerns/DefinedConstraints.phpaddDefinedConstraintsToRelation() handles pivot key (line 56), addDefinedConstraintsToQuery() handles order key (line 93).
  • Relations/Concerns/BelongsOrMorphsToMany.phpadd($model, $sessionKey, $pivotData) supports deferred binding with pivot data (line 200-216).
  • Traits/DeferredBinding.phpbindDeferred() accepts $pivotData array. commitDeferredOfType() passes pivot_data when committing belongsToMany/morphToMany.
  • Models/DeferredBinding.php — Has pivot_data column (mediumText, jsonable). Migration exists at Migrations/2021_01_19_000001_Db_Add_Pivot_Data_To_Deferred_Bindings.php.

Winter CMS Backend (modules/backend/)

  • widgets/Lists.phpinit() uses fillFromConfig([...]) array (lines 208-228). prepareQuery() applies orderBy at line 615. onRefresh() at line 354. No sortable support.
  • behaviors/ListController.phpmakeList() has $configFieldsToTransfer array (lines 142-156). Binds 8 list events (lines 169-199). No sortable config.
  • behaviors/RelationController.phpmakeViewWidget() at line 668. Uses $this->getConfig('view[key]') pattern extensively (lines 679-687). relationGetSessionKey() at lines 550-562.
  • behaviors/ReorderController.phponReorder() at line 115 calls setSortableOrder($ids, $orders). validateModel() at line 212 detects Sortable/NestedTree traits.
  • classes/WidgetBase.phpgetEventHandler($name) at line 167 returns $this->alias . '::' . $name.
  • widgets/lists/partials/_list.php — Container has data-control="listwidget" only.
  • widgets/lists/partials/_list_body_row.php<tr> has class only, no data attributes.
  • widgets/lists/assets/js/winter.list.js — 167 lines, jQuery plugin: checkbox handling, drag-scroll, shift+click. Rewritten as Snowboard plugin in this plan.

Winter CMS System (modules/system/)

  • assets/ui/js/list.sortable.js — 464 lines, HTML5 DnD, > li selectors. Used by ReorderController. Not modified in this plan.
  • assets/ui/vendor/sortable/jquery-sortable.js — Existing vendor sortable (jQuery-based, 695 lines). Different library from SortableJS.

Test plugin (plugins/winter/test/)

  • models/Post.php$belongsToMany tags via winter_test_posts_tags. No sortable config.
  • models/Category.php — Uses Sortable + SimpleTree traits.
  • controllers/posts/config_relation.yaml — Has comments, status, galleries, review. No tags config — needs full block added.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions