From bffc905c2a99babc769d984609b89e65cee548ca Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Thu, 19 Jun 2025 09:37:40 +0300 Subject: [PATCH] ref --- .../helpers/list/misc/list-tree-draggable.js | 347 ++++++++++++++++++ client/src/views/record/list-tree.js | 296 +-------------- 2 files changed, 361 insertions(+), 282 deletions(-) create mode 100644 client/src/helpers/list/misc/list-tree-draggable.js diff --git a/client/src/helpers/list/misc/list-tree-draggable.js b/client/src/helpers/list/misc/list-tree-draggable.js new file mode 100644 index 0000000000..f6ab91cd2b --- /dev/null +++ b/client/src/helpers/list/misc/list-tree-draggable.js @@ -0,0 +1,347 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2025 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +import {Draggable} from '@shopify/draggable'; +import {inject} from 'di'; +import Language from 'language'; + +/** + * @internal + */ +export default class ListTreeDraggableHelper { + + /** + * @type {Language} + */ + @inject(Language) + language + + /** + * @private + * @type {boolean} + */ + blockDraggable = false + + /** + * @private + * {Draggable} + */ + draggable + + /** + * @param {import('views/record/list-tree').default} view + */ + constructor(view) { + this.view = view; + } + + destroy() { + if (this.draggable) { + this.draggable.destroy(); + } + } + + init() { + if (this.draggable) { + this.draggable.destroy(); + } + + const draggable = this.draggable = new Draggable(this.view.element, { + distance: 8, + draggable: '.list-group-item > .cell > [data-role="moveHandle"]', + mirror: { + cursorOffsetX: 5, + cursorOffsetY: 5, + appendTo: 'body', + }, + }); + + /** @type {HTMLElement[]} */ + let rows; + /** @type {Map} */ + let levelMap; + /** @type {HTMLElement|null} */ + let movedHandle = null; + /** @type {HTMLElement|null} */ + let movedLink = null; + /** @type {HTMLElement|null} */ + let movedFromLi = null; + + draggable.on('mirror:created', event => { + const mirror = event.mirror; + const source = event.source; + const originalSource = event.originalSource; + + originalSource.style.display = ''; + source.style.display = 'none'; + + mirror.style.display = 'block'; + mirror.style.cursor = 'grabbing'; + mirror.classList.add('draggable-helper', 'draggable-helper-transparent', 'text-info'); + mirror.classList.remove('link'); + mirror.style.pointerEvents = 'auto'; + mirror.removeAttribute('href'); + mirror.style.textDecoration = 'none'; + + mirror.innerText = mirror.dataset.title; + }); + + draggable.on('mirror:move', event => { + event.mirror.style.pointerEvents = 'auto'; + }); + + draggable.on('drag:start', event => { + if (this.blockDraggable) { + event.cancel(); + + return; + } + + rows = Array.from(this.view.element.querySelectorAll('.list-group-tree > .list-group-item')); + + levelMap = new Map(); + + rows.forEach(row => { + let depth = 0; + let current = row; + + while (current && current !== this.view.element) { + current = current.parentElement; + + depth ++; + } + + levelMap.set(row, depth); + }); + + rows.sort((a, b) => levelMap.get(b) - levelMap.get(a)); + + this.view.movedId = event.source.dataset.id; + movedHandle = event.originalSource; + movedFromLi = movedHandle.parentElement.parentElement; + movedLink = movedHandle.parentElement.querySelector(`:scope > a.link`); + + movedLink.classList.add('text-info'); + }); + + let overId = null; + let overParentId = null; + let isAfter = false; + let wasOutOfSelf = false; + + draggable.on('drag:move', event => { + isAfter = false; + overId = null; + + let rowFound = null; + + for (const row of rows) { + const rect = row.getBoundingClientRect(); + + const isIn = + rect.left < event.sensorEvent.clientX && + rect.right > event.sensorEvent.clientX && + rect.top < event.sensorEvent.clientY && + rect.bottom >= event.sensorEvent.clientY; + + if (!isIn) { + continue; + } + + const itemId = row.dataset.id ?? null; + let itemParentId = null; + + if (!itemId) { + const parent = row.closest(`.list-group-item[data-id]`); + + if (parent instanceof HTMLElement) { + // Over a plus row. + itemParentId = parent.dataset.id; + } + } + + const itemIsAfter = event.sensorEvent.clientY - rect.top >= rect.bottom - event.sensorEvent.clientY; + + if (itemParentId && itemIsAfter) { + continue; + } + + if (itemId === this.view.movedId) { + break; + } + + if (movedFromLi.contains(row)) { + break; + } + + if (!itemId && !itemParentId) { + continue; + } + + if (itemParentId) { + const parent = row.closest(`.list-group-item[data-id]`); + + if (parent) { + const childCount = parent + .querySelectorAll(':scope > .children > .list > .list-group > [data-id]') + .length + + if (childCount) { + continue; + } + } + } + + isAfter = itemIsAfter; + overParentId = itemParentId; + overId = itemId; + rowFound = row; + + break; + } + + for (const row of rows) { + row.classList.remove('border-top-highlighted'); + row.classList.remove('border-bottom-highlighted'); + } + + if (!rowFound) { + return; + } + + if (isAfter) { + rowFound.classList.add('border-bottom-highlighted'); + rowFound.classList.remove('border-top-highlighted'); + } else { + rowFound.classList.add('border-top-highlighted'); + rowFound.classList.remove('border-bottom-highlighted'); + } + }); + + draggable.on('drag:stop', async () => { + const finalize = () => { + if (movedLink) { + movedLink.classList.remove('text-info'); + } + + rows.forEach(row => { + row.classList.remove('border-bottom-highlighted'); + row.classList.remove('border-top-highlighted'); + }); + + rows = undefined; + }; + + let moveType; + let referenceId = overId; + + if (overParentId || overId) { + if (overParentId) { + moveType = 'into'; + referenceId = overParentId; + } else if (isAfter) { + moveType = 'after'; + } else { + moveType = 'before'; + } + } + + if (moveType) { + this.blockDraggable = true; + + const movedId = this.view.movedId; + const affectedId = referenceId; + + Espo.Ui.notifyWait(); + + Espo.Ajax + .postRequest(`${this.view.entityType}/action/move`, { + id: this.view.movedId, + referenceId: referenceId, + type: moveType, + }) + .then(async () => { + /** + * @param {ListTreeRecordView} view + * @param {string} movedId + * @return {Promise} + */ + const update = async (view, movedId) => { + if (view.collection.has(movedId)) { + await view.collection.fetch(); + + return; + } + + for (const subView of view.getItemViews()) { + if (!subView.getChildrenView()) { + continue; + } + + await update(subView.getChildrenView(), movedId); + } + }; + + const promises = []; + + if (movedId) { + promises.push(update(this.view, movedId)); + } + + if (affectedId) { + promises.push(update(this.view, affectedId)); + } + + await Promise.all(promises); + + Espo.Ui.success(this.language.translate('Done')); + }) + .finally(() => { + this.blockDraggable = false; + + finalize(); + }); + } + + if (!moveType) { + finalize(); + } + + this.view.movedId = null; + + movedHandle = null; + movedFromLi = null; + levelMap = undefined; + overParentId = null; + overId = null; + isAfter = false; + wasOutOfSelf = false; + }); + } +} + diff --git a/client/src/views/record/list-tree.js b/client/src/views/record/list-tree.js index 34bb6f8e90..a732f5736c 100644 --- a/client/src/views/record/list-tree.js +++ b/client/src/views/record/list-tree.js @@ -30,7 +30,7 @@ import ListRecordView from 'views/record/list'; import RecordModal from 'helpers/record-modal'; -import {Draggable} from '@shopify/draggable'; +import ListTreeDraggableHelper from 'helpers/list/misc/list-tree-draggable'; class ListTreeRecordView extends ListRecordView { @@ -72,12 +72,6 @@ class ListTreeRecordView extends ListRecordView { */ movedId = null - /** - * @private - * @type {boolean} - */ - blockDraggable = false - /** * @private * @type {boolean} @@ -86,9 +80,9 @@ class ListTreeRecordView extends ListRecordView { /** * @private - * {Draggable} + * @type {ListTreeDraggableHelper} */ - draggable + draggableHelper // noinspection JSCheckFunctionSignatures data() { @@ -182,6 +176,14 @@ class ListTreeRecordView extends ListRecordView { } } + onRemove() { + super.onRemove(); + + if (this.draggableHelper) { + this.draggableHelper.destroy(); + } + } + afterRender() { super.afterRender(); @@ -357,281 +359,11 @@ class ListTreeRecordView extends ListRecordView { * @private */ initDraggableRoot() { - if (this.draggable) { - this.draggable.destroy(); + if (!this.draggableHelper) { + this.draggableHelper = new ListTreeDraggableHelper(this); } - const draggable = this.draggable = new Draggable(this.element, { - distance: 8, - draggable: '.list-group-item > .cell > [data-role="moveHandle"]', - mirror: { - cursorOffsetX: 5, - cursorOffsetY: 5, - appendTo: 'body', - }, - }); - - /** @type {HTMLElement[]} */ - let rows; - /** @type {Map} */ - let levelMap; - /** @type {HTMLElement|null} */ - let movedHandle = null; - /** @type {HTMLElement|null} */ - let movedLink = null; - /** @type {HTMLElement|null} */ - let movedFromLi = null; - - draggable.on('mirror:created', event => { - const mirror = event.mirror; - const source = event.source; - const originalSource = event.originalSource; - - originalSource.style.display = ''; - source.style.display = 'none'; - - mirror.style.display = 'block'; - mirror.style.cursor = 'grabbing'; - mirror.classList.add('draggable-helper', 'draggable-helper-transparent', 'text-info'); - mirror.classList.remove('link'); - mirror.style.pointerEvents = 'auto'; - mirror.removeAttribute('href'); - mirror.style.textDecoration = 'none'; - - mirror.innerText = mirror.dataset.title; - }); - - draggable.on('mirror:move', event => { - event.mirror.style.pointerEvents = 'auto'; - }); - - draggable.on('drag:start', event => { - if (this.blockDraggable) { - event.cancel(); - - return; - } - - rows = Array.from(this.element.querySelectorAll('.list-group-tree > .list-group-item')); - - levelMap = new Map(); - - rows.forEach(row => { - let depth = 0; - let current = row; - - while (current && current !== this.element) { - current = current.parentElement; - - depth ++; - } - - levelMap.set(row, depth); - }); - - rows.sort((a, b) => levelMap.get(b) - levelMap.get(a)); - - this.movedId = event.source.dataset.id; - movedHandle = event.originalSource; - movedFromLi = movedHandle.parentElement.parentElement; - movedLink = movedHandle.parentElement.querySelector(`:scope > a.link`); - - movedLink.classList.add('text-info'); - }); - - let overId = null; - let overParentId = null; - let isAfter = false; - let wasOutOfSelf = false; - - draggable.on('drag:move', event => { - isAfter = false; - overId = null; - - let rowFound = null; - - for (const row of rows) { - const rect = row.getBoundingClientRect(); - - const isIn = - rect.left < event.sensorEvent.clientX && - rect.right > event.sensorEvent.clientX && - rect.top < event.sensorEvent.clientY && - rect.bottom >= event.sensorEvent.clientY; - - if (!isIn) { - continue; - } - - const itemId = row.dataset.id ?? null; - let itemParentId = null; - - if (!itemId) { - const parent = row.closest(`.list-group-item[data-id]`); - - if (parent instanceof HTMLElement) { - // Over a plus row. - itemParentId = parent.dataset.id; - } - } - - const itemIsAfter = event.sensorEvent.clientY - rect.top >= rect.bottom - event.sensorEvent.clientY; - - if (itemParentId && itemIsAfter) { - continue; - } - - if (itemId === this.movedId) { - break; - } - - if (movedFromLi.contains(row)) { - break; - } - - if (!itemId && !itemParentId) { - continue; - } - - if (itemParentId) { - const parent = row.closest(`.list-group-item[data-id]`); - - if (parent) { - const childCount = parent - .querySelectorAll(':scope > .children > .list > .list-group > [data-id]') - .length - - if (childCount) { - continue; - } - } - } - - isAfter = itemIsAfter; - overParentId = itemParentId; - overId = itemId; - rowFound = row; - - break; - } - - for (const row of rows) { - row.classList.remove('border-top-highlighted'); - row.classList.remove('border-bottom-highlighted'); - } - - if (!rowFound) { - return; - } - - if (isAfter) { - rowFound.classList.add('border-bottom-highlighted'); - rowFound.classList.remove('border-top-highlighted'); - } else { - rowFound.classList.add('border-top-highlighted'); - rowFound.classList.remove('border-bottom-highlighted'); - } - }); - - draggable.on('drag:stop', async () => { - const finalize = () => { - if (movedLink) { - movedLink.classList.remove('text-info'); - } - - rows.forEach(row => { - row.classList.remove('border-bottom-highlighted'); - row.classList.remove('border-top-highlighted'); - }); - }; - - rows = undefined; - - let moveType; - let referenceId = overId; - - if (overParentId || overId) { - if (overParentId) { - moveType = 'into'; - referenceId = overParentId; - } else if (isAfter) { - moveType = 'after'; - } else { - moveType = 'before'; - } - } - - if (moveType) { - this.blockDraggable = true; - - const movedId = this.movedId; - const affectedId = referenceId; - - Espo.Ui.notifyWait(); - - Espo.Ajax - .postRequest(`${this.entityType}/action/move`, { - id: this.movedId, - referenceId: referenceId, - type: moveType, - }) - .then(async () => { - /** - * @param {ListTreeRecordView} view - * @param {string} movedId - * @return {Promise} - */ - const update = async (view, movedId) => { - if (view.collection.has(movedId)) { - await view.collection.fetch(); - - return; - } - - for (const subView of view.getItemViews()) { - if (!subView.getChildrenView()) { - continue; - } - - await update(subView.getChildrenView(), movedId); - } - }; - - const promises = []; - - if (movedId) { - promises.push(update(this, movedId)); - } - - if (affectedId) { - promises.push(update(this, affectedId)); - } - - await Promise.all(promises); - - Espo.Ui.success(this.translate('Done')); - }) - .finally(() => { - this.blockDraggable = false; - - finalize(); - }); - } - - if (!moveType) { - finalize(); - } - - this.movedId = null; - - movedHandle = null; - movedFromLi = null; - levelMap = undefined; - overParentId = null; - overId = null; - isAfter = false; - wasOutOfSelf = false; - }); + this.draggableHelper.init(); } }