1624 lines
63 KiB
JavaScript
1624 lines
63 KiB
JavaScript
/** @odoo-module **/
|
|
|
|
import { browser } from "@web/core/browser/browser";
|
|
import { makeContext } from "@web/core/context";
|
|
import { useDebugCategory } from "@web/core/debug/debug_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 { useBus, useService } from "@web/core/utils/hooks";
|
|
import { sprintf } from "@web/core/utils/strings";
|
|
import { cleanDomFromBootstrap } from "@web/legacy/utils";
|
|
import { View, ViewNotFoundError } from "@web/views/view";
|
|
import { ActionDialog } from "@web/webclient/actions/action_dialog";
|
|
import { CallbackRecorder } from "@web/webclient/actions/action_hook";
|
|
import { ReportAction } from "@web/webclient/actions/reports/report_action";
|
|
|
|
import { Component, markup, onMounted, onWillUnmount, onError, useChildSubEnv, xml, reactive } from "@odoo/owl";
|
|
import body_color from "spiffy_theme_backend.MenuJs";
|
|
const actionHandlersRegistry = registry.category("action_handlers");
|
|
const actionRegistry = registry.category("actions");
|
|
const viewRegistry = registry.category("views");
|
|
|
|
// SPIFFY MULTI TAB START
|
|
const actionServiceRegistry = registry.category("services");
|
|
var session = require("web.session");
|
|
var ajax = require('web.ajax');
|
|
var core = require('web.core');
|
|
// SPIFFY MULTI TAB END
|
|
|
|
/** @typedef {number|false} ActionId */
|
|
/** @typedef {Object} ActionDescription */
|
|
/** @typedef {"current" | "fullscreen" | "new" | "main" | "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]
|
|
*/
|
|
|
|
export async function clearUncommittedChanges(env) {
|
|
const callbacks = [];
|
|
env.bus.trigger("CLEAR-UNCOMMITTED-CHANGES", callbacks);
|
|
const res = await Promise.all(callbacks.map((fn) => fn()));
|
|
return !res.includes(false);
|
|
}
|
|
|
|
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 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 = xml`<t t-component="Component" t-props="props"/>`;
|
|
|
|
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.addEventListener("CLEAR-CACHES", () => {
|
|
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;
|
|
}
|
|
}
|
|
|
|
|
|
// SPIFFY MULTI TAB START
|
|
function updateSpiffyTabDetails(tabId, ActionId, controller) {
|
|
const updateTabTimeout = setTimeout(updateTabDetails, 500);
|
|
|
|
function updateTabDetails() {
|
|
var ControllerXmlId = controller.action.xml_id.split('.')[0]
|
|
ajax.jsonRpc('/update/tab/details','call', {
|
|
'tabId': tabId,
|
|
'ActionId': ActionId,
|
|
'url': window.location.hash,
|
|
'TabTitle': controller.displayName,
|
|
'menu_xmlid': ControllerXmlId,
|
|
}).then(function(rec) {
|
|
var ControllerXmlId = controller.action.xml_id.split('.')[0]
|
|
var tabWithMenuID = $('.multi_tab_section').find('a[multi_tab_id="'+ tabId +'"]');
|
|
var tabWithMenu = $('.multi_tab_section').find('a[data-xml-id="'+ ControllerXmlId +'"]');
|
|
if($(tabWithMenuID).length){
|
|
if (!$(tabWithMenuID).parent().hasClass('tab_active')){
|
|
$('.multi_tab_section').find('.tab_active').removeClass('tab_active');
|
|
$(tabWithMenuID).parent().addClass('tab_active');
|
|
}
|
|
$(tabWithMenuID).attr('href',window.location.hash)
|
|
$(tabWithMenuID).attr('data-action-id', ActionId)
|
|
$(tabWithMenuID).attr('data-xml-id', ControllerXmlId)
|
|
$(tabWithMenuID).find('span').text(controller.displayName)
|
|
}
|
|
});
|
|
}
|
|
|
|
function TabTimeoutStop() {
|
|
clearTimeout(updateTabTimeout);
|
|
}
|
|
}
|
|
// SPIFFY MULTI TAB END
|
|
|
|
/**
|
|
* 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)}`;
|
|
let action;
|
|
if (!actionCache[key]) {
|
|
actionCache[key] = env.services.rpc("/web/action/load", {
|
|
action_id: actionRequest,
|
|
additional_context,
|
|
});
|
|
action = await actionCache[key];
|
|
if (action.help) {
|
|
action.help = markup(action.help);
|
|
}
|
|
} else {
|
|
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 = {}) {
|
|
try {
|
|
action._originalAction = JSON.stringify(action);
|
|
} catch (_e) {
|
|
// do nothing, the action might simply not be serializable
|
|
}
|
|
action.context = makeContext([context, action.context], env.services.user.context);
|
|
if (action.domain) {
|
|
const domain = action.domain || [];
|
|
action.domain =
|
|
typeof domain === "string"
|
|
? evaluateExpr(
|
|
domain,
|
|
Object.assign({}, env.services.user.context, action.context)
|
|
)
|
|
: domain;
|
|
}
|
|
if (action.help) {
|
|
const htmlHelp = document.createElement("div");
|
|
htmlHelp.innerHTML = action.help;
|
|
if (!htmlHelp.innerText.trim()) {
|
|
delete action.help;
|
|
}
|
|
}
|
|
action = { ...action }; // manipulate a copy to keep cached action unmodified
|
|
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.views = [...action.views.map((v) => [v[0], v[1] === "tree" ? "list" : v[1]])]; // manipulate a copy to keep cached action unmodified
|
|
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,
|
|
get name() {
|
|
return controller.displayName;
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @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.help) {
|
|
lastAction.help = markup(lastAction.help);
|
|
}
|
|
if (lastAction.res_model === state.model) {
|
|
if (lastAction.context) {
|
|
// If this method is called because of a company switch, the
|
|
// stored allowed_company_ids is incorrect.
|
|
delete lastAction.context.allowed_company_ids;
|
|
}
|
|
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} action
|
|
* @param {Object} props
|
|
* @returns {{ props: ActionProps, config: Config }}
|
|
*/
|
|
function _getActionInfo(action, props) {
|
|
return {
|
|
props: Object.assign({}, props, { action, actionId: action.id }),
|
|
config: {
|
|
actionId: action.id,
|
|
actionType: "ir.actions.client",
|
|
actionFlags: action.flags,
|
|
},
|
|
displayName: action.display_name || action.name || "",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @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 {{ props: ViewProps, config: Config }}
|
|
*/
|
|
function _getViewInfo(view, action, views, props = {}) {
|
|
const target = action.target;
|
|
const viewSwitcherEntries = views
|
|
.filter((v) => v.multiRecord === view.multiRecord)
|
|
.map((v) => {
|
|
const viewSwitcherEntry = {
|
|
icon: v.icon,
|
|
name: v.display_name.toString(),
|
|
type: v.type,
|
|
multiRecord: v.multiRecord,
|
|
};
|
|
if (view.type === v.type) {
|
|
viewSwitcherEntry.active = true;
|
|
}
|
|
return viewSwitcherEntry;
|
|
});
|
|
const context = action.context || {};
|
|
let groupBy = context.group_by || [];
|
|
if (typeof groupBy === "string") {
|
|
groupBy = [groupBy];
|
|
}
|
|
const viewProps = Object.assign({}, props, {
|
|
context,
|
|
display: { mode: target === "new" ? "inDialog" : target },
|
|
domain: action.domain || [],
|
|
groupBy,
|
|
loadActionMenus: target !== "new" && target !== "inline",
|
|
loadIrFilters: action.views.some((v) => v[1] === "search"),
|
|
resModel: action.res_model,
|
|
type: view.type,
|
|
selectRecord: async (resId, { activeIds, mode }) => {
|
|
if (_getView("form")) {
|
|
await switchView("form", { mode, resId, resIds: activeIds });
|
|
}
|
|
},
|
|
createRecord: async () => {
|
|
if (_getView("form")) {
|
|
await switchView("form", { resId: false });
|
|
}
|
|
},
|
|
});
|
|
|
|
if (view.type === "form") {
|
|
if (action.target === "new") {
|
|
viewProps.mode = "edit";
|
|
if (!viewProps.onSave) {
|
|
viewProps.onSave = (record, params) => {
|
|
if (params && params.closable) {
|
|
doAction({ type: "ir.actions.act_window_close" });
|
|
}
|
|
};
|
|
}
|
|
} else if (context.form_view_initial_mode) {
|
|
viewProps.mode = context.form_view_initial_mode;
|
|
}
|
|
if (action.flags && "mode" in action.flags) {
|
|
viewProps.mode = action.flags.mode;
|
|
}
|
|
}
|
|
|
|
if (target === "inline") {
|
|
viewProps.searchMenuTypes = [];
|
|
}
|
|
|
|
const specialKeys = ["help", "useSampleModel", "limit", "count"];
|
|
for (const key of specialKeys) {
|
|
if (key in action) {
|
|
if (key === "help") {
|
|
viewProps.noContentHelp = action.help;
|
|
} else {
|
|
viewProps[key] = action[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (context.search_disable_custom_filters) {
|
|
viewProps.activateFavorite = false;
|
|
}
|
|
|
|
// view specific
|
|
if (action.res_id) {
|
|
viewProps.resId = action.res_id;
|
|
}
|
|
|
|
// LEGACY CODE COMPATIBILITY: remove when all views will be written in owl
|
|
if (view.isLegacy) {
|
|
const legacyActionInfo = { ...action, ...viewProps.action };
|
|
Object.assign(viewProps, {
|
|
action: legacyActionInfo,
|
|
View: view,
|
|
views: action.views,
|
|
});
|
|
}
|
|
// END LEGACY CODE COMPATIBILITY
|
|
|
|
viewProps.noBreadcrumbs = action.context.no_breadcrumbs;
|
|
delete action.context.no_breadcrumbs;
|
|
return {
|
|
props: viewProps,
|
|
config: {
|
|
actionId: action.id,
|
|
actionType: "ir.actions.act_window",
|
|
actionFlags: action.flags,
|
|
views: action.views,
|
|
viewSwitcherEntries,
|
|
},
|
|
displayName: action.display_name || action.name || "",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
const index = _computeStackIndex(options);
|
|
const controllerArray = [controller];
|
|
if (options.lazyController) {
|
|
controllerArray.unshift(options.lazyController);
|
|
}
|
|
const nextStack = controllerStack.slice(0, index).concat(controllerArray);
|
|
// Compute breadcrumbs
|
|
controller.config.breadcrumbs = reactive(
|
|
action.target === "new" ? [] : _getBreadcrumbs(nextStack)
|
|
);
|
|
controller.config.getDisplayName = () => controller.displayName;
|
|
controller.config.setDisplayName = (displayName) => {
|
|
controller.displayName = displayName;
|
|
if (controller === _getCurrentController()) {
|
|
// if not mounted yet, will be done in "mounted"
|
|
env.services.title.setParts({ action: controller.displayName });
|
|
}
|
|
if (action.target !== "new") {
|
|
// This is a hack to force the reactivity when a new displayName is set
|
|
controller.config.breadcrumbs.push(undefined);
|
|
controller.config.breadcrumbs.pop();
|
|
}
|
|
|
|
// SPIFFY MULTI TAB START
|
|
if (controller.action.id){
|
|
var TabDiv = $('.multi_tab_section .multi_tab_div');
|
|
// $('.multi_tab_section .multi_tab_div.tab_active a').attr('data-action-id', controller.action.id)
|
|
var UpdateAction = controller.action.id
|
|
var tabID = $('.multi_tab_section .multi_tab_div.tab_active a').attr('multi_tab_id')
|
|
// var menu_xmlid = $(TabDiv).find('a[data-xml-id="'+ controller.action.xml_id.split('.')[0] +'"]')
|
|
var menu_xmlid = $(TabDiv).find('a[multi_tab_id="'+ tabID +'"]')
|
|
|
|
/* if($(menu_xmlid).length){
|
|
updateSpiffyTabDetails(tabID, UpdateAction, controller);
|
|
} */
|
|
if($(menu_xmlid).length && !localStorage.getItem('TabClickTilteUpdate')) {
|
|
updateSpiffyTabDetails(tabID, UpdateAction, controller);
|
|
}
|
|
if(localStorage.getItem('TabClickTilteUpdate')){
|
|
localStorage.removeItem("TabClickTilteUpdate");
|
|
}
|
|
}
|
|
// SPIFFY MULTI TAB END
|
|
};
|
|
controller.config.historyBack = () => {
|
|
const previousController = controllerStack[controllerStack.length - 2];
|
|
if (previousController && !dialog) {
|
|
restore(previousController.jsId);
|
|
} else {
|
|
_executeCloseAction();
|
|
}
|
|
};
|
|
|
|
// SPIFFY MULTI TAB START
|
|
sessionStorage.setItem("spiffy_current_action", action._originalAction);
|
|
sessionStorage.setItem("spiffy_current_action_id", action.id);
|
|
// SPIFFY MULTI TAB END
|
|
|
|
class ControllerComponent extends Component {
|
|
setup() {
|
|
this.Component = controller.Component;
|
|
this.titleService = useService("title");
|
|
useDebugCategory("action", { action });
|
|
useChildSubEnv({
|
|
config: controller.config,
|
|
});
|
|
if (action.target !== "new") {
|
|
this.__beforeLeave__ = new CallbackRecorder();
|
|
this.__getGlobalState__ = new CallbackRecorder();
|
|
this.__getLocalState__ = new CallbackRecorder();
|
|
useBus(env.bus, "CLEAR-UNCOMMITTED-CHANGES", (ev) => {
|
|
const callbacks = ev.detail;
|
|
const beforeLeaveFns = this.__beforeLeave__.callbacks;
|
|
callbacks.push(...beforeLeaveFns);
|
|
});
|
|
useChildSubEnv({
|
|
__beforeLeave__: this.__beforeLeave__,
|
|
__getGlobalState__: this.__getGlobalState__,
|
|
__getLocalState__: this.__getLocalState__,
|
|
});
|
|
}
|
|
this.isMounted = false;
|
|
|
|
onMounted(this.onMounted);
|
|
onWillUnmount(this.onWillUnmount);
|
|
onError(this.onError);
|
|
}
|
|
onError(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);
|
|
}
|
|
}
|
|
onMounted() {
|
|
if (action.target === "new") {
|
|
dialogCloseProm = new Promise((_r) => {
|
|
dialogCloseResolve = _r;
|
|
}).then(() => {
|
|
dialogCloseProm = undefined;
|
|
});
|
|
dialog = nextDialog;
|
|
} else {
|
|
controller.getGlobalState = () => {
|
|
const exportFns = this.__getGlobalState__.callbacks;
|
|
if (exportFns.length) {
|
|
return Object.assign({}, ...exportFns.map((fn) => fn()));
|
|
}
|
|
};
|
|
controller.getLocalState = () => {
|
|
const exportFns = this.__getLocalState__.callbacks;
|
|
if (exportFns.length) {
|
|
return Object.assign({}, ...exportFns.map((fn) => fn()));
|
|
}
|
|
};
|
|
|
|
// 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) {
|
|
const controller = c.action.controllers[viewType];
|
|
if (controller.view.isLegacy) {
|
|
toDestroy.add(controller);
|
|
}
|
|
}
|
|
} else {
|
|
toDestroy.add(c);
|
|
}
|
|
}
|
|
}
|
|
for (const c of toDestroy) {
|
|
if (c.exportedState && c.exportedState.__legacy_widget__) {
|
|
c.exportedState.__legacy_widget__.destroy();
|
|
}
|
|
}
|
|
// END LEGACY CODE COMPATIBILITY
|
|
controllerStack = nextStack; // the controller is mounted, commit the new stack
|
|
pushState(controller);
|
|
this.titleService.setParts({ action: controller.displayName });
|
|
browser.sessionStorage.setItem(
|
|
"current_action",
|
|
action._originalAction || "{}"
|
|
);
|
|
}
|
|
resolve();
|
|
env.bus.trigger("ACTION_MANAGER:UI-UPDATED", _getActionMode(action));
|
|
this.isMounted = true;
|
|
}
|
|
onWillUnmount() {
|
|
if (action.target === "new" && dialogCloseResolve) {
|
|
dialogCloseResolve();
|
|
}
|
|
}
|
|
}
|
|
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,
|
|
actionType: action.type,
|
|
};
|
|
if (action.name) {
|
|
actionDialogProps.title = action.name;
|
|
}
|
|
|
|
const 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.getLocalState) {
|
|
currentController.exportedState = currentController.getLocalState();
|
|
}
|
|
if (controller.exportedState) {
|
|
controller.props.state = controller.exportedState;
|
|
}
|
|
|
|
// TODO DAM Remarks:
|
|
// this thing seems useless for client actions.
|
|
// restore and switchView (at least) use this --> cannot be done in switchView only
|
|
// if prop globalState has been passed in doAction, since the action is new the prop won't be overridden in l655.
|
|
// if globalState is not useful for client actions --> maybe use that thing in useSetupView instead of useSetupAction?
|
|
// a good thing: the Object.assign seems to reflect the use of "externalState" in legacy Model class --> things should be fine.
|
|
if (currentController && currentController.getGlobalState) {
|
|
currentController.action.globalState = Object.assign(
|
|
{},
|
|
currentController.action.globalState,
|
|
currentController.getGlobalState() // what if this = {}?
|
|
);
|
|
}
|
|
if (controller.action.globalState) {
|
|
controller.props.globalState = controller.action.globalState;
|
|
}
|
|
|
|
const closingProm = _executeCloseAction();
|
|
|
|
controller.__info__ = {
|
|
id: ++id,
|
|
Component: ControllerComponent,
|
|
componentProps: controller.props,
|
|
};
|
|
env.bus.trigger("ACTION_MANAGER:UPDATE", controller.__info__);
|
|
|
|
if (action.id){
|
|
var TabDiv = $('.multi_tab_section .multi_tab_div');
|
|
$('.multi_tab_section .multi_tab_div.tab_active a').attr('data-action-id', action.id)
|
|
var UpdateAction = action.id
|
|
var tabID = $('.multi_tab_section .multi_tab_div.tab_active a').attr('multi_tab_id')
|
|
// var menu_xmlid = $(TabDiv).find('a[data-xml-id="'+ controller.action.xml_id.split('.')[0] +'"]')
|
|
var menu_xmlid = $(TabDiv).find('a[multi_tab_id="'+ tabID +'"]')
|
|
|
|
if($(menu_xmlid).length && !localStorage.getItem('TabClick')) {
|
|
updateSpiffyTabDetails(tabID, UpdateAction, controller);
|
|
}
|
|
if(localStorage.getItem('TabClick')){
|
|
localStorage.removeItem("TabClick");
|
|
}
|
|
/* if(localStorage.getItem('TabClickTilteUpdate')){
|
|
localStorage.removeItem("TabClickTilteUpdate");
|
|
} */
|
|
|
|
}
|
|
|
|
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
|
|
* @param {ActionOptions} options
|
|
*/
|
|
function _executeActURLAction(action, options) {
|
|
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",
|
|
});
|
|
}
|
|
if (options.onClose) {
|
|
options.onClose();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ir.actions.act_window
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Executes an action of type 'ir.actions.act_window'.
|
|
*
|
|
* @private
|
|
* @param {ActWindowAction} action
|
|
* @param {ActionOptions} options
|
|
*/
|
|
async function _executeActWindowAction(action, options) {
|
|
// LEGACY CODE COMPATIBILITY: load views to determine js_class if any, s.t.
|
|
// we know if the view to use is legacy or not
|
|
// When all views will be converted, this will be done exclusively by View
|
|
// #action-serv-leg-compat-js-class
|
|
const loadViewParams = {
|
|
context: action.context || {},
|
|
views: action.views,
|
|
resModel: action.res_model,
|
|
};
|
|
const loadViewOptions = {
|
|
actionId: action.id,
|
|
loadActionMenus: action.target !== "new" && action.target !== "inline",
|
|
loadIrFilters: action.views.some((v) => v[1] === "search"),
|
|
};
|
|
const prom = env.services.view.loadViews(loadViewParams, loadViewOptions);
|
|
const { views: viewDescriptions } = await keepLast.add(prom);
|
|
const domParser = new DOMParser();
|
|
const views = [];
|
|
for (const [, type] of action.views) {
|
|
if (type !== "search") {
|
|
const arch = viewDescriptions[type].arch;
|
|
const archDoc = domParser.parseFromString(arch, "text/xml").documentElement;
|
|
const jsClass = archDoc.getAttribute("js_class");
|
|
const view = viewRegistry.get(jsClass, false) || viewRegistry.get(type, false);
|
|
if (view) {
|
|
views.push(view);
|
|
}
|
|
}
|
|
}
|
|
// END LEGACY CODE COMPATIBILITY
|
|
// const views = [];
|
|
// for (const [, type] of action.views) {
|
|
// if (type !== "search" && viewRegistry.contains(type)) {
|
|
// views.push(viewRegistry.get(key));
|
|
// }
|
|
// }
|
|
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.isLegacy ? view.Controller : View,
|
|
action,
|
|
view,
|
|
views,
|
|
..._getViewInfo(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.isLegacy ? lazyView.Controller : View,
|
|
action,
|
|
view: lazyView,
|
|
views,
|
|
..._getViewInfo(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") {
|
|
const canProceed = await clearUncommittedChanges(env);
|
|
if (!canProceed) {
|
|
return;
|
|
}
|
|
if (clientAction.target) {
|
|
action.target = clientAction.target;
|
|
}
|
|
}
|
|
const controller = {
|
|
jsId: `controller_${++id}`,
|
|
Component: clientAction,
|
|
action,
|
|
..._getActionInfo(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"),
|
|
context: Object.assign({}, action.context),
|
|
});
|
|
|
|
const controller = {
|
|
jsId: `controller_${++id}`,
|
|
Component: ReportAction,
|
|
action,
|
|
..._getActionInfo(action, props),
|
|
};
|
|
|
|
return _updateUI(controller, {
|
|
clearBreadcrumbs: options.clearBreadcrumbs,
|
|
stackPosition: options.stackPosition,
|
|
onClose: options.onClose,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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: makeContext([env.services.user.context, action.context]),
|
|
});
|
|
let nextAction = await keepLast.add(runProm);
|
|
if (nextAction.help) {
|
|
nextAction.help = markup(nextAction.help);
|
|
}
|
|
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);
|
|
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;
|
|
}
|
|
}
|
|
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}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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" };
|
|
if (action.help) {
|
|
action.help = markup(action.help);
|
|
}
|
|
} 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
|
|
const 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 = {}) {
|
|
await keepLast.add(Promise.resolve());
|
|
if (dialog) {
|
|
// we don't want to switch view when there's a dialog open, as we would
|
|
// not switch in the correct action (action in background != dialog action)
|
|
return;
|
|
}
|
|
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.isLegacy ? view.Controller : 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.getLocalState();
|
|
const params = {};
|
|
if ("resId" in props) {
|
|
params.currentId = props.resId;
|
|
}
|
|
return __legacy_widget__.reload(params);
|
|
}
|
|
// END LEGACY CODE COMPATIBILITY
|
|
|
|
Object.assign(
|
|
newController,
|
|
_getViewInfo(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;
|
|
}
|
|
const canProceed = await clearUncommittedChanges(env);
|
|
if (canProceed) {
|
|
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) {
|
|
await keepLast.add(Promise.resolve());
|
|
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") {
|
|
const { action, exportedState, view, views } = controller;
|
|
const props = { ...controller.props };
|
|
if (exportedState && "resId" in exportedState) {
|
|
// When restoring, we want to use the last exported ID of the controller
|
|
props.resId = exportedState.resId;
|
|
}
|
|
Object.assign(controller, _getViewInfo(view, action, views, props));
|
|
}
|
|
const canProceed = await clearUncommittedChanges(env);
|
|
if (canProceed) {
|
|
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.state && props.state.resId) || undefined;
|
|
}
|
|
env.services.router.pushState(newState, { replace: true });
|
|
}
|
|
return {
|
|
doAction,
|
|
doActionButton,
|
|
switchView,
|
|
restore,
|
|
loadState,
|
|
async loadAction(actionRequest, context) {
|
|
const 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 function divertColorItem(env) {
|
|
const route = "/primary_color/divertable_color";
|
|
return {
|
|
type: "item",
|
|
id: "divert.account",
|
|
description: env._t("Switch/Add Account"),
|
|
href: `${browser.location.origin}${route}`,
|
|
callback: () => {
|
|
body_color.methods.divertColor();
|
|
},
|
|
sequence: 70,
|
|
};
|
|
}
|
|
|
|
export const actionService = {
|
|
dependencies: [
|
|
"effect",
|
|
"localization",
|
|
"notification",
|
|
"router",
|
|
"rpc",
|
|
"title",
|
|
"view", // for legacy view compatibility #action-serv-leg-compat-js-class
|
|
"ui",
|
|
"user",
|
|
],
|
|
start(env) {
|
|
return makeActionManager(env);
|
|
},
|
|
};
|
|
|
|
actionServiceRegistry.remove("action");
|
|
actionServiceRegistry.add("action", actionService);
|