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