/** @odoo-module */ import { Domain } from "@web/core/domain"; import { _t } from "@web/core/l10n/translation"; import { KeepLast, Mutex } from "@web/core/utils/concurrency"; import { Model } from "@web/model/model"; import { orderByToString } from "@web/search/utils/order_by"; let nodeId = 0; let forestId = 0; let treeId = 0; /** * Get the id of the given many2one field value * * @param {false | [Number, string]} value many2one value * @returns {false | Number} id of the many2one */ function getIdOfMany2oneField(value) { return value && value[0]; } export class HierarchyNode { /** * Constructor of hierarchy node stored in hierarchy tree * * @param {HierarchyModel} model * @param {Object} config * @param {Object} data * @param {HierarchyTree} tree * @param {HierarchyNode} parentNode * @param {Boolean} populateChildNodes */ constructor(model, config, data, tree, parentNode = null, populateChildNodes = true) { this.id = nodeId++; this.data = data; this.parentNode = parentNode; this.tree = tree; this.model = model; this._config = config; this.hidden = false; tree.addNode(this); if (populateChildNodes) { this.populateChildNodes(); } } /** * Get ancestor node * * @returns {HierarchyNode} ancestor node */ get ancestorNode() { return this.parentNode ? this.ancestorNode : this; } /** * Is leaf? * * @returns {Boolean} False if the current node has node as child nodes, otherwise True. */ get isLeaf() { return !this.nodes.length; } /** * Get forest of the current node * * @returns {HierarchyForest} */ get forest() { return this.tree.forest; } /** * Get the resId of current node * * @returns {Number} */ get resId() { return this.data.id; } /** * Get parent field name * * @returns {String} */ get parentFieldName() { return this.model.parentFieldName; } /** * Get parent res id * * @returns {Number} */ get parentResId() { return this.parentNode?.resId || getIdOfMany2oneField(this.data[this.parentFieldName]); } /** * Get child node res ids * * @returns {Number[]} */ get childResIds() { return this.nodes.length ? this.nodes.map((node) => node.resId) : this.data[this.childFieldName]?.map((d) => typeof d === "number" ? d : d.id) || []; } /** * Get child field name * * @returns {String} */ get childFieldName() { return this.model.childFieldName || this.model.defaultChildFieldName; } /** * Has child nodes? * * @returns {Boolean} */ get hasChildren() { return this.nodes.length > 0 || this.data[this.childFieldName]?.length > 0; } /** * Can show parent node * * Knows if the parent node can be fetched and displayed inside the view * * @returns {Boolean} True if the current node has a parent node but it is not yet displayed and the data of the * current node is not already displayed in another node. */ get canShowParentNode() { return Boolean(this.parentResId) && !this.parentNode && this.tree.forest.resIds.filter((resId) => resId === this.resId).length === 1; } /** * Can show child nodes * * Knows if the child nodes can be fetched and displayed inside the view * * @returns {Boolean} True if the current node has child nodes but they are not yet displayed and the data of the * current node is not already displayed in another node. */ get canShowChildNodes() { return this.hasChildren && this.nodes.length === 0 && this.tree.forest.resIds.filter((resId) => resId === this.resId).length === 1; } get descendantNodes() { const subNodes = []; if (!this.isLeaf) { subNodes.push(...this.nodes); for (const node of this.nodes) { if (node.descendantNodes.length) { subNodes.push(...node.descendantNodes); } } } return subNodes; } /** * Get all descendants nodes parents. If the current node has descendants, * it is also included in the result. * * @returns {Array} contains descendants parents in order of depth (closest * to root first). */ get descendantsParentNodes() { const descendantsParentNodes = []; if (!this.isLeaf) { descendantsParentNodes.push(this); this.nodes.reduce((parents, node) => { if (!node.isLeaf) { parents.push(...node.descendantsParentNodes); } return parents; }, descendantsParentNodes); } return descendantsParentNodes; } /** * Get all descendants nodes resIds * * @returns {Number[]} */ get allSubsidiaryResIds() { return this.descendantNodes.map((n) => n.resId); } /** * Populate child nodes * * Uses to create child nodes of the current one according to its data. */ populateChildNodes() { this.nodes = []; const children = this.data[this.childFieldName] || []; if ( children.length && children[0] instanceof Object && this.tree.forest.resIds.filter((resId) => resId === this.resId).length === 1 ) { this.createChildNodes(children); } } /** * create child nodes * * @param {Object[]} childNodesData data of child nodes to generate */ createChildNodes(childNodesData) { this.nodes = (childNodesData || this.data[this.childFieldName]).map( (childData) => new HierarchyNode( this.model, this._config, childData, this.tree, this ) ); } removeParentNode() { this.parentNode?.removeChildNode(this); this.parentNode = null; this.data[this.parentFieldName] = false; } /** * Fetch parent node */ async fetchParentNode() { await this.model.fetchManager(this); } /** * Fetch child nodes */ async showChildNodes() { await this.model.fetchSubordinates(this); } /** * Collapse child nodes * * Removes the descendant nodes of the current one and stores * the resIds of the child nodes in the data of the current one * to know it has child nodes to be able to show them again * when it is needed. */ collapseChildNodes() { const childrenData = []; for (const childNode of this.nodes) { childNode.data[this.childFieldName] = childNode.childResIds; childrenData.push(childNode.data); } this.data[this.childFieldName] = childrenData; this.removeChildNodes(); this.model.notify(); } removeChildNode(node) { node.removeChildNodes(); this.tree.removeNodes([node]); this.nodes = this.nodes.filter((n) => n.id !== node.id); this.data[this.childFieldName] = this.nodes.map((n) => n.data); } /** * Remove descendant nodes of the current one */ removeChildNodes() { for (const childNode of this.nodes) { if (!childNode.isLeaf) { childNode.removeChildNodes(); } } this.tree.removeNodes(this.nodes); this.nodes = []; } /** * Set parent node to the current node * * @param {HierarchyNode} node parent node to set */ setParentNode(node) { this.parentNode = node; node.addChildNode(this); const tree = node.tree; if (tree.root === this) { tree.root = node; } else if (this.tree.root === this) { this.tree.removeRoot(); this.setTree(node.tree); } } setTree(tree) { this.tree = tree; for (const childNode of this.nodes) { childNode.setTree(tree); } } /** * Adds child node to the current node * * @param {HierarchyNode} node child node to add */ addChildNode(node) { this.nodes.push(node); this.data[this.childFieldName].push(node.data); this.tree.addNode(node); } } export class HierarchyTree { /** * Constructor * * @param {HierarchyModel} model * @param {Object} config config of the model * @param {Object} data root node data of the tree to create * @param {HierarchyForest} forest hierarchy forest containing the tree to create */ constructor(model, config, data, forest) { this.id = treeId++; this.nodePerNodeId = {}; this.forest = forest; if (data) { this.root = new HierarchyNode(model, config, data, this); this.forest.nodePerNodeId = { ...this.forest.nodePerNodeId, ...this.nodePerNodeId, }; } this.model = model; this._config = config; } /** * Get node res ids inside the current tree * * @returns {Number} */ get resIds() { return Object.values(this.nodePerNodeId).map((node) => node.resId); } /** * Add node inside the current tree * * @param {HierarchyNode} node node to add inside the current tree */ addNode(node) { this.nodePerNodeId[node.id] = node; this.forest.addNode(node); } /** * Remove nodes inside the current tree * * @param {HierarchyNode} nodes nodes to remove */ removeNodes(nodes) { const nodeIds = nodes.map((node) => node.id); this.nodePerNodeId = Object.fromEntries( Object.entries(this.nodePerNodeId) .filter( ([nodeId,]) => !nodeIds.includes(Number(nodeId)) ) ); this.forest.removeNodes(nodes); } removeRoot() { this.forest.removeTree(this); } } export class HierarchyForest { /** * * @param {HierarchyModel} model * @param {Object} config model config * @param {Object[]} data list of tree root nodes data */ constructor(model, config, data) { this.id = forestId++; this.nodePerNodeId = {}; this.trees = data.map((d) => new HierarchyTree(model, config, d, this)); this.model = model; this._config = config; } /** * Get node res ids containing inside the current forest * * @returns {Number} */ get resIds() { return Object.values(this.nodePerNodeId).map((node) => node.resId); } /** * Get root node of all trees inside the current forest * * @returns {HierarchyNode[]} root nodes */ get rootNodes() { return this.trees.map((t) => t.root); } /** * Add a node inside the current forest * * @param {HierarchyNode} node node to add inside the current forest */ addNode(node) { this.nodePerNodeId[node.id] = node; } /** * Removes nodes inside the current forest * * @param {HierarchyNode} nodes nodes to remove inside the current forest */ removeNodes(nodes) { const nodeIds = nodes.map((node) => node.id); this.nodePerNodeId = Object.fromEntries( Object.entries(this.nodePerNodeId) .filter( ([nodeId,]) => !nodeIds.includes(Number(nodeId)) ) ); } addNewRootNode(node) { const tree = new HierarchyTree(this.model, this._config, null, this); tree.root = node; node.tree = tree; tree.addNode(node); for (const subNode of node.descendantNodes) { tree.addNode(subNode); } this.trees.push(tree); } removeTree(tree) { this.nodePerNodeId = Object.fromEntries( Object.entries(this.nodePerNodeId) .filter( ([nodeId, ]) => !(nodeId in tree.nodePerNodeId) ) ); this.trees = this.trees.filter((t) => t.id !== tree.id); } } export class HierarchyModel extends Model { static services = ["notification"]; setup(params, { notification }) { this.keepLast = new KeepLast(); this.mutex = new Mutex(); this.resModel = params.resModel; this.fields = params.fields; this.parentFieldName = params.parentFieldName; this.childFieldName = params.childFieldName; this.activeFields = params.activeFields; this.defaultOrderBy = params.defaultOrderBy; this.notification = notification; this.config = { domain: [], isRoot: true, }; } /** * Get parent field info * * @returns {Object} parent field info */ get parentField() { return this.fields[this.parentFieldName]; } /** * Get res ids of all nodes displayed in the view * * @returns {Number[]} resIds of all nodes displayed in the view */ get resIds() { return this.root?.resIds || []; } /** * Get default child field name when no child field name is given to the view * * @returns {String} default child field name to use */ get defaultChildFieldName() { return "__child_ids__"; } /** * Get default domain to use, when no domain is given in the config * * @returns {import("@web/src/core/domain").DomainListRepr} default domain */ get defaultDomain() { return [[this.parentFieldName, "=", false]]; } /** * Get the global domain of the view (which is the domain defined on the * view without applying filters). * * @returns {import("@web/src/core/domain").DomainListRepr} global domain */ get globalDomain() { if (!this.env.searchModel?.globalDomain.length) { return []; } return new Domain(this.env.searchModel.globalDomain).toList( this.env.searchModel.domainEvalContext ); } /** * Get active fields name * * @returns {String[]} active fields name */ get activeFieldNames() { return Object.keys(this.activeFields); } /** * Get fields to fetch * @returns {String[]} fields to fetch */ get fieldsToFetch() { const fieldsToFetch = [ ...this.activeFieldNames, ]; if (this.childFieldName) { fieldsToFetch.push(this.childFieldName); } return fieldsToFetch; } get context() { return { bin_size: true, ...(this.config.context || {}), }; } /** * Load the config and data for hierarchy view * * @param {Object} params params to use to load data of hierarchy view */ async load(params = {}) { nodeId = forestId = treeId = 0; const config = this._getNextConfig(this.config, params); const data = await this.keepLast.add(this._loadData(config)); this.root = this._createRoot(config, data); this.config = config; this.notify(); } /** * Reload the current view with all currently loaded records */ async reload() { nodeId = forestId = treeId = 0; const data = await this.keepLast.add(this._loadData(this.config, true)); this.root = this._createRoot(this.config, data); this.notify({ scrollTarget: "none" }); } /** * @override * Each notify should specify a scroll target (default is to scroll to the * bottom). */ notify(payload = { scrollTarget: "bottom" }) { super.notify(); this.bus.trigger("hierarchyScrollTarget", payload); } /** * Fetch parent node of given node * @param {HierarchyNode} node node to fetch its parent node */ async fetchManager(node) { if (this.root.trees.length > 1) { // reset the hierarchy const treeExpanded = this._findTreeExpanded(); const resIdsToFetch = [node.parentResId, node.resId, ...node.allSubsidiaryResIds]; if (treeExpanded && treeExpanded.root.id !== node.id && treeExpanded.root.parentResId === node.parentResId) { resIdsToFetch.push(...treeExpanded.root.allSubsidiaryResIds); } const config = { ...this.config, domain: ["|", [this.parentFieldName, "=", node.parentResId], ["id", "in", resIdsToFetch]], } const data = await this._loadData(config); this.root = this._createRoot(config, data); this.notify(); return; } const managerData = await this.keepLast.add(this._fetchManager(node)); if (managerData) { const parentNode = new HierarchyNode(this, this.config, managerData, node.tree, null, false); parentNode.createChildNodes(); node.setParentNode(parentNode); this.notify(); } } /** * Fetch child nodes of given node * * @param {HierarchyNode} node node to fetch its child nodes */ async fetchSubordinates(node) { const childFieldName = this.childFieldName || this.defaultChildFieldName; const children = node.data[childFieldName]; if (children.length) { const nodesToUpdate = []; if (!(children[0] instanceof Object)) { const allNodeResIds = this.root.resIds; const existingChildResIds = children.filter((childResId) => allNodeResIds.includes(childResId)) if (existingChildResIds.length) { // special case with result found with the search view for (const tree of this.root.trees) { if (existingChildResIds.includes(tree.root.resId)) { nodesToUpdate.push(tree.root); } } } const data = await this.keepLast.add(this._fetchSubordinates(node, existingChildResIds)); if (data && data.length) { node.data[childFieldName] = data; } } const nodeToCollapse = this._searchNodeToCollapse(node); if (nodeToCollapse && !nodesToUpdate.includes(nodeToCollapse)) { nodeToCollapse.collapseChildNodes(); } node.populateChildNodes(); for (const n of nodesToUpdate) { n.setParentNode(node); } this.notify(); } } /** * Search node to collapse to be able to show the child nodes of node given in parameter * * @param {HierarchyNode} node node to show its child nodes. * @returns {HierarchyNode | null} node found to collapse */ _searchNodeToCollapse(node) { const parentNode = node.parentNode; let nodeToCollapse = null; if (parentNode) { nodeToCollapse = parentNode.nodes.find((n) => n.nodes.length); } else { const treeExpanded = this._findTreeExpanded(); if (treeExpanded) { nodeToCollapse = treeExpanded.root; } } return nodeToCollapse; } _findTreeExpanded() { return this.root.trees.find((t) => t.root.nodes.length); } /** * Get the next model config to use * * @param {Object} currentConfig current model config used * @param {Object} params new params * @returns {Object} new model config to use */ _getNextConfig(currentConfig, params) { const config = Object.assign({}, currentConfig); config.context = "context" in params ? params.context : config.context; if ("domain" in params) { config.domain = params.domain; if (this.isSearchDefaultOrEmpty() && config.context.hierarchy_res_id) { config.domain = [["id", "=", config.context.hierarchy_res_id]]; const globalDomain = this.globalDomain; if (globalDomain.length) { config.domain = Domain.and([config.domain, globalDomain]); } // Just needed for the first load. delete config.context.hierarchy_res_id; } } // orderBy config.orderBy = "orderBy" in params ? params.orderBy : config.orderBy; // re-apply previous orderBy if not given (or no order) if (!config.orderBy.length) { config.orderBy = currentConfig.orderBy || []; } // apply default order if no order if (this.defaultOrderBy && !config.orderBy.length) { config.orderBy = this.defaultOrderBy; } return config; } /** * Evaluate if the current search query is the default one. * * @returns {boolean} */ isSearchDefaultOrEmpty() { if (!this.env.searchModel) { return true; } const isDisabledOptionalSearchMenuType = (type) => { return ( ["filter", "groupBy", "favorite"].includes(type) && !this.env.searchModel.searchMenuTypes.has(type) ); }; const activeSearchItems = this.env.searchModel.getSearchItems( (item) => item.isActive && !isDisabledOptionalSearchMenuType(item.type) ); if (!activeSearchItems.length) { return true; } const defaultSearchItems = this.env.searchModel.getSearchItems( (item) => item.isDefault && item.type !== "favorite" && !isDisabledOptionalSearchMenuType(item.type) ); return JSON.stringify(defaultSearchItems) === JSON.stringify(activeSearchItems); } /** * Load data for hierarchy view * * @param {Object} config model config * @param {boolean} reload all currently loaded resIds instead of using * the config domain * @returns {Object[]} main data for hierarchy view */ async _loadData(config, reload = false) { let onlyRoots = false; let domain = config.domain; const resIds = this.resIds; if (reload && resIds.length > 0) { domain = [["id", "in", resIds]]; } else if (this.isSearchDefaultOrEmpty()) { // If the current SearchModel query is the default one // configured for the action or there is no search query, an // additional constraint is added to only display "root" // records (without a parent). onlyRoots = true; domain = !domain.length ? this.defaultDomain : Domain.and([this.defaultDomain, domain]).toList({}); } const hierarchyRead = async () => { return await this.orm.call( this.resModel, "hierarchy_read", [ domain, this.fieldsToFetch, this.parentFieldName, this.childFieldName, orderByToString(config.orderBy), ], { context: this.context } ); }; let result = await hierarchyRead(); if (!result.length && onlyRoots) { domain = config.domain; result = await hierarchyRead(); } return this._formatData(result); } _formatData(data) { const dataStringified = JSON.stringify(data); const recordsPerParentId = {}; const recordPerId = {}; for (const record of data) { recordPerId[record.id] = record; const parentId = getIdOfMany2oneField(record[this.parentFieldName]); if (!(parentId.toString() in recordsPerParentId)) { recordsPerParentId[parentId] = []; } recordsPerParentId[parentId].push(record); } const formattedData = []; const recordIds = []; // to check if we have only one arborescence to display otherwise we display the data as the kanban view for (const [parentId, records] of Object.entries(recordsPerParentId)) { if (!parentId || !(parentId in recordPerId)) { formattedData.push(...records); } else { const parentRecord = recordPerId[parentId]; if (recordIds.includes(parentRecord.id)) { return JSON.parse(dataStringified); } const ancestorId = getIdOfMany2oneField(parentRecord[this.parentFieldName]); if (ancestorId in recordsPerParentId) { recordIds.push(...recordsPerParentId[ancestorId].map((r) => r.id)); } parentRecord[this.childFieldName || this.defaultChildFieldName] = records; } } if (!formattedData.length && data?.length) { formattedData.push(recordPerId[Object.keys(recordsPerParentId)[0]]); } return formattedData; } /** * Create forest * * @param {Object} config model config to use * @param {Object[]} data root data * @returns {HierarchyForest} forest hierarchy */ _createRoot(config, data) { return new HierarchyForest(this, config, data); } /** * Fetch parent node and its children nodes data * * @param {HierarchyNode} node node to fetch its parent node * @returns {Object} the parent node data with children data inside childFieldName */ async _fetchManager(node, exclude_node=true) { let domain = new Domain([ "|", ["id", "=", node.parentResId], [this.parentFieldName, "=", node.parentResId], ]); if (exclude_node) { domain = Domain.and([ domain, [["id", "!=", node.resId]], ]) } const result = await this.orm.searchRead( this.resModel, domain.toList({}), this.fieldsToFetch, { context: this.context, order: orderByToString(this.config.orderBy), }, ); let managerData = {}; const children = []; for (const data of result) { if (data.id === node.parentResId) { managerData = data; } else { children.push(data); } } if (!this.childFieldName) { if (children.length) { await this._fetchDescendants(children); } } managerData[this.childFieldName || this.defaultChildFieldName] = children; return managerData; } /** * Fetch children nodes data for a given node * * @param {HierarchyNode} node node to fetch its children nodes * @param {Array | null} excludeResIds list of ids to exclude (because the nodes already exist) * @returns {Object[]} list of child node data */ async _fetchSubordinates(node, excludeResIds = null) { let childrenResIds = node.data[this.childFieldName || this.defaultChildFieldName]; if (excludeResIds) { childrenResIds = childrenResIds.filter((childResId) => !excludeResIds.includes(childResId)); } const data = await this.orm.searchRead( this.resModel, [["id", "in", childrenResIds]], this.fieldsToFetch, { context: this.context, order: orderByToString(this.config.orderBy), }, ) if (!this.childFieldName) { await this._fetchDescendants(data); } return data; } /** * fetch descendants nodes resIds to know if the child nodes have descendants * * @param {Object[]} childrenData child nodes data to fetch its descendants */ async _fetchDescendants(childrenData) { const resIds = childrenData.map((d) => d.id); if (resIds.length) { const fetchChildren = await this.orm.readGroup( this.resModel, [[this.parentFieldName, "in", resIds]], ['id:array_agg'], [this.parentFieldName], { context: this.context || {}, orderby: orderByToString(this.config.orderBy), }, ); const childIdsPerId = Object.fromEntries( fetchChildren.map((r) => [r[this.parentFieldName][0], r.id]) ); for (const d of childrenData) { if (d.id.toString() in childIdsPerId) { d[this.defaultChildFieldName] = childIdsPerId[d.id.toString()]; } } } } /** * ORM call to update the parentId of a record during @see updateParentNode * Can be overridden to not use "write". * * @param {HierarchyNode} node node related to the record which parentId * should be changed * @param {Number} parentResId id of the new parent record */ async updateParentId(node, parentResId = false) { return this.orm.write( this.resModel, [node.resId], { [this.parentFieldName]: parentResId }, { context: this.context } ); } /** * @param {Number} nodeId of the node to update * @param {Object} parentInfo * @param {Number} [parentInfo.parentNodeId] nodeId of the parent * @param {Number | false} [parentInfo.parentResId] resId of the parent * @returns {Promise} */ async updateParentNode(nodeId, { parentNodeId, parentResId }) { const node = this.root.nodePerNodeId[nodeId]; const resId = node.resId; // Validation. if (!node) { return; } const parentNode = parentNodeId ? this.root.nodePerNodeId[parentNodeId] : null; parentResId = parentResId || parentNode?.resId || false; const oldParentNode = node.parentNode; if ( (parentNode && !this.validateUpdateParentNode(node, parentNode)) || parentNode?.resId === oldParentNode?.resId ) { return; } // Hide the node while waiting for the server response. node.hidden = true; this.notify({ scrollTarget: "none" }); // Update the parent server side. await this.mutex.exec(async () => { try { await this.updateParentId(node, parentResId); } catch (error) { // Show the node again since the operation failed, don't update the view. node.hidden = false; this.notify({ scrollTarget: "none" }); throw error; } }); // Reload impacted records. const domain = this.computeUpdateParentNodeDomain(node, parentResId, parentNode); const data = await this.orm.searchRead(this.resModel, domain, this.fieldsToFetch, { context: this.context, order: orderByToString(this.config.orderBy), }); const formattedData = this._formatData(data); // Validate that data coming from the server is still compatible with the current // configuration of the hierarchy. for (const record of formattedData) { if (getIdOfMany2oneField(record[this.parentFieldName]) !== parentResId) { node.hidden = false; this.notify({ scrollTarget: "none" }); this.notification.add( _t( `The parent of "%s" was successfully updated. Reloading records to account for other changes.`, node.data.display_name || node.data.name ), { type: "success" } ); return this.reload(); } } // Handle the expanded tree. let nodeToCollapse; const treeExpanded = this._findTreeExpanded(); const expandedParentNodeIds = treeExpanded?.root.descendantsParentNodes.map((node) => node.id) || []; if (!node.isLeaf || !expandedParentNodeIds.includes(parentNode?.id)) { // Handle cases where the expanded tree will be altered. // If node is not a leaf, the new expanded tree will contain its descendants. // If parentNode is not a parent in the current expanded tree, it will become one // in the new expanded tree. // Compute the depth of the parent of parentNode. That node is guaranteed to be a // parent in the current expanded tree. const depth = expandedParentNodeIds.findIndex( (id) => id === parentNode?.parentNode?.id ); if (depth === -1) { // Drop as root or drop as the child of a root that is not part of the current // expanded tree. The current expanded tree should be fully closed. nodeToCollapse = treeExpanded?.root; } else { // Drop anywhere else (at a position that can be related to the expanded tree with // the depth of the parent of parentNode). In that case the existing hierarchy is // split at the depth of the parent, and will be completed by node's remaining // expanded tree. const nodeIdToCollapse = expandedParentNodeIds.at(depth + 1); if (nodeIdToCollapse) { nodeToCollapse = treeExpanded?.nodePerNodeId[nodeIdToCollapse]; } } } else { // Handle cases where node is a leaf dropped in the current expanded tree. In that case, // the tree is kept open. // Descendants of parentNode will always be reloaded to account for changes caused by // the drop operation. nodeToCollapse = parentNode; } // Update the view. if (oldParentNode) { oldParentNode.removeChildNode(node); } else { node.tree.removeNodes([node]); } nodeToCollapse?.collapseChildNodes(); if (!parentNode) { // Drop as root, reset the hierarchy. nodeId = forestId = treeId = 0; this.root = this._createRoot(this.config, formattedData); } else { // Update parentNode data. parentNode.data[this.childFieldName || this.defaultChildFieldName] = formattedData; parentNode.populateChildNodes(); } const newNodeId = Object.keys(this.root.nodePerNodeId).find((key) => { return this.root.nodePerNodeId[key].resId === resId; }); this.notify({ scrollTarget: newNodeId }); } validateUpdateParentNode(node, parentNode) { if (parentNode.resId === node.resId) { this.notification.add(_t("The parent record cannot be the record dragged."), { type: "danger", }); return false; } else if (node.allSubsidiaryResIds.includes(parentNode.resId)) { this.notification.add(_t("Cannot change the parent because it will cause a cyclic."), { type: "danger", }); return false; } return true; } /** * Returns a domain to get a recordSet containing: * - node. * - all children under the new parent. * - all descendants in the final expanded tree (after the operation), which * are at a depth impacted by the update @see updateParentNode (part * about the expanded tree). * * @param {HierarchyNode} node that is moving * @param {Number | false} parentResId resId of the parent * @param {HierarchyNode} [parentNode] which receives node as its child * (undefined if node is dropped as a root). * @returns {Array} domain */ computeUpdateParentNodeDomain(node, parentResId, parentNode) { const domainsOr = [[["id", "=", node.resId]]]; // Include the new parent children (for ordering). domainsOr.push([[this.parentFieldName, "=", parentResId]]); if (!node.isLeaf) { // Include node descendants (keep that part of the expanded tree). const expandedTreeParentResIds = node.descendantsParentNodes.map((node) => node.resId); domainsOr.push([[this.parentFieldName, "in", expandedTreeParentResIds]]); } else if (!parentNode) { // Keep the current expanded tree (if any) from its root if node is a leaf dropped as a // root. const expandedTreeParentResIds = node.tree.root.descendantsParentNodes.map( (node) => node.resId ); domainsOr.push([[this.parentFieldName, "in", expandedTreeParentResIds]]); } else if (!parentNode.isLeaf) { // Keep the current expanded tree (if any) from the target parent if node is a leaf. const expandedTreeParentResIds = parentNode.descendantsParentNodes.map( (node) => node.resId ); domainsOr.push([[this.parentFieldName, "in", expandedTreeParentResIds]]); } let domain = Domain.or(domainsOr); const globalDomain = this.globalDomain; if (globalDomain.length) { domain = Domain.and([domain, globalDomain]); } return domain.toList({}); } }