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