pull/2970/merge
IT-Ideas 2025-04-24 08:02:56 +02:00 committed by GitHub
commit 09466dec2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 718 additions and 0 deletions

View File

View File

@ -0,0 +1,24 @@
# Copyright 2024 ACSONE SA/NV
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Web Hierarchy List",
"summary": """
This modules adds the hierarchy list view, which consist of a list view
and a breadcrumb.
""",
"version": "18.0.1.0.0",
"license": "AGPL-3",
"author": "ACSONE SA/NV,Odoo Community Association (OCA)",
"website": "https://github.com/OCA/web",
"depends": [
"web",
],
"assets": {
"web.assets_backend": [
"web_hierarchy_list/static/src/**/*",
],
},
"data": [],
"demo": [],
}

View File

@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -0,0 +1,10 @@
import {ListArchParser} from "@web/views/list/list_arch_parser";
import {treatHierarchyListArch} from "./hierarchy_list_arch_utils.esm";
export class HierarchyListArchParser extends ListArchParser {
parse(xmlDoc, models, modelName) {
const archInfo = super.parse(...arguments);
treatHierarchyListArch(archInfo, modelName, models[modelName].fields);
return archInfo;
}
}

View File

@ -0,0 +1,163 @@
const isParentFieldOptionsName = "isParentField";
const isChildrenFieldOptionsName = "isChildrenField";
const isNameFieldOptionsName = "isNameField";
function _handleIsParentFieldOption(archInfo, modelName, fields, column) {
if (archInfo.parentFieldColumn) {
throw new Error(
`The ${isParentFieldOptionsName} field option is already present in the view definition.`
);
}
if (fields[column.name].type !== "many2one") {
throw new Error(
`Invalid field for ${isParentFieldOptionsName} field option, it should be a Many2One field.`
);
} else if (fields[column.name].relation !== modelName) {
throw new Error(
`Invalid field for ${isParentFieldOptionsName} field option, the co-model should be same model than the current one (expected: ${modelName}).`
);
}
if ("drillDownCondition" in column.options) {
archInfo.drillDownCondition = column.options.drillDownCondition;
}
if ("drillDownIcon" in column.options) {
archInfo.drillDownIcon = column.options.drillDownIcon;
}
archInfo.parentFieldColumn = column;
}
function _handleIsChildrenFieldOption(archInfo, modelName, fields, column) {
if (archInfo.childrenFieldColumn) {
throw new Error(
`The ${isChildrenFieldOptionsName} field option is already present in the view definition.`
);
}
if (fields[column.name].type !== "one2many") {
throw new Error(
`Invalid field for ${isChildrenFieldOptionsName} field option, it should be a One2Many field.`
);
} else if (fields[column.name].relation !== modelName) {
throw new Error(
`Invalid field for ${isChildrenFieldOptionsName} field option, the co-model should be same model than the current one (expected: ${modelName}).`
);
}
archInfo.childrenFieldColumn = column;
}
function _handleIsNameFieldOption(archInfo, modelName, fields, column) {
if (archInfo.nameFieldColumn) {
throw new Error(
`The ${isNameFieldOptionsName} field option is already present in the view definition.`
);
}
archInfo.nameFieldColumn = column;
}
function _handleParentFieldColumnFallback(archInfo, modelName, fields, columnDict) {
const parentIdFieldName = "parent_id";
if (!archInfo.parentFieldColumn) {
if (
parentIdFieldName in fields &&
fields[parentIdFieldName].type === "many2one" &&
fields[parentIdFieldName].relation === modelName
) {
_handleIsParentFieldOption(
archInfo,
modelName,
fields,
columnDict[parentIdFieldName]
);
} else {
throw new Error(
`Neither ${parentIdFieldName} field is present in the view fields, nor is ${isParentFieldOptionsName} field option defined on a field.`
);
}
}
}
function _handleChildrenFieldColumnFallback(archInfo, modelName, fields, columnDict) {
const childIdsFieldName = "child_ids";
if (!archInfo.childrenFieldColumn) {
if (
childIdsFieldName in fields &&
fields[childIdsFieldName].type === "one2many" &&
fields[childIdsFieldName].relation === modelName
) {
archInfo.childrenFieldColumn = columnDict[childIdsFieldName];
}
}
}
function _handleNameFieldColumnFallback(archInfo, modelName, fields, columnDict) {
const displayNameFieldName = "display_name";
if (!archInfo.nameFieldColumn) {
if (displayNameFieldName in fields) {
archInfo.nameFieldColumn = columnDict[displayNameFieldName];
} else {
throw new Error(
`Neither ${displayNameFieldName} field is present in the view fields, nor is ${isNameFieldOptionsName} field option defined on a field.`
);
}
}
}
function _handleDrillDownConditionFallback(archInfo) {
if (!archInfo.drillDownCondition && archInfo.childrenFieldColumn) {
archInfo.drillDownCondition = `${archInfo.childrenFieldColumn.name}.length > 0`;
}
}
function _handleParentFieldColumnVisibility(archInfo) {
if (archInfo.parentFieldColumn) {
// The column tagged as parent field is made invisible, except id explicitly set otherwise.
if (
!["invisible", "column_invisible"].some(
(value) =>
![null, undefined].includes(archInfo.parentFieldColumn[value])
)
) {
archInfo.parentFieldColumn.column_invisible = "1";
}
}
}
function _handleChildrenFieldColumnVisibility(archInfo) {
if (archInfo.childrenFieldColumn) {
// The column tagged as children field is made invisible, except id explicitly set otherwise.
if (
!["invisible", "column_invisible"].some(
(value) =>
![null, undefined].includes(archInfo.childrenFieldColumn[value])
)
) {
archInfo.childrenFieldColumn.column_invisible = "1";
}
}
}
export function treatHierarchyListArch(archInfo, modelName, fields) {
const columnDict = {};
for (const column of archInfo.columns) {
columnDict[column.name] = column;
if (column.options) {
if (column.options[isParentFieldOptionsName]) {
_handleIsParentFieldOption(archInfo, modelName, fields, column);
}
if (column.options[isChildrenFieldOptionsName]) {
_handleIsChildrenFieldOption(archInfo, modelName, fields, column);
}
if (column.options[isNameFieldOptionsName]) {
_handleIsNameFieldOption(archInfo, modelName, fields, column);
}
}
}
_handleParentFieldColumnFallback(archInfo, modelName, fields, columnDict);
_handleChildrenFieldColumnFallback(archInfo, modelName, fields, columnDict);
_handleNameFieldColumnFallback(archInfo, modelName, fields, columnDict);
_handleDrillDownConditionFallback(archInfo);
_handleParentFieldColumnVisibility(archInfo);
_handleChildrenFieldColumnVisibility(archInfo);
// Inline Edition is not supported (yet?)
archInfo.activeActions.edit = false;
}

View File

@ -0,0 +1,15 @@
import {Component} from "@odoo/owl";
import {HierarchyListBreadcrumbItem} from "./hierarchy_list_breadcrumb_item.esm";
export class HierarchyListBreadcrumb extends Component {
static components = {
HierarchyListBreadcrumbItem,
};
static props = {
parentRecords: {type: Array, element: Object},
getDisplayName: Function,
navigate: Function,
reset: Function,
};
static template = "web_hierarchy_list.Breadcrumb";
}

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates>
<t t-name="web_hierarchy_list.Breadcrumb">
<div class="d-flex flex-row o_hierarchy_list_breadcrumb container-fluid py-2">
<nav aria-label="breadcrumb">
<i
class="fa fa-times pe-2 cursor-pointer"
t-on-click="props.reset"
t-if="props.parentRecords.length > 0"
/>
<ol class="breadcrumb bg-transparent d-inline-flex">
<t
t-foreach="props.parentRecords"
t-as="parentRecord"
t-key="parentRecord.resId"
>
<HierarchyListBreadcrumbItem
record="parentRecord"
getDisplayName="props.getDisplayName"
navigate="props.navigate"
/>
</t>
</ol>
</nav>
</div>
</t>
</templates>

View File

@ -0,0 +1,14 @@
import {Component} from "@odoo/owl";
export class HierarchyListBreadcrumbItem extends Component {
static props = {
record: Object,
getDisplayName: Function,
navigate: Function,
};
static template = "web_hierarchy_list.BreadcrumbItem";
onGlobalClick() {
this.props.navigate(this.props.record);
}
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates>
<t t-name="web_hierarchy_list.BreadcrumbItem">
<li class="breadcrumb-item">
<strong><span
class="cursor-pointer text-primary"
t-out="props.getDisplayName(props.record)"
t-on-click.synthetic="onGlobalClick"
/></strong>
</li>
</t>
</templates>

View File

@ -0,0 +1,45 @@
import {onWillUnmount, useChildSubEnv} from "@odoo/owl";
import {ListController} from "@web/views/list/list_controller";
export class HierarchyListController extends ListController {
static template = "web_hierarchy_list.HierarchyListView";
setup() {
super.setup(...arguments);
this.parentRecord = false;
// Initializing breadcrumbState to an empty array is important as the HierarchyListRender
// persists the breadcrumb state in the global state only if the environment variable
// is set. This restriction is put in place in order not to persist the state when
// the HierarchyListRender is mounted on a x2Many Field.
useChildSubEnv({
breadcrumbState: this.props.globalState?.breadcrumbState || [],
});
onWillUnmount(this.onWillUnmount);
}
async onWillUnmount() {
delete this.actionService.currentController.action.context[
`default_${this.archInfo.parentFieldColumn.name}`
];
}
async onParentRecordUpdate(parentRecord) {
if (parentRecord) {
this.actionService.currentController.action.context[
`default_${this.archInfo.parentFieldColumn.name}`
] = parentRecord.resId;
} else {
delete this.actionService.currentController.action.context[
`default_${this.archInfo.parentFieldColumn.name}`
];
}
const hierarchyListParentIdDomain = [
[this.props.archInfo.parentFieldColumn.name, "=", parentRecord.resId],
];
await this.model.load({hierarchyListParentIdDomain});
}
async onBreadcrumbReset() {
await this.env.searchModel._notify();
}
}

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates>
<t
t-name="web_hierarchy_list.HierarchyListView"
t-inherit="web.ListView"
t-inherit-mode="primary"
>
<xpath expr="//t[@t-component='props.Renderer']" position="attributes">
<attribute name="onParentRecordUpdate.bind">onParentRecordUpdate</attribute>
<attribute name="onBreadcrumbReset.bind">onBreadcrumbReset</attribute>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,18 @@
import {RelationalModel} from "@web/model/relational_model/relational_model";
export class HierarchyListModel extends RelationalModel {
/**
* @param {*} currentConfig
* @param {*} params
* @returns {Config}
*/
_getNextConfig(currentConfig, params) {
const nextConfig = super._getNextConfig(...arguments);
// As we need to display records according to the drill-down, we need a way to pass
// the info to the model, which is performed through the use of the hierarchyListParentIdDomain
if ("hierarchyListParentIdDomain" in params) {
nextConfig.domain = params.hierarchyListParentIdDomain;
}
return nextConfig;
}
}

View File

@ -0,0 +1,104 @@
import {onWillStart, useState} from "@odoo/owl";
import {HierarchyListBreadcrumb} from "./hierarchy_list_breadcrumb.esm";
import {ListRenderer} from "@web/views/list/list_renderer";
import {evaluateBooleanExpr} from "@web/core/py_js/py";
import {useSetupAction} from "@web/search/action_hook";
export class HierarchyListRenderer extends ListRenderer {
static components = {
...ListRenderer.components,
HierarchyListBreadcrumb,
};
static props = [...ListRenderer.props, "onParentRecordUpdate", "onBreadcrumbReset"];
static template = "web_hierarchy_list.HierarchyListRenderer";
static rowsTemplate = "web_hierarchy_list.HierarchyListRenderer.Rows";
static recordRowTemplate = "web_hierarchy_list.HierarchyListRenderer.RecordRow";
setup() {
super.setup();
useSetupAction({
getGlobalState: () => {
// We only persist the breadcrumb state in the global state if it was provided
// by the environment. Indeed, the environment variable is created by the
// HierarchyListController, which ensures that the state is only persisted there
// and not when the renderer is used in a x2Many field.
if (
!this.env.breadcrumbState ||
this.state.breadcrumbState.length === 0
) {
return {};
}
return {
breadcrumbState: this._getBreadcrumbState(),
};
},
});
// As the breadcrumb state is not provided when the renderer is mounted into a x2Many
// field, we need to have a fallback value.
this.state = useState({
breadcrumbState: this.env.breadcrumbState || [],
});
onWillStart(this.willStart);
}
async willStart() {
if (this.state.breadcrumbState.length > 0) {
this.navigate(
this.state.breadcrumbState[this.state.breadcrumbState.length - 1]
);
}
}
_getBreadcrumbState() {
return this.state.breadcrumbState.map((parentRecord) =>
this._getParentRecord(parentRecord)
);
}
getDisplayName(record) {
if (this.props.archInfo.nameFieldColumn.fieldType === "many2one") {
return record.data[this.props.archInfo.nameFieldColumn.name][1];
}
return record.data[this.props.archInfo.nameFieldColumn.name];
}
_getParentRecord(record) {
const data = {};
data[this.props.archInfo.nameFieldColumn.name] =
record.data[this.props.archInfo.nameFieldColumn.name];
return {resId: record.resId, data};
}
_updateBreadcrumbState(record) {
const existingRecordIndex = this.state.breadcrumbState
.map((r) => r.resId)
.indexOf(record.resId);
if (existingRecordIndex >= 0)
this.state.breadcrumbState = this.state.breadcrumbState.slice(
0,
existingRecordIndex + 1
);
else {
this.state.breadcrumbState.push(this._getParentRecord(record));
}
}
canDrillDown(record) {
if (!this.props.archInfo.drillDownCondition) {
return true;
}
return evaluateBooleanExpr(
this.props.archInfo.drillDownCondition,
record.evalContextWithVirtualIds
);
}
async navigate(parent) {
this._updateBreadcrumbState(parent);
await this.props.onParentRecordUpdate(parent);
}
async reset() {
this.state.breadcrumbState.length = 0;
await this.props.onBreadcrumbReset();
}
}

View File

@ -0,0 +1,12 @@
$hierarchy_list_drill_down_width: 24px;
.o_hierarchy_list_drill_down_column_header {
min-width: $hierarchy_list_drill_down_width;
max-width: $hierarchy_list_drill_down_width;
}
.o_hierarchy_list_drill_down_column {
// We do not want the left padding rule of bootstrap for the first col to be applied.
// Indeed as the column width is forced, this would result in having the icon overflowing.
padding: 0.5rem 0.3rem !important;
}

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8" ?>
<templates>
<t
t-name="web_hierarchy_list.HierarchyListRenderer"
t-inherit="web.ListRenderer"
t-inherit-mode="primary"
>
<xpath expr="//div[hasclass('o_list_renderer')]/*[1]" position="before">
<HierarchyListBreadcrumb
parentRecords="state.breadcrumbState"
getDisplayName.bind="getDisplayName"
navigate.bind="navigate"
reset.bind="reset"
/>
</xpath>
<xpath expr="//table[@t-ref='table']/thead//tr/th[1]" position="after">
<th class="o_hierarchy_list_drill_down_column_header" />
</xpath>
</t>
<t
t-name="web_hierarchy_list.HierarchyListRenderer.Rows"
t-inherit="web.ListRenderer.Rows"
t-inherit-mode="primary"
>
<xpath
expr="//t[@t-if='!list.isGrouped']//td[hasclass('o_field_x2many_list_row_add')]"
position="before"
>
<td class="o_hierarchy_list_drill_down_column">
</td>
</xpath>
<xpath
expr="//t[@t-else]//td[hasclass('o_group_field_row_add')]"
position="before"
>
<td class="o_hierarchy_list_drill_down_column">
</td>
</xpath>
<xpath expr="//tr[@t-foreach='getEmptyRowIds']">
<td class="o_hierarchy_list_drill_down_column">
</td>
</xpath>
</t>
<t
t-name="web_hierarchy_list.HierarchyListRenderer.RecordRow"
t-inherit="web.ListRenderer.RecordRow"
t-inherit-mode="primary"
>
<xpath expr="//tr[@class='o_data_row']/td[1]" position="after">
<td class="text-end o_hierarchy_list_drill_down_column">
<i
t-if="canDrillDown(record)"
t-attf-class="pr-2 cursor-pointer fa {{props.archInfo.drillDownIcon || 'fa-plus-square-o'}}"
t-on-click.stop="() => this.navigate(record)"
/>
</td>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,16 @@
import {HierarchyListArchParser} from "./hierarchy_list_arch_parser.esm";
import {HierarchyListController} from "./hierarchy_list_controller.esm";
import {HierarchyListModel} from "./hierarchy_list_model.esm";
import {HierarchyListRenderer} from "./hierarchy_list_renderer.esm";
import {listView} from "@web/views/list/list_view";
import {registry} from "@web/core/registry";
export const hierarchyListView = {
...listView,
ArchParser: HierarchyListArchParser,
Controller: HierarchyListController,
Model: HierarchyListModel,
Renderer: HierarchyListRenderer,
};
registry.category("views").add("hierarchy_list", hierarchyListView);

View File

@ -0,0 +1,153 @@
import {X2ManyField, x2ManyField} from "@web/views/fields/x2many/x2many_field";
import {HierarchyListRenderer} from "./hierarchy_list_renderer.esm";
import {RelationalModel} from "@web/model/relational_model/relational_model";
import {evaluateExpr} from "@web/core/py_js/py";
import {extractFieldsFromArchInfo} from "@web/model/relational_model/utils";
import {registry} from "@web/core/registry";
import {treatHierarchyListArch} from "./hierarchy_list_arch_utils.esm";
import {useService} from "@web/core/utils/hooks";
import {useState} from "@odoo/owl";
export class HierarchyListX2manyField extends X2ManyField {
static components = {
...X2ManyField.components,
HierarchyListRenderer,
};
static template = "web_hierarchy_list.X2ManyField";
setup() {
super.setup();
treatHierarchyListArch(
this.archInfo,
this.field.relation,
this.archInfo.fields
);
// Creation and deletion of records is not supported (yet?)
this.archInfo.activeActions.create = false;
this.archInfo.activeActions.link = false;
this.archInfo.activeActions.delete = false;
this.parentRecord = false;
const services = {};
for (const key of RelationalModel.services) {
services[key] = useService(key);
}
services.orm = services.orm || useService("orm");
this.childrenModel = useState(
new RelationalModel(this.env, this.modelParams, services)
);
}
get modelParams() {
const {rawExpand} = this.archInfo;
const {activeFields, fields} = extractFieldsFromArchInfo(
this.archInfo,
this.archInfo.fields
);
const modelConfig = {
resModel: this.field.relation,
orderBy: this.archInfo.defaultOrderBy || [],
groupBy: false,
fields,
activeFields,
openGroupsByDefault: rawExpand
? evaluateExpr(rawExpand, this.props.record.model.context)
: false,
};
return {
config: modelConfig,
state: this.props.state?.modelState,
groupByInfo: {},
defaultGroupBy: false,
defaultOrderBy: this.archInfo.defaultOrder,
limit: this.archInfo.limit || this.props.limit,
countLimit: this.archInfo.countLimit,
hooks: {},
};
}
get rendererProps() {
let props = {};
if (this.parentRecord) {
props = {
archInfo: this.archInfo,
list: this.childrenModel.root,
openRecord: this.openRecord.bind(this),
activeActions: this.archInfo.activeActions,
onOpenFormView: this.switchToForm.bind(this),
};
} else {
props = super.rendererProps;
}
props.activeActions = this.archInfo.activeActions;
return props;
}
get pagerProps() {
let list = this.list;
if (this.parentRecord) {
list = this.childrenModel.root;
}
return {
offset: list.offset,
limit: list.limit,
total: list.count,
onUpdate: async ({offset, limit}) => {
const initialLimit = this.list.limit;
const leaved = await list.leaveEditMode();
if (leaved) {
let adjustment_due_to_limit = 0;
if (
initialLimit === limit &&
initialLimit === this.list.limit + 1
) {
// Unselecting the edited record might have abandonned it. If the page
// size was reached before that record was created, the limit was temporarily
// increased to keep that new record in the current page, and abandonning it
// decreased this limit back to it's initial value, so we keep this into
// account in the offset/limit update we're about to do.
adjustment_due_to_limit -= 1;
}
await list.load({
limit: limit + adjustment_due_to_limit,
offset: offset + adjustment_due_to_limit,
});
}
},
withAccessKey: false,
};
}
async onParentRecordUpdate(parentRecord) {
this.parentRecord = parentRecord;
const context = {...this.archInfo.context};
context[`default_${this.archInfo.parentFieldColumn.name}`] =
this.parentRecord.resId;
const params = {
context,
domain: [
[this.archInfo.parentFieldColumn.name, "=", this.parentRecord.resId],
],
limit:
(this.childrenModel.root && this.childrenModel.root.limit) ||
this.archInfo.limit,
};
await this.childrenModel.load(params);
}
async onBreadcrumbReset() {
this.parentRecord = false;
this.render();
}
}
export const hierarchyListX2manyField = {
...x2ManyField,
component: HierarchyListX2manyField,
};
registry.category("fields").add("one2many_hierarchy_list", hierarchyListX2manyField);

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t
t-name="web_hierarchy_list.X2ManyField"
t-inherit="web.X2ManyField"
t-inherit-mode="primary"
>
<xpath expr="//ListRenderer" position="replace">
<HierarchyListRenderer
t-if="props.viewMode === 'list'"
t-props="rendererProps"
onParentRecordUpdate.bind="onParentRecordUpdate"
onBreadcrumbReset.bind="onBreadcrumbReset"
/>
</xpath>
</t>
</templates>