Repository: wagtail/wagtail

Issue:
🎛️ Migrate Page re-ordering to a Stimulus controller `w-orderable`
> ℹ️ **Part of the [Stimulus 🎛️ RFC 78](https://github.com/wagtail/rfcs/pull/78)**

### Is your proposal related to a problem?

At the moment we have an ad-hoc JavaScript implementation for supporting the re-ordering functionality in page listing views.

It would be great to have this implementation migrated to Stimulus so that it can easily be attached to dynamically injected HTML content and be a more flexible and testable solution.

Migrating this over will also mean that we have more compliant (for CSP) implementation.

### Describe the solution you'd like

#### Current implementation

1. [`wagtail/admin/templates/wagtailadmin/pages/index.html.html`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/templates/wagtailadmin/pages/index.html#L79-L208) - Contains the JavaScript implementation for ordering/sorting
2. [`wagtail/admin/templates/wagtailadmin/pages/listing/_ordering_cell.html`](https://github.com/wagtail/wagtail/blob/main/wagtail/admin/templates/wagtailadmin/pages/listing/_ordering_cell.html) - Adds the column for the 'handle'.

<img width="1948" alt="Screenshot 2023-09-18 at 11 05 11 am" src="https://github.com/wagtail/wagtail/assets/1396140/5c510688-3b58-4ad4-9d17-fae8efb1885e">


#### Proposed implementation

The JavaScript will need to be ported to a Stimulus controller, the code can be migrated across in the same way or possibly use a library like [sortable.js](https://github.com/SortableJS/Sortable).

**Please do some investigation and suggest an approach before preparing a PR.**

We will need a way to hook in the right data attributes.

1. `form` needs the data-controller `<form id="page-reorder-form" {% if ordering == 'ord' %}data-controller="w-sortable"{% endif %}>` could be a starting approach.
2. We will also need to support the suitable data attributes on the handle `<div class="handle" tabindex="0" aria-live="polite" data-w-sortable-target="handle" data-action="??#w-sortable#something">`
3. There may need to be some way to nicely pass in some translations for this content.

We must consider screen reader usage, as per the current state.

### Describe alternatives you've considered

- Leave code as is and just pull out to a script file.

### Additional context

- This is part of a larger goal to migrate to Stimulus and support CSP compliance.
- This may be an enabler for #10816 or generally supporting order drag/drop ordering in Wagtail.
- This may be able to be a fix for #9954 (depending on the implementation).
- Keyboard support for reordering was added in https://github.com/wagtail/wagtail/pull/8972 as a fix for https://github.com/wagtail/wagtail/issues/5410




Discussion:
@abhinav700  - please review the current code and have a full read of the Stimulus documentation https://stimulus.hotwired.dev/ & the Sortable.js documentation (if we go that direction) https://github.com/SortableJS/Sortable

Have a think about how to approach this and write a detailed comment with your implementation suggestion. From here we can discuss on this issue before you start building it in full.

@thibaudcolas  or @laymonage  - Would love your input on this, do we have any aversion to use a third party library for drag/drop code such as https://github.com/SortableJS/Sortable

Do you feel that we should consider anything as part of this change?

@lb- I'm writing to let you know that unfortunately, I will not be able to contribute to the issue we discussed yesterday. My availability has changed, and I can no longer dedicate time to this project.11
(After reading through the documentation more thoroughly, I have realized that the project demands more time and skillset than I have right now.)

I sincerely apologize for the inconvenience.
No problems at all @abhinav700 - there may be some simpler issues if you have time in the future. Feel free to reach out on Slack - `new-contributors` channel. 
@lb-  I think I can pick this up.
@salty-ivy cheers, let me know if you have questions. Thank you. 

Here's a very old POC I did using Sortable JS - may come in handy (ignore all event dispatching). 

https://github.com/lb-/stimulus-starter/blob/main/src/controllers/sortable-controller.js
https://github.com/lb-/stimulus-starter/blob/main/public/kanban.html

Also a reminder that the current code has a few text values for screen readers, these should be passed in as translations from the html if possible. Also, the screen reader instructions and keyboard only interaction should be preserved. 
Some additional links.

Stimulus-sortable - a Stimulus wrapper around sortable js - https://github.com/stimulus-components/stimulus-sortable/blob/master/src/index.ts

Pragmatic Drag & Drop - an alpha library but still interesting - provides the basics for drag & drop support https://www.npmjs.com/package/@atlaskit/pragmatic-drag-and-drop
looking into it, apologies for the delay

@lb regarding the logic for ordering only via `keyboard interactions` which is here https://github.com/wagtail/wagtail/blob/b14bc33cdc93b06257d660256e410dada3beb6ad/wagtail/admin/templates/wagtailadmin/pages/index.html#L85C6-L85C6

these keyboard only interactions doesn't seems to work, I tried testing those firefox and chrome.
@salty-ivy very odd. Maybe it's not working or broke since it was implemented. 

Keyboard support for reordering was added in https://github.com/wagtail/wagtail/pull/8972 as a fix for https://github.com/wagtail/wagtail/issues/5410

The idea is you are meant to be able to press `enter` then use up/down to move. Once you press `enter` a second time, the item should move to the new position. 

It does work, you need to press tab until you arrive at the drag-and-drop grip. Then, press enter, use the arrow keys, and press enter again. You won't see the results in real time when you press the arrow keys. It only changes when you press enter to finalise the ordering.
@laymonage @lb-  yup but, it doesn't visually tell us that row is moving up or down

Resolving patch:
diff --git a/wagtail/admin/ui/tables/pages.py b/wagtail/admin/ui/tables/pages.py
--- a/wagtail/admin/ui/tables/pages.py
+++ b/wagtail/admin/ui/tables/pages.py
@@ -131,7 +131,9 @@ def get_row_attrs(self, instance):
         attrs = super().get_row_attrs(instance)
         if self.use_row_ordering_attributes:
             attrs["id"] = "page_%d" % instance.id
-            attrs["data-page-title"] = instance.get_admin_display_title()
+            attrs["data-w-orderable-item-id"] = instance.id
+            attrs["data-w-orderable-item-label"] = instance.get_admin_display_title()
+            attrs["data-w-orderable-target"] = "item"
         return attrs
 
     def get_context_data(self, parent_context):
diff --git a/wagtail/admin/views/pages/listing.py b/wagtail/admin/views/pages/listing.py
--- a/wagtail/admin/views/pages/listing.py
+++ b/wagtail/admin/views/pages/listing.py
@@ -5,7 +5,6 @@
 from django.forms import CheckboxSelectMultiple, RadioSelect
 from django.shortcuts import get_object_or_404, redirect
 from django.urls import reverse
-from django.utils.translation import gettext
 from django.utils.translation import gettext_lazy as _
 from django_filters.filters import (
     ChoiceFilter,
@@ -322,9 +321,20 @@ def get_table_kwargs(self):
 
         if self.show_ordering_column:
             kwargs["attrs"] = {
-                "aria-description": gettext(
-                    "Press enter to select an item, use up and down arrows to move the item, press enter to complete the move or escape to cancel the current move."
+                "aria-description": _(
+                    "Press enter to select an item, use up and down arrows to move the item. Pressing enter or moving to a different item will complete the move."
+                ),
+                "data-controller": "w-orderable",
+                "data-w-orderable-active-class": "w-orderable--active",
+                "data-w-orderable-chosen-class": "w-orderable__item--active",
+                "data-w-orderable-container-value": "tbody",
+                "data-w-orderable-message-value": _(
+                    "'%(page_title)s' has been moved successfully."
                 )
+                % {"page_title": "__LABEL__"},
+                "data-w-orderable-url-value": reverse(
+                    "wagtailadmin_pages:set_page_position", args=[999999]
+                ),
             }
         return kwargs
 


Test patch:
diff --git a/client/src/controllers/OrderableController.test.js b/client/src/controllers/OrderableController.test.js
new file mode 100644
--- /dev/null
+++ b/client/src/controllers/OrderableController.test.js
@@ -0,0 +1,92 @@
+import { Application } from '@hotwired/stimulus';
+
+import { OrderableController } from './OrderableController';
+
+jest.useFakeTimers();
+
+describe('OrderableController', () => {
+  const eventNames = ['w-orderable:ready'];
+
+  const events = {};
+
+  let application;
+  let errors = [];
+
+  beforeAll(() => {
+    eventNames.forEach((name) => {
+      events[name] = [];
+    });
+
+    Object.keys(events).forEach((name) => {
+      document.addEventListener(name, (event) => {
+        events[name].push(event);
+      });
+    });
+  });
+
+  const setup = async (
+    html = `
+  <section>
+    <ul
+      data-controller="w-orderable"
+      data-w-orderable-message-value="'__label__' has been updated!"
+    >
+      <li data-w-orderable-target="item" data-w-orderable-item-id="73" data-w-orderable-item-label="Beef">
+        <button class="handle" type="button" data-w-orderable-target="handle" data-action="keyup.up->w-orderable#up:prevent keyup.down->w-orderable#down:prevent keydown.enter->w-orderable#apply blur->w-orderable#apply">--</button>
+        Item 73
+      </li>
+      <li data-w-orderable-target="item" data-w-orderable-item-id="75" data-w-orderable-item-label="Cheese">
+        <button class="handle" type="button" data-w-orderable-target="handle" data-action="keyup.up->w-orderable#up:prevent keyup.down->w-orderable#down:prevent keydown.enter->w-orderable#apply blur->w-orderable#apply">--</button>
+        Item 75
+      </li>
+      <li data-w-orderable-target="item" data-w-orderable-item-id="93" data-w-orderable-item-label="Santa">
+        <button class="handle" type="button" data-w-orderable-target="handle" data-action="keyup.up->w-orderable#up:prevent keyup.down->w-orderable#down:prevent keydown.enter->w-orderable#apply blur->w-orderable#apply">--</button>
+        Item 93
+      </li>
+    </ul>
+  </section>`,
+    identifier = 'w-orderable',
+  ) => {
+    document.body.innerHTML = `<main>${html}</main>`;
+
+    application = new Application();
+
+    application.handleError = (error, message) => {
+      errors.push({ error, message });
+    };
+
+    application.register(identifier, OrderableController);
+
+    application.start();
+
+    await jest.runAllTimersAsync();
+
+    return [
+      ...document.querySelectorAll(`[data-controller~="${identifier}"]`),
+    ].map((element) =>
+      application.getControllerForElementAndIdentifier(element, identifier),
+    );
+  };
+
+  afterEach(() => {
+    application?.stop && application.stop();
+    errors = [];
+    eventNames.forEach((name) => {
+      events[name] = [];
+    });
+  });
+
+  describe('drag & drop', () => {
+    it('should dispatch a ready event', async () => {
+      expect(events['w-orderable:ready']).toHaveLength(0);
+
+      await setup();
+
+      expect(events['w-orderable:ready']).toHaveLength(1);
+
+      expect(events['w-orderable:ready'][0]).toHaveProperty('detail', {
+        order: ['73', '75', '93'],
+      });
+    });
+  });
+});