odoo18/addons_extensions/tabbar/static/src/action_service.js

1943 lines
61 KiB
JavaScript

import { _t } from '@web/core/l10n/translation';
import { browser } from '@web/core/browser/browser';
import { makeContext } from '@web/core/context';
import { useDebugCategory } from '@web/core/debug/debug_context';
import { evaluateExpr } from '@web/core/py_js/py';
import { rpc, rpcBus } from '@web/core/network/rpc';
import { registry } from '@web/core/registry';
import { user } from '@web/core/user';
import { Deferred, KeepLast } from '@web/core/utils/concurrency';
import { useBus, useService } from '@web/core/utils/hooks';
import { View, ViewNotFoundError } from '@web/views/view';
import { ActionDialog } from '@web/webclient/actions/action_dialog';
import { ReportAction } from '@web/webclient/actions/reports/report_action';
import { UPDATE_METHODS } from '@web/core/orm_service';
import { CallbackRecorder } from '@web/search/action_hook';
import { ControlPanel } from '@web/search/control_panel/control_panel';
import {
PATH_KEYS,
router as _router,
stateToUrl,
} from '@web/core/browser/router';
import {
Component,
markup,
onMounted,
onWillUnmount,
onError,
useChildSubEnv,
xml,
reactive,
status,
useSubEnv,
} from '@odoo/owl';
import { downloadReport, getReportUrl } from '@web/webclient/actions/reports/utils';
import { zip } from '@web/core/utils/arrays';
import {
isHtmlEmpty,
} from "@web/views/kanban/kanban_record";
//import { isHtmlEmpty } from '@web/core/utils/html';
import { omit, pick, shallowEqual } from '@web/core/utils/objects';
import { session } from '@web/session';
import { exprToBoolean } from '@web/core/utils/strings';
import { clearUncommittedChanges, standardActionServiceProps, ControllerNotFoundError, InvalidButtonParamsError } from '@web/webclient/actions/action_service';
class BlankComponent extends Component {
static props = ['onMounted', 'withControlPanel', '*'];
static template = xml`
<ControlPanel display="{disableDropdown: true}" t-if="props.withControlPanel and !env.isSmall">
<t t-set-slot="layout-buttons">
<button class="btn btn-primary invisible"> empty </button>
</t>
</ControlPanel>`;
static components = { ControlPanel };
setup() {
useChildSubEnv({ config: { breadcrumbs: [], noBreadcrumbs: true } });
onMounted(() => this.props.onMounted());
}
}
const actionHandlersRegistry = registry.category('action_handlers');
const actionRegistry = registry.category('actions');
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;
}
const DIALOG_SIZES = {
'extra-large': 'xl',
large: 'lg',
medium: 'md',
small: 'sm',
};
const CTX_KEY_REGEX =
/^(?:(?:default_|search_default_|show_).+|.+_view_ref|group_by|active_id|active_ids|orderedBy)$/;
// keys added to the context for the embedded actions feature
const EMBEDDED_ACTIONS_CTX_KEYS = [
'current_embedded_action_id',
'parent_action_embedded_actions',
'parent_action_id',
'from_embedded_action',
];
// only register this template once for all dynamic classes ControllerComponent
const ControllerComponentTemplate = xml`<t t-component="Component" t-props="componentProps"/>`;
export function makeActionManager(env, router = _router) {
const breadcrumbCache = {};
// ! my edit
const controllerStacks = {};
let count = 0
const keepLast = new KeepLast();
let id = 0;
let controllerStack = [];
let dialogCloseProm;
let actionCache = {};
let dialog = null;
let nextDialog = null;
router.hideKeyFromUrl('globalState');
env.bus.addEventListener('CLEAR-CACHES', () => {
actionCache = {};
});
rpcBus.addEventListener('RPC:RESPONSE', (ev) => {
const { model, method } = ev.detail.data.params;
if (
model === 'ir.actions.act_window' &&
UPDATE_METHODS.includes(method)
) {
actionCache = {};
}
});
// ---------------------------------------------------------------------------
// misc
// ---------------------------------------------------------------------------
/**
* Create an array of virtual controllers based on the current state of the
* router.
*
* @returns {Promise<object[]>} an array of virtual controllers
*/
async function _controllersFromState() {
const state = router.current;
if (!state?.actionStack?.length) {
return [];
}
// The last controller will be created by doAction and won't be virtual
const controllers = state.actionStack
.slice(0, -1)
.map((actionState, index) => {
const controller = _makeController({
displayName: actionState.displayName,
virtual: true,
action: {},
props: {},
state: {
...actionState,
actionStack: state.actionStack.slice(0, index + 1),
},
currentState: {},
});
if (actionState.action) {
controller.action.id = actionState.action;
const [actionRequestKey, clientAction] =
actionRegistry.contains(actionState.action)
? [
actionState.action,
actionRegistry.get(actionState.action),
]
: actionRegistry
.getEntries()
.find(
(a) => a[1].path === actionState.action
) ?? [];
if (actionRequestKey && clientAction) {
if (
state.actionStack[index + 1]?.action ===
actionState.action
) {
// client actions don't have multi-record views, so we can't go further to the next controller
return;
}
controller.action.tag = actionRequestKey;
controller.action.type = 'ir.actions.client';
controller.displayName =
clientAction.displayName?.toString();
}
if (actionState.active_id) {
controller.action.context = {
active_id: actionState.active_id,
};
controller.currentState.active_id =
actionState.active_id;
}
}
if (actionState.model) {
controller.action.type = 'ir.actions.act_window';
controller.props.resModel = actionState.model;
}
if (actionState.resId) {
controller.action.type ||= 'ir.actions.act_window';
controller.props.resId = actionState.resId;
controller.currentState.resId = actionState.resId;
controller.props.type = 'form';
}
return controller;
})
.filter(Boolean);
if (
state.action &&
state.resId &&
controllers.at(-1)?.action?.id === state.action
) {
// When loading the state on a form view, we will need to load the action for it,
// and this will give us the display name of the corresponding multi-record view in
// the breadcrumb.
// By marking the last controller as a lazyController, we can in some cases avoid
// _loadBreadcrumbs from doing any network request as the breadcrumbs may only contain
// the form view and the multi-record view.
const bcControllers = await _loadBreadcrumbs(
controllers.slice(0, -1)
);
controllers.at(-1).lazy = true;
return [...bcControllers, controllers.at(-1)];
}
return _loadBreadcrumbs(controllers);
}
/**
* Load breadcrumbs for an array of controllers. This function adds display
* names to controllers that the current user has access to and for which
* the view (and record) exist. Controllers that correspond to a deleted
* record or a record/view that the user can't access are removed.
*
* @param {object[]} controllers an array of controllers whose breadcrumbs
* should be loaded
* @returns {Promise<object[]>} a new array of the displayable controllers
* to which a display name was added
*/
async function _loadBreadcrumbs(controllers) {
const toFetch = [];
const keys = [];
for (const { action, state, displayName } of controllers) {
if (
action.id === 'menu' ||
(action.type === 'ir.actions.client' && !displayName)
) {
continue;
}
const actionInfo = pick(state, 'action', 'model', 'resId');
const key = JSON.stringify(actionInfo);
keys.push(key);
if (displayName) {
breadcrumbCache[key] = { display_name: displayName };
}
if (key in breadcrumbCache) {
continue;
}
toFetch.push(actionInfo);
}
if (toFetch.length) {
const req = rpc('/web/action/load_breadcrumbs', {
actions: toFetch,
});
for (const [i, info] of toFetch.entries()) {
const key = JSON.stringify(info);
breadcrumbCache[key] = req.then((res) => {
breadcrumbCache[key] = res[i];
return res[i];
});
}
}
const results = await Promise.all(keys.map((k) => breadcrumbCache[k]));
const controllersToRemove = [];
for (const [controller, res] of zip(controllers, results)) {
if ('display_name' in res) {
controller.displayName = res.display_name;
} else {
controllersToRemove.push(controller);
if ('error' in res) {
console.warn(
'The following element was removed from the breadcrumb and from the url.\n',
controller.state,
"\nThis could be because the action wasn't found or because the user doesn't have the right to access to the record, the original error is :\n",
res.error
);
}
}
}
return controllers.filter((c) => !controllersToRemove.includes(c));
}
/**
* Removes the current dialog from the action service's state.
* It returns the dialog's onClose callback to be able to propagate it to the next dialog.
*
* @return {Function|undefined} When there was a dialog, returns its onClose callback for propagation to next dialog.
*/
function _removeDialog() {
if (dialog) {
const { onClose, remove } = dialog;
dialog = null;
// Remove the dialog from the dialog_service.
// The code is well enough designed to avoid falling in a function call loop.
remove();
return onClose;
}
}
/**
* Returns the last controller of the current controller stack.
*
* @returns {Controller|null}
*/
function _getCurrentController() {
const stack = controllerStack;
return stack.length ? stack[stack.length - 1] : null;
}
/**
* Given an id, xmlid, tag (key of the client action registry) or directly an
* object describing an action.
*
* @private
* @param {ActionRequest} actionRequest
* @param {Context} [context={}]
* @returns {Promise<Action>}
*/
async function _loadAction(actionRequest, context = {}) {
if (
typeof actionRequest === 'string' &&
actionRegistry.contains(actionRequest)
) {
// actionRequest is a key in the actionRegistry
return {
target: 'current',
tag: actionRequest,
type: 'ir.actions.client',
};
}
if (
typeof actionRequest === 'string' ||
typeof actionRequest === 'number'
) {
// actionRequest is an id or an xmlid
const ctx = makeContext([user.context, context]);
delete ctx.params;
const key = `${JSON.stringify(actionRequest)},${JSON.stringify(
ctx
)}`;
let action = await actionCache[key];
if (!action) {
actionCache[key] = rpc('/web/action/load', {
action_id: actionRequest,
context: ctx,
});
action = await actionCache[key];
if (action.help) {
action.help = markup(action.help);
}
}
return Object.assign({}, action);
}
// actionRequest is an object describing the action
return actionRequest;
}
/**
* Makes a controller from the given params.
*
* @param {Object} params
* @returns {Controller}
*/
function _makeController(params) {
return {
...params,
jsId: `controller_${++id}`,
isMounted: false,
};
}
/**
* this function returns an action description
* with a unique jsId.
*/
function _preprocessAction(action, context = {}) {
try {
action._originalAction = JSON.stringify(action);
} catch {
// do nothing, the action might simply not be serializable
}
action.context = makeContext([context, action.context], user.context);
const domain = action.domain || [];
action.domain =
typeof domain === 'string'
? evaluateExpr(
domain,
Object.assign({}, user.context, action.context)
)
: domain;
if (action.help) {
if (isHtmlEmpty(action.help)) {
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]])]; // 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']);
}
if ('no_breadcrumbs' in action.context) {
action._noBreadcrumbs = action.context.no_breadcrumbs;
delete action.context.no_breadcrumbs;
}
}
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;
},
get isFormView() {
return controller.props?.type === 'form';
},
get url() {
return stateToUrl(controller.state);
},
onSelected() {
restore(controller.jsId);
},
};
});
}
/**
* @private
* @param {object} [state] the state from which to get the action params
* @returns {{ actionRequest: object, options: object} | null}
*/
function _getActionParams(state = router.current) {
const options = {};
let actionRequest = null;
if (state.action) {
const context = {};
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];
}
// ClientAction
const [actionRequestKey, clientAction] = actionRegistry.contains(
state.action
)
? [state.action, actionRegistry.get(state.action)]
: actionRegistry
.getEntries()
.find((a) => a[1].path === state.action) ?? [];
if (actionRequestKey && clientAction) {
actionRequest = {
context,
params: state,
tag: actionRequestKey,
type: 'ir.actions.client',
};
if (clientAction.path) {
actionRequest.path = clientAction.path;
}
} else {
// The action to load isn't the current one => executes it
actionRequest = state.action;
context.params = state;
Object.assign(options, {
additionalContext: context,
viewType: state.resId ? 'form' : state.view_type,
});
}
if ((state.resId && state.resId !== 'new') || state.globalState) {
options.props = {};
if (state.resId && state.resId !== 'new') {
options.props.resId = state.resId;
}
if (state.globalState) {
options.props.globalState = state.globalState;
}
}
} else if (state.model) {
if (state.resId || state.view_type === 'form') {
actionRequest = {
res_model: state.model,
res_id: state.resId === 'new' ? undefined : state.resId,
type: 'ir.actions.act_window',
views: [[state.view_id ? state.view_id : false, 'form']],
};
} else {
// 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 (!actionRequest) {
// If the last action isn't valid (eg a model with no resId and no view_type) which can
// happen if the user edits the url and removes the id from the end of the url, we don't want
// to send him back to the home menu: we unwind the actionStack until we find a valid action
const { actionStack } = state;
if (actionStack?.length > 1) {
const nextState = { actionStack: actionStack.slice(0, -1) };
Object.assign(nextState, nextState.actionStack.at(-1));
const params = _getActionParams(nextState);
// Place the controller at the found position in the action stack to remove all the
// invalid virtual controllers.
if (params.options && params.options.index === undefined) {
params.options.index = nextState.actionStack.length - 1;
}
return params;
}
// Fall back to the home action if no valid action was found
actionRequest = user.homeActionId;
}
return actionRequest ? { actionRequest, options } : null;
}
/**
* @param {ClientAction} action
* @param {Object} props
* @returns {{ props: ActionProps, config: Config }}
*/
function _getActionInfo(action, props) {
const actionProps = Object.assign({}, props, {
action,
actionId: action.id,
});
const currentState = {
resId: actionProps.resId || false,
active_id: action.context.active_id || false,
};
actionProps.updateActionState = (controller, patchState) => {
const oldState = { ...currentState };
Object.assign(currentState, patchState);
const changed = !shallowEqual(currentState, oldState);
if (changed && action.target !== 'new' && controller.isMounted) {
pushState();
}
};
return {
props: actionProps,
currentState,
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 (action.target === 'fullscreen') {
return 'fullscreen';
}
// Default: current
return 'current';
}
/**
* @param {BaseView} view
* @param {ActWindowAction} action
* @param {BaseView[]} views
* @param {Object} props
*/
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,
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 openFormView = (resId, { activeIds, mode, force } = {}) => {
if (target !== 'new') {
if (_getView('form')) {
return switchView('form', {
mode,
resId,
resIds: activeIds,
});
} else if (force || !resId) {
return doAction(
{
type: 'ir.actions.act_window',
res_model: action.res_model,
views: [[false, 'form']],
},
{ props: { mode, resId, resIds: activeIds } }
);
}
}
};
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: openFormView,
createRecord: () => openFormView(false),
});
if (view.type === 'form') {
if (target === 'new') {
viewProps.mode = 'edit';
if (!viewProps.onSave) {
viewProps.onSave = (record, params) => {
if (params && params.closable) {
doAction({ type: 'ir.actions.act_window_close' });
}
};
}
}
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 (!viewProps.resId) {
viewProps.resId = action.res_id || false;
}
const currentState = {
resId: viewProps.resId,
active_id: action.context.active_id || false,
};
viewProps.updateActionState = (controller, patchState) => {
const oldState = { ...currentState };
Object.assign(currentState, patchState);
const changed = !shallowEqual(currentState, oldState);
if (changed && target !== 'new' && controller.isMounted) {
pushState();
}
};
viewProps.noBreadcrumbs =
'_noBreadcrumbs' in action
? action._noBreadcrumbs
: target === 'new';
const embeddedActions =
view.type === 'form'
? []
: context.parent_action_embedded_actions ||
action.embedded_action_ids;
const parentActionId =
(view.type !== 'form' && context.parent_action_id) || false;
const currentEmbeddedActionId =
context.current_embedded_action_id || false;
return {
props: viewProps,
currentState,
config: {
actionId: action.id,
actionName: action.name,
actionType: 'ir.actions.act_window',
embeddedActions,
parentActionId,
currentEmbeddedActionId,
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 {ActionOptions} options
*/
function _computeStackIndex(options) {
if (options.clearBreadcrumbs) {
return 0;
} else if (options.stackPosition === 'replaceCurrentAction') {
const currentController =
controllerStack[controllerStack.length - 1];
if (currentController) {
return 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) {
return controllerStack.findIndex(
(ct) => ct.action.jsId === last
);
}
// TODO: throw if there is no previous action?
} else if (options.index !== undefined) {
return options.index;
}
return controllerStack.length;
}
/**
* 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;
let removeDialogFn;
const currentActionProm = new Promise((_res, _rej) => {
resolve = _res;
reject = _rej;
});
const action = controller.action;
if (action.target !== 'new' && 'newStack' in options) {
controllerStack = options.newStack;
}
const index = _computeStackIndex(options);
const nextStack = [...controllerStack.slice(0, index), controller,];
if (controller.action.target != 'new') {
// ! my edit
count = count + 1
controller.count = count;
controllerStacks[nextStack[0].displayName] = nextStack;
debugger
}
// 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();
}
};
controller.config.setCurrentEmbeddedAction = (embeddedActionId) => {
controller.currentEmbeddedActionId = embeddedActionId;
};
controller.config.setEmbeddedActions = (embeddedActions) => {
controller.embeddedActions = embeddedActions;
};
controller.config.historyBack = () => {
if (dialog) {
_executeCloseAction();
} else {
const previousController =
controllerStack[controllerStack.length - 2];
if (previousController) {
restore(previousController.jsId);
} else {
env.bus.trigger('WEBCLIENT:LOAD_DEFAULT_APP');
}
}
};
class ControllerComponent extends Component {
static template = ControllerComponentTemplate;
static Component = controller.Component;
static props = {
'*': true,
};
setup() {
this.Component = controller.Component;
this.titleService = useService('title');
useDebugCategory('action', { action });
useChildSubEnv({
config: controller.config,
pushStateBeforeReload: () => {
if (controller.isMounted) {
return;
}
pushState(nextStack);
},
});
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);
});
if (this.constructor.Component !== View) {
useChildSubEnv({
__beforeLeave__: this.__beforeLeave__,
__getGlobalState__: this.__getGlobalState__,
__getLocalState__: this.__getLocalState__,
});
}
}
onMounted(this.onMounted);
onWillUnmount(this.onWillUnmount);
onError(this.onError);
}
onError(error) {
if (controller.isMounted) {
// the error occurred on the controller which is
// already in the DOM, so simply show the error
Promise.reject(error);
return;
}
if (!controller.isMounted && status(this) === 'mounted') {
// The error occured during an onMounted hook of one of the components.
env.bus.trigger('ACTION_MANAGER:UPDATE', {
id: ++id,
Component: BlankComponent,
componentProps: {
onMounted: () => { },
withControlPanel:
action.type === 'ir.actions.act_window',
},
});
Promise.reject(error);
return;
}
// forward the error to the _updateUI caller then restore the action container
// to an unbroken state
reject(error);
if (action.target === 'new') {
removeDialogFn?.();
return;
}
const index = controllerStack.findIndex(
(ct) => ct.jsId === controller.jsId
);
if (index > 0) {
// The error occurred while rendering an existing controller,
// so go back to the previous controller, of the current faulty one.
// This occurs when clicking on a breadcrumbs.
return restore(controllerStack[index - 1].jsId);
}
if (index === 0) {
// No previous controller to restore, so do nothing but display the error
return;
}
const lastController = controllerStack.at(-1);
if (lastController) {
if (lastController.jsId !== controller.jsId) {
// 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)
// ! my edit
delete controllerStacks[controller.displayName];
return restore(lastController.jsId);
}
} else {
env.bus.trigger('ACTION_MANAGER:UPDATE', {});
}
}
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())
);
}
};
controllerStack = nextStack; // the controller is mounted, commit the new stack
// todo del
window.router = router
window.controllerStack = controllerStack;
window.controllerStacks = controllerStacks;
pushState();
this.titleService.setParts({
action: controller.displayName,
});
browser.sessionStorage.setItem(
'current_action',
action._originalAction || '{}'
);
}
resolve();
env.bus.trigger(
'ACTION_MANAGER:UI-UPDATED',
_getActionMode(action)
);
controller.isMounted = true;
}
onWillUnmount() {
controller.isMounted = false;
if (action.target === 'new' && dialogCloseResolve) {
dialogCloseResolve();
}
}
get componentProps() {
const componentProps = { ...this.props };
const updateActionState = componentProps.updateActionState;
componentProps.updateActionState = (newState) =>
updateActionState(controller, newState);
if (this.constructor.Component === View) {
componentProps.__beforeLeave__ = this.__beforeLeave__;
componentProps.__getGlobalState__ = this.__getGlobalState__;
componentProps.__getLocalState__ = this.__getLocalState__;
}
return componentProps;
}
}
if (action.target === 'new') {
const actionDialogProps = {
ActionComponent: ControllerComponent,
actionProps: controller.props,
actionType: action.type,
};
if (action.name) {
actionDialogProps.title = action.name;
}
const size = DIALOG_SIZES[action.context.dialog_size];
if (size) {
actionDialogProps.size = size;
}
actionDialogProps.footer =
action.context.footer ?? actionDialogProps.footer;
const onClose = _removeDialog();
removeDialogFn = env.services.dialog.add(
ActionDialog,
actionDialogProps,
{
onClose: () => {
const onClose = _removeDialog();
if (onClose) {
onClose();
}
},
}
);
if (nextDialog) {
nextDialog.remove();
}
nextDialog = {
remove: removeDialogFn,
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) {
const globalState = Object.assign(
{},
currentController.action.globalState,
currentController.getGlobalState() // what if this = {}?
);
currentController.action.globalState = globalState;
// Avoid pushing the globalState, if the state on the router was changed.
// For instance, if a link was clicked, the state of the router will be the one of the link and not the one of the currentController.
// Or when using the back or forward buttons on the browser.
if (
currentController.state.action === router.current.action &&
currentController.state.active_id ===
router.current.active_id &&
currentController.state.resId === router.current.resId
) {
router.pushState({ globalState }, { sync: true });
}
}
if (controller.action.globalState) {
controller.props.globalState = controller.action.globalState;
}
const closingProm = _executeCloseAction({
onCloseInfo: { noReload: true },
});
// if (options.clearBreadcrumbs && !options.noEmptyTransition) {
// const def = new Deferred();
// env.bus.trigger('ACTION_MANAGER:UPDATE', {
// id: ++id,
// Component: BlankComponent,
// componentProps: {
// onMounted: () => def.resolve(),
// withControlPanel: action.type === 'ir.actions.act_window',
// },
// });
// await def;
// }
if (options.onActionReady) {
options.onActionReady(action);
}
controller.__info__ = {
id: ++id,
Component: ControllerComponent,
componentProps: controller.props,
controllerStacks,
count
};
env.services.dialog.closeAll();
env.bus.trigger('ACTION_MANAGER:UPDATE', controller.__info__);
return Promise.all([currentActionProm, closingProm]).then((r) => r[0]);
}
// ---------------------------------------------------------------------------
// ir.actions.act_url
// ---------------------------------------------------------------------------
/**
* Executes actions of type 'ir.actions.act_url', i.e. redirects to the
* given url.
*
* @private
* @param {ActURLAction} action
* @param {ActionOptions} options
*/
function _executeActURLAction(action, options) {
let url = action.url;
if (url && !(url.startsWith('http') || url.startsWith('/'))) {
url = '/' + url;
}
if (action.target === 'download' || action.target === 'self') {
browser.location.assign(url);
} else {
const w = browser.open(url, '_blank');
if (!w || w.closed || typeof w.closed === 'undefined') {
const msg = _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 (action.close) {
return doAction(
{ type: 'ir.actions.act_window_close' },
{ onClose: options.onClose }
);
} else 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) {
const views = [];
const unknown = [];
for (const [, type] of action.views) {
if (type === 'search') {
continue;
}
if (session.view_info[type]) {
const {
icon,
display_name,
multi_record: multiRecord,
} = session.view_info[type];
views.push({ icon, display_name, multiRecord, type });
} else {
unknown.push(type);
}
}
if (unknown.length) {
throw new Error(
`View types not defined ${unknown.join(
', '
)} found in act_window action ${action.id}`
);
}
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)) ||
views[0];
if (env.isSmall) {
view =
_findView(views, view.multiRecord, action.mobile_view_mode) ||
view;
}
const controller = _makeController({
Component: View,
action,
view,
views,
..._getViewInfo(view, action, views, options.props),
});
action.controllers[view.type] = controller;
const newStackLastController = options.newStack?.at(-1);
if (newStackLastController?.lazy) {
const multiView = action.views.find(
(view) => view[1] !== 'form' && view[1] !== 'search'
);
if (multiView) {
// If the current action has a multi-record view, we add the last
// controller to the breadcrumb controllers.
delete newStackLastController.lazy;
newStackLastController.displayName =
action.display_name || action.name || '';
newStackLastController.action = action;
newStackLastController.props.type = multiView[1];
} else {
// If the current action doesn't have a multi-record view,
// we don't need to add the last controller to the breadcrumb controllers
options.newStack.splice(-1);
}
}
return _updateUI(controller, options);
}
/**
* @private
* @param {Array} views an array of views
* @param {boolean} multiRecord true if we search for a multiRecord view
* @param {string} viewType type of the view to search
* @returns {Object|undefined} the requested view if it could be found
*/
function _findView(views, multiRecord, viewType) {
return views.find(
(v) => v.type === viewType && v.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);
action.path ||= clientAction.path;
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 = _makeController({
Component: clientAction,
action,
..._getActionInfo(action, options.props),
});
controller.displayName ||=
clientAction.displayName?.toString() || '';
return _updateUI(controller, options);
} else {
const next = await clientAction(env, action);
if (next) {
return doAction(next, options);
}
}
}
// ---------------------------------------------------------------------------
// ir.actions.report
// ---------------------------------------------------------------------------
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', user.context),
context: Object.assign({}, action.context),
});
const controller = _makeController({
Component: ReportAction,
action,
..._getActionInfo(action, props),
});
return _updateUI(controller, options);
}
/**
* 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' ||
action.report_type === 'qweb-text'
) {
const type = action.report_type.slice(5);
let success, message;
env.services.ui.block();
try {
const downloadContext = { ...user.context };
if (action.context) {
Object.assign(downloadContext, action.context);
}
({ success, message } = await downloadReport(
rpc,
action,
type,
downloadContext
));
} finally {
env.services.ui.unblock();
}
if (message) {
env.services.notification.add(message, {
sticky: true,
title: _t('Report'),
});
}
if (!success) {
return _executeReportClientAction(action, options);
}
const { onClose } = options;
if (action.close_on_report_download) {
return doAction(
{ type: 'ir.actions.act_window_close' },
{ onClose }
);
} else if (onClose) {
onClose();
}
} 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 = rpc('/web/action/run', {
action_id: action.id,
context: makeContext([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' };
if (typeof nextAction === 'object') {
nextAction.path ||= action.path;
}
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 new Promise(() => { });
}
}
return _executeActWindowAction(action, options);
case 'ir.actions.act_window_close':
return _executeCloseAction({
onClose: options.onClose,
onCloseInfo: action.infos,
});
case 'ir.actions.client':
return _executeClientAction(action, options);
case 'ir.actions.server':
return _executeServerAction(action, options);
case 'ir.actions.report':
return _executeReportAction(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
* @params {Object} [options={}]
* @params {boolean} [options.isEmbeddedAction] set to true if the action request is an
* embedded action. This allows to do the necessary context cleanup and avoid infinite
* recursion.
* @returns {Promise<void>}
*/
async function doActionButton(params, { isEmbeddedAction } = {}) {
// determine the action to execute according to the params
let action;
if (!isEmbeddedAction) {
for (const key of EMBEDDED_ACTIONS_CTX_KEYS) {
delete params.context?.[key];
}
}
const context = makeContext([params.context, params.buttonContext]);
const blockUi = exprToBoolean(params['block-ui']);
if (blockUi) {
env.services.ui.block();
}
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 {
browser.console.error(
'Could not JSON.parse arguments',
params.args
);
}
args = args.concat(additionalArgs);
}
const callProm = rpc(
`/web/dataset/call_button/${params.resModel}/${params.name}`,
{
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 {
if (blockUi) {
env.services.ui.unblock();
}
throw new InvalidButtonParamsError(
'Missing type for doActionButton request'
);
}
if (!isEmbeddedAction && action.embedded_action_ids?.length) {
const embeddedActionsOrder = JSON.parse(
browser.localStorage.getItem(
`orderEmbedded${action.id}+${params.resId || ''}+${user.userId
}`
)
);
const embeddedActionId = embeddedActionsOrder?.[0];
const embeddedAction = action.embedded_action_ids?.find(
(embeddedAction) => embeddedAction.id === embeddedActionId
);
if (embeddedAction) {
const embeddedActions = [
...action.embedded_action_ids,
{
id: false,
name: action.name,
parent_action_id: action.id,
parent_res_model: action.res_model,
action_id: action.id,
user_id: false,
context: {},
},
];
const context = {
...action.context,
...(embeddedAction.context
? makeContext([embeddedAction.context])
: {}),
active_id: params.resId,
active_model: params.resModel,
current_embedded_action_id: embeddedActionId,
parent_action_embedded_actions: embeddedActions,
parent_action_id: action.id,
};
await this.doActionButton(
{
name:
embeddedAction.python_method ||
embeddedAction.action_id[0] ||
embeddedAction.action_id,
resId: params.resId,
context,
type: embeddedAction.python_method
? 'object'
: 'action',
resModel: embeddedAction.parent_res_model,
viewType: embeddedAction.default_view_mode,
},
{ isEmbeddedAction: true }
);
return;
}
}
// 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 { onClose, stackPosition, viewType } = params;
const options = { onClose, stackPosition, viewType };
await doAction(action, options);
if (params.close) {
await _executeCloseAction();
}
if (blockUi) {
env.services.ui.unblock();
}
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(
_t(
"No view of type '%s' could be found in the current action.",
viewType
)
);
}
const newController =
controller.action.controllers[viewType] ||
_makeController({
Component: View,
action: controller.action,
views: controller.views,
view,
});
const canProceed = await clearUncommittedChanges(env);
if (!canProceed) {
return;
}
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;
}
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 canProceed = await clearUncommittedChanges(env);
if (!canProceed) {
return;
}
const controller = controllerStack[index];
if (controller.virtual) {
const actionParams = _getActionParams(controller.state);
if (!actionParams) {
throw new Error(
'Attempted to restore a virtual controller whose state is invalid'
);
}
const { actionRequest, options } = actionParams;
controllerStack = controllerStack.slice(0, index);
return doAction(actionRequest, options);
}
if (controller.action.type === 'ir.actions.act_window') {
if (controller.isMounted) {
controller.exportedState = controller.getLocalState();
}
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));
}
return _updateUI(controller, { index });
}
/**
* Restores a stack of virtual controllers from the current contents of the
* URL and performs a "doAction" on the last one.
*
* @returns {Promise<boolean>} true if doAction was performed
*/
async function loadState() {
const newStack = await _controllersFromState();
const actionParams = _getActionParams();
if (actionParams) {
// Params valid => performs a "doAction"
const { actionRequest, options } = actionParams;
if (options.index) {
options.newStack = newStack.slice(0, options.index);
delete options.index;
} else {
options.newStack = newStack;
}
await doAction(actionRequest, options);
return true;
}
}
function pushState(cStack = controllerStack) {
if (!cStack.length) {
return;
}
const actions = cStack.map((controller) => {
const { action, props, displayName } = controller;
const actionState = { displayName };
if (action.path || action.id) {
actionState.action = action.path || action.id;
} else if (action.type === 'ir.actions.client') {
actionState.action = action.tag;
} else if (action.type === 'ir.actions.act_window') {
actionState.model = props.resModel;
}
if (action.type === 'ir.actions.act_window') {
actionState.view_type = props.type;
if (
props.type === 'form' &&
action.res_model !== 'res.config.settings'
) {
actionState.resId = controller.currentState.resId || 'new';
}
}
if (
action.type === 'ir.actions.client' &&
controller.currentState?.resId
) {
actionState.resId = controller.currentState.resId;
}
if (controller.currentState?.active_id) {
const activeId = controller.currentState.active_id;
if (activeId) {
actionState.active_id = activeId;
}
}
Object.assign(
actionState,
omit(controller.currentState || {}, ...PATH_KEYS)
);
return actionState;
});
const newState = {
actionStack: actions,
};
const stateKeys = [...PATH_KEYS];
const { action, props, currentState } = cStack.at(-1);
if (props.type !== 'form' && props.type !== action.views?.[0][1]) {
// add view_type only when it's not already known implicitly
stateKeys.push('view_type');
}
if (currentState) {
stateKeys.push(...Object.keys(omit(currentState, ...PATH_KEYS)));
}
Object.assign(
newState,
pick(newState.actionStack.at(-1), ...stateKeys)
);
cStack.at(-1).state = newState;
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();
},
};
}
export const actionService = {
dependencies: [
'dialog',
'effect',
'localization',
'notification',
'title',
'ui',
],
start(env) {
return makeActionManager(env);
},
};
registry.category('services').remove('action');
registry.category('services').add('action', actionService);