*base_automation,iap,im_livechat,point_of_sale This commit introduces a new file in core/utils: hooks.js This file contains custom hooks (useEffect, useService, useBus...). The useHotkey hook is tightly related to hotkeys, so it has been moved to core/hotkeys. closes odoo/odoo#74535 Related: odoo/enterprise#20003 Signed-off-by: Lucas Perais (lpe) <lpe@odoo.com>
1256 lines
47 KiB
JavaScript
1256 lines
47 KiB
JavaScript
/** @odoo-module **/
|
|
|
|
import { cleanDomFromBootstrap } from "@web/legacy/utils";
|
|
import { browser } from "@web/core/browser/browser";
|
|
import { useBus } from "@web/core/utils/hooks";
|
|
import { makeContext } from "@web/core/context";
|
|
import { download } from "@web/core/network/download";
|
|
import { evaluateExpr } from "@web/core/py_js/py";
|
|
import { registry } from "@web/core/registry";
|
|
import { KeepLast } from "@web/core/utils/concurrency";
|
|
import { sprintf } from "@web/core/utils/strings";
|
|
import { ActionDialog } from "./action_dialog";
|
|
|
|
/** @typedef {number|false} ActionId */
|
|
/** @typedef {Object} ActionDescription */
|
|
/** @typedef {"current" | "fullscreen" | "new" | "self" | "inline"} ActionMode */
|
|
/** @typedef {string} ActionTag */
|
|
/** @typedef {string} ActionXMLId */
|
|
/** @typedef {Object} Context */
|
|
/** @typedef {Function} CallableFunction */
|
|
/** @typedef {string} ViewType */
|
|
|
|
/** @typedef {ActionId|ActionXMLId|ActionTag|ActionDescription} ActionRequest */
|
|
|
|
/**
|
|
* @typedef {Object} ActionOptions
|
|
* @property {Context} [additionalContext]
|
|
* @property {boolean} [clearBreadcrumbs]
|
|
* @property {CallableFunction} [onClose]
|
|
* @property {Object} [props]
|
|
* @property {ViewType} [viewType]
|
|
*/
|
|
|
|
const { Component, hooks, tags } = owl;
|
|
|
|
const viewRegistry = registry.category("views");
|
|
const actionHandlersRegistry = registry.category("action_handlers");
|
|
const actionRegistry = registry.category("actions");
|
|
|
|
export function clearUncommittedChanges(env) {
|
|
const callbacks = [];
|
|
env.bus.trigger("CLEAR-UNCOMMITTED-CHANGES", callbacks);
|
|
return Promise.all(callbacks.map((fn) => fn()));
|
|
}
|
|
|
|
function parseActiveIds(ids) {
|
|
const activeIds = [];
|
|
if (typeof ids === "string") {
|
|
activeIds.push(...ids.split(",").map(Number));
|
|
} else if (typeof ids === "number") {
|
|
activeIds.push(ids);
|
|
}
|
|
return activeIds;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Errors
|
|
// -----------------------------------------------------------------------------
|
|
export class ViewNotFoundError extends Error {}
|
|
|
|
export class ControllerNotFoundError extends Error {}
|
|
|
|
export class InvalidButtonParamsError extends Error {}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// ActionManager (Service)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// regex that matches context keys not to forward from an action to another
|
|
const CTX_KEY_REGEX = /^(?:(?:default_|search_default_|show_).+|.+_view_ref|group_by|group_by_no_leaf|active_id|active_ids|orderedBy)$/;
|
|
|
|
// only register this template once for all dynamic classes ControllerComponent
|
|
const ControllerComponentTemplate = tags.xml`<t t-component="Component" t-props="props"
|
|
registerCallback="registerCallback"
|
|
t-ref="component"
|
|
t-on-history-back="onHistoryBack"
|
|
t-on-controller-title-updated.stop="onTitleUpdated"/>`;
|
|
|
|
function makeActionManager(env) {
|
|
const keepLast = new KeepLast();
|
|
let id = 0;
|
|
let controllerStack = [];
|
|
let dialogCloseProm;
|
|
let actionCache = {};
|
|
let dialog = null;
|
|
|
|
// The state action (or default user action if none) is loaded as soon as possible
|
|
// so that the next "doAction" will have its action ready when needed.
|
|
const actionParams = _getActionParams();
|
|
if (actionParams && typeof actionParams.actionRequest === "number") {
|
|
const { actionRequest, options } = actionParams;
|
|
_loadAction(actionRequest, options.additionalContext);
|
|
}
|
|
|
|
env.bus.on("CLEAR-CACHES", null, () => {
|
|
actionCache = {};
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// misc
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Removes the current dialog from the action service's state.
|
|
* It returns the dialog's onClose callback to be able to propagate it to the next dialog.
|
|
*
|
|
* @return {Function|undefined} When there was a dialog, returns its onClose callback for propagation to next dialog.
|
|
*/
|
|
function _removeDialog() {
|
|
if (dialog) {
|
|
const { onClose, remove } = dialog;
|
|
dialog = null;
|
|
// Remove the dialog from the dialog_service.
|
|
// The code is well enough designed to avoid falling in a function call loop.
|
|
remove();
|
|
return onClose;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the last controller of the current controller stack.
|
|
*
|
|
* @returns {Controller|null}
|
|
*/
|
|
function _getCurrentController() {
|
|
const stack = controllerStack;
|
|
return stack.length ? stack[stack.length - 1] : null;
|
|
}
|
|
|
|
/**
|
|
* Given an id, xmlid, tag (key of the client action registry) or directly an
|
|
* object describing an action.
|
|
*
|
|
* @private
|
|
* @param {ActionRequest} actionRequest
|
|
* @param {Context} [context={}]
|
|
* @returns {Promise<Action>}
|
|
*/
|
|
async function _loadAction(actionRequest, context = {}) {
|
|
if (typeof actionRequest === "string" && actionRegistry.contains(actionRequest)) {
|
|
// actionRequest is a key in the actionRegistry
|
|
return {
|
|
target: "current",
|
|
tag: actionRequest,
|
|
type: "ir.actions.client",
|
|
};
|
|
}
|
|
|
|
if (typeof actionRequest === "string" || typeof actionRequest === "number") {
|
|
// actionRequest is an id or an xmlid
|
|
const additional_context = {
|
|
active_id: context.active_id,
|
|
active_ids: context.active_ids,
|
|
active_model: context.active_model,
|
|
};
|
|
const key = `${JSON.stringify(actionRequest)},${JSON.stringify(additional_context)}`;
|
|
if (!actionCache[key]) {
|
|
actionCache[key] = env.services.rpc("/web/action/load", {
|
|
action_id: actionRequest,
|
|
additional_context,
|
|
});
|
|
}
|
|
const action = await actionCache[key];
|
|
if (!action) {
|
|
return {
|
|
type: "ir.actions.client",
|
|
tag: "invalid_action",
|
|
id: actionRequest,
|
|
};
|
|
}
|
|
return Object.assign({}, action);
|
|
}
|
|
|
|
// actionRequest is an object describing the action
|
|
return actionRequest;
|
|
}
|
|
|
|
/**
|
|
* this function returns an action description
|
|
* with a unique jsId.
|
|
*/
|
|
function _preprocessAction(action, context = {}) {
|
|
action.context = makeContext(env.services.user.context, context, action.context);
|
|
if (action.domain) {
|
|
const domain = action.domain || [];
|
|
action.domain =
|
|
typeof domain === "string" ? evaluateExpr(domain, action.context) : domain;
|
|
}
|
|
action = Object.assign({}, action); // manipulate a copy
|
|
action._originalAction = JSON.stringify(action);
|
|
action.jsId = `action_${++id}`;
|
|
if (action.type === "ir.actions.act_window" || action.type === "ir.actions.client") {
|
|
action.target = action.target || "current";
|
|
}
|
|
if (action.type === "ir.actions.act_window") {
|
|
action.controllers = {};
|
|
const target = action.target;
|
|
if (target !== "inline" && !(target === "new" && action.views[0][1] === "form")) {
|
|
// FIXME: search view arch is already sent with load_action, so either remove it
|
|
// from there or load all fieldviews alongside the action for the sake of consistency
|
|
const searchViewId = action.search_view_id ? action.search_view_id[0] : false;
|
|
action.views.push([searchViewId, "search"]);
|
|
}
|
|
}
|
|
return action;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {string} viewType
|
|
* @throws {Error} if the current controller is not a view
|
|
* @returns {View | null}
|
|
*/
|
|
function _getView(viewType) {
|
|
const currentController = controllerStack[controllerStack.length - 1];
|
|
if (currentController.action.type !== "ir.actions.act_window") {
|
|
throw new Error(`switchView called but the current controller isn't a view`);
|
|
}
|
|
const view = currentController.views.find((view) => view.type === viewType);
|
|
return view || null;
|
|
}
|
|
|
|
/**
|
|
* Given a controller stack, returns the list of breadcrumb items.
|
|
*
|
|
* @private
|
|
* @param {ControllerStack} stack
|
|
* @returns {Breadcrumbs}
|
|
*/
|
|
function _getBreadcrumbs(stack) {
|
|
return stack
|
|
.filter((controller) => controller.action.tag !== "menu")
|
|
.map((controller) => {
|
|
return {
|
|
jsId: controller.jsId,
|
|
name: controller.title || controller.action.name || env._t("Undefined"),
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @returns {ActionParams | null}
|
|
*/
|
|
function _getActionParams() {
|
|
const state = env.services.router.current.hash;
|
|
const options = { clearBreadcrumbs: true };
|
|
let actionRequest = null;
|
|
if (state.action) {
|
|
// ClientAction
|
|
if (actionRegistry.contains(state.action)) {
|
|
actionRequest = {
|
|
params: state,
|
|
tag: state.action,
|
|
type: "ir.actions.client",
|
|
};
|
|
} else {
|
|
// The action to load isn't the current one => executes it
|
|
actionRequest = state.action;
|
|
const context = { params: state };
|
|
if (state.active_id) {
|
|
context.active_id = state.active_id;
|
|
}
|
|
if (state.active_ids) {
|
|
context.active_ids = parseActiveIds(state.active_ids);
|
|
} else if (state.active_id) {
|
|
context.active_ids = [state.active_id];
|
|
}
|
|
Object.assign(options, {
|
|
additionalContext: context,
|
|
viewType: state.view_type,
|
|
});
|
|
if (state.id) {
|
|
options.props = { resId: state.id };
|
|
}
|
|
}
|
|
} else if (state.model) {
|
|
if (state.id) {
|
|
actionRequest = {
|
|
res_model: state.model,
|
|
res_id: state.id,
|
|
type: "ir.actions.act_window",
|
|
views: [[state.view_id ? state.view_id : false, "form"]],
|
|
};
|
|
} else if (state.view_type) {
|
|
// This is a window action on a multi-record view => restores it from
|
|
// the session storage
|
|
const storedAction = browser.sessionStorage.getItem("current_action");
|
|
const lastAction = JSON.parse(storedAction || "{}");
|
|
if (lastAction.res_model === state.model) {
|
|
actionRequest = lastAction;
|
|
options.viewType = state.view_type;
|
|
}
|
|
}
|
|
}
|
|
// If no action => falls back on the user default action (if any).
|
|
if (!actionRequest && env.services.user.home_action_id) {
|
|
actionRequest = env.services.user.home_action_id;
|
|
}
|
|
return actionRequest ? { actionRequest, options } : null;
|
|
}
|
|
|
|
/**
|
|
* @param {ClientAction | ActWindowAction} action
|
|
* @param {Object} props
|
|
* @returns {ActionProps}
|
|
*/
|
|
function _getActionProps(action, props) {
|
|
return Object.assign({}, props, { action, actionId: action.id });
|
|
}
|
|
|
|
/**
|
|
* @param {Action} action
|
|
* @returns {ActionMode}
|
|
*/
|
|
function _getActionMode(action) {
|
|
if (action.target === "new") {
|
|
// No possible override for target="new"
|
|
return "new";
|
|
}
|
|
if (action.type === "ir.actions.client") {
|
|
const clientAction = actionRegistry.get(action.tag);
|
|
if (clientAction.target) {
|
|
// Target is forced by the definition of the client action
|
|
return clientAction.target;
|
|
}
|
|
}
|
|
if (controllerStack.some((c) => c.action.target === "fullscreen")) {
|
|
// Force fullscreen when one of the controllers is set to fullscreen
|
|
return "fullscreen";
|
|
}
|
|
// Default: current
|
|
return "current";
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @returns {SwitchViewParams | null}
|
|
*/
|
|
function _getSwitchViewParams() {
|
|
const state = env.services.router.current.hash;
|
|
if (state.action && !actionRegistry.contains(state.action)) {
|
|
const currentController = controllerStack[controllerStack.length - 1];
|
|
const currentActionId =
|
|
currentController && currentController.action && currentController.action.id;
|
|
// Window Action: determines model, viewType etc....
|
|
if (
|
|
currentController &&
|
|
currentController.action.type === "ir.actions.act_window" &&
|
|
currentActionId === state.action
|
|
) {
|
|
const props = { resId: state.id || false };
|
|
const viewType = state.view_type || currentController.view.type;
|
|
return { viewType, props };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param {BaseView} view
|
|
* @param {ActWindowAction} action
|
|
* @param {BaseView[]} views
|
|
* @param {Object} props
|
|
* @returns {ViewProps}
|
|
*/
|
|
function _getViewProps(view, action, views, props = {}) {
|
|
const target = action.target;
|
|
const viewSwitcherEntries = views
|
|
.filter((v) => v.multiRecord === view.multiRecord)
|
|
.map((v) => {
|
|
return {
|
|
// FIXME: missing accesskey
|
|
icon: v.icon,
|
|
name: v.display_name,
|
|
type: v.type,
|
|
multiRecord: v.multiRecord,
|
|
};
|
|
});
|
|
const flags = action.flags || {};
|
|
const viewProps = Object.assign(_getActionProps(action, props), {
|
|
actionFlags: Object.assign({}, flags, flags[view.type]),
|
|
context: action.context,
|
|
domain: action.domain || [],
|
|
resModel: action.res_model,
|
|
type: view.type,
|
|
views: action.views,
|
|
viewSwitcherEntries,
|
|
withActionMenus: target !== "new" && target !== "inline",
|
|
withFilters: action.views.some((v) => v[1] === "search"),
|
|
});
|
|
if (action.res_id) {
|
|
viewProps.resId = action.res_id;
|
|
}
|
|
return viewProps;
|
|
}
|
|
|
|
/**
|
|
* Computes the position of the controller in the nextStack according to options
|
|
* @param {Object} options
|
|
* @param {boolean} [options.clearBreadcrumbs=false]
|
|
* @param {'replaceLast' | 'replaceLastAction'} [options.stackPosition]
|
|
* @param {number} [options.index]
|
|
*/
|
|
function _computeStackIndex(options) {
|
|
let index = null;
|
|
if (options.clearBreadcrumbs) {
|
|
index = 0;
|
|
} else if (options.stackPosition === "replaceCurrentAction") {
|
|
const currentController = controllerStack[controllerStack.length - 1];
|
|
if (currentController) {
|
|
index = controllerStack.findIndex(
|
|
(ct) => ct.action.jsId === currentController.action.jsId
|
|
);
|
|
}
|
|
} else if (options.stackPosition === "replacePreviousAction") {
|
|
let last;
|
|
for (let i = controllerStack.length - 1; i >= 0; i--) {
|
|
const action = controllerStack[i].action.jsId;
|
|
if (!last) {
|
|
last = action;
|
|
}
|
|
if (action !== last) {
|
|
last = action;
|
|
break;
|
|
}
|
|
}
|
|
if (last) {
|
|
index = controllerStack.findIndex((ct) => ct.action.jsId === last);
|
|
}
|
|
// TODO: throw if there is no previous action?
|
|
} else if ("index" in options) {
|
|
index = options.index;
|
|
} else {
|
|
index = controllerStack.length;
|
|
}
|
|
return index;
|
|
}
|
|
|
|
/**
|
|
* Triggers a re-rendering with respect to the given controller.
|
|
*
|
|
* @private
|
|
* @param {Controller} controller
|
|
* @param {UpdateStackOptions} options
|
|
* @param {boolean} [options.clearBreadcrumbs=false]
|
|
* @param {number} [options.index]
|
|
* @returns {Promise<Number>}
|
|
*/
|
|
async function _updateUI(controller, options = {}) {
|
|
let resolve;
|
|
let reject;
|
|
let dialogCloseResolve;
|
|
const currentActionProm = new Promise((_res, _rej) => {
|
|
resolve = _res;
|
|
reject = _rej;
|
|
});
|
|
const action = controller.action;
|
|
|
|
class ControllerComponent extends Component {
|
|
setup() {
|
|
this.Component = controller.Component;
|
|
this.componentRef = hooks.useRef("component");
|
|
this.registerCallback = null;
|
|
if (action.target !== "new") {
|
|
let beforeLeaveFn;
|
|
this.registerCallback = (type, fn) => {
|
|
switch (type) {
|
|
case "export":
|
|
controller.getState = fn;
|
|
break;
|
|
case "beforeLeave":
|
|
beforeLeaveFn = fn;
|
|
break;
|
|
}
|
|
};
|
|
useBus(env.bus, "CLEAR-UNCOMMITTED-CHANGES", (callbacks) => {
|
|
if (beforeLeaveFn) {
|
|
callbacks.push(beforeLeaveFn);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
catchError(error) {
|
|
reject(error);
|
|
cleanDomFromBootstrap();
|
|
if (action.target === "new") {
|
|
// get the dialog service to close the dialog.
|
|
throw error;
|
|
} else {
|
|
const lastCt = controllerStack[controllerStack.length - 1];
|
|
let info = {};
|
|
if (lastCt) {
|
|
if (lastCt.jsId === controller.jsId) {
|
|
// the error occurred on the controller which is
|
|
// already in the DOM, so simply show the error
|
|
Promise.resolve().then(() => {
|
|
throw error;
|
|
});
|
|
return;
|
|
} else {
|
|
info = lastCt.__info__;
|
|
// the error occurred while rendering a new controller,
|
|
// so go back to the last non faulty controller
|
|
// (the error will be shown anyway as the promise
|
|
// has been rejected)
|
|
}
|
|
}
|
|
env.bus.trigger("ACTION_MANAGER:UPDATE", info);
|
|
}
|
|
}
|
|
mounted() {
|
|
if (action.target === "new") {
|
|
dialogCloseProm = new Promise((_r) => {
|
|
dialogCloseResolve = _r;
|
|
}).then(() => {
|
|
dialogCloseProm = undefined;
|
|
});
|
|
dialog = nextDialog;
|
|
} else {
|
|
// LEGACY CODE COMPATIBILITY: remove when controllers will be written in owl
|
|
// we determine here which actions no longer occur in the nextStack,
|
|
// and we manually destroy all their controller's widgets
|
|
const nextStackActionIds = nextStack.map((c) => c.action.jsId);
|
|
const toDestroy = new Set();
|
|
for (const c of controllerStack) {
|
|
if (!nextStackActionIds.includes(c.action.jsId)) {
|
|
if (c.action.type === "ir.actions.act_window") {
|
|
for (const viewType in c.action.controllers) {
|
|
toDestroy.add(c.action.controllers[viewType]);
|
|
}
|
|
} else {
|
|
toDestroy.add(c);
|
|
}
|
|
}
|
|
}
|
|
for (const c of toDestroy) {
|
|
if (c.exportedState) {
|
|
c.exportedState.__legacy_widget__.destroy();
|
|
}
|
|
}
|
|
// END LEGACY CODE COMPATIBILITY
|
|
controllerStack = nextStack; // the controller is mounted, commit the new stack
|
|
// wait Promise callbacks to be executed
|
|
pushState(controller);
|
|
browser.sessionStorage.setItem("current_action", action._originalAction);
|
|
}
|
|
resolve();
|
|
env.bus.trigger("ACTION_MANAGER:UI-UPDATED", _getActionMode(action));
|
|
}
|
|
willUnmount() {
|
|
if (action.target === "new" && dialogCloseResolve) {
|
|
dialogCloseResolve();
|
|
}
|
|
}
|
|
onHistoryBack() {
|
|
const previousController = controllerStack[controllerStack.length - 2];
|
|
if (previousController && !dialog) {
|
|
restore(previousController.jsId);
|
|
} else {
|
|
_executeCloseAction();
|
|
}
|
|
}
|
|
onTitleUpdated(ev) {
|
|
controller.title = ev.detail;
|
|
}
|
|
}
|
|
ControllerComponent.template = ControllerComponentTemplate;
|
|
ControllerComponent.Component = controller.Component;
|
|
|
|
let nextDialog = null;
|
|
if (action.target === "new") {
|
|
cleanDomFromBootstrap();
|
|
const actionDialogProps = {
|
|
// TODO add size
|
|
ActionComponent: ControllerComponent,
|
|
actionProps: controller.props,
|
|
};
|
|
if (action.name) {
|
|
actionDialogProps.title = action.name;
|
|
}
|
|
|
|
let onClose = _removeDialog();
|
|
const removeDialog = env.services.dialog.add(ActionDialog, actionDialogProps, {
|
|
onClose: () => {
|
|
const onClose = _removeDialog();
|
|
if (onClose) {
|
|
onClose();
|
|
}
|
|
cleanDomFromBootstrap();
|
|
},
|
|
});
|
|
nextDialog = {
|
|
remove: removeDialog,
|
|
onClose: onClose || options.onClose,
|
|
};
|
|
return currentActionProm;
|
|
}
|
|
|
|
const currentController = _getCurrentController();
|
|
if (currentController && currentController.getState) {
|
|
currentController.exportedState = currentController.getState();
|
|
}
|
|
|
|
if (controller.exportedState) {
|
|
controller.props.state = controller.exportedState;
|
|
}
|
|
|
|
const index = _computeStackIndex(options);
|
|
const controllerArray = [controller];
|
|
if (options.lazyController) {
|
|
controllerArray.unshift(options.lazyController);
|
|
}
|
|
const nextStack = controllerStack.slice(0, index).concat(controllerArray);
|
|
controller.props.breadcrumbs = _getBreadcrumbs(nextStack.slice(0, nextStack.length - 1));
|
|
const closingProm = _executeCloseAction();
|
|
|
|
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]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ir.actions.act_url
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Executes actions of type 'ir.actions.act_url', i.e. redirects to the
|
|
* given url.
|
|
*
|
|
* @private
|
|
* @param {ActURLAction} action
|
|
*/
|
|
function _executeActURLAction(action) {
|
|
if (action.target === "self") {
|
|
env.services.router.redirect(action.url);
|
|
} else {
|
|
const w = browser.open(action.url, "_blank");
|
|
if (!w || w.closed || typeof w.closed === "undefined") {
|
|
const msg = env._t(
|
|
"A popup window has been blocked. You may need to change your " +
|
|
"browser settings to allow popup windows for this page."
|
|
);
|
|
env.services.notification.add(msg, {
|
|
sticky: true,
|
|
type: "warning",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ir.actions.act_window
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Executes an action of type 'ir.actions.act_window'.
|
|
*
|
|
* @private
|
|
* @param {ActWindowAction} action
|
|
* @param {ActionOptions} options
|
|
*/
|
|
function _executeActWindowAction(action, options) {
|
|
const views = [];
|
|
for (const [, type] of action.views) {
|
|
if (viewRegistry.contains(type)) {
|
|
views.push(viewRegistry.get(type));
|
|
}
|
|
}
|
|
if (!views.length) {
|
|
throw new Error(`No view found for act_window action ${action.id}`);
|
|
}
|
|
|
|
let view = options.viewType && views.find((v) => v.type === options.viewType);
|
|
let lazyView;
|
|
|
|
if (view && !view.multiRecord) {
|
|
lazyView = views[0].multiRecord ? views[0] : undefined;
|
|
} else if (!view) {
|
|
view = views[0];
|
|
}
|
|
|
|
if (env.isSmall) {
|
|
if (!view.isMobileFriendly) {
|
|
view = _findMobileView(views, view.multiRecord) || view;
|
|
}
|
|
if (lazyView && !lazyView.isMobileFriendly) {
|
|
lazyView = _findMobileView(views, lazyView.multiRecord) || lazyView;
|
|
}
|
|
}
|
|
|
|
const controller = {
|
|
jsId: `controller_${++id}`,
|
|
Component: view,
|
|
action,
|
|
view,
|
|
views,
|
|
props: _getViewProps(view, action, views, options.props),
|
|
};
|
|
action.controllers[view.type] = controller;
|
|
|
|
const updateUIOptions = {
|
|
clearBreadcrumbs: options.clearBreadcrumbs,
|
|
onClose: options.onClose,
|
|
stackPosition: options.stackPosition,
|
|
};
|
|
|
|
if (lazyView) {
|
|
updateUIOptions.lazyController = {
|
|
jsId: `controller_${++id}`,
|
|
Component: lazyView,
|
|
action,
|
|
view: lazyView,
|
|
views,
|
|
props: _getViewProps(lazyView, action, views),
|
|
};
|
|
}
|
|
|
|
return _updateUI(controller, updateUIOptions);
|
|
}
|
|
|
|
/**
|
|
* Helper function to find the first mobile-friendly view, if any.
|
|
*
|
|
* @private
|
|
* @param {Array} views an array of views
|
|
* @param {boolean} multiRecord true if we search for a multiRecord view
|
|
* @returns {Object|undefined} first mobile-friendly view found
|
|
*/
|
|
function _findMobileView(views, multiRecord) {
|
|
return views.find((view) => view.isMobileFriendly && view.multiRecord === multiRecord);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ir.actions.client
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Executes an action of type 'ir.actions.client'.
|
|
*
|
|
* @private
|
|
* @param {ClientAction} action
|
|
* @param {ActionOptions} options
|
|
*/
|
|
async function _executeClientAction(action, options) {
|
|
const clientAction = actionRegistry.get(action.tag);
|
|
if (clientAction.prototype instanceof Component) {
|
|
if (action.target !== "new" && clientAction.target) {
|
|
action.target = clientAction.target;
|
|
}
|
|
const controller = {
|
|
jsId: `controller_${++id}`,
|
|
Component: clientAction,
|
|
action,
|
|
props: _getActionProps(action, options.props),
|
|
};
|
|
return _updateUI(controller, {
|
|
clearBreadcrumbs: options.clearBreadcrumbs,
|
|
stackPosition: options.stackPosition,
|
|
onClose: options.onClose,
|
|
});
|
|
} else {
|
|
const next = await clientAction(env, action);
|
|
if (next) {
|
|
return doAction(next, options);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ir.actions.report
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// messages that might be shown to the user dependening on the state of wkhtmltopdf
|
|
const link = '<br><br><a href="http://wkhtmltopdf.org/" target="_blank">wkhtmltopdf.org</a>';
|
|
const WKHTMLTOPDF_MESSAGES = {
|
|
broken:
|
|
env._t(
|
|
"Your installation of Wkhtmltopdf seems to be broken. The report will be shown " +
|
|
"in html."
|
|
) + link,
|
|
install:
|
|
env._t(
|
|
"Unable to find Wkhtmltopdf on this system. The report will be shown in " + "html."
|
|
) + link,
|
|
upgrade:
|
|
env._t(
|
|
"You should upgrade your version of Wkhtmltopdf to at least 0.12.0 in order to " +
|
|
"get a correct display of headers and footers as well as support for " +
|
|
"table-breaking between pages."
|
|
) + link,
|
|
workers: env._t(
|
|
"You need to start Odoo with at least two workers to print a pdf version of " +
|
|
"the reports."
|
|
),
|
|
};
|
|
|
|
// only check the wkhtmltopdf state once, so keep the rpc promise
|
|
let wkhtmltopdfStateProm;
|
|
|
|
/**
|
|
* Generates the report url given a report action.
|
|
*
|
|
* @private
|
|
* @param {ReportAction} action
|
|
* @param {ReportType} type
|
|
* @returns {string}
|
|
*/
|
|
function _getReportUrl(action, type) {
|
|
let url = `/report/${type}/${action.report_name}`;
|
|
const actionContext = action.context || {};
|
|
if (action.data && JSON.stringify(action.data) !== "{}") {
|
|
// build a query string with `action.data` (it's the place where reports
|
|
// using a wizard to customize the output traditionally put their options)
|
|
const options = encodeURIComponent(JSON.stringify(action.data));
|
|
const context = encodeURIComponent(JSON.stringify(actionContext));
|
|
url += `?options=${options}&context=${context}`;
|
|
} else {
|
|
if (actionContext.active_ids) {
|
|
url += `/${actionContext.active_ids.join(",")}`;
|
|
}
|
|
if (type === "html") {
|
|
const context = encodeURIComponent(JSON.stringify(env.services.user.context));
|
|
url += `?context=${context}`;
|
|
}
|
|
}
|
|
return url;
|
|
}
|
|
|
|
/**
|
|
* Launches download action of the report
|
|
*
|
|
* @private
|
|
* @param {ReportAction} action
|
|
* @param {ActionOptions} options
|
|
* @returns {Promise}
|
|
*/
|
|
async function _triggerDownload(action, options, type) {
|
|
const url = _getReportUrl(action, type);
|
|
env.services.ui.block();
|
|
try {
|
|
await download({
|
|
url: "/report/download",
|
|
data: {
|
|
data: JSON.stringify([url, action.report_type]),
|
|
context: JSON.stringify(env.services.user.context),
|
|
},
|
|
});
|
|
} finally {
|
|
env.services.ui.unblock();
|
|
}
|
|
const onClose = options.onClose;
|
|
if (action.close_on_report_download) {
|
|
return doAction({ type: "ir.actions.act_window_close" }, { onClose });
|
|
} else if (onClose) {
|
|
onClose();
|
|
}
|
|
}
|
|
|
|
function _executeReportClientAction(action, options) {
|
|
const props = Object.assign({}, options.props, {
|
|
data: action.data,
|
|
display_name: action.display_name,
|
|
name: action.name,
|
|
report_file: action.report_file,
|
|
report_name: action.report_name,
|
|
report_url: _getReportUrl(action, "html"),
|
|
});
|
|
const clientActionOptions = Object.assign({}, options, {
|
|
context: action.context,
|
|
props,
|
|
});
|
|
return doAction("report.client_action", clientActionOptions);
|
|
}
|
|
|
|
/**
|
|
* Executes actions of type 'ir.actions.report'.
|
|
*
|
|
* @private
|
|
* @param {ReportAction} action
|
|
* @param {ActionOptions} options
|
|
*/
|
|
async function _executeReportAction(action, options) {
|
|
const handlers = registry.category("ir.actions.report handlers").getAll();
|
|
for (const handler of handlers) {
|
|
const result = await handler(action, options, env);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
}
|
|
if (action.report_type === "qweb-html") {
|
|
return _executeReportClientAction(action, options);
|
|
} else if (action.report_type === "qweb-pdf") {
|
|
// check the state of wkhtmltopdf before proceeding
|
|
if (!wkhtmltopdfStateProm) {
|
|
wkhtmltopdfStateProm = env.services.rpc("/report/check_wkhtmltopdf");
|
|
}
|
|
const state = await wkhtmltopdfStateProm;
|
|
// display a notification according to wkhtmltopdf's state
|
|
if (state in WKHTMLTOPDF_MESSAGES) {
|
|
env.services.notification.add(WKHTMLTOPDF_MESSAGES[state], {
|
|
sticky: true,
|
|
title: env._t("Report"),
|
|
});
|
|
}
|
|
if (state === "upgrade" || state === "ok") {
|
|
// trigger the download of the PDF report
|
|
return _triggerDownload(action, options, "pdf");
|
|
} else {
|
|
// open the report in the client action if generating the PDF is not possible
|
|
return _executeReportClientAction(action, options);
|
|
}
|
|
} else if (action.report_type === "qweb-text") {
|
|
return _triggerDownload(action, options, "text");
|
|
} else {
|
|
console.error(
|
|
`The ActionManager can't handle reports of type ${action.report_type}`,
|
|
action
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ir.actions.server
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Executes an action of type 'ir.actions.server'.
|
|
*
|
|
* @private
|
|
* @param {ServerAction} action
|
|
* @param {ActionOptions} options
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function _executeServerAction(action, options) {
|
|
const runProm = env.services.rpc("/web/action/run", {
|
|
action_id: action.id,
|
|
context: action.context || {},
|
|
});
|
|
let nextAction = await keepLast.add(runProm);
|
|
nextAction = nextAction || { type: "ir.actions.act_window_close" };
|
|
return doAction(nextAction, options);
|
|
}
|
|
|
|
async function _executeCloseAction(params = {}) {
|
|
let onClose;
|
|
if (dialog) {
|
|
onClose = _removeDialog();
|
|
} else {
|
|
onClose = params.onClose;
|
|
}
|
|
if (onClose) {
|
|
await onClose(params.onCloseInfo);
|
|
}
|
|
|
|
return dialogCloseProm;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* 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);
|
|
switch (action.type) {
|
|
case "ir.actions.act_url":
|
|
return _executeActURLAction(action);
|
|
case "ir.actions.act_window":
|
|
if (action.target !== "new") {
|
|
await clearUncommittedChanges(env);
|
|
}
|
|
return _executeActWindowAction(action, options);
|
|
case "ir.actions.act_window_close":
|
|
return _executeCloseAction({ onClose: options.onClose, onCloseInfo: action.infos });
|
|
case "ir.actions.client":
|
|
if (action.target !== "new") {
|
|
await clearUncommittedChanges(env);
|
|
}
|
|
return _executeClientAction(action, options);
|
|
case "ir.actions.report":
|
|
return _executeReportAction(action, options);
|
|
case "ir.actions.server":
|
|
return _executeServerAction(action, options);
|
|
default: {
|
|
let 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}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes an action on top of the current one (typically, when a button in a
|
|
* view is clicked). The button may be of type 'object' (call a given method
|
|
* of a given model) or 'action' (execute a given action). Alternatively, the
|
|
* button may have the attribute 'special', and in this case an
|
|
* 'ir.actions.act_window_close' is executed.
|
|
*
|
|
* @param {DoActionButtonParams} params
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function doActionButton(params) {
|
|
// determine the action to execute according to the params
|
|
let action;
|
|
const context = makeContext(params.context, params.buttonContext);
|
|
if (params.special) {
|
|
action = { type: "ir.actions.act_window_close", infos: { special: true } };
|
|
} else if (params.type === "object") {
|
|
// call a Python Object method, which may return an action to execute
|
|
let args = params.resId ? [[params.resId]] : [params.resIds];
|
|
if (params.args) {
|
|
let additionalArgs;
|
|
try {
|
|
// warning: quotes and double quotes problem due to json and xml clash
|
|
// maybe we should force escaping in xml or do a better parse of the args array
|
|
additionalArgs = JSON.parse(params.args.replace(/'/g, '"'));
|
|
} catch (e) {
|
|
browser.console.error("Could not JSON.parse arguments", params.args);
|
|
}
|
|
args = args.concat(additionalArgs);
|
|
}
|
|
const callProm = env.services.rpc("/web/dataset/call_button", {
|
|
args,
|
|
kwargs: { context },
|
|
method: params.name,
|
|
model: params.resModel,
|
|
});
|
|
action = await keepLast.add(callProm);
|
|
action =
|
|
action && typeof action === "object"
|
|
? action
|
|
: { type: "ir.actions.act_window_close" };
|
|
} else if (params.type === "action") {
|
|
// execute a given action, so load it first
|
|
context.active_id = params.resId || null;
|
|
context.active_ids = params.resIds;
|
|
context.active_model = params.resModel;
|
|
action = await keepLast.add(_loadAction(params.name, context));
|
|
} else {
|
|
throw new InvalidButtonParamsError("Missing type for doActionButton request");
|
|
}
|
|
// filter out context keys that are specific to the current action, because:
|
|
// - wrong default_* and search_default_* values won't give the expected result
|
|
// - wrong group_by values will fail and forbid rendering of the destination view
|
|
let currentCtx = {};
|
|
for (const key in params.context) {
|
|
if (key.match(CTX_KEY_REGEX) === null) {
|
|
currentCtx[key] = params.context[key];
|
|
}
|
|
}
|
|
const activeCtx = { active_model: params.resModel };
|
|
if (params.resId) {
|
|
activeCtx.active_id = params.resId;
|
|
activeCtx.active_ids = [params.resId];
|
|
}
|
|
action.context = makeContext(currentCtx, params.buttonContext, activeCtx, action.context);
|
|
// in case an effect is returned from python and there is already an effect
|
|
// attribute on the button, the priority is given to the button attribute
|
|
const effect = params.effect ? evaluateExpr(params.effect) : action.effect;
|
|
const options = { onClose: params.onClose };
|
|
await doAction(action, options);
|
|
if (params.close) {
|
|
await _executeCloseAction();
|
|
}
|
|
if (effect) {
|
|
env.services.effect.add(effect.type, effect);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Switches to the given view type in action of the last controller of the
|
|
* stack. This action must be of type 'ir.actions.act_window'.
|
|
*
|
|
* @param {ViewType} viewType
|
|
* @param {Object} [props={}]
|
|
* @throws {ViewNotFoundError} if the viewType is not found on the current action
|
|
* @returns {Promise<Number>}
|
|
*/
|
|
async function switchView(viewType, props = {}) {
|
|
const controller = controllerStack[controllerStack.length - 1];
|
|
const view = _getView(viewType);
|
|
if (!view) {
|
|
throw new ViewNotFoundError(
|
|
sprintf(
|
|
env._t("No view of type '%s' could be found in the current action."),
|
|
viewType
|
|
)
|
|
);
|
|
}
|
|
const newController = controller.action.controllers[viewType] || {
|
|
jsId: `controller_${++id}`,
|
|
Component: view,
|
|
action: controller.action,
|
|
views: controller.views,
|
|
view,
|
|
};
|
|
|
|
// LEGACY CODE COMPATIBILITY: remove when controllers will be written in owl
|
|
if (view.isLegacy && newController.jsId === controller.jsId) {
|
|
// case where a legacy view is reloaded via the view switcher
|
|
const { __legacy_widget__ } = controller.getState();
|
|
const params = {};
|
|
if ("resId" in props) {
|
|
params.currentId = props.resId;
|
|
}
|
|
return __legacy_widget__.reload(params);
|
|
}
|
|
// END LEGACY CODE COMPATIBILITY
|
|
|
|
newController.props = _getViewProps(view, controller.action, controller.views, props);
|
|
controller.action.controllers[viewType] = newController;
|
|
let index;
|
|
if (view.multiRecord) {
|
|
index = controllerStack.findIndex((ct) => ct.action.jsId === controller.action.jsId);
|
|
index = index > -1 ? index : controllerStack.length - 1;
|
|
} else {
|
|
// This case would mostly happen when loadState detects a change in the URL.
|
|
// Also, I guess we may need it when we have other monoRecord views
|
|
index = controllerStack.findIndex(
|
|
(ct) => ct.action.jsId === controller.action.jsId && !ct.view.multiRecord
|
|
);
|
|
index = index > -1 ? index : controllerStack.length;
|
|
}
|
|
await clearUncommittedChanges(env);
|
|
return _updateUI(newController, { index });
|
|
}
|
|
|
|
/**
|
|
* Restores a controller from the controller stack given its id. Typically,
|
|
* this function is called when clicking on the breadcrumbs. If no id is given
|
|
* restores the previous controller from the stack (penultimate).
|
|
*
|
|
* @param {string} jsId
|
|
*/
|
|
async function restore(jsId) {
|
|
let index;
|
|
if (!jsId) {
|
|
index = controllerStack.length - 2;
|
|
} else {
|
|
index = controllerStack.findIndex((controller) => controller.jsId === jsId);
|
|
}
|
|
if (index < 0) {
|
|
const msg = jsId ? "Invalid controller to restore" : "No controller to restore";
|
|
throw new ControllerNotFoundError(msg);
|
|
}
|
|
const controller = controllerStack[index];
|
|
if (controller.action.type === "ir.actions.act_window") {
|
|
controller.props = _getViewProps(controller.view, controller.action, controller.views);
|
|
}
|
|
await clearUncommittedChanges(env);
|
|
return _updateUI(controller, { index });
|
|
}
|
|
|
|
/**
|
|
* Performs a "doAction" or a "switchView" according to the current content of
|
|
* the URL. The id of the underlying action is be returned if one of these
|
|
* operations has successfully started.
|
|
*
|
|
* @returns {Promise<boolean>} true iff the state could have been loaded
|
|
*/
|
|
async function loadState() {
|
|
const switchViewParams = _getSwitchViewParams();
|
|
if (switchViewParams) {
|
|
// only when we already have an action in dom
|
|
const { viewType, props } = switchViewParams;
|
|
const view = _getView(viewType);
|
|
if (view) {
|
|
// Params valid and view found => performs a "switchView"
|
|
await switchView(viewType, props);
|
|
return true;
|
|
}
|
|
} else {
|
|
const actionParams = _getActionParams();
|
|
if (actionParams) {
|
|
// Params valid => performs a "doAction"
|
|
const { actionRequest, options } = actionParams;
|
|
await doAction(actionRequest, options);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function pushState(controller) {
|
|
const newState = {};
|
|
const action = controller.action;
|
|
if (action.id) {
|
|
newState.action = action.id;
|
|
} else if (action.type === "ir.actions.client") {
|
|
newState.action = action.tag;
|
|
}
|
|
if (action.context) {
|
|
const activeId = action.context.active_id;
|
|
if (activeId) {
|
|
newState.active_id = `${activeId}`;
|
|
}
|
|
const activeIds = action.context.active_ids;
|
|
// we don't push active_ids if it's a single element array containing
|
|
// the active_id to make the url shorter in most cases
|
|
if (activeIds && !(activeIds.length === 1 && activeIds[0] === activeId)) {
|
|
newState.active_ids = activeIds.join(",");
|
|
}
|
|
}
|
|
if (action.type === "ir.actions.act_window") {
|
|
const props = controller.props;
|
|
newState.model = props.resModel;
|
|
newState.view_type = props.type;
|
|
newState.id = props.resId ? `${props.resId}` : undefined;
|
|
}
|
|
env.services.router.pushState(newState, { replace: true });
|
|
}
|
|
return {
|
|
doAction,
|
|
doActionButton,
|
|
switchView,
|
|
restore,
|
|
loadState,
|
|
async loadAction(actionRequest, context) {
|
|
let action = await _loadAction(actionRequest, context);
|
|
return _preprocessAction(action, context);
|
|
},
|
|
get currentController() {
|
|
return _getCurrentController();
|
|
},
|
|
__legacy__isActionInStack(actionId) {
|
|
return controllerStack.find((c) => c.action.jsId === actionId);
|
|
},
|
|
};
|
|
}
|
|
|
|
export const actionService = {
|
|
dependencies: ["effect", "localization", "notification", "router", "rpc", "ui", "user"],
|
|
start(env) {
|
|
return makeActionManager(env);
|
|
},
|
|
};
|
|
|
|
registry.category("services").add("action", actionService);
|