working recursive tree view

This commit is contained in:
Marc Durepos 2024-10-30 12:11:15 -04:00
parent ae086d645c
commit 6065636123
8 changed files with 186 additions and 196 deletions

View file

@ -30,11 +30,7 @@
"data": [], "data": [],
"assets": { "assets": {
"web.assets_backend": [ "web.assets_backend": [
"recursive_tree_view/static/src/list_arch_parser.js", "recursive_tree_view/static/src/**/*",
"recursive_tree_view/static/src/list_controller.js",
"recursive_tree_view/static/src/list_renderer.js",
"recursive_tree_view/static/src/tree_recursive_styles.css",
"recursive_tree_view/static/src/recursive_tree_templates.xml",
] ]
}, },
"installable": True, "installable": True,

View file

@ -10,14 +10,7 @@ patch(ListArchParser.prototype, {
const recursiveAttr = xmlDoc.getAttribute("recursive") const recursiveAttr = xmlDoc.getAttribute("recursive")
if ( recursiveAttr ) { if ( recursiveAttr ) {
result.recursive = recursiveAttr === "1" || recursiveAttr === "true" || recursiveAttr === "True"; result.recursive = recursiveAttr === "1" || recursiveAttr === "true" || recursiveAttr === "True";
result.childField = xmlDoc.getAttribute("child-field")
if (!(result.childField in result)) {
}
} }
return result; return result;
} }
}) })

View file

@ -2,80 +2,36 @@
import {ListController} from '@web/views/list/list_controller'; import {ListController} from '@web/views/list/list_controller';
import {patch} from '@web/core/utils/patch'; import {patch} from '@web/core/utils/patch';
import {useService} from '@web/core/utils/hooks';
import {onWillRender} from '@odoo/owl';
import {useBus} from "@web/core/utils/hooks"; import {useBus} from "@web/core/utils/hooks";
patch(ListController.prototype, { patch(ListController.prototype, {
async setup() { async setup() {
useBus(this.env.bus, "expandRow", this.onExpandRow)
useBus(this.env.bus, "collapseRow", this.onCollapseRow)
super.setup();
if (this.props.archInfo.recursive) { if (this.props.archInfo.recursive) {
// Fetch the parent field from the model this.recursive = true;
const parentField = await this.orm.call( useBus(this.env.bus, "expand-collapse-parent", this.onExpandCollapseParent);
'parent.field.service', } else {
'get_parent_field', this.recursive = false;
[this.props.resModel], }
); super.setup();
},
// Validate the parentField and adjust archInfo accordingly async onExpandCollapseParent(ev) {
if (parentField) { const parentId = ev.detail;
// If parentField is valid, store it and continue with recursive setup const record = this.model.findRecordInHierarchy(parentId);
this.parentField = parentField; if (record.expanded) {
this.props.archInfo.parentField = parentField; record.expanded = false;
} else { } else {
// If no valid parentField, disable recursion and childField functionality await this.model._loadChildren(record.children);
this.props.archInfo.recursive = false; record.expanded = true;
delete this.props.archInfo.childField;
}
// Proceed with recursive setup if recursive is enabled
if (this.props.archInfo.recursive) {
this.childrenByParent = {}; // Cache for loaded children
// Bind event listeners for expand and collapse
this.model.hooks.onRootLoaded = async () => {
const rootRecords = this.model.root.records;
const rootIds = rootRecords.map(record => record.resId)
const childRecords = await this.orm.searchRead(
this.props.resModel,
[[this.parentField, 'in', rootIds]],
[],
)
const rootIdsWithChildren = new Set(childRecords.map(child => child[this.parentField]));
rootRecords.forEach(record => {
record.data.hasChildren = rootIdsWithChildren.has(record.data.resId);
});
}
}
} }
}, },
async fetchChildren(parentId) { get modelParams() {
if (this.childrenByParent[parentId]) { const params = super.modelParams;
return this.childrenByParent[parentId]; if (this.recursive) {
params["config"]["recursive"] = true;
} }
return params;
const children = await this.rpc({
model: this.props.resModel,
method: 'search_read',
args: [[this.parentField, '=', parentId]],
kwargs: {fields: ['id', 'name', this.parentField]},
});
this.childrenByParent[parentId] = children;
return children;
}, },
async onExpandRow(event) {
const {parentId} = event.data;
const children = await this.fetchChildren(parentId);
this.renderer.renderChildrenRows(children, parentId);
},
onCollapseRow(event) {
const {parentId} = event.data;
this.renderer.removeChildrenRows(parentId);
},
}); });

View file

@ -2,58 +2,18 @@
import { ListRenderer } from '@web/views/list/list_renderer'; import { ListRenderer } from '@web/views/list/list_renderer';
import { patch } from '@web/core/utils/patch'; import { patch } from '@web/core/utils/patch';
import { useState } from '@odoo/owl';
patch(ListRenderer.prototype, { patch(ListRenderer.prototype, {
setup() { setup() {
super.setup(); super.setup();
this.recursive = this.props.archInfo.recursive; this.recursive = this.props.archInfo.recursive;
useState(this.props.list.records);
}, },
async _onExpandClick(ev) { async _onExpandClick(ev) {
const $button = $(ev.currentTarget); const $button = $(ev.currentTarget);
const isExpanded = $button.hasClass('expanded'); const parent = $button.data('expand');
$button.toggleClass('expanded', !isExpanded).text(isExpanded ? '+' : '-'); this.env.bus.trigger("expand-collapse-parent", parent);
const parentId = $button.data('expand');
if (!isExpanded) {
// Emit an event to request the controller to expand the row
this.env.bus.trigger('expandRow', { parentId });
} else {
// Emit an event to request the controller to collapse the row
this.env.bus.trigger('collapseRow', { parentId });
}
},
renderChildrenRows(children, parentId) {
const $parentRow = this.$(`tr[data-id="${parentId}"]`);
const parentDepth = $parentRow.data('depth') || 0;
children.forEach(child => {
const $row = $('<tr>')
.attr('data-id', child.id)
.attr('data-parent-id', parentId)
.attr('data-depth', parentDepth + 1)
.attr('data-has-children', child.hasChildren)
.addClass('o_recursive_child_row')
.css('padding-left', `${(parentDepth + 1) * 20}px`);
for (const [fieldName, fieldValue] of Object.entries(child)) {
const $cell = $('<td>').text(fieldValue);
$row.append($cell);
}
$parentRow.after($row);
});
},
removeChildrenRows(parentId) {
const childRows = this.$(`tr[data-parent-id="${parentId}"]`);
childRows.each((index, childRow) => {
const childId = $(childRow).data('id');
this.removeChildrenRows(childId);
});
childRows.remove();
}, },
}); });

View file

@ -1,21 +1,39 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<odoo> <odoo>
<!-- Extend List Renderer Header to Add Extra Column for Expand/Collapse Button Alignment --> <!-- 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"> <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"> <xpath expr="//th[hasclass('o_list_record_selector')]" position="before">
<th t-if="recursive" class="o_recursive_expand_column"></th> <th t-if="recursive"
class="o_recursive_expand_column cursor-default o_list_button">
<div style="min-width: 100px;"/>
</th>
</xpath> </xpath>
</t> </t>
<!-- Extend List Renderer Row to Add Expand/Collapse Button --> <!-- Extend List Renderer Row to Add Expand/Collapse Button -->
<t t-name="recursive_tree_view.RecordRow" t-inherit="web.ListRenderer.RecordRow" t-inherit-mode="extension"> <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 &amp;&amp; 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"> <xpath expr="//td[1]" position="before">
<td t-if="this.recursive" class="o_recursive_expand_column"> <td t-if="this.recursive"
<!-- Use @click to bind the button click to _onExpandClick --> class="o_recursive_expand_column"
<button t-if="record.data.hasChildren" >
t-att-data-expand="props.record.id.raw_value" <button t-if="record.children"
t-att-data-expand="record.resId"
t-on-click.prevent="_onExpandClick" t-on-click.prevent="_onExpandClick"
class="o_expand_button">+</button> 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> </td>
</xpath> </xpath>
</t> </t>

View file

@ -2,40 +2,67 @@
import {RelationalModel} from '@web/model/relational_model/relational_model'; import {RelationalModel} from '@web/model/relational_model/relational_model';
import {patch} from '@web/core/utils/patch'; import {patch} from '@web/core/utils/patch';
import { import {getFieldsSpec, getBasicEvalContext} from "@web/model/relational_model/utils";
getFieldsSpec,
makeActiveField,
getBasicEvalContext
} from "@web/model/relational_model/utils";
patch(RelationalModel.prototype, 'recursive-list-extension', { patch(RelationalModel.prototype, {
/** // TODO: Modify the domain to include only records with parentField = False in the root search
* Override to add child record tracking.
* Fetches records and marks records with `hasChildren` if they have child records. setup(params, services) {
*/ super.setup(...arguments);
async _loadRecords(config, evalContext = config.context) { this.hooks.onRootLoaded = () => {
const {resModel, resIds, activeFields, fields, context} = config; const root = this.root;
const parentField = await this.orm.call( const config = this.root.config;
'parent.field.service', if (config.recursive && root.records) {
'get_parent_field', this._loadChildren(root.records, config).then(() => {
[resModel], return root;
); });
activeFields[parentField] = makeActiveField() }
const records = await super._loadRecords(config, evalContext); }
// Get the parent ID field if there is one },
if (records && parentField) { async _loadData(config) {
const fieldSpec = getFieldsSpec(activeFields, fields, evalContext); if (config.recursive) {
// Fetch records with the additional field const parentField = await this._get_parent_field(config.resModel);
const parentIds = resIds const domain = [parentField, '=', false];
const children = await this.orm.webSearchRead(resModel, [[parentField, "in", parentIds]], { if (!(domain in config.domain)) {
context: {bin_size: true, ...context}, 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, specification: fieldSpec,
}); });
if (children && children.length) {
// Track children by grouping child records under each parent // Track children by grouping child records under each parent
const childrenByParent = {}; const childrenByParent = {};
for (const child of children) { for (const child of children.records) {
const parentId = child[parentField]; const parentId = child[parentField].id;
if (parentId) { if (parentId) {
if (!childrenByParent[parentId]) { if (!childrenByParent[parentId]) {
childrenByParent[parentId] = []; childrenByParent[parentId] = [];
@ -44,37 +71,69 @@ patch(RelationalModel.prototype, 'recursive-list-extension', {
} }
} }
records.forEach(record => { for (const parent of records) {
record.children = new this.constructor.DynamicRecordList(this, config, childrenByParent[record.resId]); 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;
}
}
} }
return records; },
async _get_parent_field(model) {
return await this.orm.call(
'parent.field.service',
'get_parent_field',
[model],
);
}, },
/** findRecordInHierarchy(resId) {
* Fetch and load child records dynamically for a given parent record ID. const records = this.root.records;
* Adds children to the models records to ensure theyre tracked in the models state. function findRecord(records) {
* @param {number} parentId - The ID of the parent record. for (let record of records) {
* @returns {Promise<Array>} - List of child records as model records. if (record.resId === resId) {
*/ return record;
async fetchChildren(parentId) { }
const config = self.config if (record.children && record.children.length >0) {
const {resModel, resIds, activeFields, fields, context} = config; const found = findRecord(record.children);
const evalContext = getBasicEvalContext(config); if (found) {
const fieldSpec = getFieldsSpec(activeFields, fields, evalContext); return found;
const parentRecord = this.root.records.find((r) => r.resId == parentId) }
if (!parentRecord) { }
throw Error("Attempt to find parent record failed."); }
} }
// Fetch child records where `parent_id` matches the given `parentId` return findRecord(records) || null;
const childrenData = await this.orm.webSearchRead(resModel, [[this.parentField, '=', parentId]], { }
context: context,
specification: fieldSpec,
});
if (childrenData) {
parentRecord.children = new this.constructor.DynamicRecordList(this, this.config, childrenData);
}
return parentRecord.children;
},
}); });

View file

@ -2,14 +2,27 @@
width: 30px; width: 30px;
} }
tr[data-depth="1"] .o_recursive_expand_column { .o_expand_button {
padding-left: 20px; border-color: transparent;
background-color: transparent;
} }
tr[data-depth="2"] .o_recursive_expand_column { button.o_expand_button[data-depth="1"] {
padding-left: 40px; margin-left: 15px;
} }
tr[data-depth="3"] .o_recursive_expand_column { button.o_expand_button[data-depth="2"] {
padding-left: 60px; 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;
} }

View file

@ -34,11 +34,6 @@ def schema_tree(arch, **kwargs):
_tag="{%s}attribute" % RNG_NS, _tag="{%s}attribute" % RNG_NS,
name="recursive", name="recursive",
) )
child_field_attr = etree.SubElement(
optional_attr,
_tag="{%s}attribute" % RNG_NS,
name="child-field",
)
# Create RelaxNG validator from the modified schema # Create RelaxNG validator from the modified schema
_tree_validator = etree.RelaxNG(rng_doc) _tree_validator = etree.RelaxNG(rng_doc)