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..7a91921039f6 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_arch_utils.esm.js @@ -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; +} 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..474558398e46 --- /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: {type: Array, element: 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..e1b1c3146198 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_controller.esm.js @@ -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(); + } +} 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..7bc32f444fc0 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_controller.xml @@ -0,0 +1,15 @@ + + + + + + onParentRecordUpdate + onBreadcrumbReset + + + + 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..c79d924db5e1 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_renderer.esm.js @@ -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(); + } +} 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..700eda9ce2f0 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_renderer.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..c7587358f4e7 --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_x2many_field.esm.js @@ -0,0 +1,151 @@ +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() { + if (!this.parentRecord) { + return super.pagerProps; + } + + const 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], + ], + }; + 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); 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..d6cd7130a99b --- /dev/null +++ b/web_hierarchy_list/static/src/hierarchy_list_x2many_field.xml @@ -0,0 +1,19 @@ + + + + + + + + + +