From 525ce8888e92045725ba9c31fae20da7016616aa Mon Sep 17 00:00:00 2001 From: Carlos Roca Date: Fri, 28 Aug 2026 06:56:18 +0200 Subject: [PATCH 1/2] [FIX] web_widget_one2many_tree_line_duplicate: keep relational values on the clone Extend the snapshot we re-apply after the onchanges to the relational types. Many2ones are copied as a new value object so both records don't share it, and many2manys through a SET command (and not LINK) so the copy ends up with exactly the same records as the source: this way we also drop the ones the onchange added on its own. One2manys can't be carried by that snapshot, as every sub-record has to be created on its own, so they are recreated line by line (recursively, for the nested ones): the copy's list is first emptied of whatever its onchanges generated, and then each line of the source is created, filled with its own snapshot and added to the list, the same way the core duplicates a record. --- .../static/src/list/list_renderer.esm.js | 189 ++++++++++++++---- 1 file changed, 154 insertions(+), 35 deletions(-) diff --git a/web_widget_one2many_tree_line_duplicate/static/src/list/list_renderer.esm.js b/web_widget_one2many_tree_line_duplicate/static/src/list/list_renderer.esm.js index 5ffd8ba63555..333d3beaef05 100644 --- a/web_widget_one2many_tree_line_duplicate/static/src/list/list_renderer.esm.js +++ b/web_widget_one2many_tree_line_duplicate/static/src/list/list_renderer.esm.js @@ -5,6 +5,20 @@ import {ListRenderer} from "@web/views/list/list_renderer"; import {patch} from "@web/core/utils/patch"; import {exprToBoolean} from "@web/core/utils/strings"; import {browser} from "@web/core/browser/browser"; +import {x2ManyCommands} from "@web/core/orm_service"; + +const SCALAR_TYPES = [ + "integer", + "float", + "monetary", + "char", + "text", + "boolean", + "selection", + "date", + "datetime", +]; +const MANY2ONE_TYPES = ["many2one", "many2one_reference", "reference"]; patch(ListRenderer.prototype, { setup() { @@ -47,40 +61,146 @@ patch(ListRenderer.prototype, { } return nbCols; }, - async onCloneIconClick(record) { - const list = this.props.list; - const left = await list.leaveEditMode(); - if (!left) { - return; - } - // Snapshot of the source's scalar values, including the readonly computed - // ones (subtotals...), so the copy is exact. duplicateRecords creates the - // line running its onchanges, which recompute editable fields (e.g. - // price_unit from the product) and discard the manual values. We re-apply - // this snapshot with withoutOnchange so those values are kept. - const scalarTypes = [ - "integer", - "float", - "monetary", - "char", - "text", - "boolean", - "selection", - "date", - "datetime", - ]; + /** + * Snapshot of the record's values, including the readonly computed ones + * (subtotals...), so the copy is exact. duplicateRecords creates the line + * running its onchanges, which recompute editable fields (e.g. price_unit + * from the product) and discard the manual values. We re-apply this + * snapshot with withoutOnchange so those values are kept. + * + * @param {Record} record record to copy the values from + * @param {StaticList} list list the record belongs to + * @returns {Object} changes to apply on the copy + */ + getDuplicateSnapshot(record, list) { const snapshot = {}; for (const [name, value] of Object.entries(record.data)) { const field = record.fields[name]; if ( - field && - name !== "display_name" && - name !== list.handleField && - scalarTypes.includes(field.type) + !field || + name === "display_name" || + name === list.handleField || + name === list.config.relationField ) { + continue; + } + if (SCALAR_TYPES.includes(field.type)) { snapshot[name] = value; + } else if (MANY2ONE_TYPES.includes(field.type)) { + // Always invisible many2ones are read as a plain id (see + // getFieldsSpec), so we wrap it and let the model complete the + // value (display_name...) when applying the change. The rest are + // copied as a new object, so both records don't share it. + snapshot[name] = + typeof value === "number" + ? {id: value} + : value && Object.assign({}, value); + } else if (field.type === "many2many") { + // SET (and not LINK) so the copy ends up with exactly the same + // records as the source: this way we also drop the ones the + // onchange added on its own. + snapshot[name] = [x2ManyCommands.set([...value.currentIds])]; + } + // One2many fields can't be set through a snapshot: they are copied + // sub-record by sub-record in duplicateOne2manyValues(). + } + return snapshot; + }, + /** + * Fetches the values of the sub-records of a one2many that is always + * invisible in the list: those are read with their ids only (see + * getFieldsSpec), so their datapoints are empty and copying them as they are + * would end up creating empty (and usually invalid) records. + * + * @param {Record} record source record + * @param {String} fieldName name of the one2many field + */ + async loadOne2manyValues(record, fieldName) { + const invisible = record.activeFields[fieldName].invisible; + if (invisible !== "True" && invisible !== "1") { + return; + } + const list = record.data[fieldName]; + const lines = list.records.filter((line) => line.resId); + if (!lines.length) { + return; + } + const values = await list.model._loadRecords( + {...list.config, resIds: lines.map((line) => line.resId)}, + list.evalContext + ); + for (const line of lines) { + const lineValues = values.find((vals) => vals.id === line.resId); + if (lineValues) { + line._applyValues(lineValues); + } + } + }, + /** + * Recreates on the copy the sub-records of every one2many field of the + * source. They are the only values the snapshot can't carry, as each + * sub-record has to be created (and filled) on its own. The lines the + * onchanges may have generated on the copy are dropped first, so both + * records end up with exactly the same content. + * + * @param {Record} record source record + * @param {Record} newRecord copy of the source record + */ + async duplicateOne2manyValues(record, newRecord) { + for (const [name, sourceList] of Object.entries(record.data)) { + const field = record.fields[name]; + if (!field || field.type !== "one2many" || !(name in newRecord.data)) { + continue; + } + const targetList = newRecord.data[name]; + if ( + !Object.keys(sourceList.activeFields).length || + !Object.keys(targetList.activeFields).length + ) { + // The sub-records have no fields to copy (the field has no + // sub-view here). + continue; } + await this.loadOne2manyValues(record, name); + if (targetList.records.length) { + // DELETE for the lines the onchange created on the fly, UNLINK + // for the existing ones it linked: this way we only discard the + // pending commands, without touching any stored record. + await targetList._applyCommands( + targetList.records.map((line) => + line.resId + ? [x2ManyCommands.UNLINK, line.resId] + : [x2ManyCommands.DELETE, line._virtualId] + ) + ); + } + for (const line of sourceList.records) { + const newLine = await targetList._createNewRecordDatapoint({ + mode: "readonly", + }); + const snapshot = this.getDuplicateSnapshot(line, targetList); + if (Object.keys(snapshot).length) { + await newLine._update(snapshot, { + withoutOnchange: true, + withoutParentUpdate: true, + }); + } + // Nested one2manys are copied the same way, recursively. + await this.duplicateOne2manyValues(line, newLine); + // Added at the end, like the core does when duplicating: the + // record is filled before being part of the list, so the + // intermediate values are never rendered. + await targetList._addRecord(newLine, {position: "bottom"}); + } + } + }, + async onCloneIconClick(record) { + const list = this.props.list; + const left = await list.leaveEditMode(); + if (!left) { + return; } + const snapshot = this.getDuplicateSnapshot(record, list); // Run everything in a single model transaction and notify only once, at // the end, so the intermediate (onchange-recomputed) values are never // rendered: the line appears directly with the original values, without @@ -91,15 +211,14 @@ patch(ListRenderer.prototype, { // The duplicate is inserted right after the source line; identify it // by position (references aren't stable across the re-wrapping). const newRecord = list.records[sourceIndex + 1]; - if ( - newRecord && - newRecord.id !== record.id && - Object.keys(snapshot).length - ) { - await newRecord._update(snapshot, { - withoutOnchange: true, - withoutParentUpdate: true, - }); + if (newRecord && newRecord.id !== record.id) { + if (Object.keys(snapshot).length) { + await newRecord._update(snapshot, { + withoutOnchange: true, + withoutParentUpdate: true, + }); + } + await this.duplicateOne2manyValues(record, newRecord); } await list._onUpdate(); }); From d4bfccf526ab7519de75db4e8d9dd3d247cf45d5 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 31 Aug 2026 08:06:50 +0000 Subject: [PATCH 2/2] [BOT] post-merge updates --- README.md | 2 +- web_widget_one2many_tree_line_duplicate/README.rst | 2 +- web_widget_one2many_tree_line_duplicate/__manifest__.py | 2 +- .../static/description/index.html | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a7c028fbc218..da907d11626a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ addon | version | maintainers | summary [web_tree_many2one_clickable](web_tree_many2one_clickable/) | 19.0.1.0.0 | | Open the linked resource when clicking on their name [web_widget_bokeh_chart](web_widget_bokeh_chart/) | 19.0.1.0.1 | LoisRForgeFlow JasminSForgeFlow | This widget allows to display charts using Bokeh library. [web_widget_numeric_step](web_widget_numeric_step/) | 19.0.1.0.0 | rafaelbn yajo | Web Widget Numeric Step -[web_widget_one2many_tree_line_duplicate](web_widget_one2many_tree_line_duplicate/) | 19.0.1.0.0 | | Web Widget One2many Tree Line Duplicate +[web_widget_one2many_tree_line_duplicate](web_widget_one2many_tree_line_duplicate/) | 19.0.1.0.1 | | Web Widget One2many Tree Line Duplicate [web_widget_product_label_section_and_note_full_label](web_widget_product_label_section_and_note_full_label/) | 19.0.1.0.0 | | Display the full label in the product_label_section_and_note widget. [web_widget_product_label_section_and_note_name_visibility](web_widget_product_label_section_and_note_name_visibility/) | 19.0.1.0.0 | carlos-lopez-tecnativa | Alternate the visibility of the product and description. [web_widget_section_and_note_text_scrollable](web_widget_section_and_note_text_scrollable/) | 19.0.1.0.0 | ivantodorovich | Make the text field of Section and Note widget scrollable diff --git a/web_widget_one2many_tree_line_duplicate/README.rst b/web_widget_one2many_tree_line_duplicate/README.rst index 1bb825153970..3b8f5387efcb 100644 --- a/web_widget_one2many_tree_line_duplicate/README.rst +++ b/web_widget_one2many_tree_line_duplicate/README.rst @@ -11,7 +11,7 @@ Web Widget One2many Tree Line Duplicate !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:e5b95135142c797c5a7addebf73d134f9acbec555ff441d0c2baf718accd04f0 + !! source digest: sha256:3b891d75bd44e3d0168c0baf1a0dc436298293a61dc316fe13c5f60afb0b3ff4 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/web_widget_one2many_tree_line_duplicate/__manifest__.py b/web_widget_one2many_tree_line_duplicate/__manifest__.py index 7389a638daf3..0fc49b5824a2 100644 --- a/web_widget_one2many_tree_line_duplicate/__manifest__.py +++ b/web_widget_one2many_tree_line_duplicate/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Web Widget One2many Tree Line Duplicate", "category": "web", - "version": "19.0.1.0.0", + "version": "19.0.1.0.1", "author": "Tecnativa, Odoo Community Association (OCA)", "license": "AGPL-3", "website": "https://github.com/OCA/web", diff --git a/web_widget_one2many_tree_line_duplicate/static/description/index.html b/web_widget_one2many_tree_line_duplicate/static/description/index.html index ab0938d05c18..22afc18a8f93 100644 --- a/web_widget_one2many_tree_line_duplicate/static/description/index.html +++ b/web_widget_one2many_tree_line_duplicate/static/description/index.html @@ -372,7 +372,7 @@

Web Widget One2many Tree Line Duplicate

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:e5b95135142c797c5a7addebf73d134f9acbec555ff441d0c2baf718accd04f0 +!! source digest: sha256:3b891d75bd44e3d0168c0baf1a0dc436298293a61dc316fe13c5f60afb0b3ff4 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: AGPL-3 OCA/web Translate me on Weblate Try me on Runboat

Allow to add a icon to clone the line.