[IMP] hr_org_chart: add drag&drop in hierarchy view

This commit allows the user to drag and drop a record (hierarchy
node) into another one to set the one hovered as the manager of the
one dragged. It also allows to drag a record into a row to define
the manager of the row as the manager of the record dragged.
This commit also adds an new attribute on `hierarchy` tag to use
when we define that view for a certain model to enable the drag and
drop feature.

task-3376776
This commit is contained in:
Xavier Bol (xbo)
2023-10-16 08:17:31 +02:00
parent 5ade959732
commit 2f463b564b
13 changed files with 357 additions and 10 deletions
+4
View File
@@ -24,9 +24,13 @@ This module extend the employee form with a organizational chart.
'hr_org_chart/static/src/scss/variables.scss',
],
'web.assets_backend': [
'hr_org_chart/static/src/scss/hierarchy.variables.scss',
'hr_org_chart/static/src/fields/*',
'hr_org_chart/static/src/views/**/*',
],
'web.assets_web_dark': [
('before', 'hr_org_chart/static/src/scss/hierarchy.variables.scss', 'hr_org_chart/static/src/scss/**/*.variables.dark.scss'),
],
'web.qunit_suite_tests': [
'hr_org_chart/static/tests/**/*',
],
+1
View File
@@ -15,6 +15,7 @@ HIERARCHY_VALID_ATTRIBUTES = {
'parent_field',
'child_field',
'icon',
'draggable',
}
class View(models.Model):
@@ -0,0 +1,102 @@
/** @odoo-module */
import { onWillUnmount, reactive, useEffect, useExternalListener } from "@odoo/owl";
import { useThrottleForAnimation } from "@web/core/utils/timing";
import { pick } from "@web/core/utils/objects";
import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder";
const hookParams = {
name: "useHierarchyNodeDraggable",
acceptedParams: {
rows: [String],
},
defaultParams: {
rows: null,
},
onComputeParams({ ctx, params }) {
// Row selector
ctx.rowSelector = params.rows || null;
if (ctx.rowSelector) {
ctx.fullSelector = `${ctx.rowSelector} ${ctx.fullSelector}`;
}
},
onDragStart(params) {
const { ctx, addListener, callHandler } = params;
const onElementPointerEnter = (ev) => {
const element = ev.currentTarget;
current.hierarchyElement = element;
callHandler("onElementEnter", { element });
};
const onElementPointerLeave = (ev) => {
const element = ev.currentTarget;
current.hierarchyElement = null;
callHandler("onElementLeave", { element });
};
const onRowPointerEnter = (ev) => {
const row = ev.currentTarget;
current.hierarchyRow = row;
callHandler("onRowEnter", { row });
};
const onRowPointerLeave = (ev) => {
const row = ev.currentTarget;
current.hierarchyRow = null;
callHandler("onRowLeave", { row });
};
const { ref, current, elementSelector, rowSelector } = ctx;
for (const rowEl of ref.el.querySelectorAll(rowSelector)) {
addListener(rowEl, "pointerenter", onRowPointerEnter);
addListener(rowEl, "pointerleave", onRowPointerLeave);
}
for (const siblingEl of ref.el.querySelectorAll(elementSelector)) {
if (siblingEl !== current.element) {
addListener(siblingEl, "pointerenter", onElementPointerEnter);
addListener(siblingEl, "pointerleave", onElementPointerLeave);
}
}
return pick(current, "element", "row");
},
onDragEnd({ ctx }) {
return pick(ctx.current, "element", "row", "hierarchyRow");
},
onDrop({ ctx }) {
const { current } = ctx;
const rowElement = current.hierarchyRow;
const element = current.hierarchyElement;
if ((rowElement && rowElement !== current.row) || element) {
return {
element: current.element,
row: current.row,
nextRow: rowElement && current.row !== rowElement ? rowElement : null,
newParentNode: element,
};
}
},
onWillStartDrag({ ctx }) {
const { current, rowSelector } = ctx;
if (rowSelector) {
current.row = current.element.closest(rowSelector);
}
return pick(current, "element", "row");
},
};
export function useHierarchyNodeDraggable(params) {
const setupHooks = {
addListener: useExternalListener,
setup: useEffect,
teardown: onWillUnmount,
throttle: useThrottleForAnimation,
wrapState: reactive,
}
return makeDraggableHook({ ...hookParams, setupHooks })(params);
}
@@ -0,0 +1,3 @@
$hierarchy_element_dragged-background: $gray-100 !default;
$hierarchy_element_hover-background: #{darken($gray-100, 5%)} !default;
$hierarchy_element_hover-border: #{$gray-100} !default;
@@ -0,0 +1,3 @@
$hierarchy_element_dragged-background: white !default;
$hierarchy_element_hover-background: #e6f2f3 !default;
$hierarchy_element_hover-border: #017e84 !default;
@@ -2,12 +2,13 @@
import { visitXML } from "@web/core/utils/xml";
import { Field } from "@web/views/fields/field";
import { getActiveActions } from "@web/views/utils";
import { archParseBoolean, getActiveActions } from "@web/views/utils";
export class HierarchyArchParser {
parse(xmlDoc, models, modelName) {
const archInfo = {
activeActions: getActiveActions(xmlDoc),
draggable: false,
icon: "fa-share-alt o_hierarchy_icon",
parentFieldName: "parent_id",
fieldNodes: {},
@@ -45,6 +46,9 @@ export class HierarchyArchParser {
}
archInfo.childFieldName = childFieldName;
}
if (node.hasAttribute("draggable")) {
archInfo.draggable = archParseBoolean(node.getAttribute("draggable"));
}
if (node.hasAttribute("icon")) {
archInfo.icon = node.getAttribute("icon");
}
@@ -15,6 +15,19 @@
height: 30px;
}
&.o_hierarchy_dragged {
.o_hierarchy_node {
background-color: $hierarchy_element_dragged-background;
}
}
&.o_hierarchy_hover {
.o_hierarchy_node {
background-color: $hierarchy_element_hover-background;
border: 3px solid green !important;
}
}
.o_hierarchy_node {
&:hover {
cursor: pointer;
@@ -2,7 +2,7 @@
<templates>
<t t-name="hr_org_chart.HierarchyCard">
<div class="o_hierarchy_node_container mb-4 d-flex flex-column" t-att-class="props.classNames">
<div class="o_hierarchy_node_container mb-4 d-flex flex-column" t-att-class="props.classNames" t-att-data-node-id="props.node.id">
<div class="o_hierarchy_node_button_container w-100 d-flex justify-content-end">
<button t-if="props.node.parentResId !== false and !props.node.parentNode"
name="hierarchy_search_parent_node"
@@ -1,7 +1,9 @@
/** @odoo-module */
import { Domain } from "@web/core/domain";
import { _t } from "@web/core/l10n/translation";
import { KeepLast, Mutex } from "@web/core/utils/concurrency";
import { pick } from "@web/core/utils/objects";
import { Model } from "@web/model/model";
let nodeId = 0;
@@ -218,6 +220,12 @@ export class HierarchyNode {
);
}
removeParentNode() {
this.parentNode?.removeChildNode(this);
this.parentNode = null;
this.data[this.parentFieldName] = false;
}
/**
* Fetch parent node
*/
@@ -251,6 +259,12 @@ export class HierarchyNode {
this.model.notify();
}
removeChildNode(node) {
this.tree.removeNodes([node, ...node.descendantNodes]);
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
*/
@@ -313,11 +327,13 @@ export class HierarchyTree {
this.id = treeId++;
this.nodePerNodeId = {};
this.forest = forest;
this.root = new HierarchyNode(model, config, data, this);
this.forest.nodePerNodeId = {
...this.forest.nodePerNodeId,
...this.nodePerNodeId,
};
if (data) {
this.root = new HierarchyNode(model, config, data, this);
this.forest.nodePerNodeId = {
...this.forest.nodePerNodeId,
...this.nodePerNodeId,
};
}
this.model = model;
this._config = config;
}
@@ -419,6 +435,17 @@ export class HierarchyForest {
);
}
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)
@@ -431,7 +458,9 @@ export class HierarchyForest {
}
export class HierarchyModel extends Model {
setup(params) {
static services = ["notification"];
setup(params, { notification }) {
this.keepLast = new KeepLast();
this.mutex = new Mutex();
this.resModel = params.resModel;
@@ -439,6 +468,7 @@ export class HierarchyModel extends Model {
this.parentFieldName = params.parentFieldName;
this.childFieldName = params.childFieldName;
this.activeFields = params.activeFields;
this.notification = notification;
this.config = {
domain: this.defaultDomain,
isRoot: true,
@@ -789,4 +819,106 @@ export class HierarchyModel extends Model {
}
}
}
async updateParentNode(nodeId, { parentNodeId, parentResId }) {
const node = this.root.nodePerNodeId[nodeId];
const parentNode = parentNodeId ? this.root.nodePerNodeId[parentNodeId] : null;
if (node) {
const oldParentNode = node.parentNode;
let fetchParentChildren = false;
let domain = new Domain([]);
if (oldParentNode) {
domain = new Domain([["id", "=", oldParentNode.resId]]);
}
if (parentNode) {
if (parentNode.resId === node.resId) {
this.notification.add(
_t("The parent record cannot be the record dragged."),
{
type: "danger",
}
);
return;
}
domain = Domain.or([
domain,
[["id", "in", [parentNode.resId, node.resId]]],
]);
if (parentNode.nodes.length === 0 && parentNode.childResIds.length > 0) {
fetchParentChildren = true;
domain = Domain.or([
domain,
[[this.parentFieldName, "=", parentNode.resId], ["id", "!=", node.resId]],
]);
}
if (node.id === node.tree.root.id) {
this.root.removeTree(node.tree);
node.tree = parentNode.tree;
} else {
node.removeParentNode();
}
} else {
node.removeParentNode();
this.root.addNewRootNode(node);
}
this.notify();
await this.mutex.exec(async () => {
await this.orm.write(
this.resModel,
[node.resId],
{ [this.parentFieldName]: parentResId || parentNode?.resId || false },
{ context: this.config.context }
);
});
domain = domain.toList({});
if (domain.length) {
const data = await this.orm.searchRead(
this.resModel,
domain,
this.fieldsToFetch,
{ context: this.config.context },
);
const children = [];
for (const d of data) {
if (d.id === node.resId) {
node.data = d;
} else if (d.id === oldParentNode?.resId) {
oldParentNode.data = d;
} else if (parentNode) {
if (parentNode.resId === d.id) {
const parentData = fetchParentChildren ? {} : pick(parentNode.data, this.childFieldName);
parentNode.data = {
...d,
...parentData,
};
} else if (fetchParentChildren) {
children.push(d);
}
}
}
if (children.length) {
parentNode.data[this.childFieldName || this.defaultChildFieldName] = children;
}
}
const treeExpanded = this._findTreeExpanded();
if (parentNode) {
if (treeExpanded && treeExpanded.id !== parentNode.tree.id) {
treeExpanded.root.nodes = [];
treeExpanded.nodePerNodeId = { [treeExpanded.root.id]: treeExpanded.root };
} else if (treeExpanded) {
const nodeToCollapse = this._searchNodeToCollapse(parentNode);
if (nodeToCollapse && nodeToCollapse.id !== parentNode.id) {
nodeToCollapse.collapseChildNodes();
}
}
if (fetchParentChildren) {
parentNode.populateChildNodes();
}
node.setParentNode(parentNode);
} else if (treeExpanded && node.nodes.length) {
treeExpanded.root.collapseChildNodes();
}
this.notify();
}
}
}
@@ -2,7 +2,11 @@
import { Component, useRef } from "@odoo/owl";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { HierarchyCard } from "./hierarchy_card";
import { useHierarchyNodeDraggable } from "../../hooks/hierarchy_node_draggable";
export class HierarchyRenderer extends Component {
static components = {
@@ -18,6 +22,53 @@ export class HierarchyRenderer extends Component {
setup() {
this.rendererRef = useRef("renderer");
this.notification = useService("notification");
if (this.canDragAndDropRecord) {
useHierarchyNodeDraggable({
ref: this.rendererRef,
enable: this.draggable,
elements: ".o_hierarchy_node_container",
handle: ".o_hierarchy_node",
rows: ".o_hierarchy_row",
ignore: "button",
onDragStart: ({ addClass, element }) => {
addClass(element, "o_hierarchy_dragged");
addClass(element.querySelector(".o_hierarchy_node"), "shadow");
},
onDragEnd: ({ removeClass, element, row, hierarchyRow }) => {
removeClass(element, "o_hierarchy_dragged");
if (row) {
removeClass(row, "o_hierarchy_hover");
}
if (hierarchyRow) {
removeClass(hierarchyRow, "o_hierarchy_hover");
}
},
onDrop: (params) => {
this.nodeDrop(params);
},
onElementEnter: ({ addClass, element }) => {
addClass(element, "o_hierarchy_hover");
},
onElementLeave: ({ removeClass, element }) => {
removeClass(element, "o_hierarchy_hover");
},
onRowEnter: ({ addClass, row }) => {
addClass(row, "o_hierarchy_hover");
},
onRowLeave: ({ removeClass, row }) => {
removeClass(row, "o_hierarchy_hover");
},
});
}
}
get canDragAndDropRecord() {
return this.draggable && !this.env.isSmall;
}
get draggable() {
return this.props.archInfo.draggable;
}
get rows() {
@@ -38,4 +89,32 @@ export class HierarchyRenderer extends Component {
return rows;
}
async nodeDrop({ element, row, nextRow, newParentNode }) {
let parentNodeId, parentResId;
if (newParentNode) {
parentNodeId = newParentNode.dataset.nodeId;
} else if (nextRow?.dataset.rowId !== row.dataset.rowId) {
parentNodeId = nextRow.dataset.parentNodeId;
if (!parentNodeId) {
const nodes = this.rows[nextRow.dataset.rowId].nodes || [];
if (nodes) {
parentNodeId = nodes[0].parentNode?.id;
if (!parentNodeId) {
parentResId = nodes[0].parentResId;
if (!nodes.every((node) => node.parentResId === parentResId)) {
this.notification.add(
_t("Impossible to update the parent node of the dragged node because no parent has been found."),
{
type: "danger",
}
);
return;
}
}
}
}
}
await this.props.model.updateParentNode(element.dataset.nodeId, { parentResId, parentNodeId });
}
}
@@ -4,6 +4,12 @@ $o-chart-line-color: #ababb1;
.o_hierarchy_container {
gap: 25px;
.o_hierarchy_row.o_hierarchy_hover {
background-color: $hierarchy_element_hover-background;
box-shadow: -1px -1px 0px 0px $hierarchy_element_hover-border inset,
1px 1px 0px 0px $hierarchy_element_hover-border inset;
}
.o_hierarchy_separator {
position: relative;
margin-top: 10px;
@@ -5,7 +5,7 @@
<field name="name">hr.departmnent.view.hierarchy</field>
<field name="model">hr.department</field>
<field name="arch" type="xml">
<hierarchy child_field="child_ids">
<hierarchy child_field="child_ids" draggable="1">
<field name="name" />
<field name="color" />
<field name="total_employee" />
+1 -1
View File
@@ -18,7 +18,7 @@
<field name="name">hr.employee.view.hierarchy</field>
<field name="model">hr.employee</field>
<field name="arch" type="xml">
<hierarchy child_field="child_ids" js_class="hr_employee_hierarchy" icon="fa-users">
<hierarchy child_field="child_ids" js_class="hr_employee_hierarchy" icon="fa-users" draggable="1">
<field name="name" />
<field name="job_id" />
<field name="department_color" />