[ADD] recursive_tree_view: new module for hierarchical models
Allows the viewing of hierarchical models (i.e. parent-child relationships) directly in a tree view. Simply add the recursive attribute to the tree tag with `recursive="1"` or `recursive="true"` or `recursive="True"`. Parent-child field is autodetected from the model based on the _parent_name field which is standard in the Odoo ORM. Currently there is no option for expanding all children, but this may be added in future versions.
This commit is contained in:
parent
4db6dab0eb
commit
6bab956a54
12 changed files with 501 additions and 0 deletions
2
recursive_tree_view/__init__.py
Normal file
2
recursive_tree_view/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import models
|
||||
from . import validation
|
||||
38
recursive_tree_view/__manifest__.py
Normal file
38
recursive_tree_view/__manifest__.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#
|
||||
# Bemade Inc.
|
||||
#
|
||||
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
|
||||
# Author: Marc Durepos (Contact : marc@bemade.org)
|
||||
#
|
||||
# This program is under the terms of the GNU Lesser General Public License,
|
||||
# version 3.
|
||||
#
|
||||
# For full license details, see https://www.gnu.org/licenses/lgpl-3.0.en.html.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
# DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
{
|
||||
"name": "Recursive Tree View",
|
||||
"version": "17.0.0.0.1",
|
||||
"summary": "Adds the ability to mark a tree view as recursive, to expand "
|
||||
"descendants of a parent record.",
|
||||
"category": "Technical",
|
||||
"author": "Bemade Inc.",
|
||||
"website": "http://www.bemade.org",
|
||||
"license": "LGPL-3",
|
||||
"depends": ["web"],
|
||||
"data": [],
|
||||
"assets": {
|
||||
"web.assets_backend": [
|
||||
"recursive_tree_view/static/src/**/*",
|
||||
]
|
||||
},
|
||||
"installable": True,
|
||||
"application": False,
|
||||
}
|
||||
1
recursive_tree_view/models/__init__.py
Normal file
1
recursive_tree_view/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
18
recursive_tree_view/models/models.py
Normal file
18
recursive_tree_view/models/models.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# models/parent_field_service.py
|
||||
from odoo import models, api
|
||||
|
||||
|
||||
class ParentFieldService(models.AbstractModel):
|
||||
_name = "parent.field.service"
|
||||
_description = "Service to retrieve parent field dynamically for any model"
|
||||
|
||||
@api.model
|
||||
def get_parent_field(self, model_name):
|
||||
"""
|
||||
Returns the parent field name for the given model.
|
||||
If _parent_name is not set, defaults to 'parent_id' if the field exists.
|
||||
"""
|
||||
Model = self.env[model_name] # Access the model dynamically
|
||||
return Model._parent_name or (
|
||||
"parent_id" if hasattr(Model, "parent_id") else None
|
||||
)
|
||||
104
recursive_tree_view/rng/tree_view.rng
Normal file
104
recursive_tree_view/rng/tree_view.rng
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rng:grammar xmlns:rng="http://relaxng.org/ns/structure/1.0"
|
||||
xmlns:a="http://relaxng.org/ns/annotation/1.0"
|
||||
datatypeLibrary="http://www.w3.org/2001/XMLSchema-datatypes">
|
||||
<!-- Handling of element overloading when inheriting from a base
|
||||
template
|
||||
-->
|
||||
<rng:include href="odoo/base/rng/common.rng"/>
|
||||
|
||||
<rng:define name="groupby">
|
||||
<rng:element name="groupby">
|
||||
<rng:attribute name="name"/>
|
||||
<rng:optional><rng:attribute name="expand"/></rng:optional>
|
||||
<rng:zeroOrMore>
|
||||
<rng:ref name="field"/>
|
||||
</rng:zeroOrMore>
|
||||
<rng:zeroOrMore>
|
||||
<rng:ref name="button"/>
|
||||
</rng:zeroOrMore>
|
||||
</rng:element>
|
||||
</rng:define>
|
||||
|
||||
<rng:define name="tree">
|
||||
<rng:element name="tree">
|
||||
<rng:ref name="overload"/>
|
||||
<rng:optional><rng:attribute name="name"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="create"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="delete"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="edit"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="multi_edit"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="export_xlsx"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="duplicate"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="import"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="string"/></rng:optional> <!-- deprecated, has no effect anymore -->
|
||||
<rng:optional><rng:attribute name="class"/></rng:optional>
|
||||
<!-- Allows to take a custom View widget for handling -->
|
||||
<rng:optional><rng:attribute name="js_class"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="default_order"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="default_group_by"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="decoration-bf"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="decoration-it"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="decoration-danger"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="decoration-info"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="decoration-muted"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="decoration-primary"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="decoration-success"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="decoration-warning"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="banner_route"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="sample"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="action"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="type"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="open_form_view"/></rng:optional>
|
||||
<rng:optional><rng:attribute name="recursive"/></rng:optional>
|
||||
<rng:optional>
|
||||
<rng:attribute name="limit">
|
||||
<rng:data type="int"/>
|
||||
</rng:attribute>
|
||||
</rng:optional>
|
||||
<rng:optional>
|
||||
<rng:attribute name="count_limit">
|
||||
<rng:data type="int"/>
|
||||
</rng:attribute>
|
||||
</rng:optional>
|
||||
<rng:optional>
|
||||
<rng:attribute name="groups_limit">
|
||||
<rng:data type="int"/>
|
||||
</rng:attribute>
|
||||
</rng:optional>
|
||||
<rng:optional>
|
||||
<rng:attribute name="editable">
|
||||
<rng:choice>
|
||||
<rng:value>top</rng:value>
|
||||
<rng:value>bottom</rng:value>
|
||||
</rng:choice>
|
||||
</rng:attribute>
|
||||
</rng:optional>
|
||||
<rng:optional><rng:attribute name="expand"/></rng:optional>
|
||||
<rng:zeroOrMore>
|
||||
<rng:choice>
|
||||
<rng:element name="header">
|
||||
<rng:zeroOrMore>
|
||||
<rng:ref name="button"/>
|
||||
</rng:zeroOrMore>
|
||||
</rng:element>
|
||||
<rng:ref name="control"/>
|
||||
<rng:ref name="field"/>
|
||||
<rng:ref name="widget"/>
|
||||
<rng:ref name="separator"/>
|
||||
<rng:ref name="tree"/>
|
||||
<rng:ref name="groupby"/>
|
||||
<rng:ref name="button"/>
|
||||
<rng:ref name="filter"/>
|
||||
<rng:ref name="html"/>
|
||||
<rng:element name="newline"><rng:empty/></rng:element>
|
||||
</rng:choice>
|
||||
</rng:zeroOrMore>
|
||||
</rng:element>
|
||||
</rng:define>
|
||||
<rng:start>
|
||||
<rng:choice>
|
||||
<rng:ref name="tree" />
|
||||
</rng:choice>
|
||||
</rng:start>
|
||||
</rng:grammar>
|
||||
16
recursive_tree_view/static/src/list_arch_parser.js
Normal file
16
recursive_tree_view/static/src/list_arch_parser.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { ListArchParser} from "@web/views/list/list_arch_parser";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
patch(ListArchParser.prototype, {
|
||||
parse(xmlDoc, models, modelName) {
|
||||
const result = super.parse(...arguments);
|
||||
|
||||
const recursiveAttr = xmlDoc.getAttribute("recursive")
|
||||
if ( recursiveAttr ) {
|
||||
result.recursive = recursiveAttr === "1" || recursiveAttr === "true" || recursiveAttr === "True";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
})
|
||||
37
recursive_tree_view/static/src/list_controller.js
Normal file
37
recursive_tree_view/static/src/list_controller.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {ListController} from '@web/views/list/list_controller';
|
||||
import {patch} from '@web/core/utils/patch';
|
||||
import {useBus} from "@web/core/utils/hooks";
|
||||
|
||||
patch(ListController.prototype, {
|
||||
async setup() {
|
||||
if (this.props.archInfo.recursive) {
|
||||
this.recursive = true;
|
||||
useBus(this.env.bus, "expand-collapse-parent", this.onExpandCollapseParent);
|
||||
} else {
|
||||
this.recursive = false;
|
||||
}
|
||||
super.setup();
|
||||
},
|
||||
|
||||
async onExpandCollapseParent(ev) {
|
||||
const parentId = ev.detail;
|
||||
const record = this.model.findRecordInHierarchy(parentId);
|
||||
if (record.expanded) {
|
||||
record.expanded = false;
|
||||
} else {
|
||||
await this.model._loadChildren(record.children);
|
||||
record.expanded = true;
|
||||
}
|
||||
},
|
||||
|
||||
get modelParams() {
|
||||
const params = super.modelParams;
|
||||
if (this.recursive) {
|
||||
params["config"]["recursive"] = true;
|
||||
}
|
||||
return params;
|
||||
},
|
||||
|
||||
});
|
||||
19
recursive_tree_view/static/src/list_renderer.js
Normal file
19
recursive_tree_view/static/src/list_renderer.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { ListRenderer } from '@web/views/list/list_renderer';
|
||||
import { patch } from '@web/core/utils/patch';
|
||||
import { useState } from '@odoo/owl';
|
||||
|
||||
patch(ListRenderer.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.recursive = this.props.archInfo.recursive;
|
||||
useState(this.props.list.records);
|
||||
},
|
||||
|
||||
async _onExpandClick(ev) {
|
||||
const $button = $(ev.currentTarget);
|
||||
const parent = $button.data('expand');
|
||||
this.env.bus.trigger("expand-collapse-parent", parent);
|
||||
},
|
||||
});
|
||||
40
recursive_tree_view/static/src/recursive_tree_templates.xml
Normal file
40
recursive_tree_view/static/src/recursive_tree_templates.xml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<!-- Extend List Renderer Header to Add Extra Column for Expand/Collapse Button Alignment -->
|
||||
<t t-name="recursive_tree_view.Renderer" t-inherit="web.ListRenderer"
|
||||
t-inherit-mode="extension">
|
||||
<xpath expr="//th[hasclass('o_list_record_selector')]" position="before">
|
||||
<th t-if="recursive"
|
||||
class="o_recursive_expand_column cursor-default o_list_button">
|
||||
<div style="min-width: 100px;"/>
|
||||
</th>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
<!-- Extend List Renderer Row to Add Expand/Collapse Button -->
|
||||
<t t-name="web.ListRenderer.RecordRow" t-inherit="web.ListRenderer.RecordRow"
|
||||
t-inherit-mode="extension">
|
||||
<xpath expr="//tr[hasclass('o_data_row')]" position="after">
|
||||
<t t-if="record.children && record.expanded">
|
||||
<t t-foreach="record.children" t-as="record" t-key="record.id">
|
||||
<t t-call="{{ constructor.recordRowTemplate }}"/>
|
||||
</t>
|
||||
</t>
|
||||
</xpath>
|
||||
<xpath expr="//td[1]" position="before">
|
||||
<td t-if="this.recursive"
|
||||
class="o_recursive_expand_column"
|
||||
>
|
||||
<button t-if="record.children"
|
||||
t-att-data-expand="record.resId"
|
||||
t-on-click.prevent="_onExpandClick"
|
||||
t-att-data-depth="record.depth"
|
||||
class="o_expand_button">
|
||||
<span role="img"
|
||||
t-att-class="'fa ' + (record.expanded ? 'fa-angle-down' : 'fa-angle-right')"
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</xpath>
|
||||
</t>
|
||||
</odoo>
|
||||
139
recursive_tree_view/static/src/relational_model.js
Normal file
139
recursive_tree_view/static/src/relational_model.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {RelationalModel} from '@web/model/relational_model/relational_model';
|
||||
import {patch} from '@web/core/utils/patch';
|
||||
import {getFieldsSpec, getBasicEvalContext} from "@web/model/relational_model/utils";
|
||||
|
||||
patch(RelationalModel.prototype, {
|
||||
// TODO: Modify the domain to include only records with parentField = False in the root search
|
||||
|
||||
setup(params, services) {
|
||||
super.setup(...arguments);
|
||||
this.hooks.onRootLoaded = () => {
|
||||
const root = this.root;
|
||||
const config = this.root.config;
|
||||
if (config.recursive && root.records) {
|
||||
this._loadChildren(root.records, config).then(() => {
|
||||
return root;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
async _loadData(config) {
|
||||
if (config.recursive) {
|
||||
const parentField = await this._get_parent_field(config.resModel);
|
||||
const domain = [parentField, '=', false];
|
||||
if (!(domain in config.domain)) {
|
||||
config.domain = config.domain.concat([domain]);
|
||||
}
|
||||
}
|
||||
return super._loadData(config);
|
||||
},
|
||||
async _loadChildren(records, config = undefined) {
|
||||
if (!records) {
|
||||
return [];
|
||||
}
|
||||
if (!config) {
|
||||
config = this.config;
|
||||
}
|
||||
if (!Array.isArray(records)) {
|
||||
records = [records];
|
||||
}
|
||||
const {resModel, activeFields, fields, context} = config;
|
||||
if (!resModel) {
|
||||
return [];
|
||||
}
|
||||
const parentField = await this._get_parent_field(resModel);
|
||||
if (!parentField) {
|
||||
return [];
|
||||
}
|
||||
const evalContext = getBasicEvalContext(config);
|
||||
const fieldSpec = getFieldsSpec(activeFields, fields, evalContext);
|
||||
// Fetch records with the additional field
|
||||
const parentIds = records.map(record => record.resId);
|
||||
|
||||
const children = await this.orm.webSearchRead(
|
||||
resModel, [[parentField, "in", parentIds]], {
|
||||
context: {...context},
|
||||
specification: fieldSpec,
|
||||
});
|
||||
|
||||
if (children && children.length) {
|
||||
// Track children by grouping child records under each parent
|
||||
const childrenByParent = {};
|
||||
for (const child of children.records) {
|
||||
const parentId = child[parentField].id;
|
||||
if (parentId) {
|
||||
if (!childrenByParent[parentId]) {
|
||||
childrenByParent[parentId] = [];
|
||||
}
|
||||
childrenByParent[parentId].push(child);
|
||||
}
|
||||
}
|
||||
|
||||
for (const parent of records) {
|
||||
if (parent.depth == undefined) {
|
||||
parent.depth = 0;
|
||||
}
|
||||
if (parent.expanded == undefined) {
|
||||
parent.expanded = false;
|
||||
}
|
||||
if (!parent.childrenFetched) {
|
||||
if (childrenByParent[parent.resId]) {
|
||||
const childRecords = childrenByParent[parent.resId];
|
||||
parent.children = []
|
||||
for (const child of childRecords) {
|
||||
const childRecord = new this.constructor.Record(
|
||||
this,
|
||||
{
|
||||
context: context,
|
||||
activeFields: activeFields,
|
||||
resModel: resModel,
|
||||
fields: fields,
|
||||
resId: child.id,
|
||||
resIds: [child.id],
|
||||
isMonoRecord: true,
|
||||
currentCompanyId: parent.currentCompanyId,
|
||||
mode: parent.mode,
|
||||
},
|
||||
child,
|
||||
{manuallyAdded: false},
|
||||
);
|
||||
parent.children.push(childRecord);
|
||||
childRecord.parent = parent;
|
||||
childRecord.depth = parent.depth + 1;
|
||||
childRecord.expanded = false;
|
||||
childRecord.childrenFetched = false;
|
||||
}
|
||||
}
|
||||
parent.childrenFetched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async _get_parent_field(model) {
|
||||
return await this.orm.call(
|
||||
'parent.field.service',
|
||||
'get_parent_field',
|
||||
[model],
|
||||
);
|
||||
},
|
||||
|
||||
findRecordInHierarchy(resId) {
|
||||
const records = this.root.records;
|
||||
function findRecord(records) {
|
||||
for (let record of records) {
|
||||
if (record.resId === resId) {
|
||||
return record;
|
||||
}
|
||||
if (record.children && record.children.length >0) {
|
||||
const found = findRecord(record.children);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return findRecord(records) || null;
|
||||
}
|
||||
});
|
||||
28
recursive_tree_view/static/src/tree_recursive_styles.css
Normal file
28
recursive_tree_view/static/src/tree_recursive_styles.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
.o_recursive_expand_column {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.o_expand_button {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
button.o_expand_button[data-depth="1"] {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
button.o_expand_button[data-depth="2"] {
|
||||
margin-left: 30px;
|
||||
}
|
||||
|
||||
button.o_expand_button[data-depth="3"] {
|
||||
margin-left: 45px;
|
||||
}
|
||||
|
||||
button.o_expand_button[data-depth="4"] {
|
||||
margin-left: 60px;
|
||||
}
|
||||
|
||||
button.o_expand_button[data-depth="5"] {
|
||||
margin-left: 75px;
|
||||
}
|
||||
59
recursive_tree_view/validation.py
Normal file
59
recursive_tree_view/validation.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import logging
|
||||
import os
|
||||
from lxml import etree
|
||||
from odoo.tools import misc, view_validation
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_tree_validator = None
|
||||
RNG_NS = "http://relaxng.org/ns/structure/1.0"
|
||||
|
||||
|
||||
def schema_tree(arch, **kwargs):
|
||||
global _tree_validator
|
||||
|
||||
if _tree_validator is None:
|
||||
try:
|
||||
# Load and parse the existing schema file
|
||||
schema_path = os.path.join("addons", "base", "rng", "tree_view.rng")
|
||||
with misc.file_open(schema_path) as f:
|
||||
rng_doc = etree.parse(f)
|
||||
|
||||
# Find the `tree` definition in the parsed document
|
||||
tree_define = rng_doc.find(".//{%s}define[@name='tree']" % RNG_NS)
|
||||
if tree_define is not None:
|
||||
# Add the `recursive` attribute as an optional attribute in the `tree` definition
|
||||
tree_elem = tree_define.find(".//{%s}element[@name='tree']" % RNG_NS)
|
||||
if tree_elem is not None:
|
||||
optional_attr = etree.SubElement(
|
||||
tree_elem,
|
||||
_tag="{%s}optional" % RNG_NS,
|
||||
)
|
||||
recursive_attr = etree.SubElement(
|
||||
optional_attr,
|
||||
_tag="{%s}attribute" % RNG_NS,
|
||||
name="recursive",
|
||||
)
|
||||
|
||||
# Create RelaxNG validator from the modified schema
|
||||
_tree_validator = etree.RelaxNG(rng_doc)
|
||||
|
||||
except (etree.RelaxNGParseError, etree.XMLSyntaxError) as e:
|
||||
_logger.error("Error parsing RelaxNG schema: %s", e.error_log)
|
||||
return False
|
||||
except Exception as e:
|
||||
_logger.error("General error loading RelaxNG schema: %s", e)
|
||||
return False
|
||||
|
||||
# Validate the XML arch
|
||||
if _tree_validator.validate(arch):
|
||||
return True
|
||||
|
||||
# Log validation errors
|
||||
for error in _tree_validator.error_log:
|
||||
_logger.error("Validation error: %s", error)
|
||||
return False
|
||||
|
||||
|
||||
# Register the validator with Odoo's view validation
|
||||
view_validation._validators.update(tree=[schema_tree])
|
||||
Loading…
Reference in a new issue