diff --git a/web_hierarchy_list/__init__.py b/web_hierarchy_list/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/web_hierarchy_list/__manifest__.py b/web_hierarchy_list/__manifest__.py new file mode 100644 index 000000000000..9e8aa7b6e858 --- /dev/null +++ b/web_hierarchy_list/__manifest__.py @@ -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": [], +} diff --git a/web_hierarchy_list/pyproject.toml b/web_hierarchy_list/pyproject.toml new file mode 100644 index 000000000000..4231d0cccb3d --- /dev/null +++ b/web_hierarchy_list/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_hierarchy_list/static/description/icon.png b/web_hierarchy_list/static/description/icon.png new file mode 100644 index 000000000000..3a0328b516c4 Binary files /dev/null and b/web_hierarchy_list/static/description/icon.png differ diff --git a/web_hierarchy_list/static/src/hierarchy_list_arch_parser.esm.js b/web_hierarchy_list/static/src/hierarchy_list_arch_parser.esm.js new file mode 100644 index 000000000000..5ef69bdb059c --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_arch_parser.esm.js @@ -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; + } +} diff --git a/web_hierarchy_list/static/src/hierarchy_list_arch_utils.esm.js b/web_hierarchy_list/static/src/hierarchy_list_arch_utils.esm.js new file mode 100644 index 000000000000..16611fd1a029 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_arch_utils.esm.js @@ -0,0 +1,156 @@ +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 + ) { + archInfo.parentFieldColumn = 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); +} diff --git a/web_hierarchy_list/static/src/hierarchy_list_breadcrumb.esm.js b/web_hierarchy_list/static/src/hierarchy_list_breadcrumb.esm.js new file mode 100644 index 000000000000..8d7c979dbf76 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_breadcrumb.esm.js @@ -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: Array[Object], + getDisplayName: Function, + navigate: Function, + reset: Function, + }; + static template = "web_hierarchy_list.Breadcrumb"; +} diff --git a/web_hierarchy_list/static/src/hierarchy_list_breadcrumb.xml b/web_hierarchy_list/static/src/hierarchy_list_breadcrumb.xml new file mode 100644 index 000000000000..594e1aa47926 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_breadcrumb.xml @@ -0,0 +1,29 @@ + + + + +
+ +
+
+ +
diff --git a/web_hierarchy_list/static/src/hierarchy_list_breadcrumb_item.esm.js b/web_hierarchy_list/static/src/hierarchy_list_breadcrumb_item.esm.js new file mode 100644 index 000000000000..907896899d1f --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_breadcrumb_item.esm.js @@ -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); + } +} diff --git a/web_hierarchy_list/static/src/hierarchy_list_breadcrumb_item.xml b/web_hierarchy_list/static/src/hierarchy_list_breadcrumb_item.xml new file mode 100644 index 000000000000..d09568471ed8 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_breadcrumb_item.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/web_hierarchy_list/static/src/hierarchy_list_controller.esm.js b/web_hierarchy_list/static/src/hierarchy_list_controller.esm.js new file mode 100644 index 000000000000..a9567d0c84db --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_controller.esm.js @@ -0,0 +1,30 @@ +import {ListController} from "@web/views/list/list_controller"; +import {useChildSubEnv} from "@odoo/owl"; + +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 || [], + }); + } + + 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}` + ]; + } + } +} diff --git a/web_hierarchy_list/static/src/hierarchy_list_controller.xml b/web_hierarchy_list/static/src/hierarchy_list_controller.xml new file mode 100644 index 000000000000..555b1af8ee6e --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_controller.xml @@ -0,0 +1,14 @@ + + + + + + onParentRecordUpdate + + + + diff --git a/web_hierarchy_list/static/src/hierarchy_list_model.esm.js b/web_hierarchy_list/static/src/hierarchy_list_model.esm.js new file mode 100644 index 000000000000..094b866b8357 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_model.esm.js @@ -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; + } +} diff --git a/web_hierarchy_list/static/src/hierarchy_list_renderer.esm.js b/web_hierarchy_list/static/src/hierarchy_list_renderer.esm.js new file mode 100644 index 000000000000..1dfe3c8712b1 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_renderer.esm.js @@ -0,0 +1,108 @@ +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"]; + 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 + ); + } + + navigate(parent) { + this._updateBreadcrumbState(parent); + this.props.onParentRecordUpdate(parent); + const hierarchyListParentIdDomain = [ + [this.props.archInfo.parentFieldColumn.name, "=", parent.resId], + ]; + this.props.list.model.load({hierarchyListParentIdDomain}); + } + + reset() { + this.state.breadcrumbState.length = 0; + this.env.searchModel._notify(); + } +} diff --git a/web_hierarchy_list/static/src/hierarchy_list_renderer.scss b/web_hierarchy_list/static/src/hierarchy_list_renderer.scss new file mode 100644 index 000000000000..92aa1974088f --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_renderer.scss @@ -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; +} diff --git a/web_hierarchy_list/static/src/hierarchy_list_renderer.xml b/web_hierarchy_list/static/src/hierarchy_list_renderer.xml new file mode 100644 index 000000000000..7f266ca6c70e --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_renderer.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web_hierarchy_list/static/src/hierarchy_list_view.esm.js b/web_hierarchy_list/static/src/hierarchy_list_view.esm.js new file mode 100644 index 000000000000..a9c0102c67cc --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_view.esm.js @@ -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); diff --git a/web_hierarchy_list/static/src/hierarchy_list_x2many_field.esm.js b/web_hierarchy_list/static/src/hierarchy_list_x2many_field.esm.js new file mode 100644 index 000000000000..07e422ca0fe7 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_x2many_field.esm.js @@ -0,0 +1,78 @@ +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 {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.props.record.fields[this.props.name].relation, + this.archInfo.fields + ); + this.state = useState({ + parent: false, + }); + const services = {}; + for (const key of RelationalModel.services) { + services[key] = useService(key); + } + services.orm = services.orm || useService("orm"); + this.childrenModel = new RelationalModel( + this.env, + this.props.record.config, + services + ); + } + + get rendererProps() { + if (!this.state.parentRecord) { + return super.rendererProps; + } + const {archInfo} = this; + + const props = { + archInfo, + list: this.list, + openRecord: this.openRecord.bind(this), + }; + + const editable = + (this.archInfo.activeActions.edit && archInfo.editable) || + this.props.editable; + props.activeActions = this.activeActions; + props.cycleOnTab = false; + props.editable = !this.props.readonly && editable; + props.nestedKeyOptionalFieldsData = this.nestedKeyOptionalFieldsData; + props.onAdd = (params) => { + params.editable = + !this.props.readonly && + ("editable" in params ? params.editable : editable); + this.onAdd(params); + }; + props.onOpenFormView = this.switchToForm.bind(this); + props.hasOpenFormViewButton = archInfo.editable ? archInfo.openFormView : false; + return props; + } + + onParentRecordUpdate(parentRecord) { + this.state.parentRecord = parentRecord; + } +} + +export const hierarchyListX2manyField = { + ...x2ManyField, + component: HierarchyListX2manyField, +}; + +registry.category("fields").add("one2many_hierarchy_list", hierarchyListX2manyField); diff --git a/web_hierarchy_list/static/src/hierarchy_list_x2many_field.xml b/web_hierarchy_list/static/src/hierarchy_list_x2many_field.xml new file mode 100644 index 000000000000..f8a6879c77e0 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_x2many_field.xml @@ -0,0 +1,18 @@ + + + + + + + + + +