Bladeren bron

Migrate jQuery re-ordering to Stimulus controller w-orderable

- Install and use Sortable.js and not a jQuery plugin
- Set up new controller OrderableController / w-orderable
Aman Pandey 1 jaar geleden
bovenliggende
commit
bf3e317a48

+ 92 - 0
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'],
+      });
+    });
+  });
+});

+ 220 - 0
client/src/controllers/OrderableController.ts

@@ -0,0 +1,220 @@
+import { Controller } from '@hotwired/stimulus';
+import Sortable from 'sortablejs';
+
+// eslint-disable-next-line no-shadow
+enum Direction {
+  Up = 'UP',
+  Down = 'DOWN',
+}
+
+/**
+ * Enables the ability for drag & drop or manual re-ordering of elements
+ * within a prescribed container or the controlled element.
+ *
+ * Once re-ordering is completed an async request will be made to the
+ * provided URL to submit the update per item.
+ */
+export class OrderableController extends Controller<HTMLElement> {
+  static classes = ['active', 'chosen', 'drag', 'ghost'];
+  static targets = ['handle', 'item'];
+  static values = {
+    animation: { default: 200, type: Number },
+    container: { default: '', type: String },
+    message: { default: '', type: String },
+    url: String,
+  };
+
+  declare readonly handleTarget: HTMLElement;
+  declare readonly itemTarget: HTMLElement;
+
+  declare readonly activeClasses: string[];
+  declare readonly chosenClass: string;
+  declare readonly dragClass: string;
+  declare readonly ghostClass: string;
+
+  declare readonly hasChosenClass: boolean;
+  declare readonly hasDragClass: boolean;
+  declare readonly hasGhostClass: boolean;
+
+  /** Transition animation duration for re-ordering. */
+  declare animationValue: number;
+  /** A selector to determine the container that will be the parent of the orderable elements. */
+  declare containerValue: string;
+  /** A translated message template for when the update is successful, replaces `__LABEL__` with item's title. */
+  declare messageValue: string;
+  /** Base URL template to use for submitting an updated order for a specific item. */
+  declare urlValue: string;
+
+  order: string[];
+  sortable: ReturnType<typeof Sortable.create>;
+
+  constructor(context) {
+    super(context);
+    this.order = [];
+  }
+
+  connect() {
+    const containerSelector = this.containerValue;
+    const container = ((containerSelector &&
+      this.element.querySelector(containerSelector)) ||
+      this.element) as HTMLElement;
+
+    this.sortable = Sortable.create(container, this.options);
+    this.order = this.sortable.toArray();
+
+    this.dispatch('ready', {
+      cancelable: false,
+      detail: { order: this.order },
+    });
+  }
+
+  get options() {
+    const identifier = this.identifier;
+    return {
+      ...(this.hasGhostClass ? { ghostClass: this.ghostClass } : {}),
+      ...(this.hasChosenClass ? { chosenClass: this.chosenClass } : {}),
+      ...(this.hasDragClass ? { dragClass: this.dragClass } : {}),
+      animation: this.animationValue,
+      dataIdAttr: `data-${identifier}-item-id`,
+      draggable: `[data-${identifier}-target="item"]`,
+      handle: `[data-${identifier}-target="handle"]`,
+      onStart: () => {
+        this.element.classList.add(...this.activeClasses);
+      },
+      onEnd: ({
+        item,
+        newIndex,
+        oldIndex,
+      }: {
+        item: HTMLElement;
+        oldIndex: number;
+        newIndex: number;
+      }) => {
+        this.element.classList.remove(...this.activeClasses);
+        if (oldIndex === newIndex) return;
+        this.submit({ ...this.getItemData(item), newIndex });
+      },
+    };
+  }
+
+  getItemData(target: EventTarget | null) {
+    const identifier = this.identifier;
+    const item =
+      target instanceof HTMLElement &&
+      target.closest(`[data-${identifier}-target='item']`);
+
+    if (!item) return { id: '', label: '' };
+
+    return {
+      id: item.getAttribute(`data-${identifier}-item-id`) || '',
+      label: item.getAttribute(`data-${identifier}-item-label`) || '',
+    };
+  }
+
+  /**
+   * Applies a manual move using up/down methods.
+   */
+  apply({ currentTarget }: Event) {
+    const { id, label } = this.getItemData(currentTarget);
+    const newIndex = this.order.indexOf(id);
+    this.submit({ id, label, newIndex });
+  }
+
+  /**
+   * Calculate a manual move either up or down and prepare the Sortable
+   * data for re-ordering.
+   */
+  move({ currentTarget }: Event, direction: Direction) {
+    const identifier = this.identifier;
+    const item =
+      currentTarget instanceof HTMLElement &&
+      currentTarget.closest(`[data-${identifier}-target='item']`);
+
+    if (!item) return;
+
+    const id = item.getAttribute(`data-${identifier}-item-id`) || '';
+    const newIndex = this.order.indexOf(id);
+
+    this.order.splice(newIndex, 1);
+
+    if (direction === Direction.Down) {
+      this.order.splice(newIndex + 1, 0, id);
+    } else if (direction === Direction.Up && newIndex > 0) {
+      this.order.splice(newIndex - 1, 0, id);
+    } else {
+      this.order.splice(newIndex, 0, id); // to stop at the top
+    }
+
+    this.sortable.sort(this.order, true);
+  }
+
+  /**
+   * Manually move up visually but do not submit to the server.
+   */
+  up(event: KeyboardEvent) {
+    this.move(event, Direction.Up);
+    (event.currentTarget as HTMLButtonElement)?.focus();
+  }
+
+  /**
+   * Manually move down visually but do not submit to the server.
+   */
+  down(event: KeyboardEvent) {
+    this.move(event, Direction.Down);
+    (event.currentTarget as HTMLButtonElement)?.focus();
+  }
+
+  /**
+   * Submit an updated ordering to the server.
+   */
+  submit({
+    id,
+    label,
+    newIndex,
+  }: {
+    id: string;
+    label: string;
+    newIndex: number;
+  }) {
+    let url = this.urlValue.replace('999999', id);
+    if (newIndex !== null) {
+      url += '?position=' + newIndex;
+    }
+
+    const message = (this.messageValue || '__LABEL__').replace(
+      '__LABEL__',
+      label,
+    );
+
+    const formElement = this.element.closest('form');
+
+    const CSRFElement =
+      formElement &&
+      formElement.querySelector('input[name="csrfmiddlewaretoken"]');
+
+    if (CSRFElement instanceof HTMLInputElement) {
+      const CSRFToken: string = CSRFElement.value;
+      const body = new FormData();
+
+      body.append('csrfmiddlewaretoken', CSRFToken);
+
+      fetch(url, { method: 'POST', body })
+        .then((response) => {
+          if (!response.ok) {
+            throw new Error(`HTTP error! Status: ${response.status}`);
+          }
+        })
+        .then(() => {
+          this.dispatch('w-messages:add', {
+            prefix: '',
+            target: window.document,
+            detail: { clear: true, text: message, type: 'success' },
+            cancelable: false,
+          });
+        })
+        .catch((error) => {
+          throw error;
+        });
+    }
+  }
+}

+ 2 - 0
client/src/controllers/index.ts

@@ -11,6 +11,7 @@ import { DialogController } from './DialogController';
 import { DismissibleController } from './DismissibleController';
 import { DropdownController } from './DropdownController';
 import { InitController } from './InitController';
+import { OrderableController } from './OrderableController';
 import { ProgressController } from './ProgressController';
 import { RevealController } from './RevealController';
 import { SkipLinkController } from './SkipLinkController';
@@ -40,6 +41,7 @@ export const coreControllerDefinitions: Definition[] = [
   { controllerConstructor: DismissibleController, identifier: 'w-dismissible' },
   { controllerConstructor: DropdownController, identifier: 'w-dropdown' },
   { controllerConstructor: InitController, identifier: 'w-init' },
+  { controllerConstructor: OrderableController, identifier: 'w-orderable' },
   { controllerConstructor: ProgressController, identifier: 'w-progress' },
   { controllerConstructor: RevealController, identifier: 'w-breadcrumbs' },
   { controllerConstructor: RevealController, identifier: 'w-reveal' },

+ 11 - 0
package-lock.json

@@ -28,6 +28,7 @@
         "redux": "^4.0.0",
         "redux-thunk": "^2.3.0",
         "reselect": "^4.0.0",
+        "sortablejs": "^1.15.0",
         "telepath-unpack": "^0.0.3",
         "tippy.js": "^6.3.7",
         "uuid": "^9.0.0"
@@ -19312,6 +19313,11 @@
         "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
     },
+    "node_modules/sortablejs": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz",
+      "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w=="
+    },
     "node_modules/source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -35142,6 +35148,11 @@
         }
       }
     },
+    "sortablejs": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz",
+      "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w=="
+    },
     "source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

+ 1 - 0
package.json

@@ -127,6 +127,7 @@
     "redux": "^4.0.0",
     "redux-thunk": "^2.3.0",
     "reselect": "^4.0.0",
+    "sortablejs": "^1.15.0",
     "telepath-unpack": "^0.0.3",
     "tippy.js": "^6.3.7",
     "uuid": "^9.0.0"

+ 0 - 121
wagtail/admin/templates/wagtailadmin/pages/index.html

@@ -37,125 +37,4 @@
         </script>
         <script defer src="{% versioned_static 'wagtailadmin/js/bulk-actions.js' %}"></script>
     {% endif %}
-    <script type="text/javascript">
-        {% if ordering == 'ord' %}
-            $(function() {
-                var currentlySelected;
-                var currentPosition;
-                var movedPageId;
-                var moveNTimesDirection = 0;
-                var reorderCount = 0;
-                var orderform = $('#page-reorder-form');
-
-                $('.listing tbody').sortable({
-                    cursor: "move",
-                    tolerance: "pointer",
-                    containment: "parent",
-                    handle: ".handle",
-                    items: "> tr",
-                    axis: "y",
-                    placeholder: "dropzone",
-                    start: function(){
-                        $(this).parent().addClass('sorting');
-                    },
-                    stop: function(event, ui){
-                        $(this).parent().removeClass('sorting');
-
-                        // Work out what page moved and where it moved to
-                        var movedElement = ui.item[0];
-                        var movedPageId = movedElement.id.substring(5);
-                        var newPosition = $(movedElement).prevAll().length;
-
-                        // If position is last element, don't set position variable
-                        if ($(movedElement).nextAll().length == 0) {
-                            newPosition = null;
-                        }
-
-                        // Build url
-                        // TODO: Find better way to inject movedPageId
-                        var url = "{% url 'wagtailadmin_pages:set_page_position' '999999' %}".replace('999999', movedPageId);
-                        if (newPosition != null) {
-                            url += '?position=' + newPosition;
-                        }
-
-                        // Get CSRF token
-                        var CSRFToken = $('input[name="csrfmiddlewaretoken"]', orderform).val();
-
-                        // Post
-                        $.post(url, {csrfmiddlewaretoken: CSRFToken}, function(){
-                            const text = `"${$(movedElement).data('page-title')}" has been moved successfully.`;
-                            const event = new CustomEvent('w-messages:add', { detail: { clear: true, text,  type: 'success' } });
-                            document.dispatchEvent(event);
-                        })
-                    }
-                });
-                $('.listing tbody').disableSelection();
-                $("[data-order-handle]").on("keydown", function(e) {
-                    let keyCodes = {
-                        enter: 13,
-                        upArrow: 38,
-                        downArrow: 40,
-                        escape: 27
-                    }
-
-                    if (currentlySelected) {
-                        // We want to prevent default key actions (like scrolling) when we have an object selected
-                        e.preventDefault();
-                    }
-
-                    var children = $('.listing tbody').children();
-
-                    let moveElement = function() {
-                        var index = currentPosition + moveNTimesDirection;
-                        if (index < 0) {
-                            index = 0
-                        } else if (index > children.length - 1) {
-                            index = children.length - 1
-                        }
-                        var url = "{% url 'wagtailadmin_pages:set_page_position' '999999' %}".replace('999999', currentlySelected.id.substring(5));
-                        url += `?position=${(index)}`;
-                        let CSRFToken = $('input[name="csrfmiddlewaretoken"]', orderform).val();
-                        $.post(url, {csrfmiddlewaretoken: CSRFToken}, function(){
-                            const text = `"${$(currentlySelected).data('page-title')}" has been moved successfully from ${currentPosition + 1} to ${index + 1}.`;
-                            const event = new CustomEvent('w-messages:add', { detail: { clear: true, text, type: 'success' } });
-                            document.dispatchEvent(event);
-                        }).done(function() {
-                            currentlySelected = undefined;
-                        })
-                        if (moveNTimesDirection > 0) {
-                            $(currentlySelected).insertAfter($(children[index]));
-                        } else {
-                            $(currentlySelected).insertBefore($(children[index]));
-                        }
-                    }
-
-                    if(!currentlySelected && e.which == keyCodes.enter) {
-                        moveNTimesDirection = 0;
-                        currentlySelected = $(this).parents('tr')[0]
-                        children.toArray().forEach(function(item, index) {
-                            if (item === currentlySelected) {
-                                currentPosition = index;
-                            }
-                        })
-                    }
-                    if (currentlySelected && e.which == keyCodes.enter) {
-                        if (moveNTimesDirection != 0) {
-                            moveElement();
-                        }
-                    }
-
-                    if (currentlySelected && e.which == keyCodes.upArrow && children.first()[0] !== currentlySelected) {
-                        moveNTimesDirection--;
-                    };
-                    if (currentlySelected && e.which == keyCodes.downArrow && children.last()[0] !== currentlySelected) {
-                        moveNTimesDirection++;
-                    };
-                    if (currentlySelected && e.which == keyCodes.escape) {
-                        currentlySelected = undefined;
-                    }
-
-                })
-            })
-        {% endif %}
-    </script>
 {% endblock %}