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
pull/11474/head
Aman Pandey 2023-11-17 13:09:42 +05:30 zatwierdzone przez LB (Ben Johnston)
rodzic e25ba7b228
commit bf3e317a48
6 zmienionych plików z 326 dodań i 121 usunięć

Wyświetl plik

@ -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'],
});
});
});
});

Wyświetl plik

@ -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;
});
}
}
}

Wyświetl plik

@ -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
package-lock.json wygenerowano
Wyświetl plik

@ -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",

Wyświetl plik

@ -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"

Wyświetl plik

@ -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 %}