[IMP] web: display empty screen when switching app/menu

The goal is to provide a better feedback to the user, especially in
mobile mode. With this commit, when switching menus, we clear the screen
and display an empty control panel (only for act_window actions though)
so we have some initial content that get filled as soon as the
view/action is ready.

Task 2900847

closes odoo/odoo#124068

Related: odoo/enterprise#43038
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
This commit is contained in:
Géry Debongnie
2023-06-23 16:35:15 +02:00
parent 16ed8d3141
commit 9f6ed9f6d1
9 changed files with 109 additions and 7 deletions
@@ -8,7 +8,7 @@
<div class="o_control_panel_main_buttons d-flex gap-1 d-empty-none d-print-none" t-ref="mainButtons" t-on-keydown="onMainButtonsKeydown">
<div class="btn-group d-xl-none o_control_panel_collapsed_create">
<t t-slot="control-panel-create-button"/>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split o_control_panel_collapsed_create" data-bs-toggle="dropdown" aria-expanded="false">
<button t-att-class="{invisible: display.disableDropdown}" type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split o_control_panel_collapsed_create" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu o_control_panel_collapsed_create">
@@ -5,7 +5,7 @@ import { makeContext } from "@web/core/context";
import { useDebugCategory } from "@web/core/debug/debug_context";
import { evaluateExpr } from "@web/core/py_js/py";
import { registry } from "@web/core/registry";
import { KeepLast } from "@web/core/utils/concurrency";
import { Deferred, KeepLast } from "@web/core/utils/concurrency";
import { useBus, useService } from "@web/core/utils/hooks";
import { sprintf } from "@web/core/utils/strings";
import { cleanDomFromBootstrap } from "@web/legacy/utils";
@@ -14,6 +14,7 @@ import { ActionDialog } from "./action_dialog";
import { CallbackRecorder } from "./action_hook";
import { ReportAction } from "./reports/report_action";
import { UPDATE_METHODS } from "@web/core/orm_service";
import { ControlPanel } from "@web/search/control_panel/control_panel";
import {
Component,
@@ -27,6 +28,22 @@ import {
} from "@odoo/owl";
import { downloadReport, getReportUrl } from "./reports/utils";
class BlankComponent extends Component {
static props = ["onMounted", "withControlPanel", "*"];
static template = xml`
<ControlPanel display="{disableDropdown: true}" t-if="props.withControlPanel and !env.isSmall">
<t t-set-slot="layout-buttons">
<button class="btn btn-primary invisible"> empty </button>
</t>
</ControlPanel>`;
static components = { ControlPanel };
setup() {
useChildSubEnv({ config: { breadcrumbs: [], noBreadcrumbs: true } });
onMounted(() => this.props.onMounted());
}
}
const actionHandlersRegistry = registry.category("action_handlers");
const actionRegistry = registry.category("actions");
const viewRegistry = registry.category("views");
@@ -809,6 +826,21 @@ function makeActionManager(env) {
const closingProm = _executeCloseAction();
if (options.clearBreadcrumbs && !options.noEmptyTransition) {
const def = new Deferred();
env.bus.trigger("ACTION_MANAGER:UPDATE", {
id: ++id,
Component: BlankComponent,
componentProps: {
onMounted: () => def.resolve(),
withControlPanel: action.type === "ir.actions.act_window",
},
});
await def;
}
if (options.onActionReady) {
options.onActionReady(action);
}
controller.__info__ = {
id: ++id,
Component: ControllerComponent,
@@ -924,6 +956,8 @@ function makeActionManager(env) {
clearBreadcrumbs: options.clearBreadcrumbs,
onClose: options.onClose,
stackPosition: options.stackPosition,
onActionReady: options.onActionReady,
noEmptyTransition: options.noEmptyTransition,
};
if (lazyView) {
@@ -985,6 +1019,8 @@ function makeActionManager(env) {
clearBreadcrumbs: options.clearBreadcrumbs,
stackPosition: options.stackPosition,
onClose: options.onClose,
onActionReady: options.onActionReady,
noEmptyTransition: options.noEmptyTransition,
});
} else {
const next = await clientAction(env, action);
@@ -53,8 +53,12 @@ function makeMenus(env, menusData, fetchLoadMenus) {
if (!menu.actionID) {
return;
}
await env.services.action.doAction(menu.actionID, { clearBreadcrumbs: true });
this.setCurrentMenu(menu);
await env.services.action.doAction(menu.actionID, {
clearBreadcrumbs: true,
onActionReady: () => {
this.setCurrentMenu(menu);
},
});
},
setCurrentMenu(menu) {
menu = typeof menu === "number" ? this.getMenu(menu) : menu;
@@ -111,6 +111,8 @@ QUnit.test("opens an app", async (assert) => {
triggerHotkey("enter");
await nextTick();
await nextTick();
// empty screen for now, wait for actual action to show up
await nextTick();
assert.strictEqual(target.querySelector(".o_menu_brand").textContent, "Contact");
assert.strictEqual(
target.querySelector(".test_client_action").textContent,
@@ -131,6 +133,8 @@ QUnit.test("opens a menu items", async (assert) => {
click(target, "#o_command_2");
await nextTick();
await nextTick();
// empty screen for now, wait for actual action to show up
await nextTick();
assert.strictEqual(target.querySelector(".o_menu_brand").textContent, "Sales");
assert.strictEqual(
target.querySelector(".test_client_action").textContent,
@@ -1777,6 +1777,8 @@ QUnit.module("Views", (hooks) => {
const webClient = await createWebClient({ serverData });
await doAction(webClient, 1);
await nextTick();
await nextTick();
assert.strictEqual(
target.querySelector(".o_main_navbar .o_menu_brand").textContent,
"App0"
@@ -516,7 +516,7 @@ QUnit.module("ActionManager", (hooks) => {
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view");
def = makeDeferred();
doAction(webClient, 4, { clearBreadcrumbs: true });
doAction(webClient, 4);
await nextTick();
assert.containsOnce(target, ".o_list_view", "should still contain the list view");
await switchView(target, "kanban");
@@ -549,7 +549,7 @@ QUnit.module("ActionManager", (hooks) => {
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, 3);
assert.containsOnce(target, ".o_list_view");
doAction(webClient, 4, { clearBreadcrumbs: true });
doAction(webClient, 4);
await nextTick();
await switchView(target, "kanban");
def.resolve();
@@ -124,6 +124,7 @@ QUnit.module("ActionManager", (hooks) => {
await createWebClient({ serverData });
await testUtils.nextTick(); // wait for the navbar to be updated
await testUtils.nextTick(); // wait for the action to be displayed
assert.containsOnce(target, ".test_client_action");
assert.strictEqual(target.querySelector(".o_menu_brand").innerText, "App1");
@@ -198,6 +199,7 @@ QUnit.module("ActionManager", (hooks) => {
action: "HelloWorldTest",
});
await testUtils.nextTick();
await testUtils.nextTick();
assert.strictEqual(
$(target).find(".o_client_action_test").text(),
"Hello World",
@@ -238,6 +240,7 @@ QUnit.module("ActionManager", (hooks) => {
model: "partner",
});
await testUtils.nextTick();
await testUtils.nextTick();
assert.containsOnce(target, ".o_form_view");
assert.deepEqual(getBreadCrumbTexts(target), ["Second record"]);
assert.verifySteps(["/web/webclient/load_menus", "get_views", "read"]);
@@ -687,6 +690,7 @@ QUnit.module("ActionManager", (hooks) => {
1: { id: 1, children: [], name: "App1", appID: 1, actionID: 1 },
};
const webClient = await createWebClient({ serverData });
await nextTick();
assert.containsOnce(target, ".o_kanban_view"); // action 1 (default app)
assert.deepEqual(getBreadCrumbTexts(target), ["Partners Action 1"]);
await loadState(webClient, { action: 3 });
@@ -964,6 +968,8 @@ QUnit.module("ActionManager", (hooks) => {
browser.location.hash = "#action=myAction";
const webClient = await createWebClient({ serverData });
assert.verifySteps([]);
await nextTick();
assert.verifySteps(["myAction mounted"]);
assert.containsOnce(target, ".not-here");
@@ -971,6 +977,9 @@ QUnit.module("ActionManager", (hooks) => {
await hashchangeDef;
await nextTick();
assert.containsNone(target, ".not-here");
assert.containsNone(target, ".test_client_action");
await nextTick();
assert.containsNone(target, ".not-here");
assert.containsOnce(target, ".test_client_action");
assert.deepEqual(webClient.env.services.router.current.hash, {
@@ -980,7 +989,7 @@ QUnit.module("ActionManager", (hooks) => {
});
QUnit.test("concurrent hashchange during action mounting -- 2", async (assert) => {
assert.expect(5);
assert.expect(6);
const baseURL = new URL(browser.location.href).toString();
@@ -999,8 +1008,11 @@ QUnit.module("ActionManager", (hooks) => {
browser.location.hash = "#action=myAction";
const webClient = await createWebClient({ serverData });
assert.verifySteps([]);
await nextTick();
assert.verifySteps(["myAction mounted"]);
await nextTick();
await nextTick();
assert.containsNone(target, ".not-here");
assert.containsOnce(target, ".test_client_action");
@@ -6,6 +6,7 @@ import { session } from "@web/session";
import AbstractAction from "web.AbstractAction";
import core from "web.core";
import { makeTestEnv } from "../../helpers/mock_env";
import testUtils from "web.test_utils";
import {
click,
getFixture,
@@ -22,6 +23,7 @@ import {
} from "./../helpers";
import { listView } from "@web/views/list/list_view";
import { companyService } from "@web/webclient/company_service";
import { onWillStart } from "@odoo/owl";
let serverData;
let target;
@@ -435,6 +437,7 @@ QUnit.module("ActionManager", (hooks) => {
// Create the web client. It should execute the stored action.
const webClient = await createWebClient({ serverData });
await nextTick();
// Check the current action context
assert.deepEqual(webClient.env.services.action.currentController.action.context, {
@@ -447,4 +450,44 @@ QUnit.module("ActionManager", (hooks) => {
});
}
);
QUnit.test(
"action is removed while waiting for another action with selectMenu",
async (assert) => {
let def;
const actionRegistry = registry.category("actions");
const ClientAction = actionRegistry.get("__test__client__action__");
patchWithCleanup(ClientAction.prototype, {
setup() {
this._super();
onWillStart(() => {
return def;
});
},
});
const webClient = await createWebClient({ serverData });
// starting point: a kanban view
await doAction(webClient, 4);
const root = document.querySelector(".o_web_client");
assert.containsOnce(root, ".o_kanban_view");
// select one new app in navbar menu
def = testUtils.makeTestPromise();
const menus = webClient.env.services.menu.getApps();
webClient.env.services.menu.selectMenu(menus[1], { resetScreen: true });
await nextTick();
// check that the action manager is empty, even though client action is loading
assert.strictEqual(root.querySelector(".o_action_manager").textContent, "");
// resolve onwillstart so client action is ready
def.resolve();
await nextTick();
assert.strictEqual(
root.querySelector(".o_action_manager").textContent,
" ClientAction_Id 1"
);
}
);
});
@@ -556,6 +556,7 @@ QUnit.module("ActionManager", (hooks) => {
'<form><button name="24" type="action" class="oe_stat_button"/></form>';
await createWebClient({ serverData });
await nextTick(); // wait for the load state (default app)
await nextTick(); // wait for the action to be mounted
assert.containsOnce(target, "nav .o_menu_brand");
assert.strictEqual(target.querySelector("nav .o_menu_brand").innerText, "MAIN APP");
await click(target.querySelector("button[name='24']"));