[IMP] web,web_editor,website_slide: sortable service for frontend usage

This commit removes the module sortable from jQueryUI and adds instead a
sortable service to use the sortable code already used by the backend.

The service can be called using `this.call("sortable", ...)` and the
sortable listeners are added when the `enable` function is called.
This function (`enable()`) returns an object that contains the `cleanup`
function (`cleanup()`).

```javascript
// start / setup
const sortable = this.call(
    "sortable",
    "create",
    {
        ref: { el: document.querySelector(".list") },
        elements: ".items",
        handle: ".drag-icon",
    },
);

// on attach callback / on mounted
const sortableInstance = sortable.enable();

// on destroy / on willUnmount
sortableInstance.cleanup();
```

Also, it adds some new property to the useSortable:
* clone: the placeholder is a clone of the drag element
* placeholderClasses: classes added to the placeholder element
* applyChangeOnDrop: on drop the change is applied to the DOM
* followingElementClasses: classes added to the element following mouse

task-3439226

closes odoo/odoo#137527

Related: odoo/enterprise#48371
Signed-off-by: Adrien Dieudonné (adr) <adr@odoo.com>
This commit is contained in:
Romeo Fragomeli
2023-10-10 01:59:35 +00:00
parent 9c970b13b7
commit 5f454f0ccb
10 changed files with 413 additions and 1587 deletions
+2 -6
View File
@@ -1,6 +1,6 @@
/*! jQuery UI - v1.12.1 - 2023-09-14
/*! jQuery UI - v1.12.1 - 2023-10-04
* http://jqueryui.com
* Includes: draggable.css, sortable.css, core.css, autocomplete.css, menu.css, theme.css
* Includes: draggable.css, core.css, autocomplete.css, menu.css, theme.css
* To view and modify this theme, visit http://jqueryui.com/themeroller/?scope=&folderName=base&cornerRadiusShadow=8px&offsetLeftShadow=0px&offsetTopShadow=0px&thicknessShadow=5px&opacityShadow=30&bgImgOpacityShadow=0&bgTextureShadow=flat&bgColorShadow=666666&opacityOverlay=30&bgImgOpacityOverlay=0&bgTextureOverlay=flat&bgColorOverlay=aaaaaa&iconColorError=cc0000&fcError=5f3f3f&borderColorError=f1a899&bgTextureError=flat&bgColorError=fddfdf&iconColorHighlight=777620&fcHighlight=777620&borderColorHighlight=dad55e&bgTextureHighlight=flat&bgColorHighlight=fffa90&iconColorActive=ffffff&fcActive=ffffff&borderColorActive=003eff&bgTextureActive=flat&bgColorActive=007fff&iconColorHover=555555&fcHover=2b2b2b&borderColorHover=cccccc&bgTextureHover=flat&bgColorHover=ededed&iconColorDefault=777777&fcDefault=454545&borderColorDefault=c5c5c5&bgTextureDefault=flat&bgColorDefault=f6f6f6&iconColorContent=444444&fcContent=333333&borderColorContent=dddddd&bgTextureContent=flat&bgColorContent=ffffff&iconColorHeader=444444&fcHeader=333333&borderColorHeader=dddddd&bgTextureHeader=flat&bgColorHeader=e9e9e9&cornerRadius=3px&fwDefault=normal&fsDefault=1em&ffDefault=Arial%2CHelvetica%2Csans-serif
* Copyright jQuery Foundation and other contributors; Licensed MIT */
@@ -8,10 +8,6 @@
-ms-touch-action: none;
touch-action: none;
}
.ui-sortable-handle {
-ms-touch-action: none;
touch-action: none;
}
/* Layout helpers
----------------------------------*/
.ui-helper-hidden {
File diff suppressed because it is too large Load Diff
@@ -81,7 +81,7 @@ import { hasTouch, isBrowserFirefox, isIOS } from "../browser/feature_detection"
*/
const DRAGGABLE_CLASS = "o_draggable";
const DRAGGED_CLASS = "o_dragged";
export const DRAGGED_CLASS = "o_dragged";
const DEFAULT_ACCEPTED_PARAMS = {
enable: [Boolean, Function],
+115 -13
View File
@@ -1,6 +1,9 @@
/** @odoo-module **/
import { makeDraggableHook as nativeMakeDraggableHook } from "@web/core/utils/draggable_hook_builder";
import {
DRAGGED_CLASS,
makeDraggableHook as nativeMakeDraggableHook,
} from "@web/core/utils/draggable_hook_builder";
import { pick } from "@web/core/utils/objects";
/** @typedef {import("@web/core/utils/draggable_hook_builder").DraggableHandlerParams} DraggableHandlerParams */
@@ -28,6 +31,10 @@ import { pick } from "@web/core/utils/objects";
* @property {boolean | (() => boolean)} [connectGroups] whether elements can be dragged
* accross different parent groups. Note that it requires a `groups` param to work.
* @property {string | (() => string)} [cursor] cursor style during the dragging sequence.
* @property {Boolean} [clone] the placeholder is a clone of the drag element.
* @property {Array} [placeholderClasses] array of classes added to the placeholder element.
* @property {Boolean} [applyChangeOnDrop] on drop the change is applied to the DOM.
* @property {Array} [followingElementClasses] array of classes added to the element that follow the pointer.
*
* HANDLERS (also optional)
*
@@ -69,11 +76,19 @@ const hookParams = {
acceptedParams: {
groups: [String, Function],
connectGroups: [Boolean, Function],
clone: [Boolean],
placeholderClasses: [Object],
applyChangeOnDrop: [Boolean],
followingElementClasses: [Object],
},
defaultParams: {
connectGroups: false,
edgeScrolling: { speed: 20, threshold: 60 },
groupSelector: null,
clone: true,
placeholderClasses: [],
applyChangeOnDrop: false,
followingElementClasses: [],
},
// Build steps
@@ -86,6 +101,11 @@ const hookParams = {
// Connection accross groups
ctx.connectGroups = params.connectGroups;
ctx.placeholderClone = params.clone;
ctx.placeholderClasses = params.placeholderClasses;
ctx.applyChangeOnDrop = params.applyChangeOnDrop;
ctx.followingElementClasses = params.followingElementClasses;
},
// Runtime steps
@@ -120,6 +140,65 @@ const hookParams = {
callHandler("onElementLeave", { element });
};
const onElementComplexPointerEnter = (ev) => {
const element = ev.currentTarget;
const siblingArray = [...element.parentElement.children].filter(
(el) =>
el === current.placeHolder ||
(el.matches(elementSelector) && !el.classList.contains(DRAGGED_CLASS))
);
const elementIndex = siblingArray.indexOf(element);
const placeholderIndex = siblingArray.indexOf(current.placeHolder);
const isDirectSibling = Math.abs(elementIndex - placeholderIndex) === 1;
if (
connectGroups ||
!groupSelector ||
current.group === element.closest(groupSelector)
) {
const pos = current.placeHolder.compareDocumentPosition(element);
if (isDirectSibling) {
if (pos === Node.DOCUMENT_POSITION_PRECEDING) {
element.before(current.placeHolder);
} else if (pos === Node.DOCUMENT_POSITION_FOLLOWING) {
element.after(current.placeHolder);
}
} else {
if (pos === Node.DOCUMENT_POSITION_FOLLOWING) {
element.before(current.placeHolder);
} else if (pos === Node.DOCUMENT_POSITION_PRECEDING) {
element.after(current.placeHolder);
}
}
}
callHandler("onElementEnter", { element });
};
/**
* Element "pointerleave" event handler.
* @param {PointerEvent} ev
*/
const onElementComplexPointerLeave = (ev) => {
const element = ev.currentTarget;
const siblingArray = [...element.parentElement.children].filter(
(el) =>
el === current.placeHolder ||
(el.matches(elementSelector) && !el.classList.contains(DRAGGED_CLASS))
);
const elementIndex = siblingArray.indexOf(element);
const isAtEdge = elementIndex === 0 || elementIndex === siblingArray.length - 1;
if (isAtEdge && (!groupSelector || current.group === element.closest(groupSelector))) {
const pos = current.placeHolder.compareDocumentPosition(element);
if (pos === Node.DOCUMENT_POSITION_PRECEDING) {
element.before(current.placeHolder);
} else if (pos === Node.DOCUMENT_POSITION_FOLLOWING) {
element.after(current.placeHolder);
}
}
callHandler("onElementLeave", { element });
};
/**
* Group "pointerenter" event handler.
* @param {PointerEvent} ev
@@ -140,15 +219,17 @@ const hookParams = {
};
const { connectGroups, current, elementSelector, groupSelector, ref } = ctx;
const { width, height } = current.elementRect;
if (ctx.placeholderClone) {
const { width, height } = current.elementRect;
// Adjusts size for the placeholder element
addStyle(current.placeHolder, {
visibility: "hidden",
display: "block",
width: `${width}px`,
height: `${height}px`,
});
// Adjusts size for the placeholder element
addStyle(current.placeHolder, {
visibility: "hidden",
display: "block",
width: `${width}px`,
height: `${height}px`,
});
}
// Binds handlers on eligible groups, if the elements are not confined to
// their parents and a 'groupSelector' has been provided.
@@ -162,8 +243,13 @@ const hookParams = {
// Binds handlers on eligible elements
for (const siblingEl of ref.el.querySelectorAll(elementSelector)) {
if (siblingEl !== current.element && siblingEl !== current.placeHolder) {
addListener(siblingEl, "pointerenter", onElementPointerEnter);
addListener(siblingEl, "pointerleave", onElementPointerLeave);
if (ctx.placeholderClone) {
addListener(siblingEl, "pointerenter", onElementPointerEnter);
addListener(siblingEl, "pointerleave", onElementPointerLeave);
} else {
addListener(siblingEl, "pointerenter", onElementComplexPointerEnter);
addListener(siblingEl, "pointerleave", onElementComplexPointerLeave);
}
}
}
@@ -180,8 +266,17 @@ const hookParams = {
const previous = current.placeHolder.previousElementSibling;
const next = current.placeHolder.nextElementSibling;
if (previous !== current.element && next !== current.element) {
const element = current.element;
if (ctx.applyChangeOnDrop) {
// Apply to the DOM the result of sortable()
if (previous) {
previous.after(element);
} else if (next) {
next.before(element);
}
}
return {
element: current.element,
element,
group: current.group,
previous,
next,
@@ -199,8 +294,15 @@ const hookParams = {
}
}
current.placeHolder = current.element.cloneNode(false);
if (ctx.placeholderClone) {
current.placeHolder = current.element.cloneNode(false);
} else {
current.placeHolder = document.createElement("div");
}
current.placeHolder.classList.add(...ctx.placeholderClasses);
current.element.classList.add(...ctx.followingElementClasses);
addCleanup(() => current.element.classList.remove(...ctx.followingElementClasses));
addCleanup(() => current.placeHolder.remove());
return pick(current, "element", "group");
@@ -0,0 +1,98 @@
/** @odoo-module **/
import { registry } from "../registry";
import { useSortable } from "@web/core/utils/sortable";
import { throttleForAnimation } from "@web/core/utils/timing";
import { reactive } from "@odoo/owl";
/**
* @typedef SortableServiceHookParams
* @extends SortableParams
* @property {{el: HTMLElement} | ReturnType<typeof import("@odoo/owl").useRef>} [ref] container of sortable
* @property {string | Symbol} [sortableId] identifier when multiple sortable on the same container
*/
const DEFAULT_SORTABLE_ID = Symbol.for("defaultSortable");
export const sortableService = {
start() {
/**
* Map to avoid to setup/enable twice or more time the same element
* @type {Map<Element, Object>}
*/
const boundElements = new Map();
return {
/**
* @param {SortableServiceHookParams} hookParams
*/
create: (hookParams) => {
const element = hookParams.ref.el;
const sortableId = hookParams.sortableId ?? DEFAULT_SORTABLE_ID;
if (boundElements.has(element)) {
const boundElement = boundElements.get(element);
if (sortableId in boundElement) {
return {
enable() {
return {
cleanup: boundElement[sortableId],
};
},
};
}
}
/**
* @type {Map<Function, function():Array>}
*/
const setupFunctions = new Map();
/**
* @type {Array<Function>}
*/
const cleanupFunctions = [];
const cleanup = () => {
const boundElement = boundElements.get(element);
if (sortableId in boundElement) {
delete boundElement[sortableId];
if (boundElement.length === 0) {
boundElements.delete(element);
}
}
cleanupFunctions.forEach((fn) => fn());
};
// Setup hookParam
const setupHooks = {
wrapState: reactive,
throttle: throttleForAnimation,
addListener: (el, type, listener) => {
el.addEventListener(type, listener);
cleanupFunctions.push(() => el.removeEventListener(type, listener));
},
setup: (setupFn, dependenciesFn) => setupFunctions.set(setupFn, dependenciesFn),
teardown: (fn) => cleanupFunctions.push(fn),
};
useSortable({ setupHooks, ...hookParams });
const boundElement = boundElements.get(element);
if (boundElement) {
boundElement[sortableId] = cleanup;
} else {
boundElements.set(element, { [sortableId]: cleanup });
}
return {
enable() {
setupFunctions.forEach((dependenciesFn, setupFn) =>
setupFn(...dependenciesFn())
);
return {
cleanup,
};
},
};
},
};
},
};
registry.category("services").add("sortable", sortableService);
@@ -498,4 +498,115 @@ QUnit.module("Draggable", ({ beforeEach }) => {
assert.verifySteps([]);
});
QUnit.test("the classes parameters (placeholderElement, helpElement)", async (assert) => {
assert.expect(7);
let dragElement;
class List extends Component {
setup() {
useSortable({
ref: useRef("root"),
elements: ".item",
placeholderClasses: ["placeholder-t1", "placeholder-t2"],
followingElementClasses: ["add-1", "add-2"],
onDragStart({ element }) {
dragElement = element;
assert.hasClass(dragElement, "add-1");
assert.hasClass(dragElement, "add-2");
// the placeholder is added in onDragStart after the current element
const children = [...dragElement.parentElement.children];
const placeholder = children[children.indexOf(dragElement) + 1];
assert.hasClass(placeholder, "placeholder-t1");
assert.hasClass(placeholder, "placeholder-t2");
},
});
}
}
List.template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
await mount(List, target);
// First item after 2nd item
const { drop, moveTo } = await drag(".item:first-child");
await moveTo(".item:nth-child(2)");
await drop();
assert.doesNotHaveClass(dragElement, "add-1");
assert.doesNotHaveClass(dragElement, "add-2");
assert.containsNone(target, ".item.placeholder-t1.placeholder-t2");
});
QUnit.test("applyChangeOnDrop option", async (assert) => {
assert.expect(2);
class List extends Component {
setup() {
useSortable({
ref: useRef("root"),
elements: ".item",
placeholderClasses: ["placeholder"],
applyChangeOnDrop: true,
onDragStart({ element }) {
const items = [...target.querySelectorAll(".item:not(.placeholder)")];
assert.strictEqual(items.map((el) => el.innerText).toString(), "1,2,3");
},
onDragEnd({ element, group }) {
const items = [...target.querySelectorAll(".item:not(.placeholder)")];
assert.strictEqual(items.map((el) => el.innerText).toString(), "2,1,3");
},
});
}
}
List.template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
await mount(List, target);
// First item after 2nd item
const { drop, moveTo } = await drag(".item:first-child");
await moveTo(".item:nth-child(2)");
await drop();
});
QUnit.test("clone option", async (assert) => {
assert.expect(2);
class List extends Component {
setup() {
useSortable({
ref: useRef("root"),
elements: ".item",
placeholderClasses: ["placeholder"],
clone: false,
onDragStart({ element }) {
assert.containsOnce(target, ".placeholder:not(.item)");
},
});
}
}
List.template = xml`
<div t-ref="root" class="root">
<ul class="list">
<li t-foreach="[1, 2, 3]" t-as="i" t-key="i" t-esc="i" class="item" />
</ul>
</div>`;
await mount(List, target);
// First item after 2nd item
const { drop, moveTo } = await drag(".item:first-child");
await moveTo(".item:nth-child(2)");
await drop();
assert.containsNone(target, ".placeholder:not(.item)");
});
});
@@ -2008,6 +2008,14 @@ const ListUserValueWidget = UserValueWidget.extend({
}
},
/**
* @override
*/
destroy() {
this.bindedSortable?.cleanup();
this._super(...arguments);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
@@ -2170,16 +2178,18 @@ const ListUserValueWidget = UserValueWidget.extend({
if (this.el.dataset.unsortable) {
return;
}
$(this.listTable).sortable({
axis: 'y',
handle: '.o_we_drag_handle',
items: 'tr',
cursor: 'move',
opacity: 0.6,
stop: (event, ui) => {
this._notifyCurrentState();
this.bindedSortable = this.call(
"sortable",
"create",
{
ref: { el: this.listTable },
elements: "tr",
followingElementClasses: ["opacity-50"],
handle: ".o_we_drag_handle",
onDrop: () => this._notifyCurrentState(),
applyChangeOnDrop: true,
},
});
).enable();
},
/**
* @private
@@ -77,7 +77,7 @@ wTourUtils.registerWebsitePreviewTour('snippet_social_media', {
content: 'Drag the facebook link at the end of the list',
trigger: 'we-list table we-button.o_we_drag_handle',
position: 'bottom',
run: "drag_and_drop we-list table tr:last-child",
run: "drag_and_drop_native we-list table tr:last-child",
},
{
content: 'Check drop completed',
@@ -111,6 +111,11 @@
});
},
destroy() {
this._unbindSortable();
return this._super(...arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
@@ -141,12 +146,23 @@
* @private
*/
_bindSortable: function () {
this.$el.sortable({
handle: '.o_wslides_js_quiz_sequence_handler',
items: '.o_wslides_js_lesson_quiz_question',
stop: this._reorderQuestions.bind(this),
placeholder: 'o_wslides_js_quiz_sequence_highlight position-relative my-3'
});
this.bindedSortable = this.call(
"sortable",
"create",
{
ref: { el: this.el },
handle: ".o_wslides_js_quiz_sequence_handler",
elements: ".o_wslides_js_lesson_quiz_question",
onDrop: this._reorderQuestions.bind(this),
clone: false,
placeholderClasses: ['o_wslides_js_quiz_sequence_highlight', 'position-relative', 'my-3'],
applyChangeOnDrop: true
},
).enable();
},
_unbindSortable: function () {
this.bindedSortable?.cleanup();
},
/**
@@ -11,11 +11,17 @@ publicWidget.registry.websiteSlidesCourseSlidesList = SlideCoursePage.extend({
this._super.apply(this,arguments);
this.channelId = this.$el.data('channelId');
this.bindedSortable = [];
this._updateHref();
this._bindSortable();
},
destroy() {
this._unbindSortable();
return this._super(...arguments);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------,
@@ -28,20 +34,43 @@ publicWidget.registry.websiteSlidesCourseSlidesList = SlideCoursePage.extend({
* @private
*/
_bindSortable: function () {
this.$('ul.o_wslides_js_slides_list_container').sortable({
handle: '.o_wslides_slides_list_drag',
stop: this._reorderSlides.bind(this),
items: '.o_wslides_slide_list_category',
placeholder: 'o_wslides_slides_list_slide_hilight position-relative mb-1'
});
const sortableBaseParam = {
clone: false,
placeholderClasses: ['o_wslides_slides_list_slide_hilight', 'position-relative', 'mb-1'],
onDrop: this._reorderSlides.bind(this),
applyChangeOnDrop: true
};
this.$('.o_wslides_js_slides_list_container ul').sortable({
handle: '.o_wslides_slides_list_drag',
connectWith: '.o_wslides_js_slides_list_container ul',
stop: this._reorderSlides.bind(this),
items: '.o_wslides_slides_list_slide:not(.o_wslides_js_slides_list_empty)',
placeholder: 'o_wslides_slides_list_slide_hilight position-relative mb-1'
});
const container = this.el.querySelector('ul.o_wslides_js_slides_list_container');
this.bindedSortable.push(this.call(
"sortable",
"create",
{
...sortableBaseParam,
ref: { el: container },
elements: ".o_wslides_slide_list_category",
handle: ".o_wslides_slide_list_category_header .o_wslides_slides_list_drag",
sortableId: "category",
},
).enable());
this.bindedSortable.push(this.call(
"sortable",
"create",
{
...sortableBaseParam,
ref: { el: container },
elements: ".o_wslides_slides_list_slide:not(.o_wslides_js_slides_list_empty):not(.o_not_editable)",
handle: ".o_wslides_slides_list_drag",
connectGroups: true,
groups: ".o_wslides_js_slides_list_container ul",
sortableId: "list",
},
).enable());
},
_unbindSortable: function () {
this.bindedSortable.forEach(sortable => sortable.cleanup());
},
/**