Files
test/spiffy_theme_backend/static/src/js/action_service.js
2023-07-14 09:21:21 +08:00

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);