diff --git a/recursive_list_view/README.md b/recursive_list_view/README.md new file mode 100644 index 0000000..5d6b053 --- /dev/null +++ b/recursive_list_view/README.md @@ -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) diff --git a/recursive_list_view/__init__.py b/recursive_list_view/__init__.py new file mode 100644 index 0000000..8222d9b --- /dev/null +++ b/recursive_list_view/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import validation diff --git a/recursive_list_view/__manifest__.py b/recursive_list_view/__manifest__.py new file mode 100644 index 0000000..0a22c2e --- /dev/null +++ b/recursive_list_view/__manifest__.py @@ -0,0 +1,41 @@ +# +# Bemade Inc. +# +# Copyright (C) 2023-June Bemade Inc. (). +# 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", + ], +} diff --git a/recursive_list_view/hierarchy-svgrepo-com.svg b/recursive_list_view/hierarchy-svgrepo-com.svg new file mode 100644 index 0000000..88bbcb3 --- /dev/null +++ b/recursive_list_view/hierarchy-svgrepo-com.svg @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/recursive_list_view/models/__init__.py b/recursive_list_view/models/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/recursive_list_view/models/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/recursive_list_view/models/models.py b/recursive_list_view/models/models.py new file mode 100644 index 0000000..9a56d25 --- /dev/null +++ b/recursive_list_view/models/models.py @@ -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 + ) diff --git a/recursive_list_view/static/description/icon.png b/recursive_list_view/static/description/icon.png new file mode 100644 index 0000000..e14b9b2 Binary files /dev/null and b/recursive_list_view/static/description/icon.png differ diff --git a/recursive_list_view/static/description/images/collapsed.png b/recursive_list_view/static/description/images/collapsed.png new file mode 100644 index 0000000..21c57f6 Binary files /dev/null and b/recursive_list_view/static/description/images/collapsed.png differ diff --git a/recursive_list_view/static/description/images/expanded.png b/recursive_list_view/static/description/images/expanded.png new file mode 100644 index 0000000..1643487 Binary files /dev/null and b/recursive_list_view/static/description/images/expanded.png differ diff --git a/recursive_list_view/static/description/images/main_screenshot.png b/recursive_list_view/static/description/images/main_screenshot.png new file mode 100644 index 0000000..1d48239 Binary files /dev/null and b/recursive_list_view/static/description/images/main_screenshot.png differ diff --git a/recursive_list_view/static/description/index.html b/recursive_list_view/static/description/index.html new file mode 100644 index 0000000..4152dda --- /dev/null +++ b/recursive_list_view/static/description/index.html @@ -0,0 +1,97 @@ + + + + + + CalDAV Synchronization + + + + +
+

Recursive Tree View

+ Main Screenshot +

Author: Bemade Inc. (Marc Durepos)

+

Website: www.bemade.org

+

License: GNU Lesser General Public License (LGPL-3)

+

Overview

+

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. +

+ +

Screenshots

+

Collapsed Tree

+ Collapsed Tree View +

Expanded Tree

+ Expanded Tree View +

Features

+ + +

Configuration

+
    +
  1. Install the module in Odoo.
  2. +
  3. 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. +
  4. +
+ +

Usage

+ + +

Technical

+ +

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. +

+
+ + diff --git a/recursive_list_view/static/src/list_arch_parser.js b/recursive_list_view/static/src/list_arch_parser.js new file mode 100644 index 0000000..20abd27 --- /dev/null +++ b/recursive_list_view/static/src/list_arch_parser.js @@ -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; + } +}) \ No newline at end of file diff --git a/recursive_list_view/static/src/list_controller.js b/recursive_list_view/static/src/list_controller.js new file mode 100644 index 0000000..64a45c5 --- /dev/null +++ b/recursive_list_view/static/src/list_controller.js @@ -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; + }, + +}); diff --git a/recursive_list_view/static/src/list_renderer.js b/recursive_list_view/static/src/list_renderer.js new file mode 100644 index 0000000..0ce664e --- /dev/null +++ b/recursive_list_view/static/src/list_renderer.js @@ -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); + }, +}); diff --git a/recursive_list_view/static/src/recursive_tree_templates.xml b/recursive_list_view/static/src/recursive_tree_templates.xml new file mode 100644 index 0000000..eb96c10 --- /dev/null +++ b/recursive_list_view/static/src/recursive_tree_templates.xml @@ -0,0 +1,47 @@ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/recursive_list_view/static/src/relational_model.js b/recursive_list_view/static/src/relational_model.js new file mode 100644 index 0000000..9439f71 --- /dev/null +++ b/recursive_list_view/static/src/relational_model.js @@ -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; + } +}); diff --git a/recursive_list_view/static/src/tree_recursive_styles.css b/recursive_list_view/static/src/tree_recursive_styles.css new file mode 100644 index 0000000..83f0b8c --- /dev/null +++ b/recursive_list_view/static/src/tree_recursive_styles.css @@ -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; } diff --git a/recursive_list_view/validation.py b/recursive_list_view/validation.py new file mode 100644 index 0000000..6c5b083 --- /dev/null +++ b/recursive_list_view/validation.py @@ -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 () and legacy () +_register_wrappers()