[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:
@@ -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/**/*',
|
||||
],
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user