odoo16 front-end framework source code reading – startup, menu, action

odoo16 front-end framework source code reading – startup, menu, action

Directory: addons/web/static/src

1.main.js

Odoo is actually a single-page application. Judging from the name, this is the entry file for the front end, and the file content is also very simple.

/** @odoo-module **/

import {<!-- --> startWebClient } from "./start";
import {<!-- --> WebClient } from "./webclient/webclient";

/**
 * This file starts the webclient. It is in its own file to allow its replacement
 * in enterprise. The enterprise version of the file uses its own webclient import,
 * which is a subclass of the above Webclient.
 */

startWebClient(WebClient);

The key is the last line of code, which calls the startWebClient function to start a WebClient. It’s very simple, and the comments also explain that the enterprise version can start the proprietary webclient.

2.start.js

There is only one function startWebClient in this module, and the comments also explain that its function is to start a webclient, and both the enterprise version and the community version will execute this function, but the webclient is different.

This file probably does the following things:

1. Define odoo.info

2. Generate env and start related services

3. Define an app object, pass the Webclient construction parameters into it, and mount the app to the body.

4. Different classes are set for the body according to different environments.

5. Finally set odoo.ready=true

Generally speaking, it is to prepare the environment, start the service, and generate the app. This is similar to what vue does.

/** @odoo-module **/

import {<!-- --> makeEnv, startServices } from "./env";
import {<!-- --> legacySetupProm } from "./legacy/legacy_setup";
import {<!-- --> mapLegacyEnvToWowlEnv } from "./legacy/utils";
import {<!-- --> localization } from "@web/core/l10n/localization";
import {<!-- --> session } from "@web/session";
import {<!-- --> renderToString } from "./core/utils/render";
import {<!-- --> setLoadXmlDefaultApp, templates } from "@web/core/assets";
import {<!-- --> hasTouch } from "@web/core/browser/feature_detection";

import {<!-- --> App, whenReady } from "@odoo/owl";

/**
 * Function to start a webclient.
 * It is used both in community and enterprise in main.js.
 * It's meant to be webclient flexible so we can have a subclass of
 * webclient in enterprise with added features.
 *
 * @param {Component} Webclient
 */
export async function startWebClient(Webclient) {<!-- -->

    odoo.info = {<!-- -->
        db: session.db,
        server_version: session.server_version,
        server_version_info: session.server_version_info,
        isEnterprise: session.server_version_info.slice(-1)[0] === "e",
    };
    odoo.isReady = false;

    // setup environment
    const env = makeEnv();
    await startServices(env);

    // start web client
    await whenReady();
    const legacyEnv = await legacySetupProm;
    mapLegacyEnvToWowlEnv(legacyEnv, env);
    const app = new App(Webclient, {<!-- -->
        env,
        templates,
        dev: env.debug,
        translatableAttributes: ["data-tooltip"],
        translateFn: env._t,
    });
    renderToString.app = app;
    setLoadXmlDefaultApp(app);
    const root = await app.mount(document.body);
    const classList = document.body.classList;
    if (localization.direction === "rtl") {<!-- -->
        classList.add("o_rtl");
    }
    if (env.services.user.userId === 1) {<!-- -->
        classList.add("o_is_superuser");
    }
    if (env.debug) {<!-- -->
        classList.add("o_debug");
    }
    if (hasTouch()) {<!-- -->
        classList.add("o_touch_device");
    }
    // delete odoo.debug; // FIXME: some legacy code rely on this
    odoo.__WOWL_DEBUG__ = {<!-- --> root };
    odoo.isReady = true;

    // Update Favicons
    const favicon = `/web/image/res.company/${<!-- -->env.services.company.currentCompany.id}/favicon`;
    const icons = document.querySelectorAll("link[rel*='icon']");
    const msIcon = document.querySelector("meta[name='msapplication-TileImage']");
    for (const icon of icons) {<!-- -->
        icon.href = favicon;
    }
    if (msIcon) {<!-- -->
        msIcon.content = favicon;
    }
}

3. WebClient

Obviously, webclient is an owl component. This is the main interface of odoo we see, and it deserves careful analysis.

The key point here is:

This.loadRouterState(); is called in the onMounted hook.

As for this function, it acquires two variables at the beginning:

 let stateLoaded = await this.actionService.loadState();
    let menuId = Number(this.router.current.hash.menu_id || 0);

What follows is processing based on different combinations of the values of these two variables. If menuId is false, the first application is returned.

/** @odoo-module **/

import {<!-- --> useOwnDebugContext } from "@web/core/debug/debug_context";
import {<!-- --> DebugMenu } from "@web/core/debug/debug_menu";
import {<!-- --> localization } from "@web/core/l10n/localization";
import {<!-- --> MainComponentsContainer } from "@web/core/main_components_container";
import {<!-- --> registry } from "@web/core/registry";
import {<!-- --> useBus, useService } from "@web/core/utils/hooks";
import {<!-- --> ActionContainer } from "./actions/action_container";
import {<!-- --> NavBar } from "./navbar/navbar";

import {<!-- --> Component, onMounted, useExternalListener, useState } from "@odoo/owl";

export class WebClient extends Component {<!-- -->
    setup() {<!-- -->
        this.menuService = useService("menu");
        this.actionService = useService("action");
        this.title = useService("title");
        this.router = useService("router");
        this.user = useService("user");
        useService("legacy_service_provider");
        useOwnDebugContext({<!-- --> categories: ["default"] });
        if (this.env.debug) {<!-- -->
            registry.category("systray").add(
                "web.debug_mode_menu",
                {<!-- -->
                    Component: DebugMenu,
                },
                {<!-- --> sequence: 100 }
            );
        }
        this.localization = localization;
        this.state = useState({<!-- -->
            fullscreen: false,
        });
        this.title.setParts({<!-- --> zopenerp: "Odoo" }); // zopenerp is easy to grep
        useBus(this.env.bus, "ROUTE_CHANGE", this.loadRouterState);
        useBus(this.env.bus, "ACTION_MANAGER:UI-UPDATED", ({<!-- --> detail: mode }) => {<!-- -->
            if (mode !== "new") {<!-- -->
                this.state.fullscreen = mode === "fullscreen";
            }
        });
        onMounted(() => {<!-- -->
            this.loadRouterState();
            // the chat window and dialog services listen to 'web_client_ready' event in
            // order to initialize themselves:
            this.env.bus.trigger("WEB_CLIENT_READY");
        });
        useExternalListener(window, "click", this.onGlobalClick, {<!-- --> capture: true });
    }

    async loadRouterState() {<!-- -->
        let stateLoaded = await this.actionService.loadState();
        let menuId = Number(this.router.current.hash.menu_id || 0);

        if (!stateLoaded & amp; & amp; menuId) {<!-- -->
            // Determines the current actionId based on the current menu
            const menu = this.menuService.getAll().find((m) => menuId === m.id);
            const actionId = menu & amp; & amp; menu.actionID;
            if (actionId) {<!-- -->
                await this.actionService.doAction(actionId, {<!-- --> clearBreadcrumbs: true });
                stateLoaded = true;
            }
        }

        if (stateLoaded & amp; & amp; !menuId) {<!-- -->
            // Determines the current menu based on the current action
            const currentController = this.actionService.currentController;
            const actionId = currentController & amp; & amp; currentController.action.id;
            const menu = this.menuService.getAll().find((m) => m.actionID === actionId);
            menuId = menu & amp; & amp; menu.appID;
        }

        if (menuId) {<!-- -->
            // Sets the menu according to the current action
            this.menuService.setCurrentMenu(menuId);
        }

        if (!stateLoaded) {<!-- -->
            // If no action => falls back to the default app
            await this._loadDefaultApp();
        }
    }

    _loadDefaultApp() {<!-- -->
        // Selects the first root menu if any
        const root = this.menuService.getMenu("root");
        const firstApp = root.children[0];
        if (firstApp) {<!-- -->
            return this.menuService.selectMenu(firstApp);
        }
    }

    /**
     * @param {MouseEvent} ev
     */
    onGlobalClick(ev) {<!-- -->
        // When a ctrl-click occurs inside an <a href/> element
        // we let the browser do the default behavior and
        // we do not want any other listener to execute.
        if (
            ev.ctrlKey & amp; & amp;
            !ev.target.isContentEditable & amp; & amp;
            ((ev.target instanceof HTMLAnchorElement & amp; & amp; ev.target.href) ||
                (ev.target instanceof HTMLElement & amp; & amp; ev.target.closest("a[href]:not([href=''])")))
        ) {<!-- -->
            ev.stopImmediatePropagation();
            return;
        }
    }
}
WebClient.components = {<!-- -->
    ActionContainer,
    NavBar,
    MainComponentsContainer,
};
WebClient.template = "web.WebClient";

4. web.WebClient

The template file of webclient is very simple and uses three components.

NavBar: Navigation bar at the top

ActionContainer: other visible parts besides the navigation bar

MainComponentsContainer: This is actually invisible, including notifications and other stuff, which is visible under certain conditions.

<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

    <t t-name="web.WebClient" owl="1">
        <t t-if="!state.fullscreen">
            <NavBar/>
        </t>
        <ActionContainer/>
        <MainComponentsContainer/>
    </t>

</templates>

5. menus\menu_service.js

Menuservice is used in Webclient. Now let’s take a look at this file.

/** @odoo-module **/

import {<!-- --> browser } from "../../core/browser/browser";
import {<!-- --> registry } from "../../core/registry";
import {<!-- --> session } from "@web/session";

const loadMenusUrl = `/web/webclient/load_menus`;

function makeFetchLoadMenus() {<!-- -->
    const cacheHashes = session.cache_hashes;
    let loadMenusHash = cacheHashes.load_menus || new Date().getTime().toString();
    return async function fetchLoadMenus(reload) {<!-- -->
        if (reload) {<!-- -->
            loadMenusHash = new Date().getTime().toString();
        } else if (odoo.loadMenusPromise) {<!-- -->
            returnodoo.loadMenusPromise;
        }
        const res = await browser.fetch(`${<!-- -->loadMenusUrl}/${<!-- -->loadMenusHash}`);
        if (!res.ok) {<!-- -->
            throw new Error("Error while fetching menus");
        }
        return res.json();
    };
}

function makeMenus(env, menusData, fetchLoadMenus) {<!-- -->
    let currentAppId;
    return {<!-- -->
        getAll() {<!-- -->
            return Object.values(menusData);
        },
        getApps() {<!-- -->
            return this.getMenu("root").children.map((mid) => this.getMenu(mid));
        },
        getMenu(menuID) {<!-- -->
            return menusData[menuID];
        },
        getCurrentApp() {<!-- -->
            if (!currentAppId) {<!-- -->
                return;
            }
            return this.getMenu(currentAppId);
        },
        getMenuAsTree(menuID) {<!-- -->
            const menu = this.getMenu(menuID);
            if (!menu.childrenTree) {<!-- -->
                menu.childrenTree = menu.children.map((mid) => this.getMenuAsTree(mid));
            }
            return menu;
        },
        async selectMenu(menu) {<!-- -->
            menu = typeof menu === "number" ? this.getMenu(menu) : menu;
            if (!menu.actionID) {<!-- -->
                return;
            }
            await env.services.action.doAction(menu.actionID, {<!-- --> clearBreadcrumbs: true });
            this.setCurrentMenu(menu);
        },
        setCurrentMenu(menu) {<!-- -->
            menu = typeof menu === "number" ? this.getMenu(menu) : menu;
            if (menu & amp; & amp; menu.appID !== currentAppId) {<!-- -->
                currentAppId = menu.appID;
                env.bus.trigger("MENUS:APP-CHANGED");
                // FIXME: lock API: maybe do something like
                // pushState({menu_id: ...}, { lock: true}); ?
                env.services.router.pushState({<!-- --> menu_id: menu.id }, {<!-- --> lock: true });
            }
        },
        async reload() {<!-- -->
            if (fetchLoadMenus) {<!-- -->
                menusData = await fetchLoadMenus(true);
                env.bus.trigger("MENUS:APP-CHANGED");
            }
        },
    };
}

export const menuService = {<!-- -->
    dependencies: ["action", "router"],
    async start(env) {<!-- -->
        const fetchLoadMenus = makeFetchLoadMenus();
        const menusData = await fetchLoadMenus();
        return makeMenus(env, menusData, fetchLoadMenus);
    },
};

registry.category("services").add("menu", menuService);

The point is this function:

 async selectMenu(menu) {<!-- -->
            menu = typeof menu === "number" ? this.getMenu(menu) : menu;
            if (!menu.actionID) {<!-- -->
                return;
            }
            await env.services.action.doAction(menu.actionID, {<!-- --> clearBreadcrumbs: true });
            this.setCurrentMenu(menu);

It calls the action’s doAction.

6. actions\action_service.js

Here only a part of the file is intercepted, and different processing is performed according to different action types.

 /**
     * Main entry point of a 'doAction' request. Loads the action and executes it.
     *
     * @param {ActionRequest} actionRequest
     * @param {ActionOptions} options
     * @returns {Promise<number | undefined | void>}
     */
    async function doAction(actionRequest, options = {<!-- -->}) {<!-- -->
        const actionProm = _loadAction(actionRequest, options.additionalContext);
        let action = await keepLast.add(actionProm);
        action = _preprocessAction(action, options.additionalContext);
        options.clearBreadcrumbs = action.target === "main" || options.clearBreadcrumbs;
        switch (action.type) {<!-- -->
            case "ir.actions.act_url":
                return _executeActURLAction(action, options);
            case "ir.actions.act_window":
                if (action.target !== "new") {<!-- -->
                    const canProceed = await clearUncommittedChanges(env);
                    if (!canProceed) {<!-- -->
                        return new Promise(() => {<!-- -->});
                    }
                }
                return _executeActWindowAction(action, options);
            case "ir.actions.act_window_close":
                return _executeCloseAction({<!-- --> onClose: options.onClose, onCloseInfo: action.infos });
            case "ir.actions.client":
                return _executeClientAction(action, options);
            case "ir.actions.report":
                return _executeReportAction(action, options);
            case "ir.actions.server":
                return _executeServerAction(action, options);
            default: {<!-- -->
                const handler = actionHandlersRegistry.get(action.type, null);
                if (handler !== null) {<!-- -->
                    return handler({<!-- --> env, action, options });
                }
                throw new Error(
                    `The ActionManager service can't handle actions of type ${<!-- -->action.type}`
                );
            }
        }
    }

Action is a Component. This function will return an action and then add it to the page.

We focus on ir.actions.act_window

 case "ir.actions.act_window":
                if (action.target !== "new") {<!-- -->
                    const canProceed = await clearUncommittedChanges(env);
                    if (!canProceed) {<!-- -->
                        return new Promise(() => {<!-- -->});
                    }
                }
                return _executeActWindowAction(action, options);

_executeActWindowAction function

....
Omit 1000 words
 return _updateUI(controller, updateUIOptions);

Finally, _updateUI is called. This function will dynamically generate a Component, and finally sends the ACTION_MANAGER:UPDATE message through the bus.

 controller.__info__ = {<!-- -->
            id: + + id,
            Component: ControllerComponent,
            componentProps: controller.props,
        };
        env.bus.trigger("ACTION_MANAGER:UPDATE", controller.__info__);
        return Promise.all([currentActionProm, closingProm]).then((r) => r[0]);

Let’s continue to see who received this message

7. action_container.js

action_container received the ACTION_MANAGER:UPDATE message, processed it, and called the render function. The ActionContainer component is a subcomponent of webClient.

In this way, the entire logic is self-consistent.

addons\web\static\src\webclient\actions\action_container.js

/** @odoo-module **/

import {<!-- --> ActionDialog } from "./action_dialog";

import {<!-- --> Component, xml, onWillDestroy } from "@odoo/owl";

//------------------------------------------------ --------------------------
// ActionContainer (Component)
//------------------------------------------------ --------------------------
export class ActionContainer extends Component {<!-- -->
    setup() {<!-- -->
        this.info = {<!-- -->};
        this.onActionManagerUpdate = ({<!-- --> detail: info }) => {<!-- -->
            this.info = info;
            this.render();
        };
        this.env.bus.addEventListener("ACTION_MANAGER:UPDATE", this.onActionManagerUpdate);
        onWillDestroy(() => {<!-- -->
            this.env.bus.removeEventListener("ACTION_MANAGER:UPDATE", this.onActionManagerUpdate);
        });
    }
}
ActionContainer.components = {<!-- --> ActionDialog };
ActionContainer.template = xml`
    <t t-name="web.ActionContainer">
      <div class="o_action_manager">
        <t t-if="info.Component" t-component="info.Component" className="'o_action'" t-props="info.componentProps" t-key="info.id"/>
      </div>
    </t>`;

The entire process above completes the startup of the client and the cycle of menu => action => page rendering. Of course, there are many details worth studying, but this is the general framework.