[FIX] web: record's images correctly cached by the browser
Before this commit, images' url where just built upon the record's id and model. Since the browser caches GET requests by url, the same record would always have the same image url. Hence it appeared that when changing a record's image in form view, and going back to the kanban, the image displayed was the old one. A good enough approximation to solve this (and was actually solved in legacy code), is to use the record's __last_update field, which basically tells the browser to re-fetch a record's image when then record go through any modification. After this commit, images are correctly updated in kanban and form views. closes odoo/odoo#97544 Signed-off-by: Lucas Perais (lpe) <lpe@odoo.com>
This commit is contained in:
@@ -8,7 +8,8 @@ import { registry } from "@web/core/registry";
|
||||
import { FileUploader } from "../file_handler";
|
||||
import { standardFieldProps } from "../standard_field_props";
|
||||
|
||||
const { Component, useState } = owl;
|
||||
const { Component, useState, onWillUpdateProps } = owl;
|
||||
const { DateTime } = luxon;
|
||||
|
||||
export const fileTypeMagicWordMap = {
|
||||
"/": "jpg",
|
||||
@@ -22,6 +23,21 @@ function isBinarySize(value) {
|
||||
return /^\d+(\.\d*)? [^0-9]+$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a value to be injected in the image's url in order for that url
|
||||
* to be correctly cached and discarded by the browser (the browser caches
|
||||
* fetch requests with the url as key).
|
||||
*
|
||||
* For records, a not-so-bad approximation is to compute that key on the basis
|
||||
* of the record's __last_update field.
|
||||
*/
|
||||
export function imageCacheKey(value) {
|
||||
if (value instanceof DateTime) {
|
||||
return value.ts;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export class ImageField extends Component {
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
@@ -29,6 +45,15 @@ export class ImageField extends Component {
|
||||
this.state = useState({
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
this.rawCacheKey = this.props.record.data.__last_update;
|
||||
onWillUpdateProps((nextProps) => {
|
||||
const { record } = this.props;
|
||||
const { record: nextRecord } = nextProps;
|
||||
if (record.resId !== nextRecord.resId || nextRecord.mode === "readonly") {
|
||||
this.rawCacheKey = nextRecord.data.__last_update;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get sizeStyle() {
|
||||
@@ -58,6 +83,7 @@ export class ImageField extends Component {
|
||||
model: this.props.record.resModel,
|
||||
id: this.props.record.resId,
|
||||
field: previewFieldName,
|
||||
unique: imageCacheKey(this.rawCacheKey),
|
||||
});
|
||||
} else {
|
||||
// Use magic-word technique for detecting image type
|
||||
@@ -103,6 +129,10 @@ ImageField.defaultProps = {
|
||||
ImageField.displayName = _lt("Image");
|
||||
ImageField.supportedTypes = ["binary"];
|
||||
|
||||
ImageField.fieldDependencies = {
|
||||
__last_update: { type: "datetime" },
|
||||
};
|
||||
|
||||
ImageField.extractProps = ({ attrs }) => {
|
||||
return {
|
||||
enableZoom: attrs.options.zoom,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useService } from "@web/core/utils/hooks";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
import { url } from "@web/core/utils/urls";
|
||||
import { Field } from "@web/views/fields/field";
|
||||
import { fileTypeMagicWordMap } from "@web/views/fields/image/image_field";
|
||||
import { fileTypeMagicWordMap, imageCacheKey } from "@web/views/fields/image/image_field";
|
||||
import { ViewButton } from "@web/views/view_button/view_button";
|
||||
import { useViewCompiler } from "@web/views/view_compiler";
|
||||
import { Widget } from "@web/views/widgets/widget";
|
||||
@@ -82,7 +82,12 @@ function getImageSrcFromRecordInfo(record, model, field, idOrIds, placeholder) {
|
||||
return placeholder;
|
||||
} else {
|
||||
// Else: fetches the image related to the given id.
|
||||
return url("/web/image", { model, field, id });
|
||||
return url("/web/image", {
|
||||
model,
|
||||
field,
|
||||
id,
|
||||
unique: imageCacheKey(record.data.__last_update),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { click, getFixture, nextTick, triggerEvent } from "@web/../tests/helpers/utils";
|
||||
import {
|
||||
click,
|
||||
getFixture,
|
||||
nextTick,
|
||||
triggerEvent,
|
||||
clickEdit,
|
||||
clickSave,
|
||||
editInput,
|
||||
} from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { pagerNext } from "@web/../tests/search/helpers";
|
||||
|
||||
const MY_IMAGE =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
|
||||
@@ -25,6 +34,7 @@ QUnit.module("Fields", (hooks) => {
|
||||
relation: "partner_type",
|
||||
searchable: true,
|
||||
},
|
||||
foo: { type: "char" },
|
||||
document: { string: "Binary", type: "binary" },
|
||||
},
|
||||
records: [
|
||||
@@ -82,7 +92,7 @@ QUnit.module("Fields", (hooks) => {
|
||||
if (route === "/web/dataset/call_kw/partner/read") {
|
||||
assert.deepEqual(
|
||||
args[1],
|
||||
["document", /*"__last_update",*/ "display_name"],
|
||||
["__last_update", "document", "display_name"],
|
||||
"The fields document, display_name and __last_update should be present when reading an image"
|
||||
);
|
||||
}
|
||||
@@ -315,6 +325,7 @@ QUnit.module("Fields", (hooks) => {
|
||||
"ImageField displays the right images with zoom and preview_image options",
|
||||
async function (assert) {
|
||||
serverData.models.partner.records[0].document = "3 kb";
|
||||
serverData.models.partner.records[0].__last_update = "2022-08-05 08:37:00";
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
@@ -330,7 +341,7 @@ QUnit.module("Fields", (hooks) => {
|
||||
assert.ok(
|
||||
JSON.parse(
|
||||
target.querySelector(".o_field_image img").dataset["tooltipInfo"]
|
||||
).url.endsWith("/web/image?model=partner&id=1&field=document"),
|
||||
).url.endsWith("/web/image?model=partner&id=1&field=document&unique=1659688620000"),
|
||||
"tooltip show the full image from the field value"
|
||||
);
|
||||
assert.strictEqual(
|
||||
@@ -341,7 +352,9 @@ QUnit.module("Fields", (hooks) => {
|
||||
assert.ok(
|
||||
target
|
||||
.querySelector(".o_field_image img")
|
||||
.dataset.src.endsWith("/web/image?model=partner&id=1&field=document_preview"),
|
||||
.dataset.src.endsWith(
|
||||
"/web/image?model=partner&id=1&field=document_preview&unique=1659688620000"
|
||||
),
|
||||
"image src is the preview image given in option"
|
||||
);
|
||||
}
|
||||
@@ -448,4 +461,93 @@ QUnit.module("Fields", (hooks) => {
|
||||
"image field should be displayed as invalid"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("unique in url doesn't change on onchange", async (assert) => {
|
||||
serverData.models.partner.onchanges = {
|
||||
foo: () => {},
|
||||
};
|
||||
|
||||
const rec = serverData.models.partner.records.find((rec) => rec.id === 1);
|
||||
rec.document = "3 kb";
|
||||
rec.__last_update = "2022-08-05 08:37:00";
|
||||
|
||||
await makeView({
|
||||
resId: 1,
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="foo" />
|
||||
<field name="document" widget="image" required="1" />
|
||||
</form>`,
|
||||
mockRPC(route, { method, args }) {
|
||||
assert.step(method);
|
||||
if (method === "onchange") {
|
||||
return {
|
||||
value: {
|
||||
__last_update: "", // actual return of the server
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === "write") {
|
||||
args[1].__last_update = "2022-08-05 09:37:00";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function getUnique(target) {
|
||||
const src = target.dataset.src;
|
||||
return new URL(src).searchParams.get("unique");
|
||||
}
|
||||
|
||||
assert.verifySteps(["get_views", "read"]);
|
||||
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659688620000");
|
||||
|
||||
await clickEdit(target);
|
||||
assert.verifySteps([]);
|
||||
// same unique as before
|
||||
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659688620000");
|
||||
|
||||
await editInput(target, ".o_field_widget[name='foo'] input", "grrr");
|
||||
assert.verifySteps(["onchange"]);
|
||||
// also same unique
|
||||
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659688620000");
|
||||
|
||||
await clickSave(target);
|
||||
assert.verifySteps(["write", "read"]);
|
||||
// different unique: the record has been written
|
||||
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659692220000");
|
||||
});
|
||||
|
||||
QUnit.test("unique in url change on record change", async (assert) => {
|
||||
const rec = serverData.models.partner.records.find((rec) => rec.id === 1);
|
||||
rec.document = "3 kb";
|
||||
rec.__last_update = "2022-08-05 08:37:00";
|
||||
|
||||
const rec2 = serverData.models.partner.records.find((rec) => rec.id === 2);
|
||||
rec2.document = "3 kb";
|
||||
rec2.__last_update = "2022-08-05 09:37:00";
|
||||
|
||||
await makeView({
|
||||
resIds: [1, 2],
|
||||
resId: 1,
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<field name="document" widget="image" required="1" />
|
||||
</form>`,
|
||||
});
|
||||
|
||||
function getUnique(target) {
|
||||
const src = target.dataset.src;
|
||||
return new URL(src).searchParams.get("unique");
|
||||
}
|
||||
|
||||
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659688620000");
|
||||
await pagerNext(target);
|
||||
assert.strictEqual(getUnique(target.querySelector(".o_field_image img")), "1659692220000");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8403,7 +8403,10 @@ QUnit.module("Views", (hooks) => {
|
||||
|
||||
QUnit.test("test displaying image (__last_update field)", async (assert) => {
|
||||
// the presence of __last_update field ensures that the image is reloaded when necessary
|
||||
assert.expect(1);
|
||||
assert.expect(2);
|
||||
|
||||
const rec = serverData.models.partner.records.find((r) => r.id === 1);
|
||||
rec.__last_update = "2022-08-05 08:37:00";
|
||||
|
||||
await makeView({
|
||||
type: "kanban",
|
||||
@@ -8421,7 +8424,17 @@ QUnit.module("Views", (hooks) => {
|
||||
assert.deepEqual(kwargs.fields, ["id", "__last_update"]);
|
||||
}
|
||||
},
|
||||
domain: [["id", "in", [1]]],
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
target
|
||||
.querySelector(".o_kanban_record:not(.o_kanban_ghost) img")
|
||||
.dataset.src.endsWith(
|
||||
"/web/image?model=partner&field=image&id=1&unique=1659688620000"
|
||||
),
|
||||
"image src is the preview image given in option"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("test displaying image (binary & placeholder)", async (assert) => {
|
||||
@@ -8521,12 +8534,12 @@ QUnit.module("Views", (hooks) => {
|
||||
});
|
||||
assert.containsOnce(
|
||||
target,
|
||||
'img[data-src*="/web/image"][data-src$="&id=1"]',
|
||||
'img[data-src*="/web/image"][data-src$="&id=1&unique="]',
|
||||
"image url should contain id of set partner_id"
|
||||
);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
'img[data-src*="/web/image"][data-src$="&id="]',
|
||||
'img[data-src*="/web/image"][data-src$="&id=&unique="]',
|
||||
"image url should contain an empty id if partner_id is not set"
|
||||
);
|
||||
});
|
||||
@@ -10897,14 +10910,16 @@ QUnit.module("Views", (hooks) => {
|
||||
type: "kanban",
|
||||
resModel: "partner",
|
||||
serverData,
|
||||
arch:
|
||||
"<kanban>" +
|
||||
'<templates><t t-name="kanban-box">' +
|
||||
"<div>" +
|
||||
'<field name="int_field"/>' +
|
||||
"</div>" +
|
||||
"</t></templates>" +
|
||||
"</kanban>",
|
||||
arch: `
|
||||
<kanban>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div>
|
||||
<field name="int_field"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
groupBy: ["product_id"],
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user