Odoo18-Base/addons/web/static/tests/views/kanban/kanban_view_legacy.test.js
2025-01-06 10:57:38 +07:00

2283 lines
73 KiB
JavaScript

import { after, beforeEach, expect, test } from "@odoo/hoot";
import {
click,
dblclick,
queryAll,
queryAllTexts,
queryFirst,
queryOne,
queryText,
setInputFiles,
} from "@odoo/hoot-dom";
import { FileInput } from "@web/core/file_input/file_input";
import { Deferred, animationFrame } from "@odoo/hoot-mock";
import { Component, onRendered, xml } from "@odoo/owl";
import {
contains,
defineModels,
fields,
getDropdownMenu,
getKanbanColumn,
getKanbanColumnDropdownMenu,
getKanbanRecord,
getKanbanRecordTexts,
getService,
mockService,
models,
mountView,
mountWithCleanup,
onRpc,
patchWithCleanup,
stepAllNetworkCalls,
toggleKanbanColumnActions,
toggleKanbanRecordDropdown,
validateSearch,
webModels,
} from "@web/../tests/web_test_helpers";
import { currencies } from "@web/core/currency";
import { registry } from "@web/core/registry";
import { user } from "@web/core/user";
import { getOrigin } from "@web/core/utils/urls";
import { KanbanCompiler } from "@web/views/kanban/kanban_compiler";
import { KanbanRecord } from "@web/views/kanban/kanban_record";
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
import { kanbanView } from "@web/views/kanban/kanban_view";
import { ViewButton } from "@web/views/view_button/view_button";
import { AnimatedNumber } from "@web/views/view_components/animated_number";
import { WebClient } from "@web/webclient/webclient";
const { IrAttachment } = webModels;
const fieldRegistry = registry.category("fields");
const viewRegistry = registry.category("views");
const viewWidgetRegistry = registry.category("view_widgets");
async function createFileInput({ mockPost, mockAdd, props }) {
mockService("notification", {
add: mockAdd || (() => {}),
});
mockService("http", {
post: mockPost || (() => {}),
});
await mountWithCleanup(FileInput, { props });
}
class Partner extends models.Model {
_name = "partner";
_rec_name = "foo";
foo = fields.Char();
bar = fields.Boolean();
sequence = fields.Integer();
int_field = fields.Integer({ aggregator: "sum", sortable: true });
float_field = fields.Float({ aggregator: "sum" });
product_id = fields.Many2one({ relation: "product" });
category_ids = fields.Many2many({ relation: "category" });
date = fields.Date();
datetime = fields.Datetime();
state = fields.Selection({
type: "selection",
selection: [
["abc", "ABC"],
["def", "DEF"],
["ghi", "GHI"],
],
});
salary = fields.Monetary({ aggregator: "sum", currency_field: this.currency_id });
currency_id = fields.Many2one({ relation: "res.currency" });
_records = [
{
id: 1,
foo: "yop",
bar: true,
int_field: 10,
float_field: 0.4,
product_id: 3,
category_ids: [],
state: "abc",
salary: 1750,
currency_id: 1,
},
{
id: 2,
foo: "blip",
bar: true,
int_field: 9,
float_field: 13,
product_id: 5,
category_ids: [6],
state: "def",
salary: 1500,
currency_id: 1,
},
{
id: 3,
foo: "gnap",
bar: true,
int_field: 17,
float_field: -3,
product_id: 3,
category_ids: [7],
state: "ghi",
salary: 2000,
currency_id: 2,
},
{
id: 4,
foo: "blip",
bar: false,
int_field: -4,
float_field: 9,
product_id: 5,
category_ids: [],
state: "ghi",
salary: 2222,
currency_id: 1,
},
];
}
class Product extends models.Model {
_name = "product";
name = fields.Char();
_records = [
{ id: 3, name: "hello" },
{ id: 5, name: "xmo" },
];
}
class Category extends models.Model {
_name = "category";
name = fields.Char();
color = fields.Integer();
_records = [
{ id: 6, name: "gold", color: 2 },
{ id: 7, name: "silver", color: 5 },
];
}
class Currency extends models.Model {
_name = "res.currency";
name = fields.Char();
symbol = fields.Char();
position = fields.Selection({
selection: [
["after", "A"],
["before", "B"],
],
});
_records = [
{ id: 1, name: "USD", symbol: "$", position: "before" },
{ id: 2, name: "EUR", symbol: "€", position: "after" },
];
}
defineModels([Partner, Product, Category, Currency, IrAttachment]);
beforeEach(() => {
patchWithCleanup(AnimatedNumber, { enableAnimations: false });
// avoid "kanban-box" deprecation warnings in this suite, which defines legacy kanban on purpose
const originalConsoleWarn = console.warn;
patchWithCleanup(console, {
warn: (msg) => {
if (msg !== "'kanban-box' is deprecated, define a 'card' template instead") {
originalConsoleWarn(msg);
}
},
});
});
test("display full is supported on fields", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban class="o_kanban_test">
<templates>
<t t-name="kanban-box">
<field name="foo" display="full"/>
</t>
</templates>
</kanban>`,
});
expect(".o_kanban_record span.o_text_block").toHaveCount(4);
expect("span.o_text_block:first").toHaveText("yop");
});
test.tags("desktop");
test("basic grouped rendering", async () => {
expect.assertions(16);
patchWithCleanup(KanbanRenderer.prototype, {
setup() {
super.setup(...arguments);
onRendered(() => {
expect.step("rendered");
});
},
});
onRpc("web_read_group", ({ kwargs }) => {
// the lazy option is important, so the server can fill in the empty groups
expect(kwargs.lazy).toBe(true, { message: "should use lazy read_group" });
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban class="o_kanban_test">
<field name="bar" />
<templates>
<t t-name="kanban-box">
<div>
<field name="foo" />
</div>
</t>
</templates>
</kanban>`,
groupBy: ["bar"],
});
expect(".o_kanban_view").toHaveClass("o_kanban_test");
expect(".o_kanban_renderer").toHaveClass("o_kanban_grouped");
expect(".o_control_panel_main_buttons button.o-kanban-button-new").toHaveCount(1);
expect(".o_kanban_group").toHaveCount(2);
expect(".o_kanban_group:first-child .o_kanban_record").toHaveCount(1);
expect(".o_kanban_group:nth-child(2) .o_kanban_record").toHaveCount(3);
expect.verifySteps(["rendered"]);
await toggleKanbanColumnActions(0);
// check available actions in kanban header's config dropdown
expect(".o-dropdown--menu .o_kanban_toggle_fold").toHaveCount(1);
expect(".o_kanban_header:first-child .o_kanban_config .o_column_edit").toHaveCount(0);
expect(".o_kanban_header:first-child .o_kanban_config .o_column_delete").toHaveCount(0);
expect(".o_kanban_header:first-child .o_kanban_config .o_column_archive_records").toHaveCount(
0
);
expect(".o_kanban_header:first-child .o_kanban_config .o_column_unarchive_records").toHaveCount(
0
);
// focuses the search bar and closes the dropdown
await click(".o_searchview input");
// the next line makes sure that reload works properly. It looks useless,
// but it actually test that a grouped local record can be reloaded without
// changing its result.
await validateSearch();
expect(".o_kanban_group:nth-child(2) .o_kanban_record").toHaveCount(3);
expect.verifySteps(["rendered"]);
});
test("basic grouped rendering with no record", async () => {
Partner._records = [];
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban class="o_kanban_test">
<field name="bar" />
<templates>
<t t-name="kanban-box">
<div>
<field name="foo" />
</div>
</t>
</templates>
</kanban>`,
groupBy: ["bar"],
});
expect(".o_kanban_grouped").toHaveCount(1);
expect(".o_view_nocontent").toHaveCount(1);
expect(".o_control_panel_main_buttons button.o-kanban-button-new").toHaveCount(1, {
message:
"There should be a 'New' button even though there is no column when groupby is not a many2one",
});
});
test("grouped rendering with active field (archivable by default)", async () => {
// add active field on partner model and make all records active
Partner._fields.active = fields.Boolean({ default: true });
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="active"/>
<field name="bar"/>
<templates>
<t t-name="kanban-box">
<div>
<field name="foo"/>
</div>
</t>
</templates>
</kanban>`,
groupBy: ["bar"],
});
const clickColumnAction = await toggleKanbanColumnActions(1);
// check archive/restore all actions in kanban header's config dropdown
expect(".o_column_archive_records").toHaveCount(1, { root: getKanbanColumnDropdownMenu(0) });
expect(".o_column_unarchive_records").toHaveCount(1, { root: getKanbanColumnDropdownMenu(0) });
expect(".o_kanban_group").toHaveCount(2);
expect(queryAll(".o_kanban_record", { root: getKanbanColumn(0) })).toHaveCount(1);
expect(queryAll(".o_kanban_record", { root: getKanbanColumn(1) })).toHaveCount(3);
await clickColumnAction("Archive All");
expect(".o_dialog").toHaveCount(1);
await contains(".o_dialog footer .btn-primary").click();
expect(".o_kanban_group").toHaveCount(2);
expect(queryAll(".o_kanban_record", { root: getKanbanColumn(0) })).toHaveCount(1);
expect(queryAll(".o_kanban_record", { root: getKanbanColumn(1) })).toHaveCount(0);
});
test("context can be used in kanban template", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<t t-if="context.some_key">
<field name="foo"/>
</t>
</div>
</t>
</templates>
</kanban>`,
context: { some_key: 1 },
domain: [["id", "=", 1]],
});
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1);
expect(".o_kanban_record span:contains(yop)").toHaveCount(1);
});
test("user context can be used in kanban template", async () => {
patchWithCleanup(user, { context: { some_key: true } });
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<div t-name="kanban-box">
<field t-if="user_context.some_key" name="foo"/>
</div>
</templates>
</kanban>`,
domain: [["id", "=", 1]],
});
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1);
expect(".o_kanban_record span:contains(yop)").toHaveCount(1);
});
test("kanban with sub-template", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<t t-call="another-template"/>
</div>
</t>
<t t-name="another-template">
<span><field name="foo"/></span>
</t>
</templates>
</kanban>`,
});
expect(queryAllTexts(".o_kanban_record:not(.o_kanban_ghost)")).toEqual([
"yop",
"blip",
"gnap",
"blip",
]);
});
test("kanban with t-set outside card", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="int_field"/>
<templates>
<t t-name="kanban-box">
<t t-set="x" t-value="record.int_field.value"/>
<div>
<t t-esc="x"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(queryAllTexts(".o_kanban_record:not(.o_kanban_ghost)")).toEqual(["10", "9", "17", "-4"]);
});
test("kanban with t-if/t-else on field", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field t-if="record.int_field.value > -1" name="int_field"/>
<t t-else="">Negative value</t>
</div>
</t>
</templates>
</kanban>`,
});
expect(queryAllTexts(".o_kanban_record:not(.o_kanban_ghost)")).toEqual([
"10",
"9",
"17",
"Negative value",
]);
});
test("kanban with t-if/t-else on field with widget", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field t-if="record.int_field.value > -1" name="int_field" widget="integer"/>
<t t-else="">Negative value</t>
</div>
</t>
</templates>
</kanban>`,
});
expect(queryAllTexts(".o_kanban_record:not(.o_kanban_ghost)")).toEqual([
"10",
"9",
"17",
"Negative value",
]);
});
test("field with widget and dynamic attributes in kanban", async () => {
const myField = {
component: class MyField extends Component {
static template = xml`<span/>`;
static props = ["*"];
},
extractProps: ({ attrs }) => {
expect.step(
`${attrs["dyn-bool"]}/${attrs["interp-str"]}/${attrs["interp-str2"]}/${attrs["interp-str3"]}`
);
},
};
fieldRegistry.add("my_field", myField);
after(() => fieldRegistry.remove("my_field"));
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="foo"/>
<templates>
<t t-name="kanban-box">
<div>
<field name="int_field" widget="my_field"
t-att-dyn-bool="record.foo.value.length > 3"
t-attf-interp-str="hello {{record.foo.value}}"
t-attf-interp-str2="hello #{record.foo.value} !"
t-attf-interp-str3="hello {{record.foo.value}} }}"
/>
</div>
</t>
</templates>
</kanban>`,
});
expect.verifySteps([
"false/hello yop/hello yop !/hello yop }}",
"true/hello blip/hello blip !/hello blip }}",
"true/hello gnap/hello gnap !/hello gnap }}",
"true/hello blip/hello blip !/hello blip }}",
]);
});
test("view button and string interpolated attribute in kanban", async () => {
patchWithCleanup(ViewButton.prototype, {
setup() {
super.setup();
expect.step(`[${this.props.clickParams["name"]}] className: '${this.props.className}'`);
},
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="foo"/>
<templates>
<t t-name="kanban-box">
<div>
<a name="one" type="object" class="hola"/>
<a name="two" type="object" class="hola" t-attf-class="hello"/>
<a name="sri" type="object" class="hola" t-attf-class="{{record.foo.value}}"/>
<a name="foa" type="object" class="hola" t-attf-class="{{record.foo.value}} olleh"/>
<a name="fye" type="object" class="hola" t-attf-class="hello {{record.foo.value}}"/>
</div>
</t>
</templates>
</kanban>`,
});
expect.verifySteps([
"[one] className: 'hola oe_kanban_action'",
"[two] className: 'hola oe_kanban_action hello'",
"[sri] className: 'hola oe_kanban_action yop'",
"[foa] className: 'hola oe_kanban_action yop olleh'",
"[fye] className: 'hola oe_kanban_action hello yop'",
"[one] className: 'hola oe_kanban_action'",
"[two] className: 'hola oe_kanban_action hello'",
"[sri] className: 'hola oe_kanban_action blip'",
"[foa] className: 'hola oe_kanban_action blip olleh'",
"[fye] className: 'hola oe_kanban_action hello blip'",
"[one] className: 'hola oe_kanban_action'",
"[two] className: 'hola oe_kanban_action hello'",
"[sri] className: 'hola oe_kanban_action gnap'",
"[foa] className: 'hola oe_kanban_action gnap olleh'",
"[fye] className: 'hola oe_kanban_action hello gnap'",
"[one] className: 'hola oe_kanban_action'",
"[two] className: 'hola oe_kanban_action hello'",
"[sri] className: 'hola oe_kanban_action blip'",
"[foa] className: 'hola oe_kanban_action blip olleh'",
"[fye] className: 'hola oe_kanban_action hello blip'",
]);
});
test("click on a button type='delete' to delete a record in a column", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban limit="3">
<templates>
<t t-name="kanban-box">
<div>
<div><a role="menuitem" type="delete" class="dropdown-item o_delete">Delete</a></div>
<field name="foo"/>
</div>
</t>
</templates>
</kanban>`,
groupBy: ["product_id"],
});
expect(queryAll(".o_kanban_record", { root: getKanbanColumn(0) })).toHaveCount(2);
expect(queryAll(".o_kanban_load_more", { root: getKanbanColumn(0) })).toHaveCount(0);
await click(queryFirst(".o_kanban_record .o_delete", { root: getKanbanColumn(0) }));
await animationFrame();
expect(".modal").toHaveCount(1);
await contains(".modal .btn-primary").click();
expect(queryAll(".o_kanban_record", { root: getKanbanColumn(0) })).toHaveCount(1);
expect(queryAll(".o_kanban_load_more", { root: getKanbanColumn(0) })).toHaveCount(0);
});
test("click on a button type='archive' to archive a record in a column", async () => {
onRpc("action_archive", ({ args }) => {
expect.step(`archive:${args[0]}`);
return true;
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban limit="3">
<templates>
<t t-name="kanban-box">
<div>
<div><a role="menuitem" type="archive" class="dropdown-item o_archive">archive</a></div>
<field name="foo"/>
</div>
</t>
</templates>
</kanban>`,
groupBy: ["product_id"],
});
expect(queryAll(".o_kanban_record", { root: getKanbanColumn(0) })).toHaveCount(2);
await contains(".o_kanban_record .o_archive").click();
expect(".modal").toHaveCount(1);
expect.verifySteps([]);
await contains(".modal .btn-primary").click();
expect.verifySteps(["archive:1"]);
});
test("click on a button type='unarchive' to unarchive a record in a column", async () => {
onRpc("action_unarchive", ({ args }) => {
expect.step(`unarchive:${args[0]}`);
return true;
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban limit="3">
<templates>
<t t-name="kanban-box">
<div>
<div><a role="menuitem" type="unarchive" class="dropdown-item o_unarchive">unarchive</a></div>
<field name="foo"/>
</div>
</t>
</templates>
</kanban>`,
groupBy: ["product_id"],
});
expect(queryAll(".o_kanban_record", { root: getKanbanColumn(0) })).toHaveCount(2);
await contains(".o_kanban_record .o_unarchive").click();
expect.verifySteps(["unarchive:1"]);
});
test("many2many_tags in kanban views", async () => {
Partner._records[0].category_ids = [6, 7];
Partner._records[1].category_ids = [7, 8];
Category._records.push({
id: 8,
name: "hello",
color: 0,
});
stepAllNetworkCalls();
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="category_ids" widget="many2many_tags" options="{'color_field': 'color'}"/>
<field name="foo"/>
<field name="state" widget="priority"/>
</div>
</t>
</templates>
</kanban>`,
selectRecord: (resId) => {
expect(resId).toBe(1, {
message: "should trigger an event to open the clicked record in a form view",
});
},
});
expect(
queryAll(".o_field_many2many_tags .o_tag", { root: getKanbanRecord({ index: 0 }) })
).toHaveCount(2, {
message: "first record should contain 2 tags",
});
expect(queryAll(".o_tag.o_tag_color_2", { root: getKanbanRecord({ index: 0 }) })).toHaveCount(
1,
{
message: "first tag should have color 2",
}
);
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"get_views",
"web_search_read",
]);
// Checks that second records has only one tag as one should be hidden (color 0)
expect(".o_kanban_record:nth-child(2) .o_tag").toHaveCount(1, {
message: "there should be only one tag in second record",
});
expect(".o_kanban_record:nth-child(2) .o_tag:first").toHaveText("silver");
// Write on the record using the priority widget to trigger a re-render in readonly
await contains(".o_kanban_record:first-child .o_priority_star:first-child").click();
expect.verifySteps(["web_save"]);
expect(".o_kanban_record:first-child .o_field_many2many_tags .o_tag").toHaveCount(2, {
message: "first record should still contain only 2 tags",
});
expect(".o_kanban_record:first-child .o_tag:eq(0)").toHaveText("gold");
expect(".o_kanban_record:first-child .o_tag:eq(1)").toHaveText("silver");
// click on a tag (should trigger switch_view)
await contains(".o_kanban_record:first-child .o_tag:first-child").click();
});
test("priority field should not be editable when missing access rights", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban edit="0">
<templates>
<t t-name="kanban-box">
<div>
<field name="foo"/>
<field name="state" widget="priority"/>
</div>
</t>
</templates>
</kanban>`,
});
// Try to fill one star in the priority field of the first record
await contains(".o_kanban_record:first-child .o_priority_star:first-child").click();
expect(".o_kanban_record:first-child .o_priority .fa-star-o").toHaveCount(2, {
message: "first record should still contain 2 empty stars",
});
});
test("Do not open record when clicking on `a` with `href`", async () => {
expect.assertions(6);
Partner._records = [{ id: 1, foo: "yop" }];
mockService("action", {
async switchView() {
// when clicking on a record in kanban view,
// it switches to form view.
expect.step("switchView");
},
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="foo"/>
<div>
<a class="o_test_link" href="#">test link</a>
</div>
</div>
</t>
</templates>
</kanban>`,
});
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1);
expect(".o_kanban_record a").toHaveCount(1);
const testLink = queryFirst(".o_kanban_record a");
expect(!!testLink.href).toBe(true, {
message: "link inside kanban record should have non-empty href",
});
// Prevent the browser default behaviour when clicking on anything.
// This includes clicking on a `<a>` with `href`, so that it does not
// change the URL in the address bar.
// Note that we should not specify a click listener on 'a', otherwise
// it may influence the kanban record global click handler to not open
// the record.
testLink.addEventListener("click", (ev) => {
expect(ev.defaultPrevented).toBe(false, {
message: "should not prevented browser default behaviour beforehand",
});
expect(ev.target).toBe(testLink, {
message: "should have clicked on the test link in the kanban record",
});
ev.preventDefault();
});
await click(testLink);
expect.verifySteps([]);
});
test("Open record when clicking on widget field", async function (assert) {
expect.assertions(2);
Product._views["form,false"] = `<form string="Product"><field name="display_name"/></form>`;
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="salary" widget="monetary"/>
</div>
</t>
</templates>
</kanban>`,
selectRecord: (resId) => {
expect(resId).toBe(1, { message: "should trigger an event to open the form view" });
},
});
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(4);
await click(queryFirst(".o_field_monetary[name=salary]"));
});
test("clicking on a link triggers correct event", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div><a type="edit">Edit</a></div>
</t>
</templates>
</kanban>`,
selectRecord: (resId, { mode }) => {
expect(resId).toBe(1);
expect(mode).toBe("edit");
},
});
await contains("a", { root: getKanbanRecord({ index: 0 }) }).click();
});
test("empty grouped kanban with sample data and many2many_tags", async () => {
onRpc("web_read_group", function ({ kwargs, parent }) {
const result = parent();
// override read_group to return empty groups, as this is
// the case for several models (e.g. project.task grouped
// by stage_id)
result.groups.forEach((group) => {
group[`${kwargs.groupby[0]}_count`] = 0;
});
return result;
});
stepAllNetworkCalls();
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban sample="1">
<field name="product_id"/>
<templates>
<t t-name="kanban-box">
<div>
<field name="int_field"/>
<field name="category_ids" widget="many2many_tags"/>
</div>
</t>
</templates>
</kanban>`,
groupBy: ["product_id"],
});
expect(".o_kanban_group").toHaveCount(2, { message: "there should be 2 'real' columns" });
expect(queryFirst(".o_content")).toHaveClass("o_view_sample_data");
expect(queryAll(".o_kanban_record").length >= 1).toBe(true, {
message: "there should be sample records",
});
expect(queryAll(".o_field_many2many_tags .o_tag").length >= 1).toBe(true, {
message: "there should be tags",
});
// should not read the tags
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"get_views",
"web_read_group",
]);
});
test("buttons with modifiers", async () => {
Partner._records[1].bar = false; // so that test is more complete
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="foo"/>
<field name="bar"/>
<field name="state"/>
<templates>
<div t-name="kanban-box">
<button class="o_btn_test_1" type="object" name="a1" invisible="foo != 'yop'"/>
<button class="o_btn_test_2" type="object" name="a2" invisible="bar and state not in ['abc', 'def']"/>
</div>
</templates>
</kanban>`,
});
expect(".o_btn_test_1").toHaveCount(1, { message: "kanban should have one buttons of type 1" });
expect(".o_btn_test_2").toHaveCount(3, {
message: "kanban should have three buttons of type 2",
});
});
test("support styling of anchor tags with action type", async function (assert) {
expect.assertions(3);
mockService("action", {
doActionButton(action) {
expect(action.name).toBe("42");
},
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<div t-name="kanban-box">
<field name="foo"/>
<a type="action" name="42" class="btn-primary" style="margin-left: 10px"><i class="oi oi-arrow-right"/> Click me !</a>
</div>
</templates>
</kanban>`,
});
await click(queryFirst("a[type='action']"));
expect(queryFirst("a[type='action']")).toHaveClass("btn-primary");
expect(queryFirst("a[type='action']").style.marginLeft).toBe("10px");
});
test("button executes action and reloads", async () => {
stepAllNetworkCalls();
let count = 0;
mockService("action", {
async doActionButton({ onClose }) {
count++;
await animationFrame();
onClose();
},
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<div t-name="kanban-box">
<field name="foo"/>
<button type="object" name="a1" class="a1">
A1
</button>
</div>
</templates>
</kanban>`,
});
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"get_views",
"web_search_read",
]);
expect("button.a1").toHaveCount(4);
expect("button.a1:first").not.toHaveAttribute("disabled");
await click("button.a1");
expect("button.a1:first").toHaveAttribute("disabled");
await animationFrame();
expect("button.a1:first").not.toHaveAttribute("disabled");
expect(count).toBe(1, { message: "should have triggered an execute action only once" });
// the records should be reloaded after executing a button action
expect.verifySteps(["web_search_read"]);
});
test("button executes action and check domain", async () => {
Partner._fields.active = fields.Boolean({ default: true });
for (let i = 0; i < Partner.length; i++) {
Partner._records[i].active = true;
}
mockService("action", {
doActionButton({ onClose }) {
Partner._records[0].active = false;
onClose();
},
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="active"/>
<templates>
<div t-name="kanban-box">
<field name="foo"/>
<button type="object" name="a1" />
<button type="object" name="toggle_active" class="toggle-active" />
</div>
</templates>
</kanban>`,
});
expect(queryText("span", { root: getKanbanRecord({ index: 0 }) })).toBe("yop", {
message: "should display 'yop' record",
});
await contains("button.toggle-active", { root: getKanbanRecord({ index: 0 }) }).click();
expect(queryText("span", { root: getKanbanRecord({ index: 0 }) })).not.toBe("yop", {
message: "should have removed 'yop' record from the view",
});
});
test("field tag with modifiers but no widget", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="foo" invisible="id == 1"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(".o_kanban_record:first").toHaveText("");
expect(".o_kanban_record:eq(1)").toHaveText("blip");
});
test("field tag with widget and class attributes", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="foo" widget="char" class="hi"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(".o_field_widget.hi").toHaveCount(4);
});
test("rendering date and datetime (value)", async () => {
Partner._records[0].date = "2017-01-25";
Partner._records[1].datetime = "2016-12-12 10:55:05";
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="date"/>
<field name="datetime"/>
<templates>
<t t-name="kanban-box">
<div>
<span class="date" t-esc="record.date.value"/>
<span class="datetime" t-esc="record.datetime.value"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(getKanbanRecord({ index: 0 }).querySelector(".date")).toHaveText("01/25/2017");
expect(getKanbanRecord({ index: 1 }).querySelector(".datetime")).toHaveText(
"12/12/2016 11:55:05"
);
});
test("rendering date and datetime (raw value)", async () => {
Partner._records[0].date = "2017-01-25";
Partner._records[1].datetime = "2016-12-12 10:55:05";
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="date"/>
<field name="datetime"/>
<templates>
<t t-name="kanban-box">
<div>
<span class="date" t-esc="record.date.raw_value"/>
<span class="datetime" t-esc="record.datetime.raw_value"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(getKanbanRecord({ index: 0 }).querySelector(".date")).toHaveText(
"2017-01-25T00:00:00.000+01:00"
);
expect(getKanbanRecord({ index: 1 }).querySelector(".datetime")).toHaveText(
"2016-12-12T11:55:05.000+01:00"
);
});
test("rendering many2one (value)", async () => {
Partner._records[1].product_id = false;
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="product_id"/>
<templates>
<t t-name="kanban-box">
<div>
<span class="product_id" t-esc="record.product_id.value"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(getKanbanRecordTexts()).toEqual(["hello", "", "hello", "xmo"]);
});
test("rendering many2one (raw value)", async () => {
Partner._records[1].product_id = false;
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="product_id"/>
<templates>
<t t-name="kanban-box">
<div>
<span class="product_id" t-esc="record.product_id.raw_value"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(getKanbanRecordTexts()).toEqual(["3", "false", "3", "5"]);
});
test("evaluate conditions on relational fields", async () => {
Partner._records[0].product_id = false;
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="product_id"/>
<field name="category_ids"/>
<templates>
<t t-name="kanban-box">
<div>
<button t-if="!record.product_id.raw_value" class="btn_a">A</button>
<button t-if="!record.category_ids.raw_value.length" class="btn_b">B</button>
</div>
</t>
</templates>
</kanban>`,
});
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(4, {
message: "there should be 4 records",
});
expect(".o_kanban_record:not(.o_kanban_ghost) .btn_a").toHaveCount(1, {
message: "only 1 of them should have the 'Action' button",
});
expect(".o_kanban_record:not(.o_kanban_ghost) .btn_b").toHaveCount(2, {
message: "only 2 of them should have the 'Action' button",
});
});
test("properly evaluate more complex domains", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="foo"/>
<field name="bar"/>
<field name="category_ids"/>
<templates>
<t t-name="kanban-box">
<div>
<field name="foo"/>
<button type="object" invisible="bar or category_ids" class="btn btn-primary float-end" name="arbitrary">Join</button>
</div>
</t>
</templates>
</kanban>`,
});
expect("button.float-end.oe_kanban_action").toHaveCount(1, {
message: "only one button should be visible",
});
});
test("basic support for widgets (being Owl Components)", async () => {
class MyComponent extends Component {
static template = xml`<div t-att-class="props.class" t-esc="value"/>`;
static props = ["*"];
get value() {
return JSON.stringify(this.props.record.data);
}
}
const myComponent = {
component: MyComponent,
};
viewWidgetRegistry.add("test", myComponent);
after(() => viewWidgetRegistry.remove("test"));
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="foo"/>
<templates>
<t t-name="kanban-box">
<div>
<t t-esc="record.foo.value"/>
<widget name="test"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(getKanbanRecord({ index: 2 }).querySelector(".o_widget")).toHaveText('{"foo":"gnap"}');
});
test("kanban card: record value should be updated", async () => {
class MyComponent extends Component {
static template = xml`<div><button t-on-click="onClick">CLick</button></div>`;
static props = ["*"];
onClick() {
this.props.record.update({ foo: "yolo" });
}
}
const myComponent = {
component: MyComponent,
};
viewWidgetRegistry.add("test", myComponent);
after(() => viewWidgetRegistry.remove("test"));
await mountView({
type: "kanban",
resModel: "partner",
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>`,
});
expect(queryText(".foo", { root: getKanbanRecord({ index: 0 }) })).toBe("yop");
await click(queryOne("button", { root: getKanbanRecord({ index: 0 }) }));
await animationFrame();
await animationFrame();
expect(queryText(".foo", { root: getKanbanRecord({ index: 0 }) })).toBe("yolo");
});
test("test displaying image (URL, image field not set)", async () => {
patchWithCleanup(KanbanCompiler.prototype, {
compileImage(el) {
el.setAttribute("t-att-data-src", el.getAttribute("t-att-src"));
el.removeAttribute("t-att-src");
return super.compileImage(el);
},
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="id"/>
<templates>
<t t-name="kanban-box">
<div>
<img t-att-src="kanban_image('partner', 'image', record.id.raw_value)"/>
</div>
</t>
</templates>
</kanban>`,
});
// since the field image is not set, kanban_image will generate an URL
expect(queryAll(`.o_kanban_record img`).map((img) => img.dataset.src.split("?")[0])).toEqual([
`${getOrigin()}/web/image/partner/1/image`,
`${getOrigin()}/web/image/partner/2/image`,
`${getOrigin()}/web/image/partner/3/image`,
`${getOrigin()}/web/image/partner/4/image`,
]);
expect(queryFirst(".o_kanban_record img").loading).toBe("lazy");
});
test("test displaying image (write_date field)", async () => {
// the presence of write_date field ensures that the image is reloaded when necessary
expect.assertions(2);
patchWithCleanup(KanbanCompiler.prototype, {
compileImage(el) {
el.setAttribute("t-att-data-src", el.getAttribute("t-att-src"));
el.removeAttribute("t-att-src");
return super.compileImage(el);
},
});
const rec = Partner._records.find((r) => r.id === 1);
rec.write_date = "2022-08-05 08:37:00";
onRpc("web_search_read", ({ kwargs }) => {
expect(kwargs.specification).toEqual({ id: {}, write_date: {} });
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="id"/>
<templates>
<t t-name="kanban-box"><div>
<img t-att-src="kanban_image('partner', 'image', record.id.raw_value)"/>
</div></t>
</templates>
</kanban>`,
domain: [["id", "in", [1]]],
});
expect(
`.o_kanban_record img[data-src='${getOrigin()}/web/image/partner/1/image?unique=1659688620000']`
).toHaveCount(1);
});
test("test displaying image (binary & placeholder)", async () => {
patchWithCleanup(KanbanCompiler.prototype, {
compileImage(el) {
el.setAttribute("t-att-data-src", el.getAttribute("t-att-src"));
el.removeAttribute("t-att-src");
return super.compileImage(el);
},
});
Partner._fields.image = fields.Binary();
Partner._records[0].image = "R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==";
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="id"/>
<field name="image"/>
<templates>
<t t-name="kanban-box">
<div>
<img t-att-src="kanban_image('partner', 'image', record.id.raw_value)"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(queryAll(`.o_kanban_record img`).map((img) => img.dataset.src.split("?")[0])).toEqual([
"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==",
`${getOrigin()}/web/image/partner/2/image`,
`${getOrigin()}/web/image/partner/3/image`,
`${getOrigin()}/web/image/partner/4/image`,
]);
});
test("test displaying image (for another record)", async () => {
patchWithCleanup(KanbanCompiler.prototype, {
compileImage(el) {
el.setAttribute("t-att-data-src", el.getAttribute("t-att-src"));
el.removeAttribute("t-att-src");
return super.compileImage(el);
},
});
Partner._fields.image = fields.Binary();
Partner._records[0].image = "R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==";
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="id"/>
<field name="image"/>
<templates>
<t t-name="kanban-box">
<div>
<img t-att-src="kanban_image('partner', 'image', 1)"/>
</div>
</t>
</templates>
</kanban>`,
});
// the field image is set, but we request the image for a specific id
// -> for the record matching the ID, the base64 should be returned
// -> for all the other records, the image should be displayed by url
expect(queryAll(`.o_kanban_record img`).map((img) => img.dataset.src.split("?")[0])).toEqual([
"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==",
`${getOrigin()}/web/image/partner/1/image`,
`${getOrigin()}/web/image/partner/1/image`,
`${getOrigin()}/web/image/partner/1/image`,
]);
});
test("test displaying image from m2o field (m2o field not set)", async () => {
patchWithCleanup(KanbanCompiler.prototype, {
compileImage(el) {
el.setAttribute("t-att-data-src", el.getAttribute("t-att-src"));
el.removeAttribute("t-att-src");
return super.compileImage(el);
},
});
class FooPartner extends models.Model {
_name = "foo.partner";
name = fields.Char();
partner_id = fields.Many2one({ relation: "partner" });
_records = [
{ id: 1, name: "foo_with_partner_image", partner_id: 1 },
{ id: 2, name: "foo_no_partner" },
];
}
defineModels([FooPartner]);
await mountView({
type: "kanban",
resModel: "foo.partner",
arch: `
<kanban>
<templates>
<div t-name="kanban-box">
<field name="name"/>
<field name="partner_id"/>
<img t-att-src="kanban_image('partner', 'image', record.partner_id.raw_value)"/>
</div>
</templates>
</kanban>`,
});
expect(queryAll(`.o_kanban_record img`).map((img) => img.dataset.src.split("?")[0])).toEqual([
`${getOrigin()}/web/image/partner/1/image`,
`${getOrigin()}/web/image/partner/null/image`,
]);
});
test.tags("desktop");
test("set cover image", async () => {
expect.assertions(9);
IrAttachment._records = [
{
id: 1,
name: "1.png",
mimetype: "image/png",
res_model: "partner",
res_id: 1,
},
{
id: 2,
name: "2.png",
mimetype: "image/png",
res_model: "partner",
res_id: 2,
},
];
Partner._fields.displayed_image_id = fields.Many2one({
string: "Cover",
relation: "ir.attachment",
});
onRpc(({ model, method, args }) => {
if (model === "partner" && method === "web_save") {
expect.step(String(args[0][0]));
}
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-menu">
<a type="set_cover" data-field="displayed_image_id" class="dropdown-item">Set Cover Image</a>
</t>
<t t-name="kanban-box">
<div>
<field name="foo"/>
<div>
<field name="displayed_image_id" widget="attachment_image"/>
</div>
</div>
</t>
</templates>
</kanban>`,
});
mockService("action", {
switchView(_viewType, { mode, resModel, res_id, view_type }) {
expect({ mode, resModel, res_id, view_type }).toBe({
mode: "readonly",
resModel: "partner",
res_id: 1,
view_type: "form",
});
},
});
await toggleKanbanRecordDropdown(0);
await contains(".oe_kanban_action", {
root: getDropdownMenu(getKanbanRecord({ index: 0 })),
}).click();
expect(queryAll("img", { root: getKanbanRecord({ index: 0 }) })).toHaveCount(0, {
message: "Initially there is no image.",
});
await contains(".modal .o_kanban_cover_image img").click();
await contains(".modal .btn-primary:first-child").click();
expect('img[data-src*="/web/image/1"]').toHaveCount(1);
await toggleKanbanRecordDropdown(1);
const coverButton = getDropdownMenu(getKanbanRecord({ index: 1 })).querySelector("a");
expect(queryText(coverButton)).toBe("Set Cover Image");
await contains(coverButton).click();
expect(".modal .o_kanban_cover_image").toHaveCount(1);
expect(".modal .btn:contains(Select)").toHaveCount(1);
expect(".modal .btn:contains(Discard)").toHaveCount(1);
expect(".modal .btn:contains(Remove Cover Image)").toHaveCount(0);
await dblclick(".modal .o_kanban_cover_image img"); // doesn't work
await animationFrame();
expect('img[data-src*="/web/image/2"]').toHaveCount(1);
await contains(".o_kanban_record:first-child .o_attachment_image").click(); //Not sure, to discuss
// should writes on both kanban records
expect.verifySteps(["1", "2"]);
});
test.tags("desktop");
test("open file explorer if no cover image", async () => {
expect.assertions(2);
Partner._fields.displayed_image_id = fields.Many2one({
string: "Cover",
relation: "ir.attachment",
});
const uploadedPromise = new Deferred();
await createFileInput({
mockPost: async (route) => {
if (route === "/web/binary/upload_attachment") {
await uploadedPromise;
}
return "[]";
},
props: {},
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-menu">
<a type="set_cover" data-field="displayed_image_id" class="dropdown-item">Set Cover Image</a>
</t>
<t t-name="kanban-box">
<div class="oe_kanban_global_click">
<field name="foo"/>
<div>
<field name="displayed_image_id" widget="attachment_image"/>
</div>
</div>
</t>
</templates>
</kanban>`,
});
await toggleKanbanRecordDropdown(0);
await contains(".oe_kanban_action", {
root: getDropdownMenu(getKanbanRecord({ index: 0 })),
}).click();
await setInputFiles([]);
await animationFrame();
expect(`.o_file_input input`).not.toBeEnabled({
message: "the upload button should be disabled on upload",
});
uploadedPromise.resolve();
await animationFrame();
expect(`.o_file_input input`).toBeEnabled({
message: "the upload button should be enabled for upload",
});
});
test.tags("desktop");
test("unset cover image", async () => {
IrAttachment._records = [
{
id: 1,
name: "1.png",
mimetype: "image/png",
res_model: "partner",
res_id: 1,
},
{
id: 2,
name: "2.png",
mimetype: "image/png",
res_model: "partner",
res_id: 2,
},
];
Partner._fields.displayed_image_id = fields.Many2one({
string: "Cover",
relation: "ir.attachment",
});
Partner._records[0].displayed_image_id = 1;
Partner._records[1].displayed_image_id = 2;
onRpc(({ model, method, args }) => {
if (model === "partner" && method === "web_save") {
expect.step(String(args[0][0]));
expect(args[1].displayed_image_id).toBe(false);
}
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-menu">
<a type="set_cover" data-field="displayed_image_id" class="dropdown-item">Set Cover Image</a>
</t>
<t t-name="kanban-box">
<div>
<field name="foo"/>
<div>
<field name="displayed_image_id" widget="attachment_image"/>
</div>
</div>
</t>
</templates>
</kanban>`,
});
await toggleKanbanRecordDropdown(0);
await contains(".oe_kanban_action", {
root: getDropdownMenu(getKanbanRecord({ index: 0 })),
}).click();
expect(
queryAll('img[data-src*="/web/image/1"]', { root: getKanbanRecord({ index: 0 }) })
).toHaveCount(1);
expect(
queryAll('img[data-src*="/web/image/2"]', { root: getKanbanRecord({ index: 1 }) })
).toHaveCount(1);
expect(".modal .o_kanban_cover_image").toHaveCount(1);
expect(".modal .btn:contains(Select)").toHaveCount(1);
expect(".modal .btn:contains(Discard)").toHaveCount(1);
expect(".modal .btn:contains(Remove Cover Image)").toHaveCount(1);
await contains(".modal .btn-secondary").click(); // click on "Remove Cover Image" button
expect(queryAll("img", { root: getKanbanRecord({ index: 0 }) })).toHaveCount(0, {
message: "The cover image should be removed.",
});
await toggleKanbanRecordDropdown(1);
const coverButton = getDropdownMenu(getKanbanRecord({ index: 1 })).querySelector("a");
expect(queryText(coverButton)).toBe("Set Cover Image");
await contains(coverButton).click();
await dblclick(".modal .o_kanban_cover_image img"); // doesn't work
await animationFrame();
expect(queryAll("img", { root: getKanbanRecord({ index: 1 }) })).toHaveCount(0, {
message: "The cover image should be removed.",
});
// should writes on both kanban records
expect.verifySteps(["1", "2"]);
});
test.tags("desktop");
test("ungrouped kanban with handle field", async () => {
expect.assertions(3);
onRpc("/web/dataset/resequence", async (request) => {
const { params } = await request.json();
expect(params.ids).toEqual([2, 1, 3, 4], {
message: "should write the sequence in correct order",
});
return true;
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="int_field" widget="handle" />
<templates>
<t t-name="kanban-box">
<div>
<field name="foo"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(getKanbanRecordTexts()).toEqual(["blip", "blip", "yop", "gnap"]);
await contains(".o_kanban_record").dragAndDrop(queryFirst(".o_kanban_record:nth-child(4)"));
expect(getKanbanRecordTexts()).toEqual(["blip", "yop", "gnap", "blip"]);
});
test("ungrouped kanban without handle field", async () => {
onRpc("/web/dataset/resequence", () => {
expect.step("resequence");
return true;
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="foo"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(getKanbanRecordTexts()).toEqual(["yop", "blip", "gnap", "blip"]);
await contains(".o_kanban_record").dragAndDrop(queryFirst(".o_kanban_record:nth-child(4)"));
expect(getKanbanRecordTexts()).toEqual(["yop", "blip", "gnap", "blip"]);
expect.verifySteps([]);
});
test("click on image field in kanban (with default global_click)", async () => {
expect.assertions(2);
Partner._fields.image = fields.Binary();
Partner._records[0].image = "R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA==";
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<field name="image" widget="image"/>
</div>
</t>
</templates>
</kanban>`,
selectRecord(recordId) {
expect(recordId).toBe(1, {
message: "should call its selectRecord prop with the clicked record",
});
},
});
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(4);
await contains(".o_field_image").click();
});
test("kanban view with boolean field", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div><field name="bar"/></div>
</t>
</templates>
</kanban>`,
});
expect(".o_kanban_record input:disabled").toHaveCount(4);
expect(".o_kanban_record input:checked").toHaveCount(3);
expect(".o_kanban_record input:not(:checked)").toHaveCount(1);
});
test("kanban view with boolean widget", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div><field name="bar" widget="boolean"/></div>
</t>
</templates>
</kanban>`,
});
expect(
queryAll("div.o_field_boolean .o-checkbox", { root: getKanbanRecord({ index: 0 }) })
).toHaveCount(1);
});
test("kanban view with boolean toggle widget", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div><field name="bar" widget="boolean_toggle"/></div>
</t>
</templates>
</kanban>`,
});
expect(getKanbanRecord({ index: 0 }).querySelector("[name='bar'] input")).toBeChecked();
expect(getKanbanRecord({ index: 1 }).querySelector("[name='bar'] input")).toBeChecked();
await click(queryOne("[name='bar'] input", { root: getKanbanRecord({ index: 1 }) }));
await animationFrame();
expect(getKanbanRecord({ index: 0 }).querySelector("[name='bar'] input")).toBeChecked();
expect(getKanbanRecord({ index: 1 }).querySelector("[name='bar'] input")).not.toBeChecked();
});
test("kanban view with monetary and currency fields without widget", async () => {
const mockedCurrencies = {};
for (const record of Currency._records) {
mockedCurrencies[record.id] = record;
}
patchWithCleanup(currencies, mockedCurrencies);
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="currency_id"/>
<templates>
<t t-name="kanban-box">
<div><field name="salary"/></div>
</t>
</templates>
</kanban>`,
});
expect(getKanbanRecordTexts()).toEqual([
`$ 1,750.00`,
`$ 1,500.00`,
`2,000.00 €`,
`$ 2,222.00`,
]);
});
test("kanban with isHtmlEmpty method", async () => {
Product._fields.description = fields.Html();
Product._records.push({
id: 11,
name: "product 11",
description: "<span class='text-info'>hello</hello>",
});
Product._records.push({
id: 12,
name: "product 12",
description: "<p class='a'><span style='color:red;'/><br/></p>",
});
await mountView({
type: "kanban",
resModel: "product",
arch: `
<kanban>
<field name="description"/>
<templates>
<t t-name="kanban-box">
<div>
<field name="display_name"/>
<div class="test" t-if="!widget.isHtmlEmpty(record.description.raw_value)">
<t t-out="record.description.value"/>
</div>
</div>
</t>
</templates>
</kanban>`,
domain: [["id", "in", [11, 12]]],
});
expect(".o_kanban_record:first-child div.test").toHaveCount(1, {
message: "the container is displayed if description have actual content",
});
expect(queryText("span.text-info", { root: getKanbanRecord({ index: 0 }) })).toBe("hello", {
message: "the inner html content is rendered properly",
});
expect(".o_kanban_record:last-child div.test").toHaveCount(0, {
message:
"the container is not displayed if description just have formatting tags and no actual content",
});
});
test("kanban widget can extract props from attrs", async () => {
class TestWidget extends Component {
static template = xml`<div class="o-test-widget-option" t-esc="props.title"/>`;
static props = ["*"];
}
const testWidget = {
component: TestWidget,
extractProps: ({ attrs }) => {
return {
title: attrs.title,
};
},
};
viewWidgetRegistry.add("widget_test_option", testWidget);
after(() => viewWidgetRegistry.remove("widget_test_option"));
await mountView({
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<widget name="widget_test_option" title="Widget with Option"/>
</div>
</t>
</templates>
</kanban>`,
resModel: "partner",
type: "kanban",
});
expect(".o-test-widget-option").toHaveCount(4);
expect(".o-test-widget-option:first").toHaveText("Widget with Option");
});
test("action/type attributes on kanban arch, type='object'", async () => {
mockService("action", {
doActionButton(params) {
expect.step(`doActionButton type ${params.type} name ${params.name}`);
params.onClose();
},
});
stepAllNetworkCalls();
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban action="a1" type="object">
<templates>
<t t-name="kanban-box">
<div>
<p>some value</p><field name="foo"/>
</div>
</t>
</templates>
</kanban>`,
});
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"get_views",
"web_search_read",
]);
await contains(".o_kanban_record p").click();
expect.verifySteps(["doActionButton type object name a1", "web_search_read"]);
});
test("action/type attributes on kanban arch, type='action'", async () => {
mockService("action", {
doActionButton(params) {
expect.step(`doActionButton type ${params.type} name ${params.name}`);
params.onClose();
},
});
stepAllNetworkCalls();
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban action="a1" type="action">
<templates>
<t t-name="kanban-box">
<div>
<p>some value</p><field name="foo"/>
</div>
</t>
</templates>
</kanban>`,
});
expect.verifySteps([
"/web/webclient/translations",
"/web/webclient/load_menus",
"get_views",
"web_search_read",
]);
await contains(".o_kanban_record p").click();
expect.verifySteps(["doActionButton type action name a1", "web_search_read"]);
});
test("Missing t-key is automatically filled with a warning", async () => {
patchWithCleanup(console, {
warn: (msg) => {
if (msg !== "'kanban-box' is deprecated, define a 'card' template instead") {
expect.step("warning");
}
},
});
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<span t-foreach="[1, 2, 3]" t-as="i" t-esc="i" />
</div>
</t>
</templates>
</kanban>`,
});
expect.verifySteps(["warning"]);
expect(getKanbanRecord({ index: 0 })).toHaveText("123");
});
test("Allow use of 'editable'/'deletable' in ungrouped kanban", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban on_create="quick_create">
<templates>
<div t-name="kanban-box">
<button t-if="widget.editable">EDIT</button>
<button t-if="widget.deletable">DELETE</button>
</div>
</templates>
</kanban>`,
});
expect(getKanbanRecordTexts()).toEqual([
"EDITDELETE",
"EDITDELETE",
"EDITDELETE",
"EDITDELETE",
]);
});
test("can use JSON in kanban template", async () => {
Partner._records = [{ id: 1, foo: '["g", "e", "d"]' }];
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<field name="foo"/>
<templates>
<t t-name="kanban-box">
<div>
<span t-foreach="JSON.parse(record.foo.raw_value)" t-as="v" t-key="v_index" t-esc="v"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1);
expect(".o_kanban_record span").toHaveCount(3);
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveText("ged");
});
test("Can't use KanbanRecord implementation details in arch", async () => {
await mountView({
type: "kanban",
resModel: "partner",
arch: `
<kanban>
<templates>
<t t-name="kanban-box">
<div>
<t t-esc="__owl__"/>
<t t-esc="props"/>
<t t-esc="env"/>
<t t-esc="render"/>
</div>
</t>
</templates>
</kanban>`,
});
expect(queryFirst(".o_kanban_record").innerHTML).toBe("<div></div>");
});
test("kanbans with basic and custom compiler, same arch", async () => {
// In this test, the exact same arch will be rendered by 2 different kanban renderers:
// once with the basic one, and once with a custom renderer having a custom compiler. The
// purpose of the test is to ensure that the template is compiled twice, once by each
// compiler, even though the arch is the same.
class MyKanbanCompiler extends KanbanCompiler {
setup() {
super.setup();
this.compilers.push({ selector: "div", fn: this.compileDiv });
}
compileDiv(node, params) {
const compiledNode = this.compileGenericNode(node, params);
compiledNode.setAttribute("class", "my_kanban_compiler");
return compiledNode;
}
}
class MyKanbanRecord extends KanbanRecord {}
MyKanbanRecord.Compiler = MyKanbanCompiler;
class MyKanbanRenderer extends KanbanRenderer {}
MyKanbanRenderer.components = {
...KanbanRenderer.components,
KanbanRecord: MyKanbanRecord,
};
viewRegistry.add("my_kanban", {
...kanbanView,
Renderer: MyKanbanRenderer,
});
after(() => viewRegistry.remove("my_kanban"));
Partner._fields.one2many = fields.One2many({ relation: "partner" });
Partner._records[0].one2many = [1];
Partner._views["form,false"] = `<form><field name="one2many" mode="kanban"/></form>`;
Partner._views["search,false"] = `<search/>`;
Partner._views["kanban,false"] = `
<kanban js_class="my_kanban">
<templates>
<t t-name="kanban-box">
<div><field name="foo"/></div>
</t>
</templates>
</kanban>`;
await mountWithCleanup(WebClient);
await getService("action").doAction({
res_model: "partner",
type: "ir.actions.act_window",
views: [
[false, "kanban"],
[false, "form"],
],
});
// main kanban, custom view
expect(".o_kanban_view").toHaveCount(1);
expect(".o_my_kanban_view").toHaveCount(1);
expect(".my_kanban_compiler").toHaveCount(4);
// switch to form
await contains(".o_kanban_record").click();
await animationFrame();
expect(".o_form_view").toHaveCount(1);
expect(".o_form_view .o_field_widget[name=one2many]").toHaveCount(1);
// x2many kanban, basic renderer
expect(".o_kanban_record:not(.o_kanban_ghost)").toHaveCount(1);
expect(".my_kanban_compiler").toHaveCount(0);
});