recursive_list_view
This commit is contained in:
parent
8f252372e4
commit
b779529d91
18 changed files with 585 additions and 0 deletions
42
recursive_list_view/README.md
Normal file
42
recursive_list_view/README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Recursive Tree View
|
||||||
|
|
||||||
|
Bemade Inc.
|
||||||
|
|
||||||
|
Copyright (C) 2023-June Bemade Inc. ([https://www.bemade.org](https://www.bemade.org)).
|
||||||
|
Author: Marc Durepos (Contact : [marc@bemade.org](mailto\:marc@bemade.org))
|
||||||
|
|
||||||
|
This program is under the terms of the GNU Lesser General Public License (LGPL-3).
|
||||||
|
For details, visit [https://www.gnu.org/licenses/lgpl-3.0.en.html](https://www.gnu.org/licenses/lgpl-3.0.en.html)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Recursive Tree View module for Odoo allows users to easily visualize hierarchical data structures in Odoo list (tree) views. With this module, you can mark a tree view as recursive, enabling the expansion of descendants of a parent record in a seamless and user-friendly way.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Expand and collapse descendants directly in tree views.
|
||||||
|
- Mark specific tree views as recursive to represent hierarchical data structures.
|
||||||
|
- Enhance the visualization of complex relationships, making it easier to navigate and understand.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
1. Install the module in Odoo.
|
||||||
|
2. Configure the desired tree views to be recursive by modifying the view definition. Simply add the attribute recursive="1" (or true or True) to the tree element.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Navigate to the model with the hierarchical structure (e.g., organizational units, product categories).
|
||||||
|
2. Expand a parent record in the tree view to visualize its descendants.
|
||||||
|
3. Use the expand/collapse functionality to navigate the entire hierarchy.
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
- The module extends the `tree` view type to add recursive behavior,
|
||||||
|
allowing descendants to be dynamically loaded and displayed.
|
||||||
|
- This feature is particularly useful for models that represent hierarchical relationships, such as categories, organizational units, or nested tasks.
|
||||||
|
- The module uses the field specified by `_parent_name` on the model, which is `parent_id` by default.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This program is under the terms of the GNU Lesser General Public License (LGPL-3).
|
||||||
|
For details, visit [https://www.gnu.org/licenses/lgpl-3.0.en.html](https://www.gnu.org/licenses/lgpl-3.0.en.html)
|
||||||
2
recursive_list_view/__init__.py
Normal file
2
recursive_list_view/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
from . import models
|
||||||
|
from . import validation
|
||||||
41
recursive_list_view/__manifest__.py
Normal file
41
recursive_list_view/__manifest__.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#
|
||||||
|
# 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 List View",
|
||||||
|
"version": "18.0.0.0.2",
|
||||||
|
"summary": "Adds the ability to mark a List 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_list_view/static/src/**/*",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"installable": True,
|
||||||
|
"application": False,
|
||||||
|
"images": [
|
||||||
|
"static/description/images/main_screenshot.png",
|
||||||
|
],
|
||||||
|
}
|
||||||
15
recursive_list_view/hierarchy-svgrepo-com.svg
Normal file
15
recursive_list_view/hierarchy-svgrepo-com.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 97.5 97.5" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<path d="M95.5,62.844h-6.164V58.84c0-7.135-5.804-12.938-12.938-12.938H52.445V34.656h6.088c1.104,0,2-0.896,2-2V13.09
|
||||||
|
c0-1.104-0.896-2-2-2H38.967c-1.104,0-2,0.896-2,2v19.566c0,1.104,0.896,2,2,2h6.087v11.245H21.103
|
||||||
|
c-7.135,0-12.938,5.804-12.938,12.938v4.004H2c-1.104,0-2,0.896-2,2V84.41c0,1.104,0.896,2,2,2h19.566c1.104,0,2-0.896,2-2V64.844
|
||||||
|
c0-1.104-0.896-2-2-2h-6.009V58.84c0-3.058,2.487-5.545,5.545-5.545h23.951v9.549h-6.087c-1.104,0-2,0.896-2,2V84.41
|
||||||
|
c0,1.104,0.896,2,2,2h19.566c1.104,0,2-0.896,2-2V64.844c0-1.104-0.896-2-2-2h-6.087v-9.549h23.951
|
||||||
|
c3.058,0,5.545,2.487,5.545,5.545v4.004h-6.01c-1.104,0-2,0.896-2,2V84.41c0,1.104,0.896,2,2,2H95.5c1.104,0,2-0.896,2-2V64.844
|
||||||
|
C97.5,63.739,96.604,62.844,95.5,62.844z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
1
recursive_list_view/models/__init__.py
Normal file
1
recursive_list_view/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import models
|
||||||
18
recursive_list_view/models/models.py
Normal file
18
recursive_list_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 "parent_id" in Model._fields else None
|
||||||
|
)
|
||||||
BIN
recursive_list_view/static/description/icon.png
Normal file
BIN
recursive_list_view/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
recursive_list_view/static/description/images/collapsed.png
Normal file
BIN
recursive_list_view/static/description/images/collapsed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
BIN
recursive_list_view/static/description/images/expanded.png
Normal file
BIN
recursive_list_view/static/description/images/expanded.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
97
recursive_list_view/static/description/index.html
Normal file
97
recursive_list_view/static/description/index.html
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CalDAV Synchronization</title>
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
background-color: #000000; /* Black background */
|
||||||
|
color: #b48a1d; /* Gold color */
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
color: #b48a1d; /* Gold color */
|
||||||
|
}
|
||||||
|
|
||||||
|
p, ul, ol {
|
||||||
|
color: #ffffff; /* White color for better readability on black background */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="text-center">Recursive Tree View</h1>
|
||||||
|
<img src="images/main_screenshot.png" class="img-fluid my-4"
|
||||||
|
alt="Main Screenshot"/>
|
||||||
|
<p><strong>Author:</strong> Bemade Inc. (Marc Durepos)</p>
|
||||||
|
<p><strong>Website:</strong> <a href="https://www.bemade.org" class="text-warning">www.bemade.org</a></p>
|
||||||
|
<p><strong>License:</strong> GNU Lesser General Public License (LGPL-3)</p>
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<p>The Recursive Tree View module for Odoo allows users to easily visualize
|
||||||
|
hierarchical data structures in tree views. Mark a tree view as
|
||||||
|
recursive to enable expansion of descendants of a parent record,
|
||||||
|
enhancing the visualization of complex data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Screenshots</h2>
|
||||||
|
<h3>Collapsed Tree</h3>
|
||||||
|
<img src="images/collapsed.png" class="img-fluid my-4"
|
||||||
|
alt="Collapsed Tree View"/>
|
||||||
|
<h3>Expanded Tree</h3>
|
||||||
|
<img src="images/expanded.png" class="img-fluid my-4"
|
||||||
|
alt="Expanded Tree View"/>
|
||||||
|
<h2>Features</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Expand and collapse descendants directly in tree views.</li>
|
||||||
|
<li>Mark specific tree views as recursive to represent hierarchical
|
||||||
|
data.
|
||||||
|
</li>
|
||||||
|
<li>Improve navigation and understanding of complex relationships.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Install the module in Odoo.</li>
|
||||||
|
<li>Configure the desired tree views to be recursive by modifying the
|
||||||
|
view definition. Simply add the attribute recursive="1" to
|
||||||
|
a given tree element to make it recursive.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2>Usage</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Navigate to the view marked as recursive.</li>
|
||||||
|
<li>Click the arrow to the left of a parent record to
|
||||||
|
expand it and see its descendants.</li>
|
||||||
|
<li>Use expand/collapse to navigate the hierarchy as desired
|
||||||
|
.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Technical</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
The module extends the `tree` view type to add recursive
|
||||||
|
behavior, allowing descendants to be dynamically loaded and
|
||||||
|
displayed.
|
||||||
|
</li>
|
||||||
|
<li>This feature is particularly useful for models that represent hierarchical relationships, such as categories, organizational units, or nested tasks.</li>
|
||||||
|
<li>The module uses the field specified by `_parent_name` on the model, which is `parent_id` by default.</li>
|
||||||
|
</ul>
|
||||||
|
<h2>License</h2>
|
||||||
|
<p>This program is under the terms of the GNU Lesser General Public License
|
||||||
|
(LGPL-3). For details, visit <a
|
||||||
|
href="https://www.gnu.org/licenses/lgpl-3.0.en.html">
|
||||||
|
https://www.gnu.org/licenses/lgpl-3.0.en.html</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
recursive_list_view/static/src/list_arch_parser.js
Normal file
16
recursive_list_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;
|
||||||
|
}
|
||||||
|
})
|
||||||
40
recursive_list_view/static/src/list_controller.js
Normal file
40
recursive_list_view/static/src/list_controller.js
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
/** @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 && typeof ev.detail === 'object' ? ev.detail.id : ev.detail;
|
||||||
|
const record = this.model.findRecordInHierarchy(parentId);
|
||||||
|
if (!record) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (record.expanded) {
|
||||||
|
record.expanded = false;
|
||||||
|
} else {
|
||||||
|
await this.model._loadChildren(record);
|
||||||
|
record.expanded = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get modelParams() {
|
||||||
|
const params = super.modelParams;
|
||||||
|
if (this.recursive) {
|
||||||
|
params["config"]["recursive"] = true;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
19
recursive_list_view/static/src/list_renderer.js
Normal file
19
recursive_list_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 raw = ev.currentTarget.dataset.expand;
|
||||||
|
const parent = isNaN(raw) ? raw : Number(raw);
|
||||||
|
this.env.bus.trigger("expand-collapse-parent", parent);
|
||||||
|
},
|
||||||
|
});
|
||||||
47
recursive_list_view/static/src/recursive_tree_templates.xml
Normal file
47
recursive_list_view/static/src/recursive_tree_templates.xml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?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 and Bullet for nested rows -->
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<!-- Expand/Collapse button if there are children -->
|
||||||
|
<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-caret-down' : 'fa-caret-right')"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<!-- Bullet indicator for nested rows (depth > 0) -->
|
||||||
|
<span t-if="!record.children and record.depth > 0"
|
||||||
|
class="o_recursive_bullet"
|
||||||
|
t-att-data-depth="record.depth"
|
||||||
|
aria-hidden="true">
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</odoo>
|
||||||
145
recursive_list_view/static/src/relational_model.js
Normal file
145
recursive_list_view/static/src/relational_model.js
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import {RelationalModel} from '@web/model/relational_model/relational_model';
|
||||||
|
import {patch} from '@web/core/utils/patch';
|
||||||
|
import {getFieldsSpec, getBasicEvalContext, makeActiveField} 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]);
|
||||||
|
}
|
||||||
|
if (!(parentField in config.activeFields) || !config.activeFields[parentField]) {
|
||||||
|
config.activeFields[parentField] = makeActiveField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
const target = String(resId);
|
||||||
|
|
||||||
|
function findRecord(list) {
|
||||||
|
for (let record of list) {
|
||||||
|
if (String(record.resId) === target) {
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
if (record.children && record.children.length > 0) {
|
||||||
|
const found = findRecord(record.children);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findRecord(records) || null;
|
||||||
|
}
|
||||||
|
});
|
||||||
44
recursive_list_view/static/src/tree_recursive_styles.css
Normal file
44
recursive_list_view/static/src/tree_recursive_styles.css
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bullet indicator for leaf records (no children) */
|
||||||
|
.o_recursive_bullet {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--o-brand-primary, #714B67);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_recursive_bullet[data-depth="1"] { margin-left: 18px; }
|
||||||
|
.o_recursive_bullet[data-depth="2"] { margin-left: 33px; }
|
||||||
|
.o_recursive_bullet[data-depth="3"] { margin-left: 48px; }
|
||||||
|
.o_recursive_bullet[data-depth="4"] { margin-left: 63px; }
|
||||||
|
.o_recursive_bullet[data-depth="5"] { margin-left: 78px; }
|
||||||
58
recursive_list_view/validation.py
Normal file
58
recursive_list_view/validation.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import copy
|
||||||
|
from lxml import etree
|
||||||
|
from odoo.tools import misc, view_validation
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_validator_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_wrapper(kind, original_validators):
|
||||||
|
"""
|
||||||
|
Returns a validator function for `kind` ("list" or "tree") that:
|
||||||
|
- clones the arch
|
||||||
|
- strips the `recursive` attribute on the root if present
|
||||||
|
- delegates validation to Odoo's original validators for that kind
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _wrapper(arch, **kwargs):
|
||||||
|
try:
|
||||||
|
# Deep copy the arch element to avoid mutating the original
|
||||||
|
arch_copy = etree.fromstring(etree.tostring(arch))
|
||||||
|
|
||||||
|
# Strip the 'recursive' attribute on the root if present
|
||||||
|
if arch_copy is not None and arch_copy.tag in ("list", "tree"):
|
||||||
|
if "recursive" in arch_copy.attrib:
|
||||||
|
del arch_copy.attrib["recursive"]
|
||||||
|
|
||||||
|
# Run original validators against the sanitized copy
|
||||||
|
for validator in original_validators:
|
||||||
|
if not validator(arch_copy, **kwargs):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
# Fail open: do not block view loading because of our wrapper
|
||||||
|
_logger.exception("Validation wrapper failed for %s view; skipping validation.", kind)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Tag the wrapper to detect duplicates upon reloading
|
||||||
|
_wrapper.__name__ = f"recursive_attr_wrapper_{kind}"
|
||||||
|
return _wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _register_wrappers():
|
||||||
|
with _validator_lock:
|
||||||
|
for kind in ("list", "tree"):
|
||||||
|
originals = list(view_validation._validators.get(kind) or [])
|
||||||
|
# If already wrapped, skip
|
||||||
|
if originals and getattr(originals[0], "__name__", "").startswith("recursive_attr_wrapper_"):
|
||||||
|
continue
|
||||||
|
wrapper = _make_wrapper(kind, originals)
|
||||||
|
view_validation._validators[kind] = [wrapper]
|
||||||
|
|
||||||
|
|
||||||
|
# Register wrapper validators for Odoo 18 (<list>) and legacy (<tree>)
|
||||||
|
_register_wrappers()
|
||||||
Loading…
Reference in a new issue