kopia lustrzana https://github.com/wagtail/wagtail
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-orderablepull/11474/head
rodzic
e25ba7b228
commit
bf3e317a48
|
@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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' },
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 %}
|
||||
|
|
Ładowanie…
Reference in New Issue