[FIX] web, *: record key is reactive in KanbanRecord

Before this commit, calls to record[fieldname][value/raw_value] in
the arch of a kanban view did not take into account updates on the Record
Datapoint. So if any data in the Record changed, the [raw_value/value]
was not modified.

In a custom KanbanRecord, you should never use the record containing the
value and raw_value. We will therefore replace this call by using the
datapoint record directly.

How to reproduce:
    - Have a kanban view with a record.my_field.value and a widget allowing
        you to modify the value of my_field.
    - Click on the widget

Before this commit:
    The value of my_field does not change

After this commit:
    The value of my_field has been updated.

closes odoo/odoo#131165

Related: odoo/enterprise#45413
Signed-off-by: Jorge Pinna Puissant (jpp) <jpp@odoo.com>
This commit is contained in:
FrancoisGe
2023-08-09 03:08:00 +02:00
parent e351d9fe48
commit c7524e2d8b
7 changed files with 89 additions and 26 deletions
@@ -3,7 +3,7 @@
<t t-name="im_livechat.KanbanRecordMenu" t-inherit="web.KanbanRecordMenu">
<xpath expr="//Dropdown" position="attributes">
<attribute name="position">'bottom-start'</attribute>
<attribute name="menuClass" add="` rounded-0 ${record.available_operator_ids.raw_value.length > 0 ? 'o-livechat-ChannelKanban-highlighted': ''}`" separator="+"/>
<attribute name="menuClass" add="` rounded-0 ${props.record.data.available_operator_ids.count > 0 ? 'o-livechat-ChannelKanban-highlighted': ''}`" separator="+"/>
<attribute name="togglerClass" add="' bg-transparent px-2'" separator="+"/>
</xpath>
</t>
@@ -21,7 +21,7 @@ export class ProductCatalogKanbanRecord extends KanbanRecord {
useSubEnv({
currencyId: this.props.record.context.product_catalog_currency_id,
orderId: this.props.record.context.product_catalog_order_id,
productId: this.record.id.raw_value,
productId: this.props.record.resId,
addProduct: this.addProduct.bind(this),
removeProduct: this.removeProduct.bind(this),
increaseQuantity: this.increaseQuantity.bind(this),
@@ -11,7 +11,7 @@
t-att-class="{'text-bg-primary': state.quantity || props.record.productCatalogData.readOnly}">
<t t-call="{{ templates[this.constructor.KANBAN_BOX_ATTRIBUTE] }}"
t-call-context="this.renderingContext"/>
<ProductCatalogSOL productId="record.id.raw_value"
<ProductCatalogSOL productId="props.record.resId"
quantity="state.quantity"
price="state.price"
readOnly="props.record.productCatalogData.readOnly"/>
@@ -503,19 +503,28 @@ export function useRecordObserver(callback) {
let alive = true;
const fct = (props) => {
const def = new Deferred();
let firstCall = true;
effect(
batched(
async (record) => {
if (!alive) {
// effect doesn't clean up when the component is unmounted.
// We must do it manually.
return;
}
async (record) => {
if (firstCall) {
firstCall = false;
await callback(record);
def.resolve();
},
() => new Promise((resolve) => window.requestAnimationFrame(resolve))
),
} else {
return batched(
async (record) => {
if (!alive) {
// effect doesn't clean up when the component is unmounted.
// We must do it manually.
return;
}
await callback(record);
def.resolve();
},
() => new Promise((resolve) => window.requestAnimationFrame(resolve))
)(record);
}
},
[props.record]
);
return def;
@@ -1,7 +1,7 @@
/** @odoo-module **/
import { evalDomain } from "@web/core/domain";
import { ColorList } from "@web/core/colorlist/colorlist";
import { evalDomain } from "@web/core/domain";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { registry } from "@web/core/registry";
@@ -9,6 +9,7 @@ import { useTooltip } from "@web/core/tooltip/tooltip_hook";
import { useService } from "@web/core/utils/hooks";
import { sprintf } from "@web/core/utils/strings";
import { url } from "@web/core/utils/urls";
import { useRecordObserver } from "@web/model/relational_model/utils";
import { Field } from "@web/views/fields/field";
import { fileTypeMagicWordMap, imageCacheKey } from "@web/views/fields/image/image_field";
import { ViewButton } from "@web/views/view_button/view_button";
@@ -24,7 +25,7 @@ import { KanbanCompiler } from "./kanban_compiler";
import { KanbanCoverImageDialog } from "./kanban_cover_image_dialog";
import { KanbanDropdownMenuWrapper } from "./kanban_dropdown_menu_wrapper";
import { Component, onMounted, onWillUpdateProps, useRef } from "@odoo/owl";
import { Component, onMounted, onWillUpdateProps, useRef, useState } from "@odoo/owl";
const { COLORS } = ColorList;
const formatters = registry.category("formatters");
@@ -106,7 +107,13 @@ function getValue(record, fieldName) {
}
export function getFormattedRecord(record) {
const formattedRecord = {};
const formattedRecord = {
id: {
value: record.resId,
raw_value: record.resId,
},
};
for (const fieldName of record.fieldNames) {
formattedRecord[fieldName] = {
value: getValue(record, fieldName),
@@ -195,13 +202,21 @@ export class KanbanRecord extends Component {
});
}
this.createRecordAndWidget(this.props);
this.dataState = useState({ record: {}, widget: {} });
this.createWidget(this.props);
onWillUpdateProps(this.createWidget);
useRecordObserver((record) =>
Object.assign(this.dataState.record, getFormattedRecord(record))
);
this.rootRef = useRef("root");
onMounted(() => {
// FIXME: this needs to be changed to an attribute on the root node...
this.allowGlobalClick = !!this.rootRef.el.querySelector(ALLOW_GLOBAL_CLICK);
});
onWillUpdateProps(this.createRecordAndWidget);
}
get record() {
return this.dataState.record;
}
getFormattedValue(fieldId) {
@@ -211,20 +226,18 @@ export class KanbanRecord extends Component {
}
/**
* Assigns both "record" and "widget" properties on the kanban record.
* Assigns "widget" properties on the kanban record.
*
* @param {Object} props
*/
createRecordAndWidget(props) {
createWidget(props) {
const { archInfo, list } = props;
const { activeActions } = archInfo;
this.record = getFormattedRecord(this.props.record);
// Widget
const deletable =
activeActions.delete && (!list.groupByField || list.groupByField.type !== "many2many");
const editable = activeActions.edit;
this.widget = {
this.dataState.widget = {
deletable,
editable,
isHtmlEmpty,
@@ -357,10 +370,10 @@ export class KanbanRecord extends Component {
kanban_image: (...args) => getImageSrcFromRecordInfo(this.props.record, ...args),
luxon,
read_only_mode: this.props.readonly,
record: this.record,
record: this.dataState.record,
selection_mode: this.props.forceGlobalClick,
user_context: this.user.context,
widget: this.widget,
widget: this.dataState.widget,
__comp__: Object.assign(Object.create(this), { this: this }),
};
}
@@ -125,7 +125,7 @@
</t>
<t t-name="web.KanbanColorPicker" owl="1">
<ul t-if="this.widget.editable" class="oe_kanban_colorpicker">
<ul t-if="this.dataState.widget.editable" class="oe_kanban_colorpicker">
<!--
Used in KanbanRecord
Note: `props` is only accessible through `this` as we call the compiled template with
@@ -9078,6 +9078,7 @@ QUnit.module("Views", (hooks) => {
QUnit.test("resequence a record twice", async (assert) => {
serverData.models.partner.records = [];
const def = makeDeferred();
await makeView({
type: "kanban",
resModel: "partner",
@@ -9092,6 +9093,7 @@ QUnit.module("Views", (hooks) => {
async mockRPC(route) {
if (route === "/web/dataset/resequence") {
assert.step("resequence");
await def;
}
},
});
@@ -9116,6 +9118,8 @@ QUnit.module("Views", (hooks) => {
);
await dragAndDrop(".o_kanban_record:nth-child(2)", ".o_kanban_record:nth-child(3)");
def.resolve();
await nextTick();
assert.containsN(target, ".o_kanban_group:first-child .o_kanban_record", 2);
assert.deepEqual(
@@ -9168,6 +9172,43 @@ QUnit.module("Views", (hooks) => {
assert.strictEqual(getCard(2).querySelector(".o_widget").innerText, '{"foo":"gnap"}');
});
QUnit.test("kanban card: record value should be update", async (assert) => {
class MyComponent extends Component {
static template = owl.xml`<div><button t-on-click="onClick">CLick</button></div>`;
onClick() {
this.props.record.update({ foo: "yolo" });
}
}
const myComponent = {
component: MyComponent,
};
viewWidgetRegistry.add("test", myComponent);
await makeView({
type: "kanban",
resModel: "partner",
serverData,
arch: `
<kanban>
<field name="foo"/>
<templates>
<t t-name="kanban-box">
<div>
<div class="foo" t-esc="record.foo.value"/>
<widget name="test"/>
</div>
</t>
</templates>
</kanban>`,
});
assert.strictEqual(getCard(0).querySelector(".foo").innerText, "yop");
await click(getCard(0).querySelector("button"));
await nextTick();
assert.strictEqual(getCard(0).querySelector(".foo").innerText, "yolo");
});
QUnit.test("column progressbars properly work", async (assert) => {
await makeView({
type: "kanban",