diff --git a/addons_extensions/tabbar/__init__.py b/addons_extensions/tabbar/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/addons_extensions/tabbar/__manifest__.py b/addons_extensions/tabbar/__manifest__.py new file mode 100644 index 000000000..95111363d --- /dev/null +++ b/addons_extensions/tabbar/__manifest__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +{ + 'name': "Multi Tabs", + + 'summary': "Multi Tabs for Odoo 18", + + 'description': """ + Multi Tabs + """, + + "author": "1311793927@qq.com", + 'support': '1311793927qq.com', + 'images': ['static/description/main_banner.png'], + 'category': 'General', + 'version': '0.1', + "license": "LGPL-3", + 'depends': ['base','web'], + "installable": True, + "auto_install": False, + "assets": { + "web.assets_backend": [ + "tabbar/static/src/**/*", + ], + }, + +} diff --git a/addons_extensions/tabbar/static/description/1.png b/addons_extensions/tabbar/static/description/1.png new file mode 100644 index 000000000..04cc792e0 Binary files /dev/null and b/addons_extensions/tabbar/static/description/1.png differ diff --git a/addons_extensions/tabbar/static/description/icon.png b/addons_extensions/tabbar/static/description/icon.png new file mode 100644 index 000000000..fd8242404 Binary files /dev/null and b/addons_extensions/tabbar/static/description/icon.png differ diff --git a/addons_extensions/tabbar/static/description/index.html b/addons_extensions/tabbar/static/description/index.html new file mode 100644 index 000000000..fc996a05c --- /dev/null +++ b/addons_extensions/tabbar/static/description/index.html @@ -0,0 +1,10 @@ +
+ +The basic functions have been implemented, and further optimization will be carried out later + + +
+
+ + +
diff --git a/addons_extensions/tabbar/static/description/main_banner.png b/addons_extensions/tabbar/static/description/main_banner.png new file mode 100644 index 000000000..e85a4140d Binary files /dev/null and b/addons_extensions/tabbar/static/description/main_banner.png differ diff --git a/addons_extensions/tabbar/static/src/action_service.js b/addons_extensions/tabbar/static/src/action_service.js new file mode 100644 index 000000000..fe9e262dc --- /dev/null +++ b/addons_extensions/tabbar/static/src/action_service.js @@ -0,0 +1,1942 @@ +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` + + + + + `; + 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``; + +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} 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} 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} + */ + 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} + */ + 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} + */ + 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} + */ + 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} + */ + 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} + */ + 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} 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); diff --git a/addons_extensions/tabbar/static/src/akl_action_container.js b/addons_extensions/tabbar/static/src/akl_action_container.js new file mode 100644 index 000000000..bfc603c05 --- /dev/null +++ b/addons_extensions/tabbar/static/src/akl_action_container.js @@ -0,0 +1,128 @@ +import { ActionContainer } from '@web/webclient/actions/action_container'; +import { patch } from '@web/core/utils/patch'; +import { AklMultiTab } from './components/multi_tab/akl_multi_tab'; + +import { xml, useState } from '@odoo/owl'; +import { browser } from '@web/core/browser/browser'; +import { useService } from '@web/core/utils/hooks'; +import { + router as _router, +} from '@web/core/browser/router'; +patch(ActionContainer.prototype, { + setup() { + + super.setup(); + this.action_infos = []; + this.controllerStacks = {}; + // this.action_service = useService('action'); + + this.env.bus.addEventListener( + 'ACTION_MANAGER:UPDATE', + ({ detail: info }) => { + debugger + this.action_infos = this.get_controllers(info); + this.controllerStacks = info.controllerStacks; + this.render(); + } + ); + }, + get_controllers(info) { + const action_infos = []; + const entries = Object.entries(info.controllerStacks); + + entries.forEach(([key, stack]) => { + const lastController = stack[stack.length - 1]; + + const action_info = { + key: key, + __info__: lastController, + Component: lastController.__info__.Component, + active: false, + componentProps: lastController.__info__.componentProps || {}, + } + + if (lastController.count == info.count) { + action_info.active = true; + } + action_infos.push(action_info); + }) + + + return action_infos; + }, + + _on_close_action(action_info) { + this.action_infos = this.action_infos.filter((info) => { + return info.key !== action_info.key; + }); + if (this.action_infos.length > 0) { + + delete this.controllerStacks[action_info.key]; + this.action_infos[this.action_infos.length - 1].active = true; // Set last + this.render(); + + } + + }, + _on_active_action(action_info) { + debugger + this.action_infos.forEach((info) => { + info.active = info.key === action_info.key; + }); + const url = _router.stateToUrl(action_info.__info__.state) + browser.history.pushState({}, "", url); + this.render(); + }, + _close_other_action() { + this.action_infos = this.action_infos.filter((info) => { + if (info.active == false) { + delete this.controllerStacks[info.key]; + } + return info.active == true + }); + + this.render(); + }, + _close_current_action() { + debugger + this.action_infos = this.action_infos.filter((info) => { + if (info.active == true) { + delete this.controllerStacks[info.key]; + } + return info.active == false + }); + this.action_infos[this.action_infos.length - 1].active = true; + this.render(); + }, + _on_close_all_action() { + debugger + this.action_infos.forEach((info) => { + delete this.controllerStacks[info.key]; + }); + this.action_infos = {} + window.location.href = "/"; + + } +}); +ActionContainer.components = { + ...ActionContainer.components, + AklMultiTab, +}; +ActionContainer.template = xml` + + +
+ +
+ +
+
+
+`; diff --git a/addons_extensions/tabbar/static/src/akl_action_container.scss b/addons_extensions/tabbar/static/src/akl_action_container.scss new file mode 100644 index 000000000..f9c4f4b8b --- /dev/null +++ b/addons_extensions/tabbar/static/src/akl_action_container.scss @@ -0,0 +1,29 @@ +.akl_controller_container { + overflow-y: hidden; + flex: 1 1 auto; + + .o_view_controller { + display: flex; + height: 100%; + overflow: hidden; + flex-direction: column; + + .o_content { + flex: 1 1 auto; + overflow-y: auto; + } + } + + .o_action { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + background-color: white; + .o_content { + flex: 1 1 auto; + overflow-y: auto; + background-color: white; + } + } +} diff --git a/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.js b/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.js new file mode 100644 index 000000000..103e19688 --- /dev/null +++ b/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.js @@ -0,0 +1,31 @@ +import { Component, useRef } from '@odoo/owl'; +import { Dropdown } from '@web/core/dropdown/dropdown'; +import { DropdownItem } from '@web/core/dropdown/dropdown_item'; +import { DropdownGroup } from '@web/core/dropdown/dropdown_group'; +export class AklMultiTab extends Component { + static template = 'akl_multi_tab.tab'; + static components = { Dropdown, DropdownItem, DropdownGroup }; + static props = ['*']; + setup() { + super.setup(); + this.tabContainerRef = []; + } + rollPage() { } + _close_all_action() { this.props.close_all_action(); } + _close_current_action() { + this.props.close_current_action(); + } + _close_other_action() { + this.props.close_other_action(); + } + _on_click_tab_close(info) { + this.props.close_action(info); + } + _on_click_tab_item(info) { + this.props.active_action(info); + } + _on_multi_tab_next() { } + _on_multi_tab_prev() { } + get action_infos() { } + get current_action_info() { } +} diff --git a/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.scss b/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.scss new file mode 100644 index 000000000..ccdb69bf0 --- /dev/null +++ b/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.scss @@ -0,0 +1,295 @@ +.akl_multi_tab_container { + line-height: 40px; + background-color: #fff; + border-bottom: 1px solid #dee2e6; + box-sizing: border-box; + z-index: 3; + + .akl_multi_tab { + position: relative; + padding: 0 80px 0 40px; + height: 40px; + display: flex; + flex-direction: row; + box-sizing: border-box; + + .akl_tab_scroll { + width: 100%; + height: 100%; + overflow: hidden; + + .akl_page_items { + position: relative; + left: 0px; + height: 100%; + flex: 1 1 auto; + background: rgb(255, 255, 255); + overflow: visible; + white-space: nowrap; + padding: 0px; + + li { + display: inline-block; + *display: inline; + *zoom: 1; + + vertical-align: middle; + font-size: 14px; + transition: all 0.2s; + -webkit-transition: all 0.2s; + min-width: 65px; + padding: 0 15px; + text-align: center; + cursor: pointer; + + line-height: 40px; + max-width: 160px; + text-overflow: ellipsis; + padding-right: 40px; + overflow: hidden; + border-right: 1px solid #f6f6f6; + vertical-align: top; + position: relative; + white-space: nowrap; + padding: 0px 30px 0px 10px; + user-select: none; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 0; + height: 2px; + border-radius: 0; + background-color: #292b34; + transition: all 0.3s; + -webkit-transition: all 0.3s; + } + + &.akl_multi_tab_active { + &::after { + width: 100%; + } + } + + &:hover { + background-color: #f6f6f6; + + &::after { + width: 100%; + } + } + + .akl_tab_close { + position: absolute; + right: 8px; + top: 50%; + margin: -8px 0 0 0; + width: 16px; + height: 16px; + line-height: 16px; + border-radius: 50%; + font-size: 12px; + + &:first { + display: none; + } + + &:hover { + background-color: #ff5722; + color: #fff; + } + + svg { + margin-top: -2px; + } + } + + &.active { + background-color: $o-brand-primary; + color:white; + } + } + } + } + + .akl_tab_control { + width: 40px; + height: 100%; + text-align: center; + cursor: pointer; + transition: all 0.3s; + -webkit-transition: all 0.3s; + box-sizing: border-box; + border-left: 1px solid #f6f6f6; + + &:hover { + background-color: #f6f6f6; + } + } + + .akl_icon_prev { + border-right: 1px solid #f6f6f6; + color: #666; + line-height: 40px; + margin: 0; + padding: 0; + + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + -webkit-font-smoothing: antialiased; + -webkit-transition: all 0.3s; + + font-size: 16px; + font-style: normal; + position: absolute; + + top: 0; + left: 0; + + width: 40px; + height: 100%; + text-align: center; + cursor: pointer; + box-sizing: border-box; + border-left: none; + border-right: 1px solid #f6f6f6; + } + + .akl_icon_next { + border-right: 1px solid #f6f6f6; + color: #666; + line-height: 40px; + margin: 0; + padding: 0; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + font-family: layui-icon !important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + position: absolute; + top: 0; + width: 40px; + height: 100%; + text-align: center; + cursor: pointer; + -webkit-transition: all 0.3s; + box-sizing: border-box; + border-left: 1px solid #f6f6f6; + right: 40px; + } + + .akl_icon_down { + //right: 1px; + color: #666; + line-height: 40px; + margin: 0; + padding: 0; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + -webkit-transition: all 0.3s; + font-family: layui-icon !important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + position: absolute; + top: 0; + width: 40px; + height: 100%; + text-align: center; + cursor: pointer; + box-sizing: border-box; + border-left: 1px solid #f6f6f6; + right: 0; + + .dropdown-toggle { + width: 100%; + height: 100%; + background: none; + border: none; + } + + .dropdown-menu { + .dropdown-item { + line-height: 30px; + } + } + } + + .akl_multi_tab_active { + background-color: #f6f6f6; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 2px; + border-radius: 0; + background-color: #292b34; + transition: all 0.3s; + -webkit-transition: all 0.3s; + } + } + } + + .dropdown-menu { + z-index: 1000; + } +} + +.o_action_manager { + display: flex; + flex-direction: column; + + .akl_tab_page_container { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1 1 auto; + + // > div { + // flex: 1 1 auto; + // overflow: auto; + // display: flex; + // flex-direction: column; + // } + + .o_view_controller { + display: flex; + flex: 1 1 auto; + flex-direction: column; + overflow: hidden; + height: 100%; + + .o_content { + overflow: auto; + } + } + } +} + +.o_home_menu_background { + .akl_multi_tab_container { + display: none !important; + } +} + +[name='product_template_id'] { + div, + span { + width: 100% !important; + display: block !important; + } + span { + white-space: pre-wrap !important; + word-break: break-all !important; + text-overflow: initial !important; + } +} + +td[name='product_id'] { + white-space: pre-wrap !important; + word-break: break-all !important; + text-overflow: initial !important; +} diff --git a/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.xml b/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.xml new file mode 100644 index 000000000..e4accb557 --- /dev/null +++ b/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.xml @@ -0,0 +1,99 @@ + + + + +
+
+
+ + + + +
+ +
+ + + + +
+
+ + + + + + + + + + Close Current Tab + + + Close Other Tabs + + + Close All Tabs + + + + + +
+ +
+
    +
  • + + + + + + + + + +
  • +
+
+ +
+
+
+
diff --git a/addons_extensions/tabbar/static/src/scss/tabbar.scss b/addons_extensions/tabbar/static/src/scss/tabbar.scss new file mode 100644 index 000000000..aaf5b5c4e --- /dev/null +++ b/addons_extensions/tabbar/static/src/scss/tabbar.scss @@ -0,0 +1,18 @@ +.tabbar { + height: 30px; + margin-left: 0px; + width: 100%; + display: flex; + .tabbar_left { + display: flex; + overflow: hidden; + flex: 1 1 0%; + height: 100%; + } + .tabbar_right { + display: flex; + height: 100%; + align-items: center; + justify-content: center; + } +}