From afbdce664fb882a7efdff09e43996127fe693402 Mon Sep 17 00:00:00 2001 From: FrancoisGe Date: Thu, 6 Apr 2023 08:24:26 +0000 Subject: [PATCH] [IMP] web: remove read in kanban grouped with group_by_tooltip This commit introduces an optimization to reduce the number of read operations performed in kanban views that are grouped by a field with a group_by_tooltip. Specifically, we now fetch the required values from group_by_tooltip only when the tooltip is first opened, instead of performing a read every time the view is loaded. Part of Task: 3179751 closes odoo/odoo#117758 Related: odoo/enterprise#39349 Signed-off-by: Aaron Bohy (aab) --- .../views/crm_kanban/crm_kanban_renderer.js | 12 +- .../views/crm_kanban/crm_kanban_renderer.xml | 6 +- .../forecast_kanban_renderer.xml | 2 +- .../src/views/kanban/kanban_controller.js | 1 - .../static/src/views/kanban/kanban_header.js | 201 ++++++++++++++++++ .../static/src/views/kanban/kanban_header.xml | 63 ++++++ .../static/src/views/kanban/kanban_model.js | 36 +--- .../src/views/kanban/kanban_renderer.js | 131 ++---------- .../src/views/kanban/kanban_renderer.xml | 67 +----- .../static/tests/views/kanban_view_tests.js | 161 ++++++-------- 10 files changed, 363 insertions(+), 317 deletions(-) create mode 100644 addons/web/static/src/views/kanban/kanban_header.js create mode 100644 addons/web/static/src/views/kanban/kanban_header.xml diff --git a/addons/crm/static/src/views/crm_kanban/crm_kanban_renderer.js b/addons/crm/static/src/views/crm_kanban/crm_kanban_renderer.js index 11a6dcf47f8..268c499d040 100644 --- a/addons/crm/static/src/views/crm_kanban/crm_kanban_renderer.js +++ b/addons/crm/static/src/views/crm_kanban/crm_kanban_renderer.js @@ -2,10 +2,18 @@ import { CrmColumnProgress } from "./crm_column_progress"; import { KanbanRenderer } from "@web/views/kanban/kanban_renderer"; +import { KanbanHeader } from "@web/views/kanban/kanban_header"; + +class CrmKanbanHeader extends KanbanHeader { + static template = "crm.CrmKanbanHeader"; + static components = { + ...KanbanHeader.components, + ColumnProgress: CrmColumnProgress, + }; +} export class CrmKanbanRenderer extends KanbanRenderer {} -CrmKanbanRenderer.template = "crm.CrmKanbanRenderer"; CrmKanbanRenderer.components = { ...KanbanRenderer.components, - CrmColumnProgress, + KanbanHeader: CrmKanbanHeader, }; diff --git a/addons/crm/static/src/views/crm_kanban/crm_kanban_renderer.xml b/addons/crm/static/src/views/crm_kanban/crm_kanban_renderer.xml index 6016e1355cf..776b13d7775 100644 --- a/addons/crm/static/src/views/crm_kanban/crm_kanban_renderer.xml +++ b/addons/crm/static/src/views/crm_kanban/crm_kanban_renderer.xml @@ -1,8 +1,8 @@ - - - + + + props.group.model.progressAttributes diff --git a/addons/crm/static/src/views/forecast_kanban/forecast_kanban_renderer.xml b/addons/crm/static/src/views/forecast_kanban/forecast_kanban_renderer.xml index 1152d423729..511534d4d91 100644 --- a/addons/crm/static/src/views/forecast_kanban/forecast_kanban_renderer.xml +++ b/addons/crm/static/src/views/forecast_kanban/forecast_kanban_renderer.xml @@ -1,6 +1,6 @@ - + { + shouldOpenTooltip = true; + if (this.hasTooltip) { + const tooltip = await this.loadTooltip(); + if (tooltip.length && shouldOpenTooltip) { + this.closeTooltip = popover.add(titleRef.el, KanbanHeaderTooltip, { + tooltip: tooltip, + }); + shouldOpenTooltip = false; + } + } + }, 400); + + this.openTooltip = () => { + this.closeTooltip = () => { + shouldOpenTooltip = false; + debouncedOpenTooltip.cancel(); + }; + debouncedOpenTooltip(); + }; + } + + // ------------------------------------------------------------------------ + // Getters + // ------------------------------------------------------------------------ + + get group() { + return this.props.group; + } + + get groupName() { + const { groupByField, count, displayName, isFolded } = this.group; + let name = displayName; + if (groupByField.type === "boolean") { + name = name ? this.env._t("Yes") : this.env._t("No"); + } else if (!name) { + if ( + isRelational(groupByField) || + groupByField.type === "date" || + groupByField.type === "datetime" || + isNull(name) + ) { + name = this.env._t("None"); + } + } + return !this.env.isSmall && isFolded ? `${name} (${count})` : name; + } + + get groupAggregate() { + const { sumField } = this.group.model.progressAttributes; + const value = this.group.getAggregates(sumField && sumField.name); + const title = sumField ? sumField.string : this.env._t("Count"); + return { value, title }; + } + + // ------------------------------------------------------------------------ + // Tooltip methods + // ------------------------------------------------------------------------ + + get hasTooltip() { + const { name, type } = this.group.groupByField; + return type === "many2one" && this.group.value && name in this.props.tooltipInfo; + } + + loadTooltip = memoize(async () => { + const { name, relation: resModel } = this.group.groupByField; + const tooltipInfo = this.props.tooltipInfo[name]; + const fieldNames = Object.keys(tooltipInfo); + const [values] = await this.orm.silent.read( + resModel, + [this.group.value], + ["display_name", ...fieldNames] + ); + + return fieldNames + .filter((fieldName) => values[fieldName]) + .map((fieldName) => ({ title: tooltipInfo[fieldName], value: values[fieldName] })); + }); + + // ------------------------------------------------------------------------ + // Edition methods + // ------------------------------------------------------------------------ + + archiveGroup() { + this.dialog.add(ConfirmationDialog, { + body: this.env._t( + "Are you sure that you want to archive all the records from this column?" + ), + confirm: () => this.group.list.archive(), + cancel: () => {}, + }); + } + + unarchiveGroup() { + this.group.list.unarchive(); + } + + deleteGroup() { + this.dialog.add(ConfirmationDialog, { + body: this.env._t("Are you sure you want to delete this column?"), + confirm: async () => { + this.props.deleteGroup(this.group); + }, + cancel: () => {}, + }); + } + + editGroup() { + const { context, displayName, resModel, value } = this.group; + this.props.dialogClose.push( + this.dialog.add(FormViewDialog, { + context, + resId: value, + resModel, + title: sprintf(this.env._t("Edit: %s"), displayName), + onRecordSaved: async () => { + await this.props.list.load(); + this.props.list.model.notify(); + }, + }) + ); + } + + quickCreate(group) { + this.props.quickCreateState.groupId = this.group.id; + } + + toggleGroup() { + return this.group.toggle(); + } + + // ------------------------------------------------------------------------ + // Permissions + // ------------------------------------------------------------------------ + + canArchiveGroup() { + const { archiveGroup } = this.props.activeActions; + const hasActiveField = "active" in this.group.fields; + return archiveGroup && hasActiveField && this.group.groupByField.type !== "many2many"; + } + + canDeleteGroup() { + const { deleteGroup } = this.props.activeActions; + const { groupByField, value } = this.group; + return deleteGroup && isRelational(groupByField) && value; + } + + canEditGroup() { + const { editGroup } = this.props.activeActions; + const { groupByField, value } = this.group; + return editGroup && isRelational(groupByField) && value; + } + + canQuickCreate() { + return this.props.canQuickCreate; + } +} diff --git a/addons/web/static/src/views/kanban/kanban_header.xml b/addons/web/static/src/views/kanban/kanban_header.xml new file mode 100644 index 00000000000..68c72d588e0 --- /dev/null +++ b/addons/web/static/src/views/kanban/kanban_header.xml @@ -0,0 +1,63 @@ + + + + +
+
+ + + + + + + + Fold + + + + Edit + + + + + Delete + + + + + Archive All + + + Unarchive All + + + + + + +
+
+ +
+
+
+ + +
+ + +
+ +
+
+
+
+ +
diff --git a/addons/web/static/src/views/kanban/kanban_model.js b/addons/web/static/src/views/kanban/kanban_model.js index d06e86c2ba4..19ab65f2041 100644 --- a/addons/web/static/src/views/kanban/kanban_model.js +++ b/addons/web/static/src/views/kanban/kanban_model.js @@ -1,14 +1,13 @@ /** @odoo-module **/ import { Domain } from "@web/core/domain"; -import { isRelational } from "@web/views/utils"; import { DynamicGroupList, DynamicRecordList, Group, RelationalModel, } from "@web/views/relational_model"; -import { KeepLast } from "@web/core/utils/concurrency"; +import { isRelational } from "@web/views/utils"; /** * @typedef ProgressBar @@ -143,38 +142,6 @@ class KanbanGroup extends Group { }; } - async load() { - this.loadTooltip(); - await super.load(); - } - - /** - * Requests the groups tooltips to the server and store them in this.tooltip - * - * @returns {Promise} - */ - async loadTooltip() { - const groupName = this.groupByField.name; - if ( - this.groupByField.type === "many2one" && - this.value && - groupName in this.model.tooltipInfo - ) { - this.tooltipKeepLast = this.tooltipKeepLast || new KeepLast(); - const resModel = this.groupByField.relation; - const tooltipInfo = this.model.tooltipInfo[groupName]; - const fieldNames = Object.keys(tooltipInfo); - // This read will be batched for all groups - const [values] = await this.tooltipKeepLast.add( - this.model.orm.silent.read(resModel, [this.value], ["display_name", ...fieldNames]) - ); - this.tooltip = fieldNames - .filter((fieldName) => values[fieldName]) - .map((fieldName) => ({ title: tooltipInfo[fieldName], value: values[fieldName] })); - this.model.notify(); - } - } - /** * Checks if the current active progress bar value contains records, and * deactivates it if not. @@ -544,7 +511,6 @@ export class KanbanModel extends RelationalModel { super.setup(...arguments); this.progressAttributes = params.progressAttributes; - this.tooltipInfo = params.tooltipInfo; this.transaction = makeTransactionManager(); } diff --git a/addons/web/static/src/views/kanban/kanban_renderer.js b/addons/web/static/src/views/kanban/kanban_renderer.js index 02c3e67bc8b..b4cee962ac7 100644 --- a/addons/web/static/src/views/kanban/kanban_renderer.js +++ b/addons/web/static/src/views/kanban/kanban_renderer.js @@ -1,6 +1,5 @@ /** @odoo-module **/ -import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { Dropdown } from "@web/core/dropdown/dropdown"; import { DropdownItem } from "@web/core/dropdown/dropdown_item"; import { useHotkey } from "@web/core/hotkeys/hotkey_hook"; @@ -8,11 +7,11 @@ import { registry } from "@web/core/registry"; import { useBus, useService } from "@web/core/utils/hooks"; import { useSortable } from "@web/core/utils/sortable"; import { sprintf } from "@web/core/utils/strings"; -import { isNull, isRelational } from "@web/views/utils"; +import { isNull } from "@web/views/utils"; import { ColumnProgress } from "@web/views/view_components/column_progress"; -import { FormViewDialog } from "@web/views/view_dialogs/form_view_dialog"; import { useBounceButton } from "@web/views/view_hook"; import { KanbanColumnQuickCreate } from "./kanban_column_quick_create"; +import { KanbanHeader } from "./kanban_header"; import { KanbanRecord } from "./kanban_record"; import { KanbanRecordQuickCreate } from "./kanban_record_quick_create"; @@ -42,6 +41,7 @@ export class KanbanRenderer extends Component { DropdownItem, ColumnProgress, KanbanColumnQuickCreate, + KanbanHeader, KanbanRecord, KanbanRecordQuickCreate, }; @@ -57,9 +57,11 @@ export class KanbanRenderer extends Component { "canQuickCreate?", "quickCreateState?", ]; + static defaultProps = { scrollTop: () => {}, quickCreateState: { groupId: false }, + tooltipInfo: {}, }; setup() { @@ -280,23 +282,6 @@ export class KanbanRenderer extends Component { } } - getGroupName({ groupByField, count, displayName, isFolded }) { - let name = displayName; - if (groupByField.type === "boolean") { - name = name ? this.env._t("Yes") : this.env._t("No"); - } else if (!name) { - if ( - isRelational(groupByField) || - groupByField.type === "date" || - groupByField.type === "datetime" || - isNull(name) - ) { - name = this.env._t("None"); - } - } - return !this.env.isSmall && isFolded ? `${name} (${count})` : name; - } - /** * @param {RelationalGroup} group * @param {boolean} isGroupProcessing @@ -332,13 +317,6 @@ export class KanbanRenderer extends Component { return (progressBar ? progressBar.count : group.count) - records.length; } - getGroupAggregate(group) { - const { sumField } = this.props.list.model.progressAttributes; - const value = group.getAggregates(sumField && sumField.name); - const title = sumField ? sumField.string : this.env._t("Count"); - return { value, title }; - } - generateGhostColumns() { let colNames; if (this.exampleData && this.exampleData.ghostColumns) { @@ -368,42 +346,11 @@ export class KanbanRenderer extends Component { // Permissions // ------------------------------------------------------------------------ - canArchiveGroup(group) { - const { activeActions } = this.props.archInfo; - const hasActiveField = "active" in group.fields; - return ( - activeActions.archiveGroup && - hasActiveField && - this.props.list.groupByField.type !== "many2many" - ); - } - canCreateGroup() { const { activeActions } = this.props.archInfo; return activeActions.createGroup && this.props.list.groupByField.type === "many2one"; } - canDeleteGroup(group) { - const { activeActions } = this.props.archInfo; - const { groupByField } = this.props.list; - return activeActions.deleteGroup && isRelational(groupByField) && group.value; - } - - canDeleteRecord() { - const { activeActions } = this.props.archInfo; - return activeActions.delete && this.props.list.groupByField.type !== "many2many"; - } - - canEditGroup(group) { - const { activeActions } = this.props.archInfo; - const { groupByField } = this.props.list; - return activeActions.editGroup && isRelational(groupByField) && group.value; - } - - canEditRecord() { - return this.props.archInfo.activeActions.edit; - } - canQuickCreate() { return this.props.canQuickCreate; } @@ -412,10 +359,6 @@ export class KanbanRenderer extends Component { // Edition methods // ------------------------------------------------------------------------ - quickCreate(group) { - this.props.quickCreateState.groupId = group.id; - } - async validateQuickCreate(recordId, mode, group) { this.props.quickCreateState.groupId = false; if (mode === "add") { @@ -435,6 +378,13 @@ export class KanbanRenderer extends Component { this.props.quickCreateState.groupId = false; } + async deleteGroup(group) { + await this.props.list.deleteGroups([group]); + if (this.props.list.groups.length === 0) { + this.state.columnQuickCreateIsFolded = false; + } + } + toggleGroup(group) { return group.toggle(); } @@ -443,53 +393,6 @@ export class KanbanRenderer extends Component { return group.list.loadMore(); } - editGroup(group) { - this.dialogClose.push( - this.dialog.add(FormViewDialog, { - context: group.context, - resId: group.value, - resModel: group.resModel, - title: sprintf(this.env._t("Edit: %s"), group.displayName), - - onRecordSaved: async () => { - await this.props.list.load(); - this.props.list.model.notify(); - }, - }) - ); - } - - archiveGroup(group) { - this.dialog.add(ConfirmationDialog, { - body: this.env._t( - "Are you sure that you want to archive all the records from this column?" - ), - confirm: () => group.list.archive(), - cancel: () => {}, - }); - } - - unarchiveGroup(group) { - group.list.unarchive(); - } - - deleteGroup(group) { - this.dialog.add(ConfirmationDialog, { - body: this.env._t("Are you sure you want to delete this column?"), - confirm: async () => { - await this.props.list.deleteGroups([group]); - if (this.props.list.groups.length === 0) { - this.state.columnQuickCreateIsFolded = false; - } - }, - cancel: () => {}, - }); - } - - getGroupEl(groupId) { - return this.rootRef.el.querySelector(`.o_kanban_group[data-id="${groupId}"]`); - } - /** * @param {string} id * @param {boolean} isProcessing @@ -657,14 +560,4 @@ export class KanbanRenderer extends Component { return true; } } - - tooltipAttributes(group) { - if (!group.tooltip.length) { - return {}; - } - return { - "data-tooltip-template": "web.KanbanGroupTooltip", - "data-tooltip-info": JSON.stringify({ entries: group.tooltip }), - }; - } } diff --git a/addons/web/static/src/views/kanban/kanban_renderer.xml b/addons/web/static/src/views/kanban/kanban_renderer.xml index 32888415106..682fd523bab 100644 --- a/addons/web/static/src/views/kanban/kanban_renderer.xml +++ b/addons/web/static/src/views/kanban/kanban_renderer.xml @@ -15,51 +15,17 @@ t-att-data-id="group.id" t-on-click="(ev) => this.onGroupClick(group, ev)" > -
-
- - - - - - - - Fold - - - - Edit - - - - - Delete - - - - - Archive All - - - Unarchive All - - - - - - -
-
- -
-
+ - -
- - -
- -
-
-
-
-
diff --git a/addons/web/static/tests/views/kanban_view_tests.js b/addons/web/static/tests/views/kanban_view_tests.js index 323aa2ac8bc..6679daa4d8e 100644 --- a/addons/web/static/tests/views/kanban_view_tests.js +++ b/addons/web/static/tests/views/kanban_view_tests.js @@ -11,6 +11,7 @@ import { getNodesTextContent, makeDeferred, mockAnimationFrame, + mouseEnter, nextTick, patchWithCleanup, selectDropdownItem, @@ -8459,7 +8460,9 @@ QUnit.module("Views", (hooks) => { }); QUnit.test("group_by_tooltip option when grouping on a many2one", async (assert) => { - assert.expect(14); + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + }); delete serverData.models.partner.records[3].product_id; const kanban = await makeView({ type: "kanban", @@ -8477,7 +8480,7 @@ QUnit.module("Views", (hooks) => { `, async mockRPC(route, args) { if (route === "/web/dataset/call_kw/product/read") { - assert.strictEqual(args.args[0].length, 2, "read on two groups"); + assert.step("read: product"); assert.deepEqual( args.args[1], ["display_name", "name"], @@ -8518,38 +8521,38 @@ QUnit.module("Views", (hooks) => { "None", "first column should have a default title for when no value is provided" ); - assert.ok( - !target.querySelector(".o_kanban_group:first-child .o_kanban_header_title").dataset - .tooltipInfo, + + const groupsTitle = [...target.querySelectorAll(".o_kanban_group .o_kanban_header_title")]; + await mouseEnter(groupsTitle[0]); + assert.containsNone( + target, + ".o-tooltip", "tooltip of first column should not defined, since group_by_tooltip title and the many2one field has no value" ); - assert.ok( - !target.querySelector(".o_kanban_group:first-child .o_kanban_header_title").dataset - .tooltipTemplate, - "tooltip of first column should not defined, since group_by_tooltip title and the many2one field has no value" + assert.verifySteps([], "should not have done any read on product because no value"); + + await mouseEnter(groupsTitle[1]); + assert.containsOnce( + target, + ".o-tooltip", + "second column should have a tooltip with the group_by_tooltip title and many2one field value" ); + assert.strictEqual(target.querySelector(".o-tooltip").textContent, "Kikouhello"); assert.strictEqual( target.querySelector(".o_kanban_group:nth-child(2) span.o_column_title").textContent, "hello", "second column should have a title with a value from the many2one" ); - assert.strictEqual( - target.querySelector(".o_kanban_group:nth-child(2) .o_kanban_header_title").dataset - .tooltipInfo, - `{"entries":[{"title":"Kikou","value":"hello"}]}`, - "second column should have a tooltip with the group_by_tooltip title and many2one field value" - ); - assert.strictEqual( - target.querySelector(".o_kanban_group:nth-child(2) .o_kanban_header_title").dataset - .tooltipTemplate, - "web.KanbanGroupTooltip", - "second column should have a tooltip with the group_by_tooltip title and many2one field value" + assert.verifySteps( + ["read: product"], + "should have done one read on product for the second column tooltip" ); }); QUnit.test("asynchronous tooltips when grouped", async (assert) => { - assert.expect(10); - serviceRegistry.add("tooltip", tooltipService); + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + }); const prom = makeDeferred(); await makeView({ type: "kanban", @@ -8567,6 +8570,7 @@ QUnit.module("Views", (hooks) => { `, async mockRPC(route, args) { if (route === "/web/dataset/call_kw/product/read") { + assert.step("read: product"); await prom; } }, @@ -8574,56 +8578,32 @@ QUnit.module("Views", (hooks) => { assert.hasClass(target.querySelector(".o_kanban_renderer"), "o_kanban_grouped"); assert.containsN(target, ".o_column_title", 2); - assert.strictEqual( - target - .querySelectorAll(".o_kanban_header_title")[0] - .getAttribute("data-tooltip-template"), - null - ); - assert.strictEqual( - target.querySelectorAll(".o_kanban_header_title")[0].getAttribute("data-tooltip-info"), - null - ); - assert.strictEqual( - target - .querySelectorAll(".o_kanban_header_title")[1] - .getAttribute("data-tooltip-template"), - null - ); - assert.strictEqual( - target.querySelectorAll(".o_kanban_header_title")[1].getAttribute("data-tooltip-info"), - null + + await mouseEnter(target.querySelectorAll(".o_kanban_group .o_kanban_header_title")[0]); + assert.containsNone(target, ".o-tooltip"); + + await triggerEvent( + target.querySelectorAll(".o_kanban_group .o_kanban_header_title")[0], + null, + "mouseleave" ); + assert.containsNone(target, ".o-tooltip"); + + await mouseEnter(target.querySelectorAll(".o_kanban_group .o_kanban_header_title")[0]); + assert.containsNone(target, ".o-tooltip"); + prom.resolve(); await nextTick(); - assert.strictEqual( - target - .querySelectorAll(".o_kanban_header_title")[0] - .getAttribute("data-tooltip-template"), - "web.KanbanGroupTooltip" - ); - assert.strictEqual( - target - .querySelectorAll(".o_kanban_header_title")[1] - .getAttribute("data-tooltip-template"), - "web.KanbanGroupTooltip" - ); - assert.strictEqual( - target.querySelectorAll(".o_kanban_header_title")[0].getAttribute("data-tooltip-info"), - '{"entries":[{"title":"Name","value":"hello"}]}' - ); - assert.strictEqual( - target.querySelectorAll(".o_kanban_header_title")[1].getAttribute("data-tooltip-info"), - '{"entries":[{"title":"Name","value":"xmo"}]}' - ); + assert.containsOnce(target, ".o-tooltip"); + assert.strictEqual(target.querySelector(".o-tooltip").textContent.trim(), "Namehello"); + assert.verifySteps(["read: product"]); }); - QUnit.test("concurrency asynchronous tooltips when grouped", async (assert) => { - assert.expect(2); - serviceRegistry.add("tooltip", tooltipService); - const prom = makeDeferred(); - let rpcCount = 0; + QUnit.test("loads data tooltips only when first opening", async (assert) => { + patchWithCleanup(browser, { + setTimeout: (fn) => fn(), + }); await makeView({ type: "kanban", resModel: "partner", @@ -8638,48 +8618,29 @@ QUnit.module("Views", (hooks) => { `, - searchViewArch: ` - - - - `, async mockRPC(route, args) { if (route === "/web/dataset/call_kw/product/read") { - if (rpcCount++ == 0) { - await prom; - } else { - return [ - { - id: 3, - display_name: "hello", - name: "hello", - }, - { - id: 5, - display_name: "xmo", - name: "xm", - }, - ]; - } + assert.step("read: product"); } }, }); - // The first tooltip rpc request is blocked and user changes the group by - await click(target, ".o_group_by_menu > .dropdown-toggle"); - await click(target, ".o_group_by_menu > div > span"); - // The first tooltip request arrives after the second request - prom.resolve(); - await nextTick(); + await mouseEnter(target.querySelectorAll(".o_kanban_group .o_kanban_header_title")[0]); + assert.containsOnce(target, ".o-tooltip"); + assert.strictEqual(target.querySelector(".o-tooltip").textContent.trim(), "Namehello"); + assert.verifySteps(["read: product"]); - assert.strictEqual( - target.querySelectorAll(".o_kanban_header_title")[0].getAttribute("data-tooltip-info"), - '{"entries":[{"title":"Name","value":"hello"}]}' - ); - assert.strictEqual( - target.querySelectorAll(".o_kanban_header_title")[1].getAttribute("data-tooltip-info"), - '{"entries":[{"title":"Name","value":"xm"}]}' + await triggerEvent( + target.querySelectorAll(".o_kanban_group .o_kanban_header_title")[0], + null, + "mouseleave" ); + assert.containsNone(target, ".o-tooltip", "tooltip should be closed"); + + await mouseEnter(target.querySelectorAll(".o_kanban_group .o_kanban_header_title")[0]); + assert.containsOnce(target, ".o-tooltip"); + assert.strictEqual(target.querySelector(".o-tooltip").textContent.trim(), "Namehello"); + assert.verifySteps([]); }); QUnit.test("move a record then put it again in the same column", async (assert) => {