[REF] web,*: remove legacy client action support

This commit removes a lot of legacy code that we kept for the few
legacy client actions that were still to be converted to owl. Now
that the last one has been converted, we can finally get rid of a
lot of legacy code (ActionAdapter, ActionMixin, ControlPanel,
ActionMenus, DropdownMenu(Item), Pager, DomainSelector...).

Part of task~3439226

closes odoo/odoo#129314

Related: odoo/enterprise#44465
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
This commit is contained in:
Aaron Bohy
2023-07-25 17:35:47 +02:00
parent f5442e970a
commit bc95c6a348
44 changed files with 23 additions and 7510 deletions
-11
View File
@@ -89,9 +89,7 @@
# Form style should be computed before
'web/static/src/views/form/button_box/*.scss',
'web/static/src/legacy/action_adapters.js',
'web/static/src/legacy/legacy_service_provider.js',
'web/static/src/legacy/legacy_client_actions.js',
'web/static/src/legacy/legacy_dialog.js',
'web/static/src/legacy/legacy_load_views.js',
'web/static/src/legacy/legacy_promise_error_handler.js',
@@ -102,11 +100,7 @@
'web/static/src/legacy/backend_utils.js',
'web/static/src/legacy/utils.js',
'web/static/src/legacy/web_client.js',
'web/static/src/legacy/js/chrome/*',
'web/static/src/legacy/js/components/*',
'web/static/src/legacy/js/control_panel/*',
'web/static/src/legacy/js/core/domain.js',
'web/static/src/legacy/js/core/mvc.js',
'web/static/src/legacy/js/core/py_utils.js',
'web/static/src/legacy/js/core/context.js',
'web/static/src/legacy/js/core/misc.js',
@@ -118,12 +112,7 @@
'web/static/src/legacy/js/fields/field_utils.js',
'web/static/src/legacy/js/services/data_manager.js',
'web/static/src/legacy/js/services/session.js',
'web/static/src/legacy/js/tools/tools.js',
'web/static/src/legacy/js/widgets/date_picker.js',
'web/static/src/legacy/js/widgets/domain_selector_dialog.js',
'web/static/src/legacy/js/widgets/domain_selector.js',
'web/static/src/legacy/js/widgets/model_field_selector.js',
'web/static/src/legacy/js/widgets/model_field_selector_popover.js',
'web/static/src/legacy/js/env.js',
'web/static/src/legacy/js/owl_compatibility.js',
'web/static/src/legacy/pseudo_web_client.js',
-1
View File
@@ -156,7 +156,6 @@
'web/static/src/legacy/js/**/*',
('remove', 'web/static/src/legacy/js/libs/**/*'),
('remove', 'web/static/src/legacy/js/public/**/*'),
'web/static/src/legacy/action_adapters.js',
'web/static/src/legacy/backend_utils.js',
'web/static/src/legacy/legacy_load_views.js',
'web/static/src/legacy/legacy_service_provider.js',
-11
View File
@@ -140,9 +140,7 @@
# Form style should be computed before
'web/static/src/views/form/button_box/*.scss',
'web/static/src/legacy/action_adapters.js',
'web/static/src/legacy/legacy_service_provider.js',
'web/static/src/legacy/legacy_client_actions.js',
'web/static/src/legacy/legacy_dialog.js',
'web/static/src/legacy/legacy_load_views.js',
'web/static/src/legacy/legacy_promise_error_handler.js',
@@ -154,11 +152,7 @@
'web/static/src/legacy/utils.js',
'web/static/src/legacy/pseudo_web_client.js',
'web/static/src/legacy/web_client.js',
'web/static/src/legacy/js/chrome/*',
'web/static/src/legacy/js/components/*',
'web/static/src/legacy/js/control_panel/*',
'web/static/src/legacy/js/core/domain.js',
'web/static/src/legacy/js/core/mvc.js',
'web/static/src/legacy/js/core/py_utils.js',
'web/static/src/legacy/js/core/context.js',
'web/static/src/legacy/js/core/misc.js',
@@ -171,12 +165,7 @@
'web/static/src/legacy/js/fields/field_utils.js',
'web/static/src/legacy/js/services/data_manager.js',
'web/static/src/legacy/js/services/session.js',
'web/static/src/legacy/js/tools/tools.js',
'web/static/src/legacy/js/widgets/date_picker.js',
'web/static/src/legacy/js/widgets/domain_selector_dialog.js',
'web/static/src/legacy/js/widgets/domain_selector.js',
'web/static/src/legacy/js/widgets/model_field_selector.js',
'web/static/src/legacy/js/widgets/model_field_selector_popover.js',
'web/static/src/legacy/js/env.js',
'web/static/src/legacy/js/owl_compatibility.js',
@@ -2,6 +2,7 @@
import { makeServerError } from "@web/../tests/helpers/mock_server";
import { click, editInput, getFixture, nextTick, triggerHotkey } from "@web/../tests/helpers/utils";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
import { errorService } from "@web/core/errors/error_service";
import { registry } from "@web/core/registry";
@@ -172,6 +173,12 @@ QUnit.module("QuestionPageOneToManyField", (hooks) => {
QUnit.test("A validation error from saving parent form notifies and prevents dialog from closing", async (assert) => {
registry.category("services").add("error", errorService);
// need to preventDefault to remove error from console (so python test pass)
const handler = (ev) => ev.preventDefault();
window.addEventListener("unhandledrejection", handler);
registerCleanup(() => window.removeEventListener("unhandledrejection", handler));
await makeView({
type: "form",
resModel: "survey",
-15
View File
@@ -200,7 +200,6 @@ This module provides the core of the Odoo Web Client.
'web/static/lib/py.js/lib/py_extras.js',
'web/static/lib/jquery.ba-bbq/jquery.ba-bbq.js',
'web/static/src/legacy/scss/domain_selector.scss',
'web/static/src/legacy/scss/model_field_selector.scss',
'web/static/src/legacy/scss/dropdown.scss',
'web/static/src/legacy/scss/tooltip.scss',
@@ -217,9 +216,7 @@ This module provides the core of the Odoo Web Client.
# Form style should be computed before
'web/static/src/views/form/button_box/*.scss',
'web/static/src/legacy/action_adapters.js',
'web/static/src/legacy/legacy_service_provider.js',
'web/static/src/legacy/legacy_client_actions.js',
'web/static/src/legacy/legacy_dialog.js',
'web/static/src/legacy/legacy_load_views.js',
'web/static/src/legacy/legacy_promise_error_handler.js',
@@ -229,24 +226,14 @@ This module provides the core of the Odoo Web Client.
'web/static/src/legacy/utils.js',
'web/static/src/legacy/pseudo_web_client.js',
'web/static/src/legacy/web_client.js',
'web/static/src/legacy/js/chrome/*',
'web/static/src/legacy/js/components/*',
'web/static/src/legacy/js/control_panel/*',
'web/static/src/legacy/js/core/domain.js',
'web/static/src/legacy/js/core/mvc.js',
'web/static/src/legacy/js/core/py_utils.js',
'web/static/src/legacy/js/core/context.js',
'web/static/src/legacy/js/core/misc.js',
'web/static/src/legacy/js/fields/*',
'web/static/src/legacy/js/services/data_manager.js',
'web/static/src/legacy/js/services/session.js',
'web/static/src/legacy/js/tools/tools.js',
'web/static/src/legacy/js/widgets/colorpicker_dialog.js',
'web/static/src/legacy/js/widgets/date_picker.js',
'web/static/src/legacy/js/widgets/domain_selector_dialog.js',
'web/static/src/legacy/js/widgets/domain_selector.js',
'web/static/src/legacy/js/widgets/model_field_selector.js',
'web/static/src/legacy/js/widgets/model_field_selector_popover.js',
'web/static/src/legacy/js/env.js',
'web/static/src/legacy/js/owl_compatibility.js',
@@ -653,7 +640,6 @@ This module provides the core of the Odoo Web Client.
# Legacy
'web/static/lib/tempusdominus/tempusdominus.js',
'web/static/tests/legacy/**/*.js',
('remove', 'web/static/tests/legacy/**/*_mobile_tests.js'),
('remove', 'web/static/tests/legacy/helpers/**/*.js'),
('remove', 'web/static/tests/legacy/legacy_setup.js'),
@@ -661,7 +647,6 @@ This module provides the core of the Odoo Web Client.
],
'web.qunit_mobile_suite_tests': [
'web/static/tests/mobile/**/*.js',
'web/static/tests/legacy/components/dropdown_menu_mobile_tests.js',
],
# Used during the transition of the web architecture
@@ -1,244 +0,0 @@
/** @odoo-module **/
import Context from "web.Context";
import core from "web.core";
import { ComponentAdapter } from "web.OwlCompatibility";
import { objectToQuery } from "../core/browser/router_service";
import { useDebugCategory } from "../core/debug/debug_context";
import { Dialog } from "../core/dialog/dialog";
import { useService } from "@web/core/utils/hooks";
import { cleanDomFromBootstrap, wrapSuccessOrFail, useLegacyRefs } from "./utils";
import { mapDoActionOptionAPI } from "./backend_utils";
import {
Component,
onMounted,
onWillUnmount,
onWillUpdateProps,
useEffect,
useExternalListener,
xml,
} from "@odoo/owl";
class WarningDialog extends Component {}
WarningDialog.template = xml`<Dialog title="props.title">
<p style="white-space:pre-wrap" t-esc="props.message"/>
</Dialog>`;
WarningDialog.components = { Dialog };
class ActionAdapter extends ComponentAdapter {
setup() {
super.setup();
this.actionService = useService("action");
this.router = useService("router");
this.title = useService("title");
this.notifications = useService("notification");
this.dialogs = useService("dialog");
this.wowlEnv = this.env;
const legacyRefs = useLegacyRefs();
legacyRefs.component = this;
// a legacy widget widget can push_state anytime including during its async rendering
// In Wowl, we want to have all states pushed during the same setTimeout.
// This is protected in legacy (backward compatibility) but should not e supported in Wowl
this.tempQuery = {};
let originalUpdateControlPanel;
useEffect(
() => {
legacyRefs.widget = this.widget;
const query = this.widget.getState();
Object.assign(query, this.tempQuery);
this.tempQuery = null;
this.__widget = this.widget;
if (!this.wowlEnv.inDialog) {
this.pushState(query);
}
const onActionManagerUpdate = () => {
this.env.bus.trigger("close_dialogs");
cleanDomFromBootstrap();
};
this.wowlEnv.bus.addEventListener("ACTION_MANAGER:UPDATE", onActionManagerUpdate);
originalUpdateControlPanel = this.__widget.updateControlPanel.bind(this.__widget);
this.__widget.updateControlPanel = (newProps) => {
this.wowlEnv.config.setDisplayName(this.__widget.getTitle());
return originalUpdateControlPanel(newProps);
};
core.bus.trigger("DOM_updated");
return () => {
this.__widget.updateControlPanel = originalUpdateControlPanel;
this.wowlEnv.bus.removeEventListener(
"ACTION_MANAGER:UPDATE",
onActionManagerUpdate
);
};
},
() => []
);
useExternalListener(window, "click", () => {
cleanDomFromBootstrap();
});
onWillUpdateProps(() => {
if (this.widget === null) {
this.widget = this.__widget;
}
});
onWillUnmount(() => {
if (this.__widget && this.__widget.on_detach_callback) {
this.__widget.on_detach_callback();
}
});
this.onScrollTo = (payload) => {
const contentEl = this.el.querySelector(".o_content");
if (contentEl) {
contentEl.scrollLeft = payload.left || 0;
contentEl.scrollTop = payload.top || 0;
}
};
}
get actionId() {
throw new Error("Should be implement by specific adapters");
}
pushState(state) {
if (this.wowlEnv.inDialog) {
return;
}
const query = objectToQuery(state);
if (this.tempQuery) {
Object.assign(this.tempQuery, query);
return;
}
if (this.widget) {
const actionTitle = this.widget.getTitle();
if (actionTitle) {
this.wowlEnv.config.setDisplayName(actionTitle);
}
}
if (this.props.onPushState) {
this.props.onPushState(query);
} else {
this.router.pushState(query);
}
}
_trigger_up(ev) {
const payload = ev.data;
if (ev.name === "do_action") {
if (payload.action.context) {
payload.action.context = new Context(payload.action.context).eval();
}
this.onReverseBreadcrumb = payload.options && payload.options.on_reverse_breadcrumb;
const legacyOptions = mapDoActionOptionAPI(payload.options);
wrapSuccessOrFail(this.actionService.doAction(payload.action, legacyOptions), payload);
} else if (ev.name === "breadcrumb_clicked") {
this.actionService.restore(payload.controllerID);
} else if (ev.name === "push_state") {
this.pushState(payload.state);
} else if (ev.name === "set_title_part") {
const { part, title } = payload;
this.title.setParts({ [part]: title || null });
} else if (ev.name === "warning") {
if (payload.type === "dialog") {
this.dialogs.add(WarningDialog, {
title: payload.title,
message: payload.message,
});
} else {
this.notifications.add(payload.message, {
className: payload.className,
sticky: payload.sticky,
title: payload.title,
type: "warning",
});
}
} else if (ev.name === "history_back") {
this.wowlEnv.config.historyBack();
} else if (ev.name === "scrollTo") {
this.onScrollTo(payload);
} else {
super._trigger_up(ev);
}
}
/**
* This function is called just before the component will be unmounted,
* because it will be replaced by another one. However, we need to keep it
* alive, because we might come back to this one later. We thus return the
* widget instance, and set this.widget to null so that it is not destroyed
* by the compatibility layer. That instance will be destroyed by the
* ActionManager service when it will be removed from the controller stack,
* and if we ever come back to that controller, the instance will be given
* in props so that we can re-use it.
*/
exportState() {
this.widget = null;
return {
__legacy_widget__: this.__widget,
__on_reverse_breadcrumb__: this.onReverseBreadcrumb,
};
}
canBeRemoved() {
return this.__widget.canBeRemoved();
}
__destroy() {
if (this.actionService.__legacy__isActionInStack(this.actionId)) {
this.widget = null;
}
super.__destroy(...arguments);
}
get el() {
return (this.widget || this.__widget).el;
}
}
export class ClientActionAdapter extends ActionAdapter {
setup() {
super.setup();
useDebugCategory("action", { action: this.props.widgetArgs[0] });
onMounted(() => {
const action = this.props.widgetArgs[0];
if ("params" in action) {
const newState = {};
Object.entries(action.params).forEach(([k, v]) => {
if (typeof v === "string" || typeof v === "number") {
newState[k] = v;
}
});
this.wowlEnv.services.router.pushState(newState);
}
});
this.env = Component.env;
}
get actionId() {
return this.props.widgetArgs[0].jsId;
}
async onWillStart() {
if (this.props.widget) {
this.widget = this.props.widget;
this.widget.setParent(this);
if (this.props.onReverseBreadcrumb) {
await this.props.onReverseBreadcrumb();
}
return this.updateWidget();
}
return super.onWillStart();
}
/**
* @override
*/
updateWidget() {
return this.widget.do_show();
}
do_push_state(state) {
this.pushState(state);
}
}
@@ -1,82 +0,0 @@
/** @odoo-module alias=web.AbstractAction **/
/**
* We define here the AbstractAction widget, which implements the ActionMixin.
* All client actions must extend this widget.
*
* @module @web/chrome/abstract_action
*/
import ActionMixin from "web.ActionMixin";
import ControlPanel from "web.ControlPanel";
import Widget from "web.Widget";
import { ComponentWrapper } from "web.OwlCompatibility";
const AbstractAction = Widget.extend(ActionMixin, {
config: {
ControlPanel: ControlPanel,
},
/**
* If this flag is set to true, the client action will create a control
* panel whenever it is created.
*
* @type boolean
*/
hasControlPanel: false,
/**
* @override
*
* @param {Widget} parent
* @param {Object} action
* @param {Object} [options]
*/
init: function (parent, action, options) {
this._super(parent);
this._title = action.display_name || action.name;
if (this.hasControlPanel) {
this.controlPanelProps = {
action,
breadcrumbs: options && options.breadcrumbs,
};
}
},
/**
* @override
*/
start: async function () {
await this._super(...arguments);
if (this.hasControlPanel) {
if ('title' in this.controlPanelProps) {
this._setTitle(this.controlPanelProps.title);
}
this.controlPanelProps.title = this.getTitle();
this._controlPanelWrapper = new ComponentWrapper(this, this.config.ControlPanel, this.controlPanelProps);
await this._controlPanelWrapper.mount(this.el, { position: 'first-child' });
}
},
/**
* @override
*/
destroy: function() {
this._super.apply(this, arguments);
ActionMixin.destroy.call(this);
},
/**
* @override
*/
on_attach_callback: function () {
ActionMixin.on_attach_callback.call(this);
},
/**
* @override
*/
on_detach_callback: function () {
ActionMixin.on_detach_callback.call(this);
},
});
export default AbstractAction;
@@ -1,181 +0,0 @@
/** @odoo-module alias=web.ActionMixin **/
/**
* We define here the ActionMixin, the generic notion of action (from the point
* of view of the web client). In short, an action is a widget which controls
* the main part of the screen (everything below the navbar).
*
* More precisely, the action manager is the component that coordinates a stack
* of actions. Whenever the user navigates in the interface, switches views,
* open different menus, the action manager creates/updates/destroys special
* widgets which implements the ActionMixin. These actions need to answer to a
* standardised API, which is the reason for this mixin.
*
* In practice, most actions are view controllers (coming from an
* ir.action.act_window). However, some actions are 'client actions'. They
* also need to implement the ActionMixin for a better cooperation with the
* action manager.
*
* @module web.ActionMixin
* @extends WidgetAdapterMixin
*/
import core from "web.core";
import { WidgetAdapterMixin } from "web.OwlCompatibility";
const ActionMixin = Object.assign({}, WidgetAdapterMixin, {
template: 'Action',
/**
* The action mixin assumes that it is rendered with the 'Action' template.
* This template has a special zone ('.o_content') where the content should
* be added. Actions that want to automatically render a template there
* should define the contentTemplate key. In short, client actions should
* probably define a contentTemplate key, and not a template key.
*/
contentTemplate: null,
/**
* If an action wants to use a control panel, it will be created and
* registered in this _controlPanel key (the widget). The way this control
* panel is created is up to the implementation (so, view controllers or
* client actions may have different needs).
*
* Note that most of the time, this key should be set by the framework, not
* by the code of the client action.
*/
_controlPanel: null,
/**
* String containing the title of the client action (which may be needed to
* display in the breadcrumbs zone of the control panel).
*
* @see _setTitle
*/
_title: '',
/**
* @override
*/
renderElement: function () {
this._super.apply(this, arguments);
if (this.contentTemplate) {
const content = core.qweb.render(this.contentTemplate, { widget: this });
this.$('.o_content').append(content);
}
},
/**
* Called by the action manager when action is restored (typically, when
* the user clicks on the action in the breadcrumb)
*
* @returns {Promise|undefined}
*/
willRestore: function () { },
//---------------------------------------------------------------------
// Public
//---------------------------------------------------------------------
/**
* In some situations, we need confirmation from the controller that the
* current state can be destroyed without prejudice to the user. For
* example, if the user has edited a form, maybe we should ask him if we
* can discard all his changes when we switch to another action. In that
* case, the action manager will call this method. If the returned
* promise is successfully resolved, then we can destroy the current action,
* otherwise, we need to stop.
*
* @returns {Promise} resolved if the action can be removed, rejected
* otherwise
*/
canBeRemoved: function () {
return Promise.resolve();
},
/**
* Returns a serializable state that will be pushed in the URL by
* the action manager, allowing the action to be restarted correctly
* upon refresh. This function should be overriden to add extra information.
* Note that some keys are reserved by the framework and will thus be
* ignored ('action', 'active_id', 'active_ids' and 'title', for all
* actions, and 'model' and 'view_type' for act_window actions).
*
* @returns {Object}
*/
getState: function () {
return {};
},
/**
* Returns a title that may be displayed in the breadcrumb area. For
* example, the name of the record (for a form view). This is actually
* important for the action manager: this is the way it is able to give
* the proper titles for other actions.
*
* @returns {string}
*/
getTitle: function () {
return this._title;
},
/**
* Renders the buttons to append, in most cases, to the control panel (in
* the bottom left corner). When the action is rendered in a dialog, those
* buttons might be moved to the dialog's footer.
*
* @param {jQuery Node} $node
*/
renderButtons: function ($node) { },
/**
* Method used to update the widget buttons state.
*/
updateButtons: function () { },
/**
* The parameter newProps is used to update the props of
* the controlPanelWrapper before render it. The key 'cp_content'
* is not a prop of the control panel itself. One should if possible use
* the slot mechanism.
*
* @param {Object} [newProps={}]
* @returns {Promise}
*/
updateControlPanel: async function (newProps = {}) {
if (!this.withControlPanel && !this.hasControlPanel) {
return;
}
const props = Object.assign({}, newProps); // Work with a clean new object
if ('title' in props) {
this._setTitle(props.title);
this.controlPanelProps.title = this.getTitle();
delete props.title;
}
if ('cp_content' in props) {
// cp_content has been updated: refresh it.
this.controlPanelProps.cp_content = Object.assign({},
this.controlPanelProps.cp_content,
props.cp_content,
);
delete props.cp_content;
}
// Update props state
Object.assign(this.controlPanelProps, props);
return this._controlPanelWrapper.update(this.controlPanelProps);
},
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* @private
* @param {string} title
*/
_setTitle: function (title) {
this._title = title;
},
});
export default ActionMixin;
@@ -1,206 +0,0 @@
/** @odoo-module alias=web.ActionMenus **/
import Context from "web.Context";
import DropdownMenu from "web.DropdownMenu";
import Registry from "web.Registry";
import { LegacyComponent } from "@web/legacy/legacy_component";
const {
onMounted,
onPatched,
onWillStart,
onWillUpdateProps,
} = owl;
let registryActionId = 1;
/**
* Action menus (or Action/Print bar, previously called 'Sidebar')
*
* The side bar is the group of dropdown menus located on the left side of the
* control panel. Its role is to display a list of items depending on the view
* type and selected records and to execute a set of actions on active records.
* It is made out of 2 dropdown menus: Print and Action.
*
* This component also provides a registry to use custom components in the ActionMenus's
* Action menu.
* @extends Component
*/
class ActionMenus extends LegacyComponent {
setup() {
this.actionButtonStrings = {
title: this.env._t("Action"),
hotkey: this.env._t("Additionnal actions"),
};
this.printButtonStrings = {
title: this.env._t("Print"),
hotkey: this.env._t("Printing options"),
};
onWillStart(async () => {
this.actionItems = await this._setActionItems(this.props);
this.printItems = await this._setPrintItems(this.props);
});
onWillUpdateProps(async (nextProps) => {
this.actionItems = await this._setActionItems(nextProps);
this.printItems = await this._setPrintItems(nextProps);
});
onMounted(() => {
this._addTooltips();
});
onPatched(() => {
this._addTooltips();
});
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* Add the tooltips to the items
* @private
*/
_addTooltips() {
$(this.el.querySelectorAll('[title]')).tooltip({
delay: { show: 500, hide: 0 }
});
}
/**
* @private
* @param {Object} props
* @returns {Promise<Object[]>}
*/
async _setActionItems(props) {
// Callback based actions
const callbackActions = (props.items.other || []).map(
action => Object.assign({ key: `action-${action.description}` }, action)
);
// Action based actions
const actionActions = props.items.action || [];
const relateActions = props.items.relate || [];
const formattedActions = [...actionActions, ...relateActions].map(
action => ({ action, description: action.name, key: action.id })
);
// ActionMenus action registry components
const registryActions = [];
const rpc = this.rpc.bind(this);
for (const { Component, getProps } of this.constructor.registry.values()) {
const itemProps = await getProps(props, this.env, rpc);
if (itemProps) {
registryActions.push({
Component,
key: `registry-action-${registryActionId++}`,
props: itemProps,
});
}
}
return [...callbackActions, ...formattedActions, ...registryActions];
}
/**
* @private
* @param {Object} props
* @returns {Promise<Object[]>}
*/
async _setPrintItems(props) {
const printActions = props.items.print || [];
const printItems = printActions.map(
action => ({ action, description: action.name, key: action.id })
);
return printItems;
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* Perform the action for the item clicked after getting the data
* necessary with a trigger.
* @private
* @param {OwlEvent} ev
*/
async _executeAction(action) {
let activeIds = this.props.activeIds;
if (this.props.isDomainSelected) {
activeIds = await this.rpc({
model: this.env.action.res_model,
method: 'search',
args: [this.props.domain],
kwargs: {
limit: this.env.session.active_ids_limit,
context: this.props.context,
},
});
}
const activeIdsContext = {
active_id: activeIds[0],
active_ids: activeIds,
active_model: this.env.action.res_model,
};
if (this.props.domain) {
// keep active_domain in context for backward compatibility
// reasons, and to allow actions to bypass the active_ids_limit
activeIdsContext.active_domain = this.props.domain;
}
const context = new Context(this.props.context, activeIdsContext).eval();
const result = await this.rpc({
route: '/web/action/load',
params: { action_id: action.id, context },
});
result.context = new Context(result.context || {}, activeIdsContext)
.set_eval_context(context);
this.trigger('do-action', {
action: result,
options: {
on_close: () => this.trigger('reload'),
},
});
}
/**
* Handler used to determine which way must be used to execute a selected
* action: it will be either:
* - a callback (function given by the view controller);
* - an action ID (string);
* - an URL (string).
* @private
* @param {OwlEvent} ev
*/
_onItemSelected(ev) {
ev.stopPropagation();
const { item } = ev.detail;
if (item.callback) {
item.callback([item]);
} else if (item.action) {
this._executeAction(item.action);
} else if (item.url) {
// Event has been prevented at its source: we need to redirect manually.
this.env.services.navigate(item.url);
}
}
}
ActionMenus.registry = new Registry();
ActionMenus.components = { DropdownMenu };
ActionMenus.props = {
activeIds: { type: Array, element: [Number, String] }, // virtual IDs are strings.
context: Object,
domain: { type: Array, optional: 1 },
isDomainSelected: { type: Boolean, optional: 1 },
items: {
type: Object,
shape: {
action: { type: Array, optional: 1 },
print: { type: Array, optional: 1 },
other: { type: Array, optional: 1 },
},
},
};
ActionMenus.template = 'web.Legacy.ActionMenus';
export default ActionMenus;
@@ -1,87 +0,0 @@
/** @odoo-module alias=web.CustomFileInput **/
import { LegacyComponent } from "@web/legacy/legacy_component";
const { useRef } = owl;
/**
* Custom file input
*
* Component representing a customized input of type file. It takes a sub-template
* in its default t-slot and uses it as the trigger to open the file upload
* prompt.
* @extends Component
*/
class CustomFileInput extends LegacyComponent {
/**
* @param {Object} [props]
* @param {string} [props.accepted_file_extensions='*'] Comma-separated
* list of authorized file extensions (default to all).
* @param {string} [props.action='/web/binary/upload'] Route called when
* a file is uploaded in the input.
* @param {string} [props.id]
* @param {string} [props.model]
* @param {string} [props.multi_upload=false] Whether the input should allow
* to upload multiple files at once.
*/
setup() {
this.fileInputRef = useRef('file-input');
}
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Upload an attachment to the given action with the given parameters:
* - ufile: list of files contained in the file input
* - csrf_token: CSRF token provided by the odoo global object
* - model: a specific model which will be given when creating the attachment
* - id: the id of the model target instance
* @private
*/
async _onFileInputChange() {
const { action, model, id } = this.props;
const params = {
csrf_token: odoo.csrf_token,
ufile: [...this.fileInputRef.el.files],
};
if (model) {
params.model = model;
}
if (id) {
params.id = id;
}
const fileData = await this.env.services.httpRequest(action, params, 'text');
const parsedFileData = JSON.parse(fileData);
if (parsedFileData.error) {
throw new Error(parsedFileData.error);
}
this.trigger('uploaded', { files: parsedFileData });
}
/**
* Redirect clicks from the trigger element to the input.
* @private
*/
_onTriggerClicked() {
this.fileInputRef.el.click();
}
}
CustomFileInput.defaultProps = {
accepted_file_extensions: '*',
action: '/web/binary/upload',
multi_upload: false,
onUpload: () => {},
};
CustomFileInput.props = {
accepted_file_extensions: { type: String, optional: true },
action: { type: String, optional: true },
id: { type: Number, optional: true },
model: { type: String, optional: true },
multi_upload: { type: Boolean, optional: true },
onUpload: { type: Function, optional: true },
slots: { type: Object, optional: true },
};
CustomFileInput.template = 'web.CustomFileInput';
export default CustomFileInput;
@@ -1,189 +0,0 @@
/** @odoo-module alias=web.DropdownMenu **/
import { _t } from "web.core";
import DropdownMenuItem from "web.DropdownMenuItem";
import { LegacyComponent } from "@web/legacy/legacy_component";
const { useExternalListener, useRef, useState } = owl;
/**
* Dropdown menu
*
* Generic component used to generate a list of interactive items. It uses some
* bootstrap classes but most interactions are handled in here or in the dropdown
* menu item class definition, including some keyboard navigation and escaping
* system (click outside to close the dropdown).
*
* The layout of a dropdown menu is as following:
* > a Button (always rendered) with a `title` and an optional `icon`;
* > a Dropdown (rendered when open) containing a collection of given items.
* These items must be objects and can have two shapes:
* 1. item.Component & item.props > will instantiate the given Component with
* the given props. Any additional key will be useless.
* 2. any other shape > will instantiate a DropdownMenuItem with the item
* object being its props. There is no props validation as this object
* will be passed as-is when `selected` and can contain additional meta-keys
* that will not affect the displayed item. For more information regarding
* the behaviour of these items, @see DropdownMenuItem.
* @extends Component
*/
class DropdownMenu extends LegacyComponent {
setup() {
this.dropdownMenu = useRef('dropdown');
this.state = useState({ open: false });
useExternalListener(window, 'click', this._onWindowClick, true);
useExternalListener(window, 'keydown', this._onWindowKeydown);
}
//---------------------------------------------------------------------
// Getters
//---------------------------------------------------------------------
/**
* In desktop, by default, we do not display a caret icon next to the
* dropdown.
* @returns {boolean}
*/
get displayCaret() {
return false;
}
/**
* In mobile, by default, we display a chevron icon next to the dropdown
* button. Note that when 'displayCaret' is true, we display a caret
* instead of a chevron, no matter the value of 'displayChevron'.
* @returns {boolean}
*/
get displayChevron() {
return this.env.device.isMobile;
}
/**
* In mobile, by default, we use the middle of the screen as a threshold value to choose
* the alignment of the open dropdown against its triggering button.
*
* FIXME: Needs to be adapted for desktop, current calculation are only relevant on mobile screen
*
* @return {string} Bootstrap's dropdown alignment class
*/
get dropdownMenuAlignClass() {
if (this.env.device.isMobile) {
const threshold = document.documentElement.clientWidth / 2;
const { left, right } = this.el.getBoundingClientRect();
if (_t.database.parameters.direction === 'rtl') {
return right > threshold ? 'dropdown-menu-start' : 'dropdown-menu-end';
}
return left > threshold ? 'dropdown-menu-end' : 'dropdown-menu-start';
}
return '';
}
/**
* Can be overriden to force an icon on an inheriting class.
* @returns {string} Font Awesome icon class
*/
get icon() {
return this.props.icon;
}
/**
* Meant to be overriden to provide the list of items to display.
* @returns {Object[]}
*/
get items() {
return this.props.items;
}
/**
* @returns {string}
*/
get title() {
return this.props.title;
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* @private
* @param {KeyboardEvent} ev
*/
_onButtonKeydown(ev) {
switch (ev.key) {
case 'ArrowLeft':
case 'ArrowRight':
case 'ArrowUp':
case 'ArrowDown':
const firstItem = this.el.querySelector('.dropdown-item');
if (firstItem) {
ev.preventDefault();
firstItem.focus();
}
}
}
/**
* @private
* @param {OwlEvent} ev
*/
_onItemSelected(/* ev */) {
if (this.props.closeOnSelected) {
this.state.open = false;
}
}
/**
* @private
* @param {MouseEvent} ev
*/
_onWindowClick(ev) {
if (
this.state.open &&
!this.el.contains(ev.target) &&
!this.el.contains(document.activeElement)
) {
if (document.body.classList.contains("modal-open")) {
// retrieve the active modal and check if the dropdown is a child of this modal
const modal = this.el.closest(".modal[role='dialog']:not(.o_inactive_modal)")
if (!modal) {
return;
}
}
// check for an active open bootstrap calendar like the filter dropdown inside the search panel)
if (document.querySelector('body > .bootstrap-datetimepicker-widget')) {
return;
}
this.state.open = false;
}
}
/**
* @private
* @param {KeyboardEvent} ev
*/
_onWindowKeydown(ev) {
if (this.state.open && ev.key === 'Escape') {
this.state.open = false;
}
}
}
DropdownMenu.components = { DropdownMenuItem };
DropdownMenu.defaultProps = { items: [] };
DropdownMenu.props = {
icon: { type: String, optional: 1 },
items: {
type: Array,
element: Object,
optional: 1,
},
title: { type: String, optional: 1 },
closeOnSelected: { type: Boolean, optional: 1 },
hotkey: { type: String, optional: 1 },
hotkeyTitle: { type: String, optional: 1 },
};
DropdownMenu.template = 'web.DropdownMenu';
export default DropdownMenu;
@@ -1,127 +0,0 @@
/** @odoo-module alias=web.DropdownMenuItem **/
import { useListener } from "@web/core/utils/hooks";
import { LegacyComponent } from "@web/legacy/legacy_component";
const { useExternalListener, useRef, useState } = owl;
/**
* Dropdown menu item
*
* Generic component instantiated by a dropdown menu (@see DropdownMenu) in
* the absence of `Component` and `props` keys in a given item object.
*
* In its simplest form, a dropdown menu item will be given a description (optional,
* but highly recommended) and will trigger a 'select-item' when clicked on.
* Additionaly it can receive the following props:
* - isActive: will add a `checked` symbol on the left side of the item
* - removable: will add a `remove` trash icon on the right side of the item.
* when clicked, will trigger a 'remove-item' event.
* - options: will change the behaviour of the item ; instead of triggering
* an event, the item will act as a nested dropdown menu and display
* its given options. These will have the same definition as another
* dropdown item but cannot have options of their own.
*
* It is recommended to extend this class when defining a Component which will
* be put inside of a dropdown menu (@see CustomFilterItem as example).
* @extends Component
*/
class DropdownMenuItem extends LegacyComponent {
setup() {
this.canBeOpened = Boolean(this.props.options && this.props.options.length);
this.fallbackFocusRef = useRef('fallback-focus');
this.state = useState({ open: false });
useExternalListener(window, 'click', this._onWindowClick);
useListener('keydown', this._onKeydown);
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* @private
* @param {KeyboardEvent} ev
*/
_onKeydown(ev) {
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) {
return;
}
// Inspired from BS5 dropdown
if (['ArrowUp', 'ArrowDown'].includes(ev.key)) {
const { key, target } = ev;
const items = [].concat(...Element.prototype.querySelectorAll.call(this.el.parentElement, '.o-dropdown-menu .dropdown-item:not(.disabled):not(:disabled)'))
if (!items.length) {
return
}
const shouldGetNext = key === 'ArrowDown';
const isCycleAllowed = !items.includes(target);
let index = items.indexOf(target)
// if the element does not exist in the list return an element depending on the direction and if cycle is allowed
if (index === -1) {
items[!shouldGetNext && isCycleAllowed ? items.length - 1 : 0].focus();
} else {
const listLength = items.length
index += shouldGetNext ? 1 : -1
if (isCycleAllowed) {
index = (index + listLength) % listLength
}
items[Math.max(0, Math.min(index, listLength - 1))].focus();
}
}
switch (ev.key) {
case 'ArrowLeft':
if (this.canBeOpened && this.state.open) {
ev.preventDefault();
if (this.fallbackFocusRef.el) {
this.fallbackFocusRef.el.focus();
}
this.state.open = false;
}
break;
case 'ArrowRight':
if (this.canBeOpened && !this.state.open) {
ev.preventDefault();
this.state.open = true;
}
break;
case 'Escape':
ev.target.blur();
if (this.canBeOpened && this.state.open) {
ev.preventDefault();
ev.stopPropagation();
if (this.fallbackFocusRef.el) {
this.fallbackFocusRef.el.focus();
}
this.state.open = false;
}
}
}
/**
* @private
* @param {MouseEvent} ev
*/
_onWindowClick(ev) {
if (
this.state.open &&
!this.el.contains(ev.target) &&
!this.el.contains(document.activeElement)
) {
this.state.open = false;
}
}
}
DropdownMenuItem.template = 'web.DropdownMenuItem';
export default DropdownMenuItem;
@@ -1,224 +0,0 @@
/** @odoo-module alias=web.Pager **/
import { useAutofocus } from "@web/core/utils/hooks";
import { LegacyComponent } from "@web/legacy/legacy_component";
const { onWillUpdateProps, useState } = owl;
/**
* Pager
*
* The pager goes from 1 to size (included).
* The current value is currentMinimum if limit === 1 or the interval:
* [currentMinimum, currentMinimum + limit[ if limit > 1].
* The value can be manually changed by clicking on the pager value and giving
* an input matching the pattern: min[,max] (in which the comma can be a dash
* or a semicolon).
* The pager also provides two buttons to quickly change the current page (next
* or previous).
* @extends Component
*/
class Pager extends LegacyComponent {
/**
* @param {Object} [props]
* @param {int} [props.size] the total number of elements
* @param {int} [props.currentMinimum] the first element of the current_page
* @param {int} [props.limit] the number of elements per page
* @param {boolean} [props.editable] editable feature of the pager
* @param {function} [props.validate] callback returning a Promise to
* validate changes
* @param {boolean} [props.withAccessKey] can be disabled, for example,
* for x2m widgets
*/
setup() {
this.state = useState({
disabled: false,
editing: false,
});
useAutofocus();
onWillUpdateProps(this.onWillUpdateProps);
}
async onWillUpdateProps() {
this.state.editing = false;
this.state.disabled = false;
}
//---------------------------------------------------------------------
// Getters
//---------------------------------------------------------------------
/**
* @returns {number}
*/
get maximum() {
return Math.min(this.props.currentMinimum + this.props.limit - 1, this.props.size);
}
/**
* @returns {boolean} true iff there is only one page
*/
get singlePage() {
return (1 === this.props.currentMinimum) && (this.maximum === this.props.size);
}
/**
* @returns {number}
*/
get value() {
return this.props.currentMinimum + (this.props.limit > 1 ? `-${this.maximum}` : '');
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* Update the pager's state according to a pager action
* @private
* @param {number} [direction] the action (previous or next) on the pager
*/
async _changeSelection(direction) {
try {
await this.props.validate();
} catch {
return;
}
const { limit, size } = this.props;
// Compute the new currentMinimum
let currentMinimum = (this.props.currentMinimum + limit * direction);
if (currentMinimum > size) {
currentMinimum = 1;
} else if ((currentMinimum < 1) && (limit === 1)) {
currentMinimum = size;
} else if ((currentMinimum < 1) && (limit > 1)) {
currentMinimum = size - ((size % limit) || limit) + 1;
}
// The re-rendering of the pager must be done before the trigger of
// event 'pager-changed' as the rendering may enable the pager
// (and a common use is to disable the pager when this event is
// triggered, and to re-enable it when the data have been reloaded).
this._updateAndDisable(currentMinimum, limit);
}
/**
* Save the state from the content of the input
* @private
* @param {string} value the new raw pager value
* @returns {Promise}
*/
async _saveValue(value) {
try {
await this.props.validate();
} catch {
return;
}
const [min, max] = value.trim().split(/\s*[\-\s,;]\s*/);
let currentMinimum = Math.max(Math.min(parseInt(min, 10), this.props.size), 1);
let maximum = max ? Math.max(Math.min(parseInt(max, 10), this.props.size), 1) : min;
if (
!isNaN(currentMinimum) &&
!isNaN(maximum) &&
currentMinimum <= maximum
) {
const limit = Math.max(maximum - currentMinimum) + 1;
this._updateAndDisable(currentMinimum, limit);
}
}
/**
* Commits the current input value. There are two scenarios:
* - the value is the same: the pager toggles back to readonly
* - the value changed: the pager is disabled to prevent furtherchanges
* Either way the "pager-changed" event is triggered to reload the
* view.
* @private
* @param {number} currentMinimum
* @param {number} limit
*/
_updateAndDisable(currentMinimum, limit) {
if (
currentMinimum !== this.props.currentMinimum ||
limit !== this.props.limit
) {
this.state.disabled = true;
} else {
// In this case we want to trigger an update, but since it will
// not re-render the pager (current props === next props) we
// have to disable the edition manually here.
this.state.editing = false;
}
this.props.onPagerChanged({ currentMinimum, limit });
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* @private
*/
_onEdit() {
if (
this.props.editable && // editable
!this.state.editing && // not already editing
!this.state.disabled // not being changed already
) {
this.state.editing = true;
}
}
/**
* @private
* @param {InputEvent} ev
*/
_onValueChange(ev) {
this._saveValue(ev.currentTarget.value);
if (!this.state.disabled) {
ev.preventDefault();
}
}
/**
* @private
* @param {KeyboardEvent} ev
*/
_onValueKeydown(ev) {
switch (ev.key) {
case 'Enter':
ev.preventDefault();
ev.stopPropagation();
this._saveValue(ev.currentTarget.value);
break;
case 'Escape':
ev.preventDefault();
ev.stopPropagation();
this.state.editing = false;
break;
}
}
}
Pager.defaultProps = {
editable: true,
validate: async () => { },
withAccessKey: true,
};
Pager.props = {
currentMinimum: { type: Number, optional: 1 },
editable: { type: Boolean, optional: true },
limit: { validate: l => !isNaN(l), optional: 1 },
size: { type: Number, optional: 1 },
validate: { type: Function, optional: true },
withAccessKey: { type: Boolean, optional: true },
onPagerChanged: Function,
};
Pager.template = 'web.legacy.Pager';
export default Pager;
@@ -1,181 +0,0 @@
/** @odoo-module alias=web.ControlPanel **/
import ActionMenus from "web.ActionMenus";
import Pager from "web.Pager";
import { LegacyComponent } from "@web/legacy/legacy_component";
import { sprintf } from "@web/core/utils/strings";
const {
onMounted,
onPatched,
onWillDestroy,
onWillUpdateProps,
toRaw,
useRef,
useSubEnv,
} = owl;
/**
* TODO: remove this whole mechanism as soon as `cp_content` is completely removed.
* Extract the 'cp_content' key of the given props and return them as well as
* the extracted content.
* @param {Object} props
* @returns {Object}
*/
function getAdditionalContent(props) {
const additionalContent = {};
if ('cp_content' in props) {
const content = props.cp_content || {};
if ('$buttons' in content) {
additionalContent.buttons = content.$buttons;
}
if ('$searchview' in content) {
additionalContent.searchView = content.$searchview;
}
if ('$pager' in content) {
additionalContent.pager = content.$pager;
}
if ('$searchview_buttons' in content) {
additionalContent.searchViewButtons = content.$searchview_buttons;
}
}
return additionalContent;
}
/**
* Control panel
*
* The control panel of the action|view. In its standard form, it is composed of
* several sections/subcomponents.
*
* Note: an additional temporary (and ugly) mechanic allows to inject a jQuery element
* given in `props.cp_content` in a related section:
* $buttons -> [Buttons]
* $searchview -> [Search View]
* $searchview_buttons -> [Search Menus]
* $pager -> [Pager]
* This system must be replaced by proper slot usage and the static template
* inheritance mechanism when converting the views/actions.
* @extends Component
*/
class ControlPanel extends LegacyComponent {
setup() {
this.additionalContent = getAdditionalContent(this.props);
let subEnvView = this.props.view;
useSubEnv({
action: this.props.action,
get view() {
return subEnvView;
},
});
// Reference hooks
this.contentRefs = {
buttons: useRef('buttons'),
pager: useRef('pager'),
searchView: useRef('searchView'),
searchViewButtons: useRef('searchViewButtons'),
};
this.fields = this._formatFields(toRaw(this.props.fields || {}));
this.sprintf = sprintf;
onWillDestroy(() => {
const content = this.props.cp_content;
if (content) {
if (content.$buttons) {
content.$buttons.remove();
}
if (content.$searchview) {
content.$searchview.remove();
}
if (content.$pager) {
content.$pager.remove();
}
if (content.$searchview_buttons) {
content.$searchview_buttons.remove();
}
}
});
// Cannot use useEffect. See prepareForFinish in owl_compatibility.js
onMounted(() => this._attachAdditionalContent());
onPatched(() => this._attachAdditionalContent());
onWillUpdateProps((nextProps) => {
// Note: action and searchModel are not likely to change during
// the lifespan of a ControlPanel instance, so we only need to update
// the view information.
if ("view" in nextProps) {
subEnvView = nextProps.view;
}
if ("fields" in nextProps) {
this.fields = this._formatFields(toRaw(nextProps.fields));
}
this.additionalContent = getAdditionalContent(nextProps);
});
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* Attach additional content extracted from the props 'cp_content' key, if any.
* @private
*/
_attachAdditionalContent() {
for (const key in this.additionalContent) {
if (this.additionalContent[key] && this.additionalContent[key].length) {
const target = this.contentRefs[key].el;
if (target) {
target.innerHTML = "";
target.append(...this.additionalContent[key]);
}
}
}
}
/**
* Give `name` and `description` keys to the fields given to the control
* panel.
* @private
* @param {Object} fields
* @returns {Object}
*/
_formatFields(fields) {
const formattedFields = {};
for (const fieldName in fields) {
formattedFields[fieldName] = Object.assign({
description: fields[fieldName].string,
name: fieldName,
}, fields[fieldName]);
}
return formattedFields;
}
}
ControlPanel.components = {
ActionMenus, Pager,
};
ControlPanel.defaultProps = {
breadcrumbs: [],
fields: {},
views: [],
withBreadcrumbs: true,
};
ControlPanel.props = {
action: Object,
breadcrumbs: { type: Array, optional: true },
cp_content: { type: Object, optional: 1 },
fields: { type: Object, optional: true },
pager: { validate: p => typeof p === 'object' || p === null, optional: 1 },
actionMenus: { validate: s => typeof s === 'object' || s === null, optional: 1 },
title: { type: String, optional: 1 },
view: { type: Object, optional: 1 },
views: { type: Array, optional: true },
withBreadcrumbs: { type: Boolean, optional: true },
};
ControlPanel.template = 'web.Legacy.ControlPanel';
export default ControlPanel;
@@ -1,547 +0,0 @@
/** @odoo-module alias=web.searchUtils **/
import { _lt, _t } from "web.core";
import Domain from "web.Domain";
import pyUtils from "web.py_utils";
//-------------------------------------------------------------------------
// Constants
//-------------------------------------------------------------------------
// Filter menu parameters
const FIELD_OPERATORS = {
binary: [
{ symbol: "!=", description: _lt("is set"), value: false },
{ symbol: "=", description: _lt("is not set"), value: false },
],
boolean: [
{ symbol: "=", description: _lt("is Yes"), value: true },
{ symbol: "!=", description: _lt("is No"), value: true },
],
char: [
{ symbol: "ilike", description: _lt("contains") },
{ symbol: "not ilike", description: _lt("doesn't contain") },
{ symbol: "=", description: _lt("is equal to") },
{ symbol: "!=", description: _lt("is not equal to") },
{ symbol: "!=", description: _lt("is set"), value: false },
{ symbol: "=", description: _lt("is not set"), value: false },
],
date: [
{ symbol: "=", description: _lt("is equal to") },
{ symbol: "!=", description: _lt("is not equal to") },
{ symbol: ">", description: _lt("is after") },
{ symbol: "<", description: _lt("is before") },
{ symbol: ">=", description: _lt("is after or equal to") },
{ symbol: "<=", description: _lt("is before or equal to") },
{ symbol: "between", description: _lt("is between") },
{ symbol: "!=", description: _lt("is set"), value: false },
{ symbol: "=", description: _lt("is not set"), value: false },
],
datetime: [
{ symbol: "between", description: _lt("is between") },
{ symbol: "=", description: _lt("is equal to") },
{ symbol: "!=", description: _lt("is not equal to") },
{ symbol: ">", description: _lt("is after") },
{ symbol: "<", description: _lt("is before") },
{ symbol: ">=", description: _lt("is after or equal to") },
{ symbol: "<=", description: _lt("is before or equal to") },
{ symbol: "!=", description: _lt("is set"), value: false },
{ symbol: "=", description: _lt("is not set"), value: false },
],
id: [
{ symbol: "=", description: _lt("is") },
{ symbol: "<=", description: _lt("less than or equal to")},
{ symbol: ">", description: _lt("greater than")},
],
number: [
{ symbol: "=", description: _lt("is equal to") },
{ symbol: "!=", description: _lt("is not equal to") },
{ symbol: ">", description: _lt("greater than") },
{ symbol: "<", description: _lt("less than") },
{ symbol: ">=", description: _lt("greater than or equal to") },
{ symbol: "<=", description: _lt("less than or equal to") },
{ symbol: "!=", description: _lt("is set"), value: false },
{ symbol: "=", description: _lt("is not set"), value: false },
],
selection: [
{ symbol: "=", description: _lt("is") },
{ symbol: "!=", description: _lt("is not") },
{ symbol: "!=", description: _lt("is set"), value: false },
{ symbol: "=", description: _lt("is not set"), value: false },
],
};
const FIELD_TYPES = {
binary: 'binary',
boolean: 'boolean',
char: 'char',
date: 'date',
datetime: 'datetime',
float: 'number',
id: 'id',
integer: 'number',
html: 'char',
many2many: 'char',
many2one: 'char',
monetary: 'number',
one2many: 'char',
text: 'char',
selection: 'selection',
};
const DEFAULT_PERIOD = 'this_month';
const QUARTERS = {
1: { description: _lt("Q1"), coveredMonths: [0, 1, 2] },
2: { description: _lt("Q2"), coveredMonths: [3, 4, 5] },
3: { description: _lt("Q3"), coveredMonths: [6, 7, 8] },
4: { description: _lt("Q4"), coveredMonths: [9, 10, 11] },
};
const MONTH_OPTIONS = {
this_month: {
id: 'this_month', groupNumber: 1, format: 'MMMM',
addParam: {}, granularity: 'month',
},
last_month: {
id: 'last_month', groupNumber: 1, format: 'MMMM',
addParam: { months: -1 }, granularity: 'month',
},
antepenultimate_month: {
id: 'antepenultimate_month', groupNumber: 1, format: 'MMMM',
addParam: { months: -2 }, granularity: 'month',
},
};
const QUARTER_OPTIONS = {
fourth_quarter: {
id: 'fourth_quarter', groupNumber: 1, description: QUARTERS[4].description,
setParam: { quarter: 4 }, granularity: 'quarter',
},
third_quarter: {
id: 'third_quarter', groupNumber: 1, description: QUARTERS[3].description,
setParam: { quarter: 3 }, granularity: 'quarter',
},
second_quarter: {
id: 'second_quarter', groupNumber: 1, description: QUARTERS[2].description,
setParam: { quarter: 2 }, granularity: 'quarter',
},
first_quarter: {
id: 'first_quarter', groupNumber: 1, description: QUARTERS[1].description,
setParam: { quarter: 1 }, granularity: 'quarter',
},
};
const YEAR_OPTIONS = {
this_year: {
id: 'this_year', groupNumber: 2, format: 'YYYY',
addParam: {}, granularity: 'year',
},
last_year: {
id: 'last_year', groupNumber: 2, format: 'YYYY',
addParam: { years: -1 }, granularity: 'year',
},
antepenultimate_year: {
id: 'antepenultimate_year', groupNumber: 2, format: 'YYYY',
addParam: { years: -2 }, granularity: 'year',
},
};
const PERIOD_OPTIONS = Object.assign({}, MONTH_OPTIONS, QUARTER_OPTIONS, YEAR_OPTIONS);
// GroupBy menu parameters
const GROUPABLE_TYPES = [
'boolean',
'char',
'date',
'datetime',
'integer',
'many2one',
'many2many',
'selection',
];
const DEFAULT_INTERVAL = 'month';
const INTERVAL_OPTIONS = {
year: { description: _lt("Year"), id: 'year', groupNumber: 1 },
quarter: { description: _lt("Quarter"), id: 'quarter', groupNumber: 1 },
month: { description: _lt("Month"), id: 'month', groupNumber: 1 },
week: { description: _lt("Week"), id: 'week', groupNumber: 1 },
day: { description: _lt("Day"), id: 'day', groupNumber: 1 }
};
// Comparison menu parameters
const COMPARISON_OPTIONS = {
previous_period: {
description: _lt("Previous Period"), id: 'previous_period',
},
previous_year: {
description: _lt("Previous Year"), id: 'previous_year', addParam: { years: -1 },
},
};
const PER_YEAR = {
year: 1,
quarter: 4,
month: 12,
};
// Search bar
const FACET_ICONS = {
filter: 'fa fa-filter',
groupBy: 'oi oi-group',
favorite: 'fa fa-star',
comparison: 'fa fa-adjust',
};
//-------------------------------------------------------------------------
// Functions
//-------------------------------------------------------------------------
/**
* Constructs the string representation of a domain and its description. The
* domain is of the form:
* ['|',..., '|', d_1,..., d_n]
* where d_i is a time range of the form
* ['&', [fieldName, >=, leftBound_i], [fieldName, <=, rightBound_i]]
* where leftBound_i and rightBound_i are date or datetime computed accordingly
* to the given options and reference moment.
* (@see constructDateRange).
* @param {moment} referenceMoment
* @param {string} fieldName
* @param {string} fieldType
* @param {string[]} selectedOptionIds
* @param {string} [comparisonOptionId]
* @returns {{ domain: string, description: string }}
*/
function constructDateDomain(
referenceMoment,
fieldName,
fieldType,
selectedOptionIds,
comparisonOptionId
) {
let addParam;
let selectedOptions;
if (comparisonOptionId) {
[addParam, selectedOptions] = getComparisonParams(
referenceMoment,
selectedOptionIds,
comparisonOptionId);
} else {
selectedOptions = getSelectedOptions(referenceMoment, selectedOptionIds);
}
const yearOptions = selectedOptions.year;
const otherOptions = [
...(selectedOptions.quarter || []),
...(selectedOptions.month || [])
];
sortPeriodOptions(yearOptions);
sortPeriodOptions(otherOptions);
const ranges = [];
for (const yearOption of yearOptions) {
const constructRangeParams = {
referenceMoment,
fieldName,
fieldType,
addParam,
};
if (otherOptions.length) {
for (const option of otherOptions) {
const setParam = Object.assign({},
yearOption.setParam,
option ? option.setParam : {}
);
const { granularity } = option;
const range = constructDateRange(Object.assign(
{ granularity, setParam },
constructRangeParams
));
ranges.push(range);
}
} else {
const { granularity, setParam } = yearOption;
const range = constructDateRange(Object.assign(
{ granularity, setParam },
constructRangeParams
));
ranges.push(range);
}
}
const domain = pyUtils.assembleDomains(ranges.map(range => range.domain), 'OR');
const description = ranges.map(range => range.description).join("/");
return { domain, description };
}
/**
* Constructs the string representation of a domain and its description. The
* domain is a time range of the form:
* ['&', [fieldName, >=, leftBound],[fieldName, <=, rightBound]]
* where leftBound and rightBound are some date or datetime determined by setParam,
* addParam, granularity and the reference moment.
* @param {Object} params
* @param {moment} params.referenceMoment
* @param {string} params.fieldName
* @param {string} params.fieldType
* @param {string} params.granularity
* @param {Object} params.setParam
* @param {Object} [params.addParam]
* @returns {{ domain: string, description: string }}
*/
function constructDateRange({
referenceMoment,
fieldName,
fieldType,
granularity,
setParam,
addParam,
}) {
const date = referenceMoment.clone().set(setParam).add(addParam || {});
// compute domain
let leftBound = date.clone().locale('en').startOf(granularity);
let rightBound = date.clone().locale('en').endOf(granularity);
if (fieldType === 'date') {
leftBound = leftBound.format('YYYY-MM-DD');
rightBound = rightBound.format('YYYY-MM-DD');
} else {
leftBound = leftBound.utc().format('YYYY-MM-DD HH:mm:ss');
rightBound = rightBound.utc().format('YYYY-MM-DD HH:mm:ss');
}
const domain = Domain.prototype.arrayToString([
'&',
[fieldName, '>=', leftBound],
[fieldName, '<=', rightBound]
]);
// compute description
const descriptions = [date.format("YYYY")];
const method = _t.database.parameters.direction === "rtl" ? "push" : "unshift";
if (granularity === "month") {
descriptions[method](date.format("MMMM"));
} else if (granularity === "quarter") {
descriptions[method](QUARTERS[date.quarter()].description);
}
const description = descriptions.join(" ");
return { domain, description, };
}
/**
* Returns a version of the options in COMPARISON_OPTIONS with translated descriptions.
* @see getOptionsWithDescriptions
*/
function getComparisonOptions() {
return getOptionsWithDescriptions(COMPARISON_OPTIONS);
}
/**
* Returns the params addParam and selectedOptions necessary for the computation
* of a comparison domain.
* @param {moment} referenceMoment
* @param {string{}} selectedOptionIds
* @param {string} comparisonOptionId
* @returns {Object[]}
*/
function getComparisonParams(referenceMoment, selectedOptionIds, comparisonOptionId) {
const comparisonOption = COMPARISON_OPTIONS[comparisonOptionId];
const selectedOptions = getSelectedOptions(referenceMoment, selectedOptionIds);
let addParam = comparisonOption.addParam;
if (addParam) {
return [addParam, selectedOptions];
}
addParam = {};
let globalGranularity = 'year';
if (selectedOptions.month) {
globalGranularity = 'month';
} else if (selectedOptions.quarter) {
globalGranularity = 'quarter';
}
const granularityFactor = PER_YEAR[globalGranularity];
const years = selectedOptions.year.map(o => o.setParam.year);
const yearMin = Math.min(...years);
const yearMax = Math.max(...years);
let optionMin = 0;
let optionMax = 0;
if (selectedOptions.quarter) {
const quarters = selectedOptions.quarter.map(o => o.setParam.quarter);
if (globalGranularity === 'month') {
delete selectedOptions.quarter;
for (const quarter of quarters) {
for (const month of QUARTERS[quarter].coveredMonths) {
const monthOption = selectedOptions.month.find(
o => o.setParam.month === month
);
if (!monthOption) {
selectedOptions.month.push({
setParam: { month, }, granularity: 'month',
});
}
}
}
} else {
optionMin = Math.min(...quarters);
optionMax = Math.max(...quarters);
}
}
if (selectedOptions.month) {
const months = selectedOptions.month.map(o => o.setParam.month);
optionMin = Math.min(...months);
optionMax = Math.max(...months);
}
addParam[globalGranularity] = -1 +
granularityFactor * (yearMin - yearMax) +
optionMin - optionMax;
return [addParam, selectedOptions];
}
/**
* Returns a version of the options in INTERVAL_OPTIONS with translated descriptions.
* @see getOptionsWithDescriptions
*/
function getIntervalOptions() {
return getOptionsWithDescriptions(INTERVAL_OPTIONS);
}
/**
* Returns a version of the options in PERIOD_OPTIONS with translated descriptions
* and a key defautlYearId used in the control panel model when toggling a period option.
* @param {moment} referenceMoment
* @returns {Object[]}
*/
function getPeriodOptions(referenceMoment) {
const options = [];
for (const option of Object.values(PERIOD_OPTIONS)) {
const { id, groupNumber, description, } = option;
const res = { id, groupNumber, };
const date = referenceMoment.clone().set(option.setParam).add(option.addParam);
if (description) {
res.description = description.toString();
} else {
res.description = date.format(option.format.toString());
}
res.setParam = getSetParam(option, referenceMoment);
res.defaultYear = date.year();
options.push(res);
}
for (const option of options) {
const yearOption = options.find(
o => o.setParam && o.setParam.year === option.defaultYear
);
option.defaultYearId = yearOption.id;
delete option.defaultYear;
delete option.setParam;
}
return options;
}
/**
* Returns a version of the options in OPTIONS with translated descriptions (if any).
* @param {Object{}} OPTIONS
* @returns {Object[]}
*/
function getOptionsWithDescriptions(OPTIONS) {
const options = [];
for (const option of Object.values(OPTIONS)) {
const { id, groupNumber, description, } = option;
const res = { id, };
if (description) {
res.description = description.toString();
}
if (groupNumber) {
res.groupNumber = groupNumber;
}
options.push(res);
}
return options;
}
/**
* Returns a version of the period options whose ids are in selectedOptionIds
* partitioned by granularity.
* @param {moment} referenceMoment
* @param {string[]} selectedOptionIds
* @param {Object}
*/
function getSelectedOptions(referenceMoment, selectedOptionIds) {
const selectedOptions = { year: [] };
for (const optionId of selectedOptionIds) {
const option = PERIOD_OPTIONS[optionId];
const setParam = getSetParam(option, referenceMoment);
const granularity = option.granularity;
if (!selectedOptions[granularity]) {
selectedOptions[granularity] = [];
}
selectedOptions[granularity].push({ granularity, setParam });
}
return selectedOptions;
}
/**
* Returns the setParam object associated with the given periodOption and
* referenceMoment.
* @param {Object} periodOption
* @param {moment} referenceMoment
* @returns {Object}
*/
function getSetParam(periodOption, referenceMoment) {
if (periodOption.setParam) {
return periodOption.setParam;
}
const date = referenceMoment.clone().add(periodOption.addParam);
const setParam = {};
setParam[periodOption.granularity] = date[periodOption.granularity]();
return setParam;
}
/**
* @param {string} intervalOptionId
* @returns {number} index
*/
function rankInterval(intervalOptionId) {
return Object.keys(INTERVAL_OPTIONS).indexOf(intervalOptionId);
}
/**
* Sorts in place an array of 'period' options.
* @param {Object[]} options supposed to be of the form:
* { granularity, setParam, }
*/
function sortPeriodOptions(options) {
options.sort((o1, o2) => {
const granularity1 = o1.granularity;
const granularity2 = o2.granularity;
if (granularity1 === granularity2) {
return o1.setParam[granularity1] - o2.setParam[granularity1];
}
return granularity1 < granularity2 ? -1 : 1;
});
}
/**
* Checks if a year id is among the given array of period option ids.
* @param {string[]} selectedOptionIds
* @returns {boolean}
*/
function yearSelected(selectedOptionIds) {
return selectedOptionIds.some(optionId => !!YEAR_OPTIONS[optionId]);
}
export default {
COMPARISON_OPTIONS,
DEFAULT_INTERVAL,
DEFAULT_PERIOD,
FACET_ICONS,
FIELD_OPERATORS,
FIELD_TYPES,
GROUPABLE_TYPES,
INTERVAL_OPTIONS,
PERIOD_OPTIONS,
constructDateRange,
constructDateDomain,
getComparisonOptions,
getIntervalOptions,
getPeriodOptions,
rankInterval,
yearSelected,
};
-247
View File
@@ -1,247 +0,0 @@
/** @odoo-module alias=web.mvc **/
/**
* This file contains a 'formalization' of a MVC pattern, applied to Odoo
* idioms.
*
* For a simple widget/component, this is definitely overkill. However, when
* working on complex systems, such as Odoo views (or the control panel, or some
* client actions), it is useful to clearly separate the code in concerns.
*
* We define here 4 classes: Factory, Model, Renderer, Controller. Note that
* for various historical reasons, we use the term Renderer instead of View. The
* main issue is that the term 'View' is used way too much in Odoo land, and
* adding it would increase the confusion.
*
* In short, here are the responsabilities of the four classes:
* - Model: this is where the main state of the system lives. This is the part
* that will talk to the server, process the results and is the owner of the
* state
* - Renderer: this is the UI code: it should only be concerned with the look
* and feel of the system: rendering, binding handlers, ...
* - Controller: coordinates the model with the renderer and the parents widgets.
* This is more a 'plumbing' widget.
* - Factory: setting up the MRC components is a complex task, because each of
* them needs the proper arguments/options, it needs to be extensible, they
* needs to be created in the proper order, ... The job of the factory is
* to process all the various arguments, and make sure each component is as
* simple as possible.
*/
import Class from "web.Class";
import mixins from "web.mixins";
import ServicesMixin from "web.ServicesMixin";
import Widget from "web.Widget";
import { loadBundle } from "@web/core/assets";
/**
* Owner of the state, this component is tasked with fetching data, processing
* it, updating it, ...
*
* Note that this is not a widget: it is a class which has not UI representation.
*
* @class Model
*/
var Model = Class.extend(mixins.EventDispatcherMixin, ServicesMixin, {
/**
* @param {Widget} parent
* @param {Object} params
*/
init: function (parent, params) {
mixins.EventDispatcherMixin.init.call(this);
this.setParent(parent);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* This method should return the complete state necessary for the renderer
* to display the current data.
*
* @returns {*}
*/
get: function () {
},
/**
* The load method is called once in a model, when we load the data for the
* first time. The method returns (a promise that resolves to) some kind
* of token/handle. The handle can then be used with the get method to
* access a representation of the data.
*
* @param {Object} params
* @returns {Promise} The promise resolves to some kind of handle
*/
load: function () {
return Promise.resolve();
},
});
/**
* Only responsibility of this component is to display the user interface, and
* react to user changes.
*
* @class Renderer
*/
var Renderer = Widget.extend({
/**
* @override
* @param {any} state
* @param {Object} params
*/
init: function (parent, state, params) {
this._super(parent);
this.state = state;
},
});
/**
* The controller has to coordinate between parent, renderer and model.
*
* @class Controller
*/
var Controller = Widget.extend({
/**
* @override
* @param {Model} model
* @param {Renderer} renderer
* @param {Object} params
* @param {any} [params.handle=null]
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
this.handle = params.handle || null;
this.model = model;
this.renderer = renderer;
},
/**
* @returns {Promise}
*/
start: function () {
return Promise.all(
[this._super.apply(this, arguments),
this._startRenderer()]
);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Appends the renderer in the $el. To override to insert it elsewhere.
*
* @private
*/
_startRenderer: function () {
return this.renderer.appendTo(this.$el);
},
});
var Factory = Class.extend({
config: {
Model: Model,
Renderer: Renderer,
Controller: Controller,
},
/**
* @override
*/
init: function () {
this.rendererParams = {};
this.controllerParams = {};
this.modelParams = {};
this.loadParams = {};
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Main method of the Factory class. Create a controller, and make sure that
* data and libraries are loaded.
*
* There is a unusual thing going in this method with parents: we create
* renderer/model with parent as parent, then we have to reassign them at
* the end to make sure that we have the proper relationships. This is
* necessary to solve the problem that the controller needs the model and
* the renderer to be instantiated, but the model need a parent to be able
* to load itself, and the renderer needs the data in its constructor.
*
* @param {Widget} parent the parent of the resulting Controller (most
* likely an action manager)
* @returns {Promise<Controller>}
*/
getController: function (parent) {
var self = this;
var model = this.getModel(parent);
return Promise.all([this._loadData(model), loadBundle(this)]).then(function (result) {
const { state, handle } = result[0];
var renderer = self.getRenderer(parent, state);
var Controller = self.Controller || self.config.Controller;
const initialState = model.get(handle);
var controllerParams = Object.assign({
initialState,
handle,
}, self.controllerParams);
var controller = new Controller(parent, model, renderer, controllerParams);
model.setParent(controller);
renderer.setParent(controller);
return controller;
});
},
/**
* Returns a new model instance
*
* @param {Widget} parent the parent of the model
* @returns {Model} instance of the model
*/
getModel: function (parent) {
var Model = this.config.Model;
return new Model(parent, this.modelParams);
},
/**
* Returns a new renderer instance
*
* @param {Widget} parent the parent of the renderer
* @param {Object} state the information related to the rendered data
* @returns {Renderer} instance of the renderer
*/
getRenderer: function (parent, state) {
var Renderer = this.config.Renderer;
return new Renderer(parent, state, this.rendererParams);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Loads initial data from the model
*
* @private
* @param {Model} model a Model instance
* @param {Object} [options={}]
* @param {boolean} [options.withSampleData=true]
* @returns {Promise<*>} a promise that resolves to the value returned by
* the get method from the model
* @todo: get rid of loadParams (use modelParams instead)
*/
_loadData: function (model, options = {}) {
options.withSampleData = 'withSampleData' in options ? options.withSampleData : true;
return model.load(this.loadParams).then(function (handle) {
return { state: model.get(handle, options), handle };
});
},
});
export default {
Factory: Factory,
Model: Model,
Renderer: Renderer,
Controller: Controller,
};
@@ -31,13 +31,10 @@ export default {
// core classes and functions
Class: Class,
bus: bus,
main_bus: new Bus(),
_t: translation._t,
_lt: translation._lt,
// registries
action_registry: new Registry(),
crash_registry: new Registry(),
serviceRegistry: new Registry(),
/**
* @type {String}
@@ -1,19 +0,0 @@
/** @odoo-module alias=web.tools **/
/**
* Wrapper for deprecated functions that display a warning message.
*
* @param {Function} fn the deprecated function
* @param {string} [message=''] optional message to display
* @returns {Function}
*/
function deprecated(fn, message) {
return function () {
console.warn(message || (fn.name + ' is deprecated.'));
return fn.apply(this, arguments);
};
}
export default {
deprecated: deprecated,
};
@@ -1,47 +0,0 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { ComponentWrapper } from "web.OwlCompatibility";
import Dialog from "web.Dialog";
import { Colorpicker } from '@web/core/colorpicker/colorpicker';
export const ColorpickerDialog = Dialog.extend({
/**
* @override
*/
init: function (parent, options) {
this.options = options || {};
this._super(parent, Object.assign({
size: 'small',
title: _t('Pick a color'),
buttons: [
{text: _t('Choose'), classes: 'btn-primary', close: true, click: this._onFinalPick.bind(this)},
{text: _t('Discard'), close: true},
],
}, this.options));
},
/**
* @override
*/
start: function () {
const proms = [this._super(...arguments)];
this.colorpicker = new ComponentWrapper(this, Colorpicker, {
colorPreview: true,
});
this._colorpickerComponent = this.colorpicker.node.component;
proms.push(this.colorpicker.mount(this.$el[0]));
return Promise.all(proms);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onFinalPick: function () {
this.trigger_up('colorpicker:saved', this._colorpickerComponent.colorComponents);
},
});
File diff suppressed because it is too large Load Diff
@@ -1,67 +0,0 @@
/** @odoo-module alias=web.DomainSelectorDialog **/
import core from "web.core";
import Dialog from "web.Dialog";
import Domain from "web.Domain";
import DomainSelector from "web.DomainSelector";
var _t = core._t;
/**
* @class DomainSelectorDialog
*/
export default Dialog.extend({
custom_events: Object.assign({}, Dialog.prototype.custom_events, {
domain_changed: "_onDomainChange",
}),
init: function (parent, model, domain, options) {
this.model = model;
this.newDomain = null;
this.options = Object.assign({
readonly: true,
debugMode: false,
}, options || {});
var buttons;
if (this.options.readonly) {
buttons = [
{text: _t("Close"), close: true},
];
} else {
buttons = [
{text: _t("Save"), classes: "btn-primary", close: true, click: function () {
this.trigger_up("domain_selected", {
domain: this.newDomain !== null ? this.newDomain : this.domainSelector.getDomain(),
});
}},
{text: _t("Discard"), close: true},
];
}
this._super(parent, Object.assign({}, {
title: _t("Domain"),
buttons: buttons,
}, options || {}));
this.domainSelector = new DomainSelector(this, model, Domain.prototype.arrayToString(domain), options);
},
start: function () {
var self = this;
this.opened().then(function () {
// this restores default modal height (bootstrap) and allows field selector to overflow
self.$el.css('overflow', 'visible').closest('.modal-dialog').css('height', 'auto');
});
return Promise.all([
this._super.apply(this, arguments),
this.domainSelector.appendTo(this.$el)
]);
},
/**
* Called when the domain selector value is changed.
*
* @param {OdooEvent} ev
*/
_onDomainChange: function (ev) {
ev.stopPropagation();
this.newDomain = ev.data.domain;
},
});
@@ -1,180 +0,0 @@
/** @odoo-module alias=web.ModelFieldSelector **/
import core from "web.core";
import Widget from "web.Widget";
import ModelFieldSelectorPopover from "web.ModelFieldSelectorPopover";
/**
* The ModelFieldSelector widget can be used to display/select a particular
* field chain from a given model.
*/
var ModelFieldSelector = Widget.extend({
template: "ModelFieldSelector",
events: {},
edition_events: {
// Handle popover opening and closing
"focusin": "_onFocusIn",
"focusout": "_onFocusOut"
},
custom_events: {
"field_selector_render": "_render"
},
/**
* @constructor
* The ModelFieldSelector requires a model and a field chain to work with.
*
* @param parent
* @param {string} model - the model name (e.g. "res.partner")
* @param {string[]} chain - list of the initial field chain parts
* @param {Object} [options] - some key-value options
* @param {string} [options.order='string']
* an ordering key for displayed fields
* @param {boolean} [options.readonly=true] - true if should be readonly
* @param {function} [options.filter]
* a function to filter the fetched fields
* @param {Object} [options.filters]
* some key-value options to filter the fetched fields
* @param {boolean} [options.filters.searchable=true]
* true if only the searchable fields have to be used
* @param {Object[]} [options.fields=null]
* the list of fields info to use when no relation has
* been followed (null indicates the widget has to request
* the fields itself)
* @param {boolean|function} [options.followRelations=true]
* true if can follow relation when building the chain
* @param {boolean} [options.showSearchInput=true]
* false to hide a search input to filter displayed fields
* @param {boolean} [options.needDefaultValue=false]
* true if a default value can be entered after the selected chain
* @param {boolean} [options.cancelOnEscape=false]
* true if a the chain selected should be ignored when the user hit ESC
* @param {boolean} [options.debugMode=false]
* true if the widget is in debug mode, false otherwise
*/
init: function (parent, model, chain, options) {
this._super.apply(this, arguments);
this.model = model;
this.chain = chain;
this.options = Object.assign({
order: 'string',
readonly: true,
fields: null,
debugMode: false
}, options || {});
this.dirty = false;
this.popover = new ModelFieldSelectorPopover(this, model, chain, options);
if (!this.options.readonly) {
Object.assign(this.events, this.edition_events);
}
},
/**
* @see Widget.willStart()
* @returns {Promise}
*/
willStart: function () {
return this._super.apply(this, arguments);
},
/**
* @see Widget.start
* @returns {Promise}
*/
start: async function () {
this.$value = this.$(".o_field_selector_value");
this.$valid = this.$(".o_field_selector_warning");
if (this.options.readonly) {
this.popover.prefill().then(this._render.bind(this));
}
await this._super.apply(this, arguments);
if (!this.options.readonly) {
let popoverReadyPromiseResolve;
const popoverReadyPromise = new Promise((resolve) => popoverReadyPromiseResolve = resolve);
this.popover.on("field_chain_changed", undefined, (ev) => {
// Ensure the event target is the ModelFieldSelector.
ev.stopPropagation();
this.trigger_up("field_chain_changed", ev.data);
});
this.popover.appendTo($("<div/>"));
this.popover.once("field_selector_started", undefined, () => {
this.$el.append(this.popover.$el);
this.$popover = this.$(".o_field_selector_popover");
popoverReadyPromiseResolve();
});
await popoverReadyPromise;
}
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Returns the field information selected by the field chain.
*
* @returns {Object}
*/
getSelectedField: function () {
return this.popover.getSelectedField();
},
/**
* Indicates if the field chain is valid. If the field chain has not been
* processed yet (the widget is not ready), this method will return
* undefined.
*
* @returns {boolean}
*/
isValid: function () {
return this.popover.valid;
},
/**
* Saves a new field chain (array) and re-render.
*
* @param {string[]} chain - the new field chain
* @returns {Promise} resolved once the re-rendering is finished
*/
setChain: function (chain) {
return this.popover.setChain(chain).then(() => this.chain = this.popover.chain);
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Updates the rendering of the value (the serie of tags separated by
* arrows).
*
* @private
*/
_render: function () {
// Render the chain value
this.chain = this.popover.chain;
this.$value.html(core.qweb.render(this.template + ".value", {
chain: this.popover.chain,
pages: this.popover.pages,
findByName : (array, name) => {
return (array || []).find((arr) => arr.name === name)
}
}));
// Toggle the warning message
this.$valid.toggleClass('d-none', !!this.isValid());
},
/**
* Called when the widget is focused -> opens the popover
*/
_onFocusIn: function () {
this.popover._onFocusIn();
},
/**
* Called when the widget is blurred -> closes the popover
*/
_onFocusOut: function () {
this.popover._onFocusOut();
},
});
export default ModelFieldSelector;
@@ -1,734 +0,0 @@
/** @odoo-module alias=web.ModelFieldSelectorPopover **/
import core from "web.core";
import Widget from "web.Widget";
import { fuzzyLookup } from "@web/core/utils/search";
import { sortBy } from "@web/core/utils/arrays";
var _t = core._t;
/**
* Field Selector Cache - TODO Should be improved to use external cache ?
* - Stores fields per model used in field selector
* @see ModelFieldSelector._getModelFieldsFromCache
*/
var modelFieldsCache = {
cache: {},
cacheDefs: {},
};
core.bus.on('clear_cache', null, function () {
modelFieldsCache.cache = {};
modelFieldsCache.cacheDefs = {};
});
/**
* The ModelFieldSelector widget can be used to display/select a particular
* field chain from a given model.
*/
var ModelFieldSelectorPopover = Widget.extend({
baseTemplate: "ModelFieldSelector",
template: "ModelFieldSelector.popover",
events: {
// Handle popover opening and closing
"focusin": "_onFocusIn",
"focusout": "_onFocusOut",
"click .o_field_selector_close": "_onCloseClick",
// Handle popover field navigation
"click .o_field_selector_prev_page": "_onPrevPageClick",
"click .o_field_selector_next_page": "_onNextPageClick",
"click li.o_field_selector_select_button": "_onLastFieldClick",
// Handle a direct change in the debug input
"change input.o_field_selector_debug": "_onDebugInputChange",
// Handle a change in the search input
"keyup .o_field_selector_search > input": "_onSearchInputChange",
// Handle a change in the default value input
"keyup .o_field_selector_default_value_input > input": "_onDefaultValueChange",
// Handle keyboard and mouse navigation to build the field chain
"mouseover li.o_field_selector_item": "_onItemHover",
"keydown": "_onKeydown",
},
/**
* @constructor
* The ModelFieldSelector requires a model and a field chain to work with.
*
* @param parent
* @param {string} model - the model name (e.g. "res.partner")
* @param {string[]} chain - list of the initial field chain parts
* @param {Object} [options] - some key-value options
* @param {string} [options.order='string']
* an ordering key for displayed fields
* @param {function} [options.filter]
* a function to filter the fetched fields
* @param {Object} [options.filters]
* some key-value options to filter the fetched fields
* @param {boolean} [options.filters.searchable=true]
* true if only the searchable fields have to be used
* @param {Object[]} [options.fields=null]
* the list of fields info to use when no relation has
* been followed (null indicates the widget has to request
* the fields itself)
* @param {boolean|function} [options.followRelations=true]
* true if can follow relation when building the chain
* @param {boolean} [options.showSearchInput=true]
* false to hide a search input to filter displayed fields
* @param {boolean} [options.needDefaultValue=false]
* true if a default value can be entered after the selected chain
* @param {boolean} [options.cancelOnEscape=false]
* true if a the chain selected should be ignored when the user hit ESC
* @param {boolean} [options.debugMode=false]
* true if the widget is in debug mode, false otherwise
*/
init: function (parent, model, chain, options) {
this._super.apply(this, arguments);
this.model = model;
this.chain = chain;
this.options = Object.assign({
order: 'string',
filters: {},
fields: null,
filter: () => true,
followRelations: true,
debugMode: false,
showSearchInput: true,
needDefaultValue: false,
cancelOnEscape: false,
chainedTitle: false
}, options || {});
this.options.filters = Object.assign({
searchable: true,
}, this.options.filters);
if (typeof this.options.followRelations !== 'function') {
this.options.followRelations = this.options.followRelations ?
(() => true) :
(() => false);
}
this.pages = [];
this.titlesNames = {};
this.dirty = false;
this.searchValue = '';
this.defaultValue = '';
},
/**
* @see Widget.willStart()
* @returns {Promise}
*/
willStart: function () {
return Promise.all([
this._super.apply(this, arguments),
this.prefill()
]);
},
/**
* @see Widget.start
* @returns {Promise}
*/
start: function () {
this.$input = this.$el.find(".o_field_selector_popover_footer > input");
this.$searchInput = this.$el.find(".o_field_selector_search > input");
this.$defaultValueInput = this.$el.find(".o_field_selector_default_value_input > input");
this._render();
return this._super.apply(this, arguments).then(() => {
this.trigger_up("field_selector_started");
});
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Returns the field information selected by the field chain.
*
* @returns {Object}
*/
getSelectedField: function () {
return this.pages[this.chain.length - 1].find((page) => page.name === this.chain.at(-1));
},
/**
* Saves a new field chain (array) and re-render.
*
* @param {string[]} chain - the new field chain
* @returns {Promise} resolved once the re-rendering is finished
*/
setChain: function (chain) {
if (JSON.stringify(chain) === JSON.stringify(this.chain)) {
return Promise.resolve();
}
this.chain = chain;
return this.prefill().then(this._render.bind(this));
},
/**
* Manualy open the popover and force the chain
*
* @param {string[]} chain - the new field chain
* @param {boolean} focus - force the focus in the search field
* @returns {Promise} resolved once the re-rendering is finished
*/
open: async function (chain = [], focus = false) {
clearTimeout(this._hidePopoverTimeout);
this.$searchInput.val('');
await this.setChain(chain);
this._showPopover();
if (focus) {
this.$searchInput.focus();
}
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Adds a field name to the current field chain and marks it as dirty.
*
* @private
* @param {string} fieldName - the new field name to add at the end of the
* current field chain
*/
_addChainNode: function (fieldName) {
this.dirty = true;
this.chain = this.chain.slice(0, this.pages.length - 1);
this.chain.push(fieldName);
this.searchValue = '';
this.$searchInput.val('');
},
/**
* Searches a field in the last page by its name.
*
* @private
* @param {string} name - the name of the field to find
* @returns {Object} the field data found in the last popover page thanks
* to its name
/*/
_getLastPageField: function (name) {
return this.pages.at(-1).find((page) => page.name === name);
},
/**
* Searches the cache for the given model fields, according to the given
* filter. If the cache does not know about the model, the cache is updated.
*
* @private
* @param {string} model
* @param {Object} filters @see ModelFieldSelector.init.options.filters
* @returns {Object[]} a list of the model fields info, sorted by field
* non-technical names
*/
_getModelFieldsFromCache: function (model, filters) {
var self = this;
var def = modelFieldsCache.cacheDefs[model];
if (!def) {
def = modelFieldsCache.cacheDefs[model] = this._rpc({
model: model,
method: 'fields_get',
args: [
false,
["store", "searchable", "type", "string", "relation", "selection", "related"]
],
context: this.getSession().user_context,
})
.then((function (fields) {
modelFieldsCache.cache[model] = sortFields(fields, model, self.options.order);
}).bind(this));
}
return def.then((function () {
return modelFieldsCache.cache[model].filter(f => {
return (!filters.searchable || f.searchable) && self.options.filter(f);
});
}).bind(this));
},
/**
* Return true if the widget is currently open on the default value page
*
* @private
* @returns {Boolean} Is on default value page
*/
_isOnDefaultValuePage: function () {
return this.pages[this.pages.length - 1] === 'defaultValuePage';
},
/**
* Adds a new page to the popover following the given field relation and
* show the defauld value choice page
*
* @private
* @param {String} name - the name of the field to add to the chain node
*/
_goToDefaultValuePage: function (name) {
this._validate(true);
this._addChainNode(name);
this.pages.push('defaultValuePage');
this.$defaultValueInput.val('');
this.defaultValue = '';
this._renderDefaultValue();
},
/**
* Adds a new page to the popover following the given field relation and
* adapts the chain node according to this given field.
*
* @private
* @param {Object} field - the field to add to the chain node
*/
_goToNextPage: function (field) {
if (JSON.stringify(this._getLastPageField(field.name)) !== JSON.stringify(field)) return;
this._validate(true);
this._addChainNode(field.name);
this._pushPageData(field.relation).then(this._render.bind(this));
},
/**
* Removes the last page, adapts the field chain and displays the new
* last page.
*
* @private
*/
_goToPrevPage: function () {
if (this.pages.length <= 0) return;
this._validate(true);
this._removeChainNode();
if (this.pages.length > 1) {
this.pages.pop();
}
this._render();
},
/**
* Closes the popover and marks the field as selected. If the field chain
* changed, it notifies its parents. If not open, this does nothing.
*
* @private
* @param {boolean} cancel - prevent the triggerUp of the `field_chain_changed` event
*/
_hidePopover: function (cancel = false) {
if (!this._isOpen) return;
this._isOpen = false;
this.$searchInput.parent().removeClass('d-none');
this.$defaultValueInput.parent().addClass('d-none');
this.$el.addClass('d-none');
if (cancel) {
this.trigger_up("field_chain_cancel");
} else if (this.dirty) {
this.trigger_up("field_chain_changed", {chain: this.chain, defaultValue: this.defaultValue});
}
this.dirty = false;
this.defaultValue = '';
},
/**
* Prepares the popover by filling its pages according to the current field
* chain.
*
* @private
* @returns {Promise} resolved once the whole field chain has been
* processed
*/
prefill: function () {
this.pages = [];
return this._pushPageData(this.model).then((function () {
this._validate(true);
return (this.chain.length ? processChain.call(this, this.chain.slice().reverse()) : Promise.resolve());
}).bind(this));
function processChain(chain) {
var fieldName = chain.pop();
var field = this._getLastPageField(fieldName);
if (field && field.relation) {
if (chain.length) { // Fetch next chain node if any and possible
return this._pushPageData(field.relation).then(processChain.bind(this, chain));
} else { // Simply update the last popover page
return this._pushPageData(field.relation);
}
} else if (field && chain.length === 0) { // Last node fetched
return Promise.resolve();
} else if (!field && fieldName === "1") { // TRUE_LEAF
this._validate(true);
} else if (!field && fieldName === "0") { // FALSE_LEAF
this._validate(true);
} else { // Wrong node chain
this._validate(false);
}
return Promise.resolve();
}
},
/**
* Gets the fields of a particular model and adds them to a new last
* popover page.
*
* @private
* @param {string} model - the model name whose fields have to be fetched
* @returns {Promise} resolved once the fields have been added
*/
_pushPageData: function (model) {
var def;
if (this.model === model && this.options.fields) {
def = Promise.resolve(sortFields(this.options.fields, model, this.options.order));
} else {
def = this._getModelFieldsFromCache(model, this.options.filters);
}
return def.then((function (fields) {
this.pages.push(fields);
}).bind(this));
},
/**
* Removes the last field name at the end of the current field chain and
* marks it as dirty.
*
* @private
*/
_removeChainNode: function () {
this.dirty = true;
this.chain = this.chain.slice(0, this.pages.length - 1);
this.chain.pop();
},
/**
* Get the current value for the popover header
*
* @private
*/
_getTitle: function () {
var title = "";
if (this.pages.length > 1) {
var prevField = this.pages[this.pages.length - 2].find((page) => {
return page.name === (this.chain.length === this.pages.length)
? this.chain[this.chain.length - 2]
: this.chain.at(-1);
});
if (prevField) {
this.titlesNames[this.chain.at(-1)] = prevField.string;
title = prevField.string;
}
}
if (this.options.chainedTitle) {
title = this.chain.map((chain) => this.titlesNames[chain]).join(' > ');
}
return title;
},
/**
* Adapts the content of the popover.
*
* @private
*/
_render: function () {
this._adaptInputVisibility();
// Adapt the popover content
var page = this.pages.at(-1);
this.$(".o_field_selector_popover_header .o_field_selector_title").text(this._getTitle());
var lines = page.filter((item) => {
return Object.keys(this.options.filter).every((key) => {
return item[key] === this.options.filter[key];
});
});
if (this.searchValue) {
lines = fuzzyLookup(this.searchValue, lines, (l) => l.string);
}
this.$(".o_field_selector_page").replaceWith(core.qweb.render(this.baseTemplate + ".page", {
lines: lines,
followRelations: this.options.followRelations,
debug: this.options.debugMode,
}));
this.$input.val(this.chain.join("."));
this.trigger_up("field_selector_render");
},
/**
* Adapts the content of the popover with the default value page.
*
* @private
*/
_renderDefaultValue: function () {
this._adaptInputVisibility();
this.$defaultValueInput.focus();
// Adapt the popover content
let title = this._getTitle() + '<br>' + _t("Default value");
this.$(".o_field_selector_popover_header .o_field_selector_title")[0].replaceChildren(
...$('<div>' + title + '</div>')[0].childNodes
);
this.$(".o_field_selector_page").replaceWith(core.qweb.render(this.baseTemplate + ".defaultValue", {
line: {
string: '',
description: this.defaultValue.length ? _t("As a default text when no value are set") : _t("Default text is used when no values are set"),
}
}));
this.$input.val(this.chain.join(".") + (this.defaultValue ? " OR " + this.defaultValue : ""));
this.trigger_up("field_selector_render");
},
/**
* Adapt the visibilitty of the search and default view input.
*
* @private
*/
_adaptInputVisibility() {
this.$searchInput.parent().toggleClass("d-none", this._isOnDefaultValuePage());
this.$defaultValueInput.parent().toggleClass("d-none", !this._isOnDefaultValuePage());
},
/**
* Selects the given field and adapts the chain node according to it.
* It also closes the popover and so notifies the parents about the change.
*
* @param {Object} field - the field to select
*/
_selectField: function (field) {
if (JSON.stringify(this._getLastPageField(field.name)) !== JSON.stringify(field)) return;
this._validate(true);
this._addChainNode(field.name);
this._render();
this._hidePopover();
},
/**
* Shows the popover to select the field chain. This assumes that the
* popover has finished its rendering (fully rendered widget or resolved
* deferred of @see setChain). If already open, this does nothing.
*
* @private
*/
_showPopover: function () {
if (this._isOpen) return;
this._isOpen = true;
this.$el.removeClass('d-none');
},
/**
* Toggles the valid status of the widget and display the error message if
* it is not valid.
*
* @private
* @param {boolean} valid - true if the widget is valid, false otherwise
*/
_validate: function (valid) {
this.valid = !!valid;
if (!this.valid) {
this.displayNotification({
message: _t("Invalid field chain. You may have used a non-existing field name or followed a non-relational field."),
type: 'danger',
});
}
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Called when the widget is focused -> opens the popover
*/
_onFocusIn: function () {
clearTimeout(this._hidePopoverTimeout);
this._showPopover();
},
/**
* Called when the widget is blurred -> closes the popover
*/
_onFocusOut: function () {
clearTimeout(this._hidePopoverTimeout);
this._hidePopoverTimeout = setTimeout(() => this._hidePopover(this.options.cancelOnEscape));
},
/**
* Called when the popover "cross" icon is clicked -> closes the popover
*/
_onCloseClick: function () {
this._hidePopover(this.options.cancelOnEscape);
},
/**
* Called when the popover "previous" icon is clicked -> removes last chain
* node
*/
_onPrevPageClick: function () {
this._goToPrevPage();
},
/**
* Called when a popover relation field button is clicked -> adds it to
* the chain
*
* @param {Event} e
*/
_onNextPageClick: function (e) {
e.stopPropagation();
this._goToNextPage(this._getLastPageField($(e.currentTarget).data("name")));
},
/**
* Called when a popover non-relation field button is clicked -> adds it to
* chain and closes the popover
*
* @param {Event} e
*/
_onLastFieldClick: function (e) {
const name = $(e.currentTarget).data("name");
if (this.options.needDefaultValue && this._isOnDefaultValuePage()) {
this._hidePopover();
} else if (this.options.needDefaultValue) {
this._goToDefaultValuePage(name);
} else {
this._selectField(this._getLastPageField(name));
}
},
/**
* Called when the debug input value is changed -> adapts the chain
*/
_onDebugInputChange: function () {
var userChainStr = this.$input.val();
var userChain = userChainStr.split(".");
if (!this.options.followRelations && userChain.length > 1) {
this.displayNotification({
title: _t("Relation not allowed"),
message: _t("You cannot follow relations for this field chain construction"),
type: 'danger',
});
userChain = [userChain[0]];
}
this.setChain(userChain).then((function () {
this.trigger_up("field_chain_changed", {chain: this.chain});
}).bind(this));
},
/**
* Called when the search input value is changed -> adapts the popover
*/
_onSearchInputChange: function () {
this.searchValue = this.$searchInput.val();
this._render();
},
/**
* Called when the default value input value is changed -> adapts the popover
*/
_onDefaultValueChange: function () {
this.defaultValue = this.$defaultValueInput.val();
if (this._isOnDefaultValuePage()) {
this._renderDefaultValue();
}
},
/**
* Called when a popover field button item is hovered -> toggles its
* "active" status
*
* @param {Event} e
*/
_onItemHover: function (e) {
this.$("li.o_field_selector_item").removeClass("active");
$(e.currentTarget).addClass("active");
},
/**
* Called when the user uses the keyboard when the widget is focused
* -> handles field keyboard navigation
*
* @param {Event} e
*/
_onKeydown: function (e) {
if (!this.$el.is(":visible")) return;
var inputHasFocus = this.$input.is(":focus");
let name = this.$("li.o_field_selector_item.active").data("name");
switch (e.which) {
case $.ui.keyCode.UP:
case $.ui.keyCode.DOWN:
e.preventDefault();
var $active = this.$("li.o_field_selector_item.active");
var $to = $active[e.which === $.ui.keyCode.DOWN ? "next" : "prev"](".o_field_selector_item");
if ($to.length) {
$active.removeClass("active");
$to.addClass("active");
this.$el.focus();
var $page = $to.closest(".o_field_selector_page");
var full_height = $page.height();
var el_position = $to.position().top;
var el_height = $to.outerHeight();
var current_scroll = $page.scrollTop();
if (el_position < 0) {
$page.scrollTop(current_scroll - el_height);
} else if (full_height < el_position + el_height) {
$page.scrollTop(current_scroll + el_height);
}
}
break;
case $.ui.keyCode.RIGHT:
if (inputHasFocus) break;
e.preventDefault();
if (name) {
var field = this._getLastPageField(name);
if (field.relation) {
this._goToNextPage(field);
} else if (this.options.needDefaultValue && !this._isOnDefaultValuePage()) {
this._goToDefaultValuePage(name);
}
}
break;
case $.ui.keyCode.LEFT: {
if (inputHasFocus) break;
e.preventDefault();
const isOnDefaultValuePage = this._isOnDefaultValuePage();
this._goToPrevPage();
if (isOnDefaultValuePage) {
this.$searchInput.focus();
}
break;
}
case $.ui.keyCode.ESCAPE:
e.stopPropagation();
this._hidePopover(this.options.cancelOnEscape);
break;
case $.ui.keyCode.ENTER:
e.preventDefault();
if (!name && !this._isOnDefaultValuePage()) {
this._hidePopover(this.options.cancelOnEscape);
break;
}
if (this.options.needDefaultValue && this._isOnDefaultValuePage()) {
this._hidePopover();
} else if (this.options.needDefaultValue) {
this._goToDefaultValuePage(name);
} else {
this._selectField(this._getLastPageField(name));
}
break;
}
}
});
export default ModelFieldSelectorPopover;
/**
* Allows to transform a mapping field name -> field info in an array of the
* field infos, sorted by field user name ("string" value). The field infos in
* the final array contain an additional key "name" with the field name.
*
* @param {Object} fields - the mapping field name -> field info
* @param {string} model - IE : "partner"
* @returns {Object[]} the field infos sorted by field "string" (field infos
* contain additional keys "model" and "name" with the field
* name)
*/
function sortFields(fields, model, order) {
const fieldsArray = Object.entries(fields).map(([key, value]) => {
return Object.assign(
{
name: key,
model: model,
},
value
);
});
return sortBy(fieldsArray, (field) => field[order]);
}
@@ -1,84 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { action_registry as legacyActionRegistry } from "web.core";
import Widget from "web.Widget";
import { useSetupAction } from "../webclient/actions/action_hook";
import { ClientActionAdapter } from "./action_adapters";
import { breadcrumbsToLegacy } from "./backend_utils";
import { useLegacyRefs } from "./utils";
import { LegacyComponent } from "./legacy_component";
import { xml } from "@odoo/owl";
const actionRegistry = registry.category("actions");
const legacyClientActionTemplate = xml`
<ClientActionAdapter Component="Widget" widgetArgs="widgetArgs" widget="widget"
onReverseBreadcrumb="onReverseBreadcrumb"/>`;
// registers an action from the legacy action registry to the wowl one, ensuring
// that widget actions are actually Components
function registerClientAction(name, action) {
if (action.prototype instanceof Widget) {
// the action is a widget, wrap it into a Component and register that component
class Action extends LegacyComponent {
setup() {
this.Widget = action;
const options = {};
for (const key in this.props) {
if (key === "action" || key === "actionId") {
continue;
} else {
options[key] = this.props[key];
}
}
const legacyRefs = useLegacyRefs();
if (this.env.config.breadcrumbs) {
options.breadcrumbs = breadcrumbsToLegacy(this.env.config.breadcrumbs);
}
// always add user context to the action context
this.user = useService("user");
const clientAction = Object.assign({}, this.props.action, {
context: Object.assign({}, this.user.context, this.props.action.context),
});
this.widgetArgs = [clientAction, options];
this.widget = this.props.state && this.props.state.__legacy_widget__;
this.onReverseBreadcrumb =
this.props.state && this.props.state.__on_reverse_breadcrumb__;
const rootRef = {
get el() {
return legacyRefs.widget && legacyRefs.widget.el;
},
};
useSetupAction({
beforeLeave: () => legacyRefs.widget.canBeRemoved(),
getLocalState: () => legacyRefs.component.exportState(),
rootRef,
});
}
}
Action.template = legacyClientActionTemplate;
Action.components = { ClientActionAdapter };
Action.isLegacy = true;
Action.target = action.prototype.target;
actionRegistry.add(name, Action);
} else {
// the action is either a Component or a function, register it directly
actionRegistry.add(name, action);
}
}
// register action already in the legacy registry, and listens to future registrations
for (const [name, action] of Object.entries(legacyActionRegistry.entries())) {
if (!actionRegistry.contains(name)) {
registerClientAction(name, action);
}
}
legacyActionRegistry.onAdd(registerClientAction);
@@ -1,185 +0,0 @@
.o_domain_selector_legacy {
&.o_domain_node, .o_domain_node {
$o-domain-selector-indent: 32px;
$o-domain-selector-panel-space: 60px;
$o-domain-selector-row-height: 35px; // should be greater than a "normal" row so that "header" parts are the same size of rows
$o-domain-animation-bar-height: 15px;
position: relative;
// Rows in a domain node (selector) have a minimum height and holds the control panel
.o_domain_selector_row {
display: flex;
align-items: center;
min-height: $o-domain-selector-row-height;
> * {
flex: 0 0 auto;
}
> .o_domain_node_control_panel {
@include o-position-absolute($top: 0, $bottom: 0, $right: -$o-domain-selector-panel-space);
> button {
float: left;
width: $o-domain-selector-panel-space/2;
height: 100%;
padding: 0;
background-color: transparent;
font-size: 16px;
opacity: 0.2;
cursor: pointer;
&.o_domain_delete_node_button {
font-size: 12px;
padding-left: 5px;
}
}
}
}
// Rules specific to domain parts which contain sub-domain parts
&.o_domain_tree {
> .o_domain_tree_header {
position: relative;
}
.o_domain_tree_operator_caret::after {
@include o-caret-down;
}
& .o_domain_node_children_container {
padding-left: $o-domain-selector-indent;
}
// Rules specific to domain root
&.o_domain_selector {
> .o_domain_tree_header {
min-height: 22px; // prevents "records button" to overflow 1-row domain
}
> .o_domain_node_children_container {
padding-left: 0;
}
&.o_edit_mode > .o_domain_node_children_container {
padding-right: $o-domain-selector-panel-space; // delete button is over the row
}
> .o_domain_debug_container {
display: block;
margin-top: 16px;
padding: 8px 10px 12px;
background: var(--DomainDebugContainer-background-colomap-get, #{$o-gray-900});
color: rgba(white, 0.5);
font-family: monospace;
font-weight: normal;
> textarea {
border: none;
padding-top: 8px;
background: transparent;
color: white;
}
}
}
}
// Rules specific to domain parts which do not contain sub-domain parts
&.o_domain_leaf {
&.o_read_mode {
display: inline-flex;
margin-right: 4px;
}
> .o_domain_leaf_info {
background: $o-brand-lightsecondary;
border: 1px solid darken($o-brand-lightsecondary, 10%);
padding: 2px 4px;
> .o_field_selector {
float: left;
margin-right: 4px;
> .o_field_selector_value > .o_field_selector_chain_part {
border: none;
font-weight: 700;
}
}
.o_domain_leaf_operator {
font-style: italic;
}
.o_domain_leaf_value {
font-weight: 700;
}
}
> .o_domain_leaf_edition {
display: flex;
width: 100%;
padding: 4px ($o-domain-selector-panel-space/2) 4px 0;
> * {
flex: 1 1 15%; // operator select
width: auto;
background-color: white;
&:first-child { // field selector
flex: 0 1 55%;
}
&:last-child { // field value
flex: 1 1 25%;
}
+ * {
margin-left: 4px;
}
input, select, .o_datepicker, .o_datepicker_input {
height: 100%;
}
}
.o_domain_leaf_value_tags {
display: flex;
> * {
flex: 0 0 auto;
}
> input {
flex: 1 1 auto;
width: 0;
min-width: 50px;
}
.o_domain_leaf_value_remove_tag_button {
cursor: pointer;
}
}
}
}
// Animation effects
transition: margin-bottom .05s ease .05s;
&::after {
content: "";
@include o-position-absolute($left: 0, $right: 0, $top: 100%);
display: block;
height: $o-domain-animation-bar-height;
max-height: 0;
background-image: linear-gradient(45deg, rgba($o-brand-primary, 0.4), rgba($o-brand-primary, 0.1));
transition: max-height .05s ease .05s;
}
&.o_hover_btns, &.o_hover_add_node {
.o_domain_node_control_panel > button {
opacity: 0.5;
&:hover {
opacity: 1.0;
}
}
}
}
}
-367
View File
@@ -3,80 +3,6 @@
<!-- Owl Templates -->
<t t-name="web.Legacy.ControlPanel" owl="1">
<div class="o_control_panel px-3" data-command-category="actions">
<div class="o_cp_top mb-1">
<div class="o_cp_top_left">
<ol t-if="props.withBreadcrumbs" class="breadcrumb" role="navigation">
<li t-foreach="props.breadcrumbs" t-as="bc" t-key="bc.controllerID"
class="breadcrumb-item"
t-att-class="{ o_back_button: bc_index === props.breadcrumbs.length - 1 }"
t-att-accesskey="bc_last and 'b'"
t-on-click.prevent="()=>this.trigger('breadcrumb-clicked', { controllerID: bc.controllerID })"
title="Previous menu"
>
<a href="#">
<t t-if="bc.title" t-esc="bc.title"/>
<em t-else="" class="text-warning">Unnamed</em>
</a>
</li>
<li class="breadcrumb-item active">
<span t-attf-class="{{props.breadcrumbs.length ? 'text-muted' : 'text-900' }}" t-if="props.title" t-esc="props.title"/>
<em t-else="" class="text-warning">Unnamed</em>
</li>
</ol>
</div>
<div class="o_cp_top_right">
<div class="o_cp_searchview"
role="search"
t-ref="searchView"
>
</div>
</div>
</div>
<div class="o_cp_bottom">
<div class="o_cp_bottom_left">
<div class="o_cp_buttons" role="toolbar" aria-label="Control panel buttons" t-ref="buttons">
<t t-slot="buttons"/>
</div>
<ActionMenus t-if="props.actionMenus and props.actionMenus.items"
t-props="props.actionMenus"
/>
</div>
<div class="o_cp_bottom_right">
<div class="btn-group o_search_options position-static"
role="search"
t-ref="searchViewButtons"
>
</div>
<div class="o_cp_pager" role="search" t-ref="pager">
<Pager t-if="props.pager and props.pager.limit" t-props="props.pager"/>
</div>
<nav t-if="props.views.length gt 1" class="btn-group o_cp_switch_buttons" role="toolbar" aria-label="View switcher">
<t t-foreach="props.views" t-as="view" t-key="view.type">
<t t-call="web.ViewSwitcherButton"/>
</t>
</nav>
</div>
</div>
</div>
</t>
<t t-name="web.CustomFileInput" owl="1">
<span class="o_file_input" aria-atomic="true" t-on-uploaded="(ev) => props.onUpload(ev.detail.files)">
<span class="o_file_input_trigger" t-on-click.prevent="() => this._onTriggerClicked()">
<t t-slot="default">
<button class="btn btn-primary">Choose File</button>
</t>
</span>
<input type="file" name="ufile" class="o_input_file d-none"
t-att="{multiple: props.multi_upload, accept: props.accepted_file_extensions}"
t-ref="file-input"
t-on-change="() => this._onFileInputChange()"
/>
</span>
</t>
<t t-name="web.DialogButton.tooltip" owl="1">
<div class="oe_tooltip_string" role="tooltip">
<div class="tooltip-inner">
@@ -85,95 +11,6 @@
</div>
</t>
<t t-name="web.DropdownMenu" owl="1">
<div class="btn-group dropdown" t-att-class="{ show: state.open }">
<button type="button"
class="dropdown-toggle btn btn-secondary o-no-caret"
t-attf-class="{{ icon || displayCaret || displayChevron ? 'd-flex align-items-center ' : ''}} {{ displayCaret || displayChevron ? 'pe-1 ' : ''}}"
t-att-aria-expanded="state.open ? 'true' : 'false'"
tabindex="-1"
t-on-click="() => { state.open = !state.open }"
t-on-keydown="_onButtonKeydown"
t-att-title="props.hotkeyTitle"
t-att-data-hotkey="props.hotkey"
>
<i t-if="icon" t-att-class="icon + ' small me-1'"/>
<span class="o_dropdown_title" t-esc="title"/>
<i t-if="displayCaret" t-attf-class="o_dropdown_caret fa fa-fw fa-caret-{{ state.open ? 'down' : 'right' }}"/>
<i t-elif="displayChevron" t-attf-class="o_dropdown_chevron fa fa-fw ms-auto fa-chevron-{{ state.open ? 'down' : 'right' }}"/>
</button>
<ul t-if="state.open" t-attf-class="o-dropdown-menu {{ dropdownMenuAlignClass }} show" role="menu"
t-on-item-selected="_onItemSelected"
>
<t t-set="currentGroup" t-value="null"/>
<t t-foreach="items" t-as="item" t-key="item.key || item.id || ('item-' + item_index)">
<li t-if="currentGroup !== null and currentGroup !== item.groupNumber" class="dropdown-divider" role="separator"/>
<t t-if="item.Component" t-component="item.Component" t-props="item.props"/>
<DropdownMenuItem t-else="" t-props="item"/>
<t t-set="currentGroup" t-value="item.groupNumber"/>
</t>
</ul>
</div>
</t>
<t t-name="web.DropdownMenuItem" owl="1">
<li class="o_menu_item" role="menuitem" t-on-keydown="_onKeydown">
<t t-if="canBeOpened">
<a class="o_menu_item_parent dropdown-item d-flex align-items-center justify-content-between"
t-att-class="{ selected: props.isActive }"
t-att-aria-checked="props.isActive ? 'true' : 'false'"
role="menuitemcheckbox"
href="#"
t-ref="fallback-focus"
t-on-click.prevent="() => { state.open = !state.open }"
>
<t t-esc="props.description"/>
<i t-attf-class="o_icon_right fa fa-fw mr-n4 fa-caret-{{ state.open ? 'down' : 'right' }}"/>
</a>
<ul t-if="state.open" class="o_menu_item_options pb-2 list-unstyled">
<t t-set="currentGroup" t-value="null"/>
<t t-foreach="props.options" t-as="option" t-key="option_index">
<li t-if="currentGroup !== null and currentGroup !== option.groupNumber"
class="dropdown-divider"
role="separator"
/>
<li class="o_item_option" role="menuitem">
<a class="dropdown-item ps-5"
t-att-class="{ selected: option.isActive }"
t-att-aria-checked="option.isActive ? 'true' : 'false'"
role="menuitemcheckbox"
t-att-href="option.url or '#'"
t-esc="option.description"
t-on-click.prevent="() => this.trigger('item-selected', { item: props, option: option })"
/>
</li>
<t t-set="currentGroup" t-value="option.groupNumber"/>
</t>
</ul>
</t>
<a t-else="" class="dropdown-item"
t-att-class="{
selected: props.isActive,
[props.className]: props.className,
'd-flex': props.removable,
'align-items-center': props.removable,
'justify-content-between': props.removable
}"
t-att-aria-checked="props.isActive ? 'true' : 'false'"
role="menuitemcheckbox"
t-att-href="props.url or '#'"
t-on-click.prevent="() => this.trigger('item-selected', { item: props })"
>
<t t-esc="props.description"/>
<i t-if="props.removable"
class="o_icon_right fa fa-trash btn btn-link text-danger p-0 mr-n3"
title="Delete item"
t-on-click.stop.prevent="() => this.trigger('remove-item', { item: props })"
/>
</a>
</li>
</t>
<t t-name="web.OwlDialog" owl="1">
<t t-portal="'body'">
<div class="o_dialog" t-on-focus="_onFocus" t-on-click="_onClick" t-on-dialog-closed="_onDialogClosed" t-ref="dialog">
@@ -270,26 +107,6 @@
</nav>
</t>
<t t-name="web.Legacy.ActionMenus" owl="1">
<div class="o_cp_action_menus" t-on-item-selected.stop="_onItemSelected">
<DropdownMenu t-if="printItems.length"
title="printButtonStrings.title"
items="printItems"
icon="'fa fa-print'"
hotkey="'shift+u'"
hotkeyTitle="printButtonStrings.hotkey"
/>
<DropdownMenu t-if="actionItems.length"
title="actionButtonStrings.title"
items="actionItems"
icon="'fa fa-cog'"
closeOnSelected="true"
hotkey="'u'"
hotkeyTitle="actionButtonStrings.hotkey"
/>
</div>
</t>
<t t-name="web.ViewSwitcherButton" owl="1">
<t t-set="buttonLabel">View %s</t>
<button type="button"
@@ -318,190 +135,6 @@
</div>
</t>
<t t-name="DomainNode.ControlPanel">
<div t-if="!widget.readonly &amp;&amp; !widget.noControlPanel" class="o_domain_node_control_panel" role="toolbar" aria-label="Domain node">
<button class="btn btn-link text-danger o_domain_delete_node_button" title="Delete node" aria-label="Delete node"><i class="fa fa-times"/></button>
<button class="btn o_domain_add_node_button" title="Add node" aria-label="Add node"><i class="fa fa-plus-circle"/></button>
<button class="btn o_domain_add_node_button" title="Add branch" aria-label="Add branch" data-branch="1"><i class="fa fa-ellipsis-h"/></button>
</div>
</t>
<t t-name="DomainTree.OperatorSelector">
<div t-if="!widget.readonly" class="btn-group o_domain_tree_operator_selector" aria-atomic="true">
<button class="btn btn-sm btn-primary o_domain_tree_operator_caret" data-bs-toggle="dropdown">
<t t-if="widget.operator === '&amp;'">All</t>
<t t-if="widget.operator === '|'">Any</t>
<t t-if="widget.operator === '!'">None</t>
</button>
<div class="dropdown-menu" role="menu">
<a role="menuitem" href="#" class="dropdown-item" data-operator="&amp;">All</a>
<a role="menuitem" href="#" class="dropdown-item" data-operator="|">Any</a>
</div>
</div>
<strong t-else="">
<t t-if="widget.operator === '&amp;'">ALL</t>
<t t-if="widget.operator === '|'">ANY</t>
<t t-if="widget.operator === '!'">NONE</t>
</strong>
</t>
<div aria-atomic="true" t-name="DomainSelector" t-attf-class="o_domain_node o_domain_tree o_domain_selector o_domain_selector_legacy #{widget.readonly ? 'o_read_mode' : 'o_edit_mode'}">
<t t-if="widget.children.length === 0">
<span>Match <strong>all records</strong></span>
<button t-if="!widget.readonly" class="btn btn-sm btn-primary o_domain_add_first_node_button"><i class="fa fa-plus"/> Add filter</button>
</t>
<t t-else="">
<div class="o_domain_tree_header">
<t t-if="widget.children.length === 1">Match records with the following rule:</t>
<t t-else="">
<span>Match records with</span>
<t t-call="DomainTree.OperatorSelector"/>
<span>of the following rules:</span>
</t>
</div>
<div class="o_domain_node_children_container"/>
</t>
<label t-if="widget.debug &amp;&amp; !widget.readonly" class="o_domain_debug_container">
<span class="small"># Code editor</span>
<textarea type="text" class="o_domain_debug_input"/>
</label>
</div>
<div t-name="DomainTree" class="o_domain_node o_domain_tree">
<div class="o_domain_tree_header o_domain_selector_row">
<t t-call="DomainNode.ControlPanel"/>
<t t-call="DomainTree.OperatorSelector"/>
<span class="ml4">of:</span>
</div>
<div class="o_domain_node_children_container"/>
</div>
<div t-name="DomainLeaf" t-attf-class="o_domain_node o_domain_leaf o_domain_selector_row #{widget.readonly ? 'o_read_mode' : 'o_edit_mode'}">
<t t-call="DomainNode.ControlPanel"/>
<div t-if="!widget.readonly" class="o_domain_leaf_edition">
<!-- field selector will be instantiated here -->
<div> <!-- used for flex stretching -->
<select class="o_domain_leaf_operator_select o_input">
<option t-foreach="widget.operators" t-as="key" t-key="key_index"
t-att-value="key"
t-att-selected="widget.displayOperator === key ? 'selected' : None">
<t t-esc="key_value"/>
</option>
</select>
</div>
<div t-attf-class="o_ds_value_cell#{['set', 'not set'].includes(widget.displayOperator) ? ' d-none' : ''}">
<t t-if="widget.selectionChoices !== null">
<select class="o_domain_leaf_value_input o_input">
<option t-foreach="widget.selectionChoices" t-as="val" t-key="val_index"
t-att-value="val[0]"
t-att-selected="val.includes(widget.displayValue) ? 'selected' : None">
<t t-esc="val[1]"/>
</option>
</select>
</t>
<t t-else="">
<t t-if="['in', 'not in'].includes(widget.operator)">
<div class="o_domain_leaf_value_input">
<span class="badge rounded-pill" t-foreach="widget.displayValue" t-as="val" t-key="val_index">
<t t-esc="val"/> <i class="o_domain_leaf_value_remove_tag_button fa fa-times" t-att-data-value="val" role="img" aria-label="Remove tag" title="Remove tag"/>
</span>
</div>
<div class="o_domain_leaf_value_tags">
<input placeholder="Add new value" type="text" class="o_input"/>
<button class="btn btn-sm btn-primary fa fa-plus o_domain_leaf_value_add_tag_button" aria-label="Add tag" title="Add tag"/>
</div>
</t>
<t t-else="">
<input class="o_domain_leaf_value_input o_input" type="text" t-att-value="widget.displayValue"/>
</t>
</t>
</div>
</div>
<div t-else="" class="o_domain_leaf_info">
<!-- field selector will be instantiated here -->
<t t-if="typeof(widget.value) === 'string'">
<span class="o_domain_leaf_operator"><t t-esc="widget.operator_mapping[widget.operator]"/></span>
<span class="o_domain_leaf_value text-primary">"<t t-esc="widget.value"/>"</span>
</t>
<t t-if="Array.isArray(widget.value)">
<span class="o_domain_leaf_operator"><t t-esc="widget.operator_mapping[widget.operator]"/></span>
<t t-foreach="widget.value" t-as="v" t-key="v_index">
<span class="o_domain_leaf_value text-primary">"<t t-esc="v"/>"</span>
<t t-if="!v_last"> or </t>
</t>
</t>
<t t-if="typeof(widget.value) === 'number'">
<span class="o_domain_leaf_operator"><t t-esc="widget.operator_mapping[widget.operator]"/></span>
<span class="o_domain_leaf_value text-primary"><t t-esc="widget.value"></t></span>
</t>
<t t-if="typeof(widget.value) === 'boolean'">
is
<t t-if="widget.operator === '=' &amp;&amp; widget.value === false || widget.operator === '!=' &amp;&amp; widget.value === true">not</t>
set
</t>
</div>
</div>
<div aria-atomic="true" t-name="ModelFieldSelector" t-attf-class="o_field_selector o_legacy_field_selector#{!widget.options.readonly ? ' o_edit_mode o_input' : ''}">
<div class="o_field_selector_value" tabindex="0"/>
<div class="o_field_selector_controls" tabindex="0">
<i role="alert" class="fa fa-exclamation-triangle o_field_selector_warning d-none" title="Invalid field chain" aria-label="Invalid field chain"/>
</div>
</div>
<div t-name="ModelFieldSelector.popover" class="o_field_selector_popover o_legacy_field_selector_popover d-none" tabindex="0">
<div class="o_field_selector_popover_header text-center">
<i class="oi oi-arrow-left o_field_selector_popover_option o_field_selector_prev_page" title="Previous" role="img" aria-label="Previous"/>
<div class="o_field_selector_title"/>
<i class="fa fa-times o_field_selector_popover_option o_field_selector_close" title="Close" role="img" aria-label="Close"/>
<div t-if="widget.options.showSearchInput" class="o_field_selector_search mt-2">
<input type="text" placeholder='Search a field...' class="o_input"/>
</div>
<div t-if="widget.options.needDefaultValue" class="o_field_selector_default_value_input mt-2 d-none">
<input type="text" placeholder='type a default text or press ENTER' class="o_input"/>
</div>
</div>
<div class="o_field_selector_popover_body">
<ul class="o_field_selector_page"/>
</div>
<div t-if="widget.options.debugMode" class="o_field_selector_popover_footer">
<input type="text" class="o_input o_field_selector_debug"/>
</div>
</div>
<t t-name="ModelFieldSelector.value">
<t t-foreach="chain" t-as="fieldName" t-key="fieldName_index">
<t t-if="fieldName_index > 0">
<i class="oi oi-chevron-right" role="img" aria-label="Followed by" title="Followed by"/>
</t>
<span class="o_field_selector_chain_part">
<t t-if="fieldName === '1'">
<t t-esc="fieldName"/>
</t>
<t t-elif="fieldName === '0'">
<t t-esc="fieldName"/>
</t>
<t t-else="">
<t t-set="fieldInfo" t-value="findByName(pages[fieldName_index], fieldName)"/>
<t t-esc="fieldInfo &amp;&amp; fieldInfo.string || '?'"/>
</t>
</span>
</t>
</t>
<ul t-name="ModelFieldSelector.defaultValue" class="o_field_selector_page o_field_selector_default_value">
<li t-attf-class="o_field_selector_item active o_field_selector_select_button">
<t t-esc="line.string"/>
<div class="text-muted o_field_selector_item_title"><t t-esc="line.description"/></div>
</li>
</ul>
<ul t-name="ModelFieldSelector.page" class="o_field_selector_page">
<t t-foreach="lines" t-as="line" t-key="line_index">
<t t-set="relationToFollow" t-value="followRelations(line) &amp;&amp; line.relation"/>
<li t-attf-class="o_field_selector_item #{relationToFollow and 'o_field_selector_next_page' or 'o_field_selector_select_button'}#{line_index == 0 and ' active' or ''}"
t-att-data-name="line.name">
<t t-esc="line.string"/>
<div t-if="debug" class="text-muted o_field_selector_item_title"><t t-esc="line.name"/> (<t t-esc="line.type"/>)</div>
<i t-if="relationToFollow" class="oi oi-chevron-right o_field_selector_relation_icon" role="img" aria-label="Relation to follow" title="Relation to follow"/>
</li>
</t>
</ul>
<t t-name="web.datepicker">
<t t-set="placeholder" t-value="widget.getParent().node and widget.getParent().node.attrs.placeholder"/>
<t t-set="datepickerID" t-value="uniqueId('datepicker')"/>
@@ -133,7 +133,6 @@ export class KanbanController extends Component {
const root = this.model.root;
const { count, hasLimitedCount, isGrouped, limit, offset } = root;
if (!isGrouped) {
console.log(root);
return {
offset: offset,
limit: limit,
@@ -1,262 +0,0 @@
/** @odoo-module **/
import ActionMenus from "web.ActionMenus";
import Registry from "web.Registry";
import testUtils from "web.test_utils";
const { Component } = owl;
const { createComponent } = testUtils;
QUnit.module('Components', {
beforeEach() {
this.action = {
res_model: 'hobbit',
};
this.view = {
// needed by google_drive module, makes sense to give a view anyway.
type: 'form',
};
this.props = {
activeIds: [23],
context: {},
items: {
action: [
{ action: { id: 1 }, name: "What's taters, precious ?", id: 1 },
],
print: [
{ action: { id: 2 }, name: "Po-ta-toes", id: 2 },
],
other: [
{ description: "Boil'em", callback() { } },
{ description: "Mash'em", callback() { } },
{ description: "Stick'em in a stew", url: '#stew' },
],
},
};
// Patch the registry of the action menus
this.actionMenusRegistry = ActionMenus.registry;
ActionMenus.registry = new Registry();
},
afterEach() {
ActionMenus.registry = this.actionMenusRegistry;
},
}, function () {
QUnit.module('ActionMenus');
QUnit.test('basic interactions', async function (assert) {
assert.expect(10);
const actionMenus = await createComponent(ActionMenus, {
env: {
action: this.action,
view: this.view,
},
props: this.props,
});
const dropdowns = actionMenus.el.getElementsByClassName('dropdown');
assert.strictEqual(dropdowns.length, 2, "ActionMenus should contain 2 menus");
assert.strictEqual(dropdowns[0].querySelector('.o_dropdown_title').innerText.trim(), "Print");
assert.strictEqual(dropdowns[1].querySelector('.o_dropdown_title').innerText.trim(), "Action");
assert.containsNone(actionMenus, '.o-dropdown-menu');
await testUtils.controlPanel.toggleActionMenu(actionMenus, "Action");
assert.containsOnce(actionMenus, '.o-dropdown-menu');
assert.containsN(actionMenus, '.o-dropdown-menu .o_menu_item', 4);
const actionsTexts = [...dropdowns[1].querySelectorAll('.o_menu_item')].map(el => el.innerText.trim());
assert.deepEqual(actionsTexts, [
"Boil'em",
"Mash'em",
"Stick'em in a stew",
"What's taters, precious ?",
], "callbacks should appear before actions");
await testUtils.controlPanel.toggleActionMenu(actionMenus, "Print");
assert.containsOnce(actionMenus, '.o-dropdown-menu');
assert.containsN(actionMenus, '.o-dropdown-menu .o_menu_item', 1);
await testUtils.controlPanel.toggleActionMenu(actionMenus, "Print");
assert.containsNone(actionMenus, '.o-dropdown-menu');
});
QUnit.test("empty action menus", async function (assert) {
assert.expect(1);
ActionMenus.registry.add("test", { Component, getProps: () => false });
this.props.items = {};
const actionMenus = await createComponent(ActionMenus, {
env: {
action: this.action,
view: this.view,
},
props: this.props,
});
assert.containsNone(actionMenus, ".o_cp_action_menus > *");
});
QUnit.test('execute action', async function (assert) {
assert.expect(4);
const actionMenus = await createComponent(ActionMenus, {
env: {
action: this.action,
view: this.view,
},
props: this.props,
intercepts: {
'do-action': ev => assert.step('do-action'),
},
async mockRPC(route, args) {
switch (route) {
case '/web/action/load':
const expectedContext = {
active_id: 23,
active_ids: [23],
active_model: 'hobbit',
};
assert.deepEqual(args.context, expectedContext);
assert.step('load-action');
return { context: {}, flags: {} };
default:
return this._super(...arguments);
}
},
});
await testUtils.controlPanel.toggleActionMenu(actionMenus, "Action");
await testUtils.controlPanel.toggleMenuItem(actionMenus, "What's taters, precious ?");
assert.verifySteps(['load-action', 'do-action']);
});
QUnit.test('execute callback action', async function (assert) {
assert.expect(2);
const callbackPromise = testUtils.makeTestPromise();
this.props.items.other[0].callback = function (items) {
assert.strictEqual(items.length, 1);
assert.strictEqual(items[0].description, "Boil'em");
callbackPromise.resolve();
};
const actionMenus = await createComponent(ActionMenus, {
env: {
action: this.action,
view: this.view,
},
props: this.props,
async mockRPC(route, args) {
switch (route) {
case '/web/action/load':
throw new Error("No action should be loaded.");
default:
return this._super(...arguments);
}
},
});
await testUtils.controlPanel.toggleActionMenu(actionMenus, "Action");
await testUtils.controlPanel.toggleMenuItem(actionMenus, "Boil'em");
await callbackPromise;
});
QUnit.test('execute print action', async function (assert) {
assert.expect(4);
const actionMenus = await createComponent(ActionMenus, {
env: {
action: this.action,
view: this.view,
},
intercepts: {
'do-action': ev => assert.step('do-action'),
},
props: this.props,
async mockRPC(route, args) {
switch (route) {
case '/web/action/load':
const expectedContext = {
active_id: 23,
active_ids: [23],
active_model: 'hobbit',
};
assert.deepEqual(args.context, expectedContext);
assert.step('load-action');
return { context: {}, flags: {} };
default:
return this._super(...arguments);
}
},
});
await testUtils.controlPanel.toggleActionMenu(actionMenus, "Print");
await testUtils.controlPanel.toggleMenuItem(actionMenus, "Po-ta-toes");
assert.verifySteps(['load-action', 'do-action']);
});
QUnit.test('execute url action', async function (assert) {
assert.expect(2);
const actionMenus = await createComponent(ActionMenus, {
env: {
action: this.action,
services: {
navigate(url) {
assert.step(url);
},
},
view: this.view,
},
props: this.props,
async mockRPC(route, args) {
switch (route) {
case '/web/action/load':
throw new Error("No action should be loaded.");
default:
return this._super(...arguments);
}
},
});
await testUtils.controlPanel.toggleActionMenu(actionMenus, "Action");
await testUtils.controlPanel.toggleMenuItem(actionMenus, "Stick'em in a stew");
assert.verifySteps(['#stew']);
});
QUnit.test('execute action with context', async function (assert) {
assert.expect(1);
const actionMenus = await createComponent(ActionMenus, {
env: {
action: this.action,
view: this.view,
},
props: {
...this.props,
isDomainSelected: true,
context: {
allowed_company_ids: [112],
},
},
async mockRPC(route, args) {
if (route === "/web/dataset/call_kw/hobbit/search"){
assert.deepEqual(args.kwargs.context, { allowed_company_ids: [112] }, "The kwargs should contains the right context");
}
return this._super(...arguments);
},
});
await testUtils.controlPanel.toggleActionMenu(actionMenus, "Action");
await testUtils.controlPanel.toggleMenuItem(actionMenus, "What's taters, precious ?");
});
});
@@ -1,84 +0,0 @@
/** @odoo-module **/
import CustomFileInput from "web.CustomFileInput";
import testUtils from "web.test_utils";
const { createComponent } = testUtils;
QUnit.module('Components', {}, function () {
// This module cannot be tested as thoroughly as we want it to be:
// browsers do not let scripts programmatically assign values to inputs
// of type file
QUnit.module('CustomFileInput');
QUnit.test("Upload a file: default props", async function (assert) {
assert.expect(6);
const customFileInput = await createComponent(CustomFileInput, {
env: {
services: {
async httpRequest(route, params) {
assert.deepEqual(params, {
csrf_token: odoo.csrf_token,
ufile: [],
});
assert.step(route);
return '[]';
},
},
},
});
const input = customFileInput.el.querySelector('input');
assert.strictEqual(customFileInput.el.innerText.trim().toUpperCase(), "CHOOSE FILE",
"File input total text should match its given inner element's text");
assert.strictEqual(input.accept, '*',
"Input should accept all files by default");
await testUtils.dom.triggerEvent(input, 'change');
assert.notOk(input.multiple, "'multiple' attribute should not be set");
assert.verifySteps(['/web/binary/upload']);
});
QUnit.test("Upload a file: custom attachment", async function (assert) {
assert.expect(6);
const customFileInput = await createComponent(CustomFileInput, {
env: {
services: {
async httpRequest(route, params) {
assert.deepEqual(params, {
id: 5,
model: 'res.model',
csrf_token: odoo.csrf_token,
ufile: [],
});
assert.step(route);
return '[]';
},
},
},
props: {
accepted_file_extensions: '.png',
action: '/web/binary/upload_attachment',
id: 5,
model: 'res.model',
multi_upload: true,
},
intercepts: {
'uploaded': ev => assert.strictEqual(ev.detail.files.length, 0,
"'files' property should be an empty array"),
},
});
const input = customFileInput.el.querySelector('input');
assert.strictEqual(input.accept, '.png', "Input should now only accept pngs");
await testUtils.dom.triggerEvent(input, 'change');
assert.ok(input.multiple, "'multiple' attribute should be set");
assert.verifySteps(['/web/binary/upload_attachment']);
});
});
@@ -1,66 +0,0 @@
/** @odoo-module **/
import DropdownMenu from "web.DropdownMenu";
import testUtils from "web.test_utils";
const { createComponent } = testUtils;
QUnit.module('Components', {
before: function () {
this.items = [
{
isActive: false,
description: 'Some Item',
id: 1,
groupId: 1,
groupNumber: 1,
options: [
{ description: "First Option", groupNumber: 1, id: 1 },
{ description: "Second Option", groupNumber: 2, id: 2 },
],
}, {
isActive: true,
description: 'Some other Item',
id: 2,
groupId: 2,
groupNumber: 2,
},
];
},
}, function () {
QUnit.module('DropdownMenu');
QUnit.test('display dropdown at the right position', async function (assert) {
assert.expect(2);
const viewPort = testUtils.prepareTarget();
viewPort.style.position = 'initial';
const dropdown = await createComponent(DropdownMenu, {
env: {
device: {
isMobile: true
},
},
props: {
items: this.items,
title: "Dropdown",
},
});
await testUtils.dom.click(dropdown.el.querySelector('button'));
assert.containsOnce(dropdown.el, '.dropdown-menu-start',
"should display the dropdown menu at the right screen");
await testUtils.dom.click(dropdown.el.querySelector('button'));
// position the dropdown to the right
dropdown.el.parentNode.classList.add('clearfix');
dropdown.el.classList.add('float-end');
await testUtils.dom.click(dropdown.el.querySelector('button'));
assert.containsOnce(dropdown.el, '.dropdown-menu-end',
"should display the dropdown menu at the left screen");
dropdown.el.parentNode.classList.remove('clearfix');
viewPort.style.position = '';
});
});
@@ -1,428 +0,0 @@
/** @odoo-module **/
import DropdownMenu from "web.DropdownMenu";
import testUtils from "web.test_utils";
import makeTestEnvironment from "web.test_env";
import { mount } from "@web/../tests/helpers/utils";
import { LegacyComponent } from "@web/legacy/legacy_component";
const { useState, xml } = owl;
const { createComponent } = testUtils;
QUnit.module('Components', {
beforeEach: function () {
this.items = [
{
isActive: false,
description: 'Some Item',
id: 1,
groupId: 1,
groupNumber: 1,
options: [
{ description: "First Option", groupNumber: 1, id: 1 },
{ description: "Second Option", groupNumber: 2, id: 2 },
],
}, {
isActive: true,
description: 'Some other Item',
id: 2,
groupId: 2,
groupNumber: 2,
},
];
},
}, function () {
QUnit.module('DropdownMenu');
QUnit.test('simple rendering and basic interactions', async function (assert) {
assert.expect(8);
const dropdown = await createComponent(DropdownMenu, {
props: {
items: this.items,
title: "Dropdown",
},
});
assert.strictEqual(dropdown.el.querySelector('button').innerText.trim(), "Dropdown");
assert.containsNone(dropdown, '.o-dropdown-menu');
await testUtils.dom.click(dropdown.el.querySelector('button'));
assert.containsN(dropdown, '.dropdown-divider, .dropdown-item', 3,
'should have 3 elements counting the divider');
const itemEls = dropdown.el.querySelectorAll('.o_menu_item > .dropdown-item');
assert.strictEqual(itemEls[0].innerText.trim(), 'Some Item');
assert.doesNotHaveClass(itemEls[0], 'selected');
assert.hasClass(itemEls[1], 'selected');
const dropdownElements = dropdown.el.querySelectorAll('.o_menu_item *');
for (const dropdownEl of dropdownElements) {
await testUtils.dom.click(dropdownEl);
}
assert.containsOnce(dropdown, '.o-dropdown-menu',
"Clicking on any item of the dropdown should not close it");
await testUtils.dom.click(document.body);
assert.containsNone(dropdown, '.o-dropdown-menu',
"Clicking outside of the dropdown should close it");
});
QUnit.test('only one dropdown rendering at same time (owl vs bootstrap dropdown)', async function (assert) {
assert.expect(12);
const bsDropdown = document.createElement('div');
bsDropdown.innerHTML = `<div class="dropdown">
<button class="btn dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
BS Dropdown button
</button>
<div class="dropdown-menu">
<a class="dropdown-item" href="#">BS Action</a>
</div>
</div>`;
document.body.append(bsDropdown);
const dropdown = await createComponent(DropdownMenu, {
props: {
items: this.items,
title: "Dropdown",
},
});
await testUtils.dom.click(dropdown.el.querySelector('button'));
assert.hasClass(dropdown.el.querySelector('.o-dropdown-menu'), 'show');
assert.doesNotHaveClass(bsDropdown.querySelector('.dropdown-menu'), 'show');
assert.isVisible(dropdown.el.querySelector('.o-dropdown-menu'),
"owl dropdown menu should be visible");
assert.isNotVisible(bsDropdown.querySelector('.dropdown-menu'),
"bs dropdown menu should not be visible");
await testUtils.dom.click(bsDropdown.querySelector('.btn.dropdown-toggle'));
assert.doesNotHaveClass(dropdown.el, 'show');
assert.containsNone(dropdown.el, '.o-dropdown-menu',
"owl dropdown menu should not be set inside the dom");
assert.hasClass(bsDropdown.querySelector('.dropdown-menu'), 'show');
assert.isVisible(bsDropdown.querySelector('.dropdown-menu'),
"bs dropdown menu should be visible");
await testUtils.dom.click(document.body);
assert.doesNotHaveClass(dropdown.el, 'show');
assert.containsNone(dropdown.el, '.o-dropdown-menu',
"owl dropdown menu should not be set inside the dom");
assert.doesNotHaveClass(bsDropdown.querySelector('.dropdown-menu'), 'show');
assert.isNotVisible(bsDropdown.querySelector('.dropdown-menu'),
"bs dropdown menu should not be visible");
bsDropdown.remove();
});
QUnit.test('click on an item without options should toggle it', async function (assert) {
assert.expect(7);
delete this.items[0].options;
const dropdown = await createComponent(DropdownMenu, {
props: { items: this.items },
intercepts: {
'item-selected': function (ev) {
assert.strictEqual(ev.detail.item.id, 1);
this.state.items[0].isActive = !this.state.items[0].isActive;
},
}
});
await testUtils.dom.click(dropdown.el.querySelector('button'));
const firstItemEl = dropdown.el.querySelector('.o_menu_item > a');
assert.doesNotHaveClass(firstItemEl, 'selected');
await testUtils.dom.click(firstItemEl);
assert.hasClass(firstItemEl, 'selected');
assert.isVisible(firstItemEl);
await testUtils.dom.click(firstItemEl);
assert.doesNotHaveClass(firstItemEl, 'selected');
assert.isVisible(firstItemEl);
});
QUnit.test('click on an item should not change url', async function (assert) {
assert.expect(1);
delete this.items[0].options;
const initialHref = window.location.href;
const dropdown = await createComponent(DropdownMenu, {
props: { items: this.items },
});
await testUtils.dom.click(dropdown.el.querySelector('button'));
await testUtils.dom.click(dropdown.el.querySelector('.o_menu_item > a'));
assert.strictEqual(window.location.href, initialHref,
"the url should not have changed after a click on an item");
});
QUnit.test('options rendering', async function (assert) {
assert.expect(6);
const dropdown = await createComponent(DropdownMenu, {
props: { items: this.items },
});
await testUtils.dom.click(dropdown.el.querySelector('button'));
assert.containsN(dropdown, '.dropdown-divider, .dropdown-item', 3);
const firstItemEl = dropdown.el.querySelector('.o_menu_item > a');
assert.hasClass(firstItemEl.querySelector('i'), 'o_icon_right fa fa-caret-right');
// open options menu
await testUtils.dom.click(firstItemEl);
assert.hasClass(firstItemEl.querySelector('i'), 'o_icon_right fa fa-caret-down');
assert.containsN(dropdown, '.dropdown-divider, .dropdown-item', 6);
// close options menu
await testUtils.dom.click(firstItemEl);
assert.hasClass(firstItemEl.querySelector('i'), 'o_icon_right fa fa-caret-right');
assert.containsN(dropdown, '.dropdown-divider, .dropdown-item', 3);
});
QUnit.test('close menu closes also submenus', async function (assert) {
assert.expect(2);
const dropdown = await createComponent(DropdownMenu, {
props: { items: this.items },
});
// open dropdown menu
await testUtils.dom.click(dropdown.el.querySelector('button'));
// open options menu of first item
await testUtils.dom.click(dropdown.el.querySelector('.o_menu_item a'));
assert.containsN(dropdown, '.dropdown-divider, .dropdown-item', 6);
await testUtils.dom.click(dropdown.el.querySelector('button'));
await testUtils.dom.click(dropdown.el.querySelector('button'));
assert.containsN(dropdown, '.dropdown-divider, .dropdown-item', 3);
});
QUnit.test('click on an option should trigger the event "item_option_clicked" with appropriate data', async function (assert) {
assert.expect(18);
let eventNumber = 0;
const dropdown = await createComponent(DropdownMenu, {
props: { items: this.items },
intercepts: {
'item-selected': function (ev) {
eventNumber++;
const { option } = ev.detail;
assert.strictEqual(ev.detail.item.id, 1);
if (eventNumber === 1) {
assert.strictEqual(option.id, 1);
this.state.items[0].isActive = true;
this.state.items[0].options[0].isActive = true;
}
if (eventNumber === 2) {
assert.strictEqual(option.id, 2);
this.state.items[0].options[1].isActive = true;
}
if (eventNumber === 3) {
assert.strictEqual(option.id, 1);
this.state.items[0].options[0].isActive = false;
}
if (eventNumber === 4) {
assert.strictEqual(option.id, 2);
this.state.items[0].isActive = false;
this.state.items[0].options[1].isActive = false;
}
},
}
});
// open dropdown menu
await testUtils.dom.click(dropdown.el.querySelector('button'));
assert.containsN(dropdown, '.dropdown-divider, .o_menu_item', 3);
// open menu options of first item
await testUtils.dom.click(dropdown.el.querySelector('.o_menu_item > a'));
let optionELs = dropdown.el.querySelectorAll('.o_menu_item .o_item_option > a');
// click on first option
await testUtils.dom.click(optionELs[0]);
assert.hasClass(dropdown.el.querySelector('.o_menu_item > a'), 'selected');
optionELs = dropdown.el.querySelectorAll('.o_menu_item .o_item_option > a');
assert.hasClass(optionELs[0], 'selected');
assert.doesNotHaveClass(optionELs[1], 'selected');
// click on second option
await testUtils.dom.click(optionELs[1]);
assert.hasClass(dropdown.el.querySelector('.o_menu_item > a'), 'selected');
optionELs = dropdown.el.querySelectorAll('.o_menu_item .o_item_option > a');
assert.hasClass(optionELs[0], 'selected');
assert.hasClass(optionELs[1], 'selected');
// click again on first option
await testUtils.dom.click(optionELs[0]);
// click again on second option
await testUtils.dom.click(optionELs[1]);
assert.doesNotHaveClass(dropdown.el.querySelector('.o_menu_item > a'), 'selected');
optionELs = dropdown.el.querySelectorAll('.o_menu_item .o_item_option > a');
assert.doesNotHaveClass(optionELs[0], 'selected');
assert.doesNotHaveClass(optionELs[1], 'selected');
});
QUnit.test('keyboard navigation', async function (assert) {
assert.expect(12);
// Shorthand method to trigger a specific keydown.
// Note that BootStrap handles some of the navigation moves (up and down)
// so we need to give the event the proper "which" property. We also give
// it when it's not required to check if it has been correctly prevented.
async function navigate(key, global) {
const which = {
Enter: 13,
Escape: 27,
ArrowLeft: 37,
ArrowUp: 38,
ArrowRight: 39,
ArrowDown: 40,
}[key];
const target = global ? document.body : document.activeElement;
await testUtils.dom.triggerEvent(target, 'keydown', { key, which });
if (key === 'Enter') {
// Pressing "Enter" on a focused element triggers a click (HTML5 specs)
await testUtils.dom.click(target);
}
}
const dropdown = await createComponent(DropdownMenu, {
props: { items: this.items },
});
// Initialize active element (start at toggle button)
dropdown.el.querySelector('button').focus();
await testUtils.dom.click(dropdown.el.querySelector('button'));
await navigate('ArrowDown'); // Go to next item
assert.strictEqual(document.activeElement, dropdown.el.querySelector('.o_menu_item a'));
assert.containsNone(dropdown, '.o_item_option');
await navigate('ArrowRight'); // Unfold first item's options (w/ Right)
assert.strictEqual(document.activeElement, dropdown.el.querySelector('.o_menu_item a'));
assert.containsN(dropdown, '.o_item_option', 2);
await navigate('ArrowDown'); // Go to next option
assert.strictEqual(document.activeElement, dropdown.el.querySelector('.o_item_option a'));
await navigate('ArrowLeft'); // Fold first item's options (w/ Left)
assert.strictEqual(document.activeElement, dropdown.el.querySelector('.o_menu_item a'));
assert.containsNone(dropdown, '.o_item_option');
await navigate('Enter'); // Unfold first item's options (w/ Enter)
assert.strictEqual(document.activeElement, dropdown.el.querySelector('.o_menu_item a'));
assert.containsN(dropdown, '.o_item_option', 2);
await navigate('ArrowDown'); // Go to next option
await navigate('Escape'); // Fold first item's options (w/ Escape)
await testUtils.nextTick();
assert.strictEqual(dropdown.el.querySelector('.o_menu_item a'), document.activeElement);
assert.containsNone(dropdown, '.o_item_option');
await navigate('Escape', true); // Close the dropdown
assert.containsNone(dropdown, '.o-dropdown-menu', "Dropdown should be folded");
});
QUnit.test('interactions between multiple dropdowns', async function (assert) {
assert.expect(7);
const items = this.items;
class Parent extends LegacyComponent {
setup() {
this.state = useState({ items });
}
}
Parent.components = { DropdownMenu };
Parent.template = xml`
<div>
<DropdownMenu title="'First'" items="state.items"/>
<DropdownMenu title="'Second'" items="state.items"/>
</div>`;
const env = makeTestEnvironment();
const target = testUtils.prepareTarget();
const parent = await mount(Parent, target, { env });
const [menu1, menu2] = parent.el.querySelectorAll('.dropdown');
assert.containsNone(parent, '.o-dropdown-menu');
await testUtils.dom.click(menu1.querySelector('button'));
assert.containsOnce(parent, '.o-dropdown-menu');
const [first, second] = parent.el.querySelectorAll(".dropdown");
assert.containsOnce(first, '.o-dropdown-menu');
await testUtils.dom.click(menu2.querySelector('button'));
assert.containsOnce(parent, '.o-dropdown-menu');
assert.containsOnce(second, '.o-dropdown-menu');
await testUtils.dom.click(menu2.querySelector('.o_menu_item a'));
await testUtils.dom.click(menu1.querySelector('button'));
assert.containsOnce(parent, '.o-dropdown-menu');
assert.containsOnce(first, '.o-dropdown-menu');
});
QUnit.test("dropdown doesn't get close on mousedown inside and mouseup outside dropdown", async function (assert) {
// In this test, we simulate a case where the user clicks inside a dropdown menu item
// (e.g. in the input of the 'Save current search' item in the Favorites menu), keeps
// the click pressed, moves the cursor outside the dropdown and releases the click
// (i.e. mousedown and focus inside the item, mouseup and click outside the dropdown).
// In this case, we want to keep the dropdown menu open.
assert.expect(5);
const items = this.items;
class Parent extends LegacyComponent {
setup() {
this.items = items;
}
}
Parent.components = { DropdownMenu };
Parent.template = xml`
<div>
<DropdownMenu title="'First'" items="items"/>
</div>`;
const env = makeTestEnvironment();
const target = testUtils.prepareTarget();
const parent = await mount(Parent, target, { env });
const menu = parent.el.querySelector(".dropdown");
assert.doesNotHaveClass(menu, "show", "dropdown should not be open");
await testUtils.dom.click(menu.querySelector("button"));
assert.hasClass(menu, "show", "dropdown should be open");
const firstItemEl = menu.querySelector(".o_menu_item > a");
// open options menu
await testUtils.dom.click(firstItemEl);
assert.hasClass(firstItemEl.querySelector("i"), "o_icon_right fa fa-caret-down");
// force the focus inside the dropdown item and click outside
firstItemEl.parentElement.querySelector(".o_menu_item_options .o_item_option a").focus();
await testUtils.dom.triggerEvents(parent.el, "click");
assert.hasClass(menu, "show", "dropdown should still be open");
assert.hasClass(firstItemEl.querySelector("i"), "o_icon_right fa fa-caret-down");
});
});
@@ -1,186 +0,0 @@
/** @odoo-module **/
import Pager from "web.Pager";
import testUtils from "web.test_utils";
import { LegacyComponent } from "@web/legacy/legacy_component";
const { createComponent } = testUtils;
const { xml, useState } = owl;
class PagerController extends LegacyComponent {
setup() {
this.state = useState({ ...this.props });
}
async updateProps(nextProps) {
Object.assign(this.state, nextProps);
await testUtils.nextTick();
}
}
PagerController.template = xml`<Pager t-props="state" />`;
PagerController.components = { Pager };
QUnit.module('Components', {}, function () {
QUnit.module('Legacy Pager');
QUnit.test('basic interactions', async function (assert) {
assert.expect(2);
const pager = await createComponent(PagerController, {
props: {
currentMinimum: 1,
limit: 4,
size: 10,
onPagerChanged: function (detail) {
pager.updateProps(detail);
},
},
});
assert.strictEqual(testUtils.controlPanel.getPagerValue(pager), "1-4",
"currentMinimum should be set to 1");
await testUtils.controlPanel.pagerNext(pager);
assert.strictEqual(testUtils.controlPanel.getPagerValue(pager), "5-8",
"currentMinimum should now be 5");
});
QUnit.test('edit the pager', async function (assert) {
assert.expect(4);
const pager = await createComponent(PagerController, {
props: {
currentMinimum: 1,
limit: 4,
size: 10,
onPagerChanged: function (detail) {
pager.updateProps(detail);
},
},
});
await testUtils.dom.click(pager.el.querySelector('.o_pager_value'));
assert.containsOnce(pager, 'input',
"the pager should contain an input");
assert.strictEqual(testUtils.controlPanel.getPagerValue(pager), "1-4",
"the input should have correct value");
// change the limit
await testUtils.controlPanel.setPagerValue(pager, "1-6");
assert.containsNone(pager, 'input',
"the pager should not contain an input anymore");
assert.strictEqual(testUtils.controlPanel.getPagerValue(pager), "1-6",
"the limit should have been updated");
});
QUnit.test("keydown on pager with same value", async function (assert) {
assert.expect(7);
const pager = await createComponent(PagerController, {
props: {
currentMinimum: 1,
limit: 4,
size: 10,
onPagerChanged: () => assert.step("pager-changed"),
},
});
// Enter edit mode
await testUtils.dom.click(pager.el.querySelector('.o_pager_value'));
assert.containsOnce(pager, "input");
assert.strictEqual(testUtils.controlPanel.getPagerValue(pager), "1-4");
assert.verifySteps([]);
// Exit edit mode
await testUtils.dom.triggerEvent(pager.el.querySelector('input'), "keydown", { key: "Enter" });
assert.containsNone(pager, "input");
assert.strictEqual(testUtils.controlPanel.getPagerValue(pager), "1-4");
assert.verifySteps(["pager-changed"]);
});
QUnit.test('pager value formatting', async function (assert) {
assert.expect(8);
const pager = await createComponent(PagerController, {
props: {
currentMinimum: 1,
limit: 4,
size: 10,
onPagerChanged: (detail) => {
pager.updateProps(detail);
},
},
});
assert.strictEqual(testUtils.controlPanel.getPagerValue(pager), "1-4", "Initial value should be correct");
async function inputAndAssert(input, expected, reason) {
await testUtils.controlPanel.setPagerValue(pager, input);
assert.strictEqual(testUtils.controlPanel.getPagerValue(pager), expected,
`Pager value should be "${expected}" when given "${input}": ${reason}`);
}
await inputAndAssert("4-4", "4", "values are squashed when minimum = maximum");
await inputAndAssert("1-11", "1-10", "maximum is floored to size when out of range");
await inputAndAssert("20-15", "10", "combination of the 2 assertions above");
await inputAndAssert("6-5", "10", "fallback to previous value when minimum > maximum");
await inputAndAssert("definitelyValidNumber", "10", "fallback to previous value if not a number");
await inputAndAssert(" 1 , 2 ", "1-2", "value is normalized and accepts several separators");
await inputAndAssert("3 8", "3-8", "value accepts whitespace(s) as a separator");
});
QUnit.test('pager disabling', async function (assert) {
assert.expect(9);
const reloadPromise = testUtils.makeTestPromise();
const pager = await createComponent(PagerController, {
props: {
currentMinimum: 1,
limit: 4,
size: 10,
// The goal here is to test the reactivity of the pager; in a
// typical views, we disable the pager after switching page
// to avoid switching twice with the same action (double click).
onPagerChanged: async function (detail) {
// 1. Simulate a (long) server action
await reloadPromise;
// 2. Update the view with loaded data
pager.updateProps(detail);
},
},
});
const pagerButtons = pager.el.querySelectorAll('button');
// Click twice
await testUtils.controlPanel.pagerNext(pager);
await testUtils.controlPanel.pagerNext(pager);
// Try to edit the pager value
await testUtils.dom.click(pager.el.querySelector('.o_pager_value'));
assert.strictEqual(pagerButtons.length, 2, "the two buttons should be displayed");
assert.ok(pagerButtons[0].disabled, "'previous' is disabled");
assert.ok(pagerButtons[1].disabled, "'next' is disabled");
assert.strictEqual(pager.el.querySelector('.o_pager_value').tagName, 'SPAN',
"pager edition is prevented");
// Server action is done
reloadPromise.resolve();
await testUtils.nextTick();
assert.strictEqual(pagerButtons.length, 2, "the two buttons should be displayed");
assert.notOk(pagerButtons[0].disabled, "'previous' is enabled");
assert.notOk(pagerButtons[1].disabled, "'next' is enabled");
assert.strictEqual(testUtils.controlPanel.getPagerValue(pager), "5-8", "value has been updated");
await testUtils.dom.click(pager.el.querySelector('.o_pager_value'));
assert.strictEqual(pager.el.querySelector('.o_pager_value').tagName, 'INPUT',
"pager edition is re-enabled");
});
});
@@ -1,367 +0,0 @@
/** @odoo-module **/
import { constructDateDomain } from "web.searchUtils";
import testUtils from "web.test_utils";
import { registry } from "@web/core/registry";
import { translatedTerms } from "@web/core/l10n/translation";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { makeFakeLocalizationService } from "@web/../tests/helpers/mock_services";
import { _t } from "web.core";
const patchDate = testUtils.mock.patchDate;
QUnit.module('SearchUtils', function () {
QUnit.module('Construct domain');
QUnit.test('construct simple domain based on date field (no comparisonOptionId)', function (assert) {
assert.expect(4);
const unpatchDate = patchDate(2020, 5, 1, 13, 0, 0);
const referenceMoment = moment().utc();
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', []),
{
domain: "[]",
description: "",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['this_month', 'this_year']),
{
domain: `["&", ["date_field", ">=", "2020-06-01"], ["date_field", "<=", "2020-06-30"]]`,
description: "June 2020",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['second_quarter', 'this_year']),
{
domain: `["&", ["date_field", ">=", "2020-04-01"], ["date_field", "<=", "2020-06-30"]]`,
description: "Q2 2020",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['this_year']),
{
domain: `["&", ["date_field", ">=", "2020-01-01"], ["date_field", "<=", "2020-12-31"]]`,
description: "2020",
}
);
unpatchDate();
});
QUnit.test('construct simple domain based on datetime field (no comparisonOptionId)', function (assert) {
assert.expect(3);
const unpatchDate = patchDate(2020, 5, 1, 13, 0, 0);
const referenceMoment = moment().utc();
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'datetime', ['this_month', 'this_year']),
{
domain: `["&", ["date_field", ">=", "2020-06-01 00:00:00"], ["date_field", "<=", "2020-06-30 23:59:59"]]`,
description: "June 2020",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'datetime', ['second_quarter', 'this_year']),
{
domain: `["&", ["date_field", ">=", "2020-04-01 00:00:00"], ["date_field", "<=", "2020-06-30 23:59:59"]]`,
description: "Q2 2020",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'datetime', ['this_year']),
{
domain: `["&", ["date_field", ">=", "2020-01-01 00:00:00"], ["date_field", "<=", "2020-12-31 23:59:59"]]`,
description: "2020",
}
);
unpatchDate();
});
QUnit.test('construct domain based on date field (no comparisonOptionId)', function (assert) {
assert.expect(3);
const unpatchDate = patchDate(2020, 0, 1, 12, 0, 0);
const referenceMoment = moment().utc();
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['this_month', 'first_quarter', 'this_year']),
{
domain: "[" +
`"|", ` +
`"&", ["date_field", ">=", "2020-01-01"], ["date_field", "<=", "2020-01-31"], ` +
`"&", ["date_field", ">=", "2020-01-01"], ["date_field", "<=", "2020-03-31"]` +
"]",
description: "January 2020/Q1 2020",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['second_quarter', 'this_year', 'last_year']),
{
domain: "[" +
`"|", ` +
`"&", ["date_field", ">=", "2019-04-01"], ["date_field", "<=", "2019-06-30"], ` +
`"&", ["date_field", ">=", "2020-04-01"], ["date_field", "<=", "2020-06-30"]` +
"]",
description: "Q2 2019/Q2 2020",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['this_year', 'this_month', 'antepenultimate_month']),
{
domain: "[" +
`"|", ` +
`"&", ["date_field", ">=", "2020-01-01"], ["date_field", "<=", "2020-01-31"], ` +
`"&", ["date_field", ">=", "2020-11-01"], ["date_field", "<=", "2020-11-30"]` +
"]",
description: "January 2020/November 2020",
}
);
unpatchDate();
});
QUnit.test('construct domain based on datetime field (no comparisonOptionId)', function (assert) {
assert.expect(3);
const unpatchDate = patchDate(2020, 0, 1, 12, 0, 0);
const referenceMoment = moment().utc();
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'datetime', ['this_month', 'first_quarter', 'this_year']),
{
domain: "[" +
`"|", ` +
`"&", ["date_field", ">=", "2020-01-01 00:00:00"], ["date_field", "<=", "2020-01-31 23:59:59"], ` +
`"&", ["date_field", ">=", "2020-01-01 00:00:00"], ["date_field", "<=", "2020-03-31 23:59:59"]` +
"]",
description: "January 2020/Q1 2020",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'datetime', ['second_quarter', 'this_year', 'last_year']),
{
domain: "[" +
`"|", ` +
`"&", ["date_field", ">=", "2019-04-01 00:00:00"], ["date_field", "<=", "2019-06-30 23:59:59"], ` +
`"&", ["date_field", ">=", "2020-04-01 00:00:00"], ["date_field", "<=", "2020-06-30 23:59:59"]` +
"]",
description: "Q2 2019/Q2 2020",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'datetime', ['this_year', 'this_month', 'antepenultimate_month']),
{
domain: "[" +
`"|", ` +
`"&", ["date_field", ">=", "2020-01-01 00:00:00"], ["date_field", "<=", "2020-01-31 23:59:59"], ` +
`"&", ["date_field", ">=", "2020-11-01 00:00:00"], ["date_field", "<=", "2020-11-30 23:59:59"]` +
"]",
description: "January 2020/November 2020",
}
);
unpatchDate();
});
QUnit.test('construct comparison domain based on date field and option "previous_period"', function (assert) {
assert.expect(5);
const unpatchDate = patchDate(2020, 0, 1, 12, 0, 0);
const referenceMoment = moment().utc();
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['this_month', 'first_quarter', 'this_year'], 'previous_period'),
{
domain: "[" +
`"|", "|", ` +
`"&", ["date_field", ">=", "2019-10-01"], ["date_field", "<=", "2019-10-31"], ` +
`"&", ["date_field", ">=", "2019-11-01"], ["date_field", "<=", "2019-11-30"], ` +
`"&", ["date_field", ">=", "2019-12-01"], ["date_field", "<=", "2019-12-31"]` +
"]",
description: "October 2019/November 2019/December 2019",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['second_quarter', 'this_year', 'last_year'], 'previous_period'),
{
domain: "[" +
`"|", ` +
`"&", ["date_field", ">=", "2018-01-01"], ["date_field", "<=", "2018-03-31"], ` +
`"&", ["date_field", ">=", "2019-01-01"], ["date_field", "<=", "2019-03-31"]` +
"]",
description: "Q1 2018/Q1 2019",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['this_year', 'antepenultimate_year', 'this_month', 'antepenultimate_month'], 'previous_period'),
{
domain: "[" +
`"|", "|", "|", ` +
`"&", ["date_field", ">=", "2015-02-01"], ["date_field", "<=", "2015-02-28"], ` +
`"&", ["date_field", ">=", "2015-12-01"], ["date_field", "<=", "2015-12-31"], ` +
`"&", ["date_field", ">=", "2017-02-01"], ["date_field", "<=", "2017-02-28"], ` +
`"&", ["date_field", ">=", "2017-12-01"], ["date_field", "<=", "2017-12-31"]` +
"]",
description: "February 2015/December 2015/February 2017/December 2017",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['this_year', 'last_year'], 'previous_period'),
{
domain: "[" +
`"|", ` +
`"&", ["date_field", ">=", "2017-01-01"], ["date_field", "<=", "2017-12-31"], ` +
`"&", ["date_field", ">=", "2018-01-01"], ["date_field", "<=", "2018-12-31"]` +
"]",
description: "2017/2018",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['second_quarter', 'third_quarter', 'last_year'], 'previous_period'),
{
domain: "[" +
`"|", ` +
`"&", ["date_field", ">=", "2018-10-01"], ["date_field", "<=", "2018-12-31"], ` +
`"&", ["date_field", ">=", "2019-01-01"], ["date_field", "<=", "2019-03-31"]` +
"]",
description: "Q4 2018/Q1 2019",
}
);
unpatchDate();
});
QUnit.test('construct comparison domain based on datetime field and option "previous_year"', function (assert) {
assert.expect(3);
const unpatchDate = patchDate(2020, 5, 1, 13, 0, 0);
const referenceMoment = moment().utc();
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'datetime', ['this_month', 'first_quarter', 'this_year'], 'previous_year'),
{
domain: "[" +
`"|", ` +
`"&", ["date_field", ">=", "2019-06-01 00:00:00"], ["date_field", "<=", "2019-06-30 23:59:59"], ` +
`"&", ["date_field", ">=", "2019-01-01 00:00:00"], ["date_field", "<=", "2019-03-31 23:59:59"]` +
"]",
description: "June 2019/Q1 2019",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'datetime', ['second_quarter', 'this_year', 'last_year'], 'previous_year'),
{
domain: "[" +
`"|", ` +
`"&", ["date_field", ">=", "2018-04-01 00:00:00"], ["date_field", "<=", "2018-06-30 23:59:59"], ` +
`"&", ["date_field", ">=", "2019-04-01 00:00:00"], ["date_field", "<=", "2019-06-30 23:59:59"]` +
"]",
description: "Q2 2018/Q2 2019",
}
);
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'datetime', ['this_year', 'antepenultimate_year', 'this_month', 'antepenultimate_month'], 'previous_year'),
{
domain: "[" +
`"|", "|", "|", ` +
`"&", ["date_field", ">=", "2017-04-01 00:00:00"], ["date_field", "<=", "2017-04-30 23:59:59"], ` +
`"&", ["date_field", ">=", "2017-06-01 00:00:00"], ["date_field", "<=", "2017-06-30 23:59:59"], ` +
`"&", ["date_field", ">=", "2019-04-01 00:00:00"], ["date_field", "<=", "2019-04-30 23:59:59"], ` +
`"&", ["date_field", ">=", "2019-06-01 00:00:00"], ["date_field", "<=", "2019-06-30 23:59:59"]` +
"]",
description: "April 2017/June 2017/April 2019/June 2019",
}
);
unpatchDate();
});
QUnit.module('Options translation');
QUnit.test("Quarter option: custom translation", async function (assert) {
assert.expect(1);
const unpatchDate = patchDate(2020, 5, 1, 13, 0, 0);
const referenceMoment = moment().locale('en');
registry.category("services").add("localization", makeFakeLocalizationService());
await makeTestEnv();
patchWithCleanup(translatedTerms, {
"Q2": "Deuxième trimestre de l'an de grâce",
});
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['second_quarter', 'this_year']),
{
domain: `["&", ["date_field", ">=", "2020-04-01"], ["date_field", "<=", "2020-06-30"]]`,
description: "Deuxième trimestre de l'an de grâce 2020",
},
"Quarter term should be translated"
);
unpatchDate();
});
QUnit.test("Quarter option: right to left", async function (assert) {
assert.expect(1);
const unpatchDate = patchDate(2020, 5, 1, 13, 0, 0);
const referenceMoment = moment().locale('en');
testUtils.mock.patch(_t.database.parameters, {
direction: "rtl",
});
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['second_quarter', 'this_year']),
{
domain: `["&", ["date_field", ">=", "2020-04-01"], ["date_field", "<=", "2020-06-30"]]`,
description: "2020 Q2",
},
"Notation should be right to left"
);
unpatchDate();
testUtils.mock.unpatch(_t.database.parameters);
});
QUnit.test("Quarter option: custom translation and right to left", async function (assert) {
assert.expect(1);
const unpatchDate = patchDate(2020, 5, 1, 13, 0, 0);
const referenceMoment = moment().locale('en');
registry.category("services").add("localization", makeFakeLocalizationService());
await makeTestEnv();
patchWithCleanup(translatedTerms, {
"Q2": "2e Trimestre",
});
testUtils.mock.patch(_t.database.parameters, {
direction: "rtl",
});
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['second_quarter', 'this_year']),
{
domain: `["&", ["date_field", ">=", "2020-04-01"], ["date_field", "<=", "2020-06-30"]]`,
description: "2020 2e Trimestre",
},
"Quarter term should be translated and notation should be right to left"
);
unpatchDate();
testUtils.mock.unpatch(_t.database.parameters);
});
QUnit.test("Moment.js localization does not affect formatted domain dates", async function (assert) {
assert.expect(1);
const unpatchDate = patchDate(2020, 5, 1, 13, 0, 0);
const initialLocale = moment.locale();
moment.defineLocale('addoneForTest', {
postformat: function (string) {
return string.replace(/\d/g, match => (1 + parseInt(match)) % 10);
}
});
const referenceMoment = moment().locale('addoneForTest');
assert.deepEqual(
constructDateDomain(referenceMoment, 'date_field', 'date', ['this_month', 'this_year']),
{
domain: `["&", ["date_field", ">=", "2020-06-01"], ["date_field", "<=", "2020-06-30"]]`,
description: "June 3131",
},
"Numbers in domain should not use addoneForTest locale"
);
moment.locale(initialLocale);
moment.updateLocale("addoneForTest", null);
unpatchDate();
});
});
@@ -1,16 +1,8 @@
/** @odoo-module **/
import AbstractAction from "web.AbstractAction";
import core from "web.core";
import testUtils from "web.test_utils";
import Widget from "web.Widget";
import { dialogService } from "@web/core/dialog/dialog_service";
import { errorService } from "@web/core/errors/error_service";
import { registry } from "@web/core/registry";
import { getFixture, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
QUnit.module('core', {}, function () {
QUnit.module('mixins');
@@ -35,40 +27,4 @@ QUnit.module('core', {}, function () {
done();
});
});
QUnit.test('checks that the error generated by a do_action opens one dialog', async function (assert) {
assert.expect(1);
window.addEventListener("unhandledrejection", async (ev) => {
ev.preventDefault();
});
patchWithCleanup(QUnit, {
onUnhandledRejection: () => {},
});
const serviceRegistry = registry.category("services");
serviceRegistry.add("dialog", dialogService);
serviceRegistry.add("error", errorService);
const TestAction = AbstractAction.extend({
on_attach_callback() {
this.do_action({
id: 1,
type: "ir.actions.server",
})
},
});
core.action_registry.add("TestAction", TestAction);
const mockRPC = (route) => {
if (route === "/web/action/run") {
throw new Error("This error should be throw only once");
}
};
const target = getFixture();
const webClient = await createWebClient({ mockRPC});
await doAction(webClient, "TestAction");
await nextTick();
assert.containsOnce(target, ".o_dialog");
});
});
@@ -20,13 +20,15 @@
import testUtilsMock from "web.test_utils_mock";
import testUtilsModal from "web.test_utils_modal";
import testUtilsPivot from "web.test_utils_pivot";
import tools from "web.tools";
function deprecated(fn, type) {
const msg = `Helper 'testUtils.${fn.name}' is deprecated. ` +
`Please use 'testUtils.${type}.${fn.name}' instead.`;
return tools.deprecated(fn, msg);
return function () {
const msg = `Helper 'testUtils.${fn.name}' is deprecated. ` +
`Please use 'testUtils.${type}.${fn.name}' instead.`;
console.warn(msg);
return fn.apply(this, arguments);
};
}
/**
@@ -9,11 +9,9 @@
* testUtils file.
*/
import ActionMenus from "web.ActionMenus";
import concurrency from "web.concurrency";
import { useListener } from "@web/core/utils/hooks";
import dom from "web.dom";
import Registry from "web.Registry";
import testUtilsMock from "web.test_utils_mock";
import Widget from "web.Widget";
import { destroy, getFixture, mount, useChild } from "@web/../tests/helpers/utils";
@@ -245,12 +243,6 @@
}
}
// Override the ActionMenus registry unless told otherwise.
let actionMenusRegistry = ActionMenus.registry;
if (params.actionMenusRegistry !== true) {
ActionMenus.registry = new Registry();
}
const viewController = await view.getController(widget);
// override the view's 'destroy' so that it calls 'destroy' on the widget
// instead, as the widget is the parent of the view and the mockServer.
@@ -261,9 +253,6 @@
delete viewController.destroy;
widget.destroy();
webClient.remove();
if (params.actionMenusRegistry !== true) {
ActionMenus.registry = actionMenusRegistry;
}
};
// render the viewController in a fragment as they must be able to render correctly
@@ -1,80 +0,0 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import AbstractAction from "web.AbstractAction";
import core from "web.core";
import * as LegacyRegistry from "web.Registry";
import { registerCleanup } from "../../helpers/cleanup";
import { nextTick, patchWithCleanup } from "../../helpers/utils";
import { createWebClient, doAction } from "../../webclient/helpers";
let legacyParams;
QUnit.module("Service Provider Adapter Notification", (hooks) => {
hooks.beforeEach(() => {
legacyParams = {
serviceRegistry: new LegacyRegistry(),
};
});
QUnit.test(
"can display and close a sticky danger notification with a title (legacy)",
async function (assert) {
assert.expect(8);
let notifId;
let timeoutCB;
patchWithCleanup(browser, {
setTimeout: (cb, delay) => {
if (!delay) {
return; // Timeouts from router service
}
timeoutCB = cb;
assert.step("time: " + delay);
return 1;
},
});
const NotifyAction = AbstractAction.extend({
on_attach_callback() {
notifId = this.call("notification", "notify", {
title: "Some title",
message: "I'm a danger notification",
type: "danger",
sticky: true,
});
},
});
const CloseAction = AbstractAction.extend({
on_attach_callback() {
this.call("notification", "close", notifId, false, 3000);
},
});
core.action_registry.add("NotifyTestLeg", NotifyAction);
core.action_registry.add("CloseTestLeg", CloseAction);
registerCleanup(() => {
delete core.action_registry.map.NotifyTestLeg;
delete core.action_registry.map.CloseTestLeg;
});
const webClient = await createWebClient({ legacyParams });
await doAction(webClient, "NotifyTestLeg");
await nextTick();
assert.containsOnce(document.body, ".o_notification");
const notif = document.body.querySelector(".o_notification");
assert.strictEqual(notif.querySelector(".o_notification_title").textContent, "Some title");
assert.strictEqual(
notif.querySelector(".o_notification_content").textContent,
"I'm a danger notification"
);
assert.hasClass(notif, "border-danger");
//Close the notification
await doAction(webClient, "CloseTestLeg");
await nextTick();
assert.containsOnce(document.body, ".o_notification");
// simulate end of timeout
timeoutCB();
await nextTick();
assert.containsNone(document.body, ".o_notification");
assert.verifySteps(["time: 3000"]);
}
);
});
@@ -1,323 +0,0 @@
/** @odoo-module **/
import ModelFieldSelector from "web.ModelFieldSelector";
import testUtils from "web.test_utils";
QUnit.module('widgets', {}, function () {
QUnit.module('ModelFieldSelector', {
beforeEach: function () {
this.data = {
partner: {
fields: {
foo: {string: "Foo", type: "char", searchable: true},
bar: {string: "Bar", type: "boolean", searchable: true},
product_id: {string: "Product", type: "many2one", relation: "product", searchable: true},
},
records: [{
id: 1,
foo: "yop",
bar: true,
product_id: 37,
}, {
id: 2,
foo: "blip",
bar: true,
product_id: false,
}, {
id: 4,
foo: "abc",
bar: false,
product_id: 41,
}],
onchanges: {},
},
product: {
fields: {
name: {string: "Product Name", type: "char", searchable: true}
},
records: [{
id: 37,
display_name: "xphone",
}, {
id: 41,
display_name: "xpad",
}]
},
};
},
}, function () {
QUnit.test("creating a field chain from scratch", async function (assert) {
assert.expect(14);
var $target = $("#qunit-fixture");
// Create the field selector and its mock environment
var fieldSelector = new ModelFieldSelector(null, "partner", [], {
readonly: false,
debugMode: true,
});
await testUtils.mock.addMockEnvironment(fieldSelector.popover, {data: this.data});
await fieldSelector.appendTo($target);
var $value = fieldSelector.$("> .o_field_selector_value");
// Focusing the field selector input should open a field selector popover
fieldSelector.$el.trigger('focusin');
var $fieldSelectorPopover = fieldSelector.$(".o_field_selector_popover:visible");
assert.strictEqual($fieldSelectorPopover.length, 1,
"field selector popover should be visible");
// The field selector popover should contain the list of "partner"
// fields. "Bar" should be among them.
var $lis = $fieldSelectorPopover.find("li");
var $barLi = $();
$lis.each(function () {
var $li = $(this);
if ($li.html().indexOf("Bar") >= 0) {
$barLi = $li;
}
});
assert.strictEqual($barLi.length, 1,
"field selector popover should contain the 'Bar' field");
// Clicking the "Bar" field should close the popover and set the field
// chain to "bar" as it is a basic field
await testUtils.dom.click($barLi);
assert.notOk($fieldSelectorPopover.is("visible"),
"field selector popover should be closed now");
assert.strictEqual(getValueFromDOM($value), "Bar",
"field selector value should be displayed with a 'Bar' tag");
assert.deepEqual(fieldSelector.getSelectedField(), {
model: "partner",
name: "bar",
searchable: true,
string: "Bar",
type: "boolean",
}, "the selected field should be correctly set");
// Focusing the input again should open the same popover
fieldSelector.$el.trigger('focusin');
await testUtils.nextTick();
assert.ok($fieldSelectorPopover.is(":visible"),
"field selector popover should be visible");
// The field selector popover should contain the list of "partner"
// fields. "Product" should be among them.
$lis = $fieldSelectorPopover.find("li");
var $productLi = $();
$lis.each(function () {
var $li = $(this);
if ($li.html().indexOf("Product") >= 0) {
$productLi = $li;
}
});
assert.strictEqual($productLi.length, 1,
"field selector popover should contain the 'Product' field");
// Clicking on the "Product" field should update the popover to show
// the product fields (so only "Product Name" should be there)
await testUtils.dom.click($productLi);
$lis = $fieldSelectorPopover.find("li");
assert.strictEqual($lis.length, 1,
"there should be only one field proposition for 'product' model");
assert.ok($lis.first().html().indexOf("Product Name") >= 0,
"the name of the only suggestion should be 'Product Name'");
// Clicking on "Product Name" should close the popover and set the chain
// to "product_id.name"
await testUtils.dom.click($lis.first());
assert.notOk($fieldSelectorPopover.is("visible"),
"field selector popover should be closed now");
assert.strictEqual(getValueFromDOM($value), "Product -> Product Name",
"field selector value should be displayed with two tags: 'Product' and 'Product Name'");
// Remove the current selection and recreate it again
fieldSelector.$el.trigger('focusin');
await testUtils.nextTick();
await testUtils.dom.click(fieldSelector.$('.o_field_selector_prev_page'));
await testUtils.dom.click(fieldSelector.$('.o_field_selector_close'));
fieldSelector.$el.trigger('focusin');
await testUtils.nextTick();
$fieldSelectorPopover = fieldSelector.$(".o_field_selector_popover:visible");
$lis = $fieldSelectorPopover.find("li");
$productLi = $();
$lis.each(function () {
var $li = $(this);
if ($li.html().indexOf("Product") >= 0) {
$productLi = $li;
}
});
assert.strictEqual($productLi.length, 1,
"field selector popover should contain the 'Product' field");
await testUtils.dom.click($productLi);
$lis = $fieldSelectorPopover.find("li");
await testUtils.dom.click($lis.first());
assert.notOk($fieldSelectorPopover.is("visible"),
"field selector popover should be closed now");
assert.strictEqual(getValueFromDOM($value), "Product -> Product Name",
"field selector value should be displayed with two tags: 'Product' and 'Product Name'");
fieldSelector.destroy();
function getValueFromDOM($dom) {
return Array.from($dom.find(".o_field_selector_chain_part")).map((part) => {
return $(part).text().trim();
}).join(" -> ");
}
});
QUnit.test("default field chain should set the page data correctly", async function (assert) {
assert.expect(3);
var $target = $("#qunit-fixture");
// Create the field selector and its mock environment
// passing 'product_id' as a prefilled field-chain
var fieldSelector = new ModelFieldSelector(null, "partner", ['product_id'], {
readonly: false,
debugMode: true,
});
await testUtils.mock.addMockEnvironment(fieldSelector.popover, {data: this.data});
await fieldSelector.appendTo($target);
// Focusing the field selector input should open a field selector popover
fieldSelector.$el.trigger('focusin');
var $fieldSelectorPopover = fieldSelector.$(".o_field_selector_popover:visible");
assert.strictEqual($fieldSelectorPopover.length, 1,
"field selector popover should be visible");
// The field selector popover should contain the list of "product"
// fields. "Product Name" should be among them.
var $lis = $fieldSelectorPopover.find("li");
assert.strictEqual($lis.length, 1,
"there should be only one field proposition for 'product' model");
assert.ok($lis.first().html().indexOf("Product Name") >= 0,
"the name of the only suggestion should be 'Product Name'");
fieldSelector.destroy();
});
QUnit.test("use the filter option", async function (assert) {
assert.expect(2);
var $target = $("#qunit-fixture");
// Create the field selector and its mock environment
var fieldSelector = new ModelFieldSelector(null, "partner", [], {
readonly: false,
filter: function (field) {
return field.type === 'many2one';
},
});
await testUtils.mock.addMockEnvironment(fieldSelector.popover, {data: this.data});
await fieldSelector.appendTo($target);
fieldSelector.$el.trigger('focusin');
await testUtils.nextTick();
var $fieldSelectorPopover = fieldSelector.$(".o_field_selector_popover:visible");
var $lis = $fieldSelectorPopover.find("li");
assert.strictEqual($lis.length, 1, "there should only be one element");
assert.strictEqual($lis.text().trim(), "Product", "the available field should be the many2one");
fieldSelector.destroy();
});
QUnit.test("default `showSearchInput` option", async function (assert) {
assert.expect(6);
var $target = $("#qunit-fixture");
// Create the field selector and its mock environment
var fieldSelector = new ModelFieldSelector(null, "partner", [], {
readonly: false,
});
await testUtils.mock.addMockEnvironment(fieldSelector.popover, {data: this.data});
await fieldSelector.appendTo($target);
fieldSelector.$el.trigger('focusin');
await testUtils.nextTick();
var $fieldSelectorPopover = fieldSelector.$(".o_field_selector_popover:visible");
var $searchInput = $fieldSelectorPopover.find(".o_field_selector_search input");
assert.strictEqual($searchInput.length, 1, "there should be a search input");
// without search
assert.strictEqual($fieldSelectorPopover.find("li").length, 3, "there should be three available fields");
assert.strictEqual($fieldSelectorPopover.find("li").text().trim().replace(/\s+/g, ' '), "Bar Foo Product", "the available field should be correct");
await testUtils.fields.editAndTrigger($searchInput, 'xx', 'keyup');
assert.strictEqual($fieldSelectorPopover.find("li").length, 0, "there shouldn't be any element");
await testUtils.fields.editAndTrigger($searchInput, 'Pro', 'keyup');
assert.strictEqual($fieldSelectorPopover.find("li").length, 1, "there should only be one element");
assert.strictEqual($fieldSelectorPopover.find("li").text().trim().replace(/\s+/g, ' '), "Product", "the available field should be the Product");
fieldSelector.destroy();
});
QUnit.test("false `showSearchInput` option", async function (assert) {
assert.expect(1);
var $target = $("#qunit-fixture");
// Create the field selector and its mock environment
var fieldSelector = new ModelFieldSelector(null, "partner", [], {
readonly: false,
showSearchInput: false,
});
await testUtils.mock.addMockEnvironment(fieldSelector.popover, { data: this.data });
await fieldSelector.appendTo($target);
fieldSelector.$el.trigger('focusin');
await testUtils.nextTick();
var $fieldSelectorPopover = fieldSelector.$(".o_field_selector_popover:visible");
var $searchInput = $fieldSelectorPopover.find(".o_field_selector_search input");
assert.strictEqual($searchInput.length, 0, "there should be no search input");
fieldSelector.destroy();
});
QUnit.test("create a field chain with value 1 i.e. TRUE_LEAF", async function (assert) {
assert.expect(1);
var $target = $("#qunit-fixture");
//create the field selector with domain value ["1"]
var fieldSelector = new ModelFieldSelector(null, "partner", ["1"], {
readonly: false,
showSearchInput: false,
});
await testUtils.mock.addMockEnvironment(fieldSelector.popover, {data: this.data});
await fieldSelector.appendTo($target);
var $fieldName = fieldSelector.$('.o_field_selector_chain_part');
assert.strictEqual($fieldName.text().trim(), "1",
"field name value should be 1.");
fieldSelector.destroy();
});
QUnit.test("create a field chain with value 0 i.e. FALSE_LEAF", async function (assert) {
assert.expect(1);
var $target = $("#qunit-fixture");
//create the field selector with domain value ["0"]
var fieldSelector = new ModelFieldSelector(null, "partner", ["0"], {
readonly: false,
showSearchInput: false,
});
await testUtils.mock.addMockEnvironment(fieldSelector.popover, {data: this.data});
await fieldSelector.appendTo($target);
var $fieldName = fieldSelector.$('.o_field_selector_chain_part');
assert.strictEqual($fieldName.text().trim(), "0",
"field name value should be 0.");
fieldSelector.destroy();
});
});
});
@@ -20,6 +20,7 @@ import { RelationalModel } from "@web/model/relational_model/relational_model";
import { actionService } from "@web/webclient/actions/action_service";
import { getPickerApplyButton, getPickerCell } from "../core/datetime/datetime_test_helpers";
import { makeFakeLocalizationService, makeFakeUserService } from "../helpers/mock_services";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import {
addRow,
click,
@@ -17947,6 +17948,10 @@ QUnit.module("Views", (hooks) => {
"edit a record then select another record with a throw error when saving",
async function (assert) {
serviceRegistry.add("error", errorService);
// need to preventDefault to remove error from console (so python test pass)
const handler = (ev) => ev.preventDefault();
window.addEventListener("unhandledrejection", handler);
registerCleanup(() => window.removeEventListener("unhandledrejection", handler));
await makeView({
type: "list",
@@ -2,17 +2,8 @@
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import core from "web.core";
import AbstractAction from "web.AbstractAction";
import testUtils from "web.test_utils";
import { registerCleanup } from "../../helpers/cleanup";
import {
click,
getFixture,
legacyExtraNextTick,
nextTick,
patchWithCleanup,
} from "../../helpers/utils";
import { click, getFixture, nextTick, patchWithCleanup } from "../../helpers/utils";
import { createWebClient, doAction, getActionManagerServerData } from "./../helpers";
import { Component, xml } from "@odoo/owl";
@@ -93,35 +84,6 @@ QUnit.module("ActionManager", (hooks) => {
}
);
QUnit.test("can execute client actions from tag name (legacy)", async function (assert) {
// remove this test as soon as legacy Widgets are no longer supported
assert.expect(4);
const ClientAction = AbstractAction.extend({
start: function () {
this.$el.text("Hello World");
this.$el.addClass("o_client_action_test");
},
});
const mockRPC = async function (route, args) {
assert.step((args && args.method) || route);
};
core.action_registry.add("HelloWorldTestLeg", ClientAction);
registerCleanup(() => delete core.action_registry.map.HelloWorldTestLeg);
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, "HelloWorldTestLeg");
assert.containsNone(
document.body,
".o_control_panel",
"shouldn't have rendered a control panel"
);
assert.strictEqual(
$(target).find(".o_client_action_test").text(),
"Hello World",
"should have correctly rendered the client action"
);
assert.verifySteps(["/web/webclient/load_menus"]);
});
QUnit.test("can execute client actions from tag name", async function (assert) {
assert.expect(4);
class ClientAction extends Component {}
@@ -177,234 +139,7 @@ QUnit.module("ActionManager", (hooks) => {
}
);
QUnit.test("client action with control panel (legacy)", async function (assert) {
assert.expect(4);
// LPE Fixme: at this time we don't really know the API that wowl ClientActions implement
const ClientAction = AbstractAction.extend({
hasControlPanel: true,
start() {
this.$(".o_content").text("Hello World");
this.$el.addClass("o_client_action_test");
this.controlPanelProps.title = "Hello";
return this._super.apply(this, arguments);
},
});
core.action_registry.add("HelloWorldTest", ClientAction);
registerCleanup(() => delete core.action_registry.map.HelloWorldTest);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "HelloWorldTest");
assert.strictEqual(
$(".o_control_panel:visible").length,
1,
"should have rendered a control panel"
);
assert.containsN(
target,
".o_control_panel .breadcrumb-item",
1,
"there should be one controller in the breadcrumbs"
);
assert.strictEqual(
$(".o_control_panel .breadcrumb-item").text(),
"Hello",
"breadcrumbs should still display the title of the controller"
);
assert.strictEqual(
$(target).find(".o_client_action_test .o_content").text(),
"Hello World",
"should have correctly rendered the client action"
);
});
QUnit.test("state is pushed for client action (legacy)", async function (assert) {
assert.expect(6);
const ClientAction = AbstractAction.extend({
getTitle: function () {
return "a title";
},
getState: function () {
return { foo: "baz" };
},
});
const pushState = browser.history.pushState;
patchWithCleanup(browser, {
history: Object.assign({}, browser.history, {
pushState() {
pushState(...arguments);
assert.step("push_state");
},
}),
});
core.action_registry.add("HelloWorldTest", ClientAction);
registerCleanup(() => delete core.action_registry.map.HelloWorldTest);
const webClient = await createWebClient({ serverData });
let currentTitle = webClient.env.services.title.current;
assert.strictEqual(currentTitle, '{"zopenerp":"Odoo"}');
let currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, {});
await doAction(webClient, "HelloWorldTest");
currentTitle = webClient.env.services.title.current;
assert.strictEqual(currentTitle, '{"zopenerp":"Odoo","action":"a title"}');
currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, {
action: "HelloWorldTest",
foo: "baz",
});
assert.verifySteps(["push_state"]);
});
QUnit.test("action can use a custom control panel (legacy)", async function (assert) {
assert.expect(1);
class CustomControlPanel extends Component {}
CustomControlPanel.template = xml`
<div class="custom-control-panel">My custom control panel</div>
`;
const ClientAction = AbstractAction.extend({
hasControlPanel: true,
config: {
ControlPanel: CustomControlPanel,
},
});
core.action_registry.add("HelloWorldTest", ClientAction);
registerCleanup(() => delete core.action_registry.map.HelloWorldTest);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "HelloWorldTest");
assert.containsOnce(target, ".custom-control-panel", "should have a custom control panel");
});
QUnit.test("breadcrumb is updated on title change (legacy)", async function (assert) {
assert.expect(2);
const ClientAction = AbstractAction.extend({
hasControlPanel: true,
events: {
click: function () {
this.updateControlPanel({ title: "new title" });
},
},
start: async function () {
this.$(".o_content").text("Hello World");
this.$el.addClass("o_client_action_test");
this.controlPanelProps.title = "initial title";
await this._super.apply(this, arguments);
},
});
core.action_registry.add("HelloWorldTest", ClientAction);
registerCleanup(() => delete core.action_registry.map.HelloWorldTest);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "HelloWorldTest");
assert.strictEqual(
$("ol.breadcrumb").text(),
"initial title",
"should have initial title as breadcrumb content"
);
await testUtils.dom.click($(target).find(".o_client_action_test"));
await legacyExtraNextTick();
assert.strictEqual(
$("ol.breadcrumb").text(),
"new title",
"should have updated title as breadcrumb content"
);
});
QUnit.test("client actions can have breadcrumbs (legacy)", async function (assert) {
assert.expect(4);
const ClientAction = AbstractAction.extend({
hasControlPanel: true,
init(parent, action) {
action.display_name = "Goldeneye";
this._super.apply(this, arguments);
},
start() {
this.$el.addClass("o_client_action_test");
return this._super.apply(this, arguments);
},
});
const ClientAction2 = AbstractAction.extend({
hasControlPanel: true,
init(parent, action) {
action.display_name = "No time for sweetness";
this._super.apply(this, arguments);
},
start() {
this.$el.addClass("o_client_action_test_2");
return this._super.apply(this, arguments);
},
});
core.action_registry.add("ClientAction", ClientAction);
core.action_registry.add("ClientAction2", ClientAction2);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "ClientAction");
assert.containsOnce(target, ".breadcrumb-item");
assert.strictEqual(
target.querySelector(".breadcrumb-item.active").textContent,
"Goldeneye"
);
await doAction(webClient, "ClientAction2", { clearBreadcrumbs: false });
assert.containsN(target, ".breadcrumb-item", 2);
assert.strictEqual(
target.querySelector(".breadcrumb-item.active").textContent,
"No time for sweetness"
);
delete core.action_registry.map.ClientAction;
delete core.action_registry.map.ClientAction2;
});
QUnit.test("client action restore scrollbar (legacy)", async function (assert) {
assert.expect(7);
const ClientAction = AbstractAction.extend({
hasControlPanel: true,
init(parent, action) {
action.display_name = "Title1";
this._super.apply(this, arguments);
},
async start() {
for (let i = 0; i < 100; i++) {
const content = document.createElement("div");
content.innerText = "Paper company";
content.className = "lorem";
this.el.querySelector(".o_content").appendChild(content);
}
await this._super(arguments);
},
});
const ClientAction2 = AbstractAction.extend({
hasControlPanel: true,
init(parent, action) {
action.display_name = "Title2";
this._super.apply(this, arguments);
},
start() {
return this._super.apply(this, arguments);
},
});
core.action_registry.add("ClientAction", ClientAction);
core.action_registry.add("ClientAction2", ClientAction2);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "ClientAction");
assert.containsOnce(target, ".breadcrumb-item");
assert.strictEqual(target.querySelector(".breadcrumb-item.active").textContent, "Title1");
target.querySelector(".lorem:last-child").scrollIntoView();
const scrollPosition = target.querySelector(".o_content").scrollTop;
assert.ok(scrollPosition > 0);
await doAction(webClient, "ClientAction2", { clearBreadcrumbs: false });
assert.containsN(target, ".breadcrumb-item", 2);
assert.strictEqual(target.querySelector(".breadcrumb-item.active").textContent, "Title2");
await click(target.querySelector(".breadcrumb-item:first-child"));
assert.strictEqual(target.querySelector(".breadcrumb-item.active").textContent, "Title1");
assert.strictEqual(
target.querySelector(".o_content").scrollTop,
scrollPosition,
"Should restore the scroll"
);
delete core.action_registry.map.ClientAction;
delete core.action_registry.map.ClientAction2;
});
QUnit.test("ClientAction receives breadcrumbs and exports title (wowl)", async (assert) => {
QUnit.test("ClientAction receives breadcrumbs and exports title", async (assert) => {
assert.expect(4);
class ClientAction extends Component {
setup() {
@@ -435,7 +170,7 @@ QUnit.module("ActionManager", (hooks) => {
);
});
QUnit.test("ClientAction receives arbitrary props from doAction (wowl)", async (assert) => {
QUnit.test("ClientAction receives arbitrary props from doAction", async (assert) => {
assert.expect(1);
class ClientAction extends Component {
setup() {
@@ -450,22 +185,6 @@ QUnit.module("ActionManager", (hooks) => {
});
});
QUnit.test("ClientAction receives arbitrary props from doAction (legacy)", async (assert) => {
assert.expect(1);
const ClientAction = AbstractAction.extend({
init(parent, action, options) {
assert.strictEqual(options.division, "bell");
this._super.apply(this, arguments);
},
});
core.action_registry.add("ClientAction", ClientAction);
registerCleanup(() => delete core.action_registry.map.ClientAction);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "ClientAction", {
props: { division: "bell" },
});
});
QUnit.test("test display_notification client action", async function (assert) {
assert.expect(6);
const webClient = await createWebClient({ serverData });
@@ -4,8 +4,6 @@ import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { WebClient } from "@web/webclient/webclient";
import testUtils from "web.test_utils";
import core from "web.core";
import AbstractAction from "web.AbstractAction";
import { registerCleanup } from "../../helpers/cleanup";
import { makeTestEnv } from "../../helpers/mock_env";
import {
@@ -499,132 +497,6 @@ QUnit.module("ActionManager", (hooks) => {
assert.verifySteps(["push_state"], "should push the state of it changes afterwards");
});
QUnit.test("should not push a loaded state of a legacy client action", async function (assert) {
assert.expect(6);
const ClientAction = AbstractAction.extend({
init: function (parent, action, options) {
this._super.apply(this, arguments);
this.controllerID = options.controllerID;
},
start: function () {
const $button = $("<button id='client_action_button'>").text("Click Me!");
$button.on("click", () => {
this.trigger_up("push_state", {
controllerID: this.controllerID,
state: { someValue: "X" },
});
});
this.$el.append($button);
return this._super.apply(this, arguments);
},
});
const pushState = browser.history.pushState;
patchWithCleanup(browser, {
history: Object.assign({}, browser.history, {
pushState() {
pushState(...arguments);
assert.step("push_state");
},
}),
});
core.action_registry.add("ClientAction", ClientAction);
const webClient = await createWebClient({ serverData });
let currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, {});
await loadState(webClient, { action: 9 });
currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, {
action: 9,
});
assert.verifySteps([], "should not push the loaded state");
await testUtils.dom.click($(target).find("#client_action_button"));
await legacyExtraNextTick();
assert.verifySteps(["push_state"], "should push the state of it changes afterwards");
currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, {
action: 9,
someValue: "X",
});
delete core.action_registry.map.ClientAction;
});
QUnit.test("change a param of an ir.actions.client in the url", async function (assert) {
assert.expect(12);
const ClientAction = AbstractAction.extend({
hasControlPanel: true,
init: function (parent, action) {
this._super.apply(this, arguments);
const context = action.context;
this.a = (context.params && context.params.a) || "default value";
},
start: function () {
assert.step("start");
this.$(".o_content").text(this.a);
this.$el.addClass("o_client_action");
this.trigger_up("push_state", {
controllerID: this.controllerID,
state: { a: this.a },
});
return this._super.apply(this, arguments);
},
});
const pushState = browser.history.pushState;
patchWithCleanup(browser, {
history: Object.assign({}, browser.history, {
pushState() {
pushState(...arguments);
assert.step("push_state");
},
}),
});
core.action_registry.add("ClientAction", ClientAction);
const webClient = await createWebClient({ serverData });
let currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, {});
// execute the client action
await doAction(webClient, 9);
assert.verifySteps(["start", "push_state"]);
currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, {
action: 9,
a: "default value",
});
assert.strictEqual(
$(target).find(".o_client_action .o_content").text(),
"default value",
"should have rendered the client action"
);
assert.containsN(
target,
".o_control_panel .breadcrumb-item",
1,
"there should be one controller in the breadcrumbs"
);
// update param 'a' in the url
await loadState(webClient, {
action: 9,
a: "new value",
});
assert.verifySteps(["start"]); // No push state since the hash hasn't changed
currentHash = webClient.env.services.router.current.hash;
assert.deepEqual(currentHash, {
action: 9,
a: "new value",
});
assert.strictEqual(
$(target).find(".o_client_action .o_content").text(),
"new value",
"should have rerendered the client action with the correct param"
);
assert.containsN(
target,
".o_control_panel .breadcrumb-item",
1,
"there should still be one controller in the breadcrumbs"
);
delete core.action_registry.map.ClientAction;
});
QUnit.test("load a window action without id (in a multi-record view)", async function (assert) {
assert.expect(14);
patchWithCleanup(browser.sessionStorage, {
@@ -763,52 +635,6 @@ QUnit.module("ActionManager", (hooks) => {
assert.deepEqual(getBreadCrumbTexts(target), ["Partner", "New"]);
});
QUnit.test("hashchange does not trigger canberemoved right away", async function (assert) {
assert.expect(9);
const ClientAction = AbstractAction.extend({
start() {
this.$el.text("Hello World");
this.$el.addClass("o_client_action_test");
},
canBeRemoved() {
assert.step("canBeRemoved");
return this._super.apply(this, arguments);
},
});
const ClientAction2 = AbstractAction.extend({
start() {
this.$el.text("Hello World");
this.$el.addClass("o_client_action_test_2");
},
canBeRemoved() {
assert.step("canBeRemoved_2");
return this._super.apply(this, arguments);
},
});
const pushState = browser.history.pushState;
patchWithCleanup(browser, {
history: Object.assign({}, browser.history, {
pushState() {
pushState(...arguments);
assert.step("hashSet");
},
}),
});
core.action_registry.add("ClientAction", ClientAction);
core.action_registry.add("ClientAction2", ClientAction2);
const webClient = await createWebClient({ serverData });
assert.verifySteps([]);
await doAction(webClient, 9);
assert.verifySteps(["hashSet"]);
assert.containsOnce(target, ".o_client_action_test");
assert.verifySteps([]);
await doAction(webClient, "ClientAction2");
assert.containsOnce(target, ".o_client_action_test_2");
assert.verifySteps(["canBeRemoved", "hashSet"]);
delete core.action_registry.map.ClientAction;
delete core.action_registry.map.ClientAction2;
});
QUnit.test("state with integer active_ids should not crash", async function (assert) {
assert.expect(2);
@@ -3,18 +3,9 @@
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { session } from "@web/session";
import AbstractAction from "web.AbstractAction";
import core from "web.core";
import { makeTestEnv } from "../../helpers/mock_env";
import testUtils from "web.test_utils";
import {
click,
getFixture,
hushConsole,
legacyExtraNextTick,
nextTick,
patchWithCleanup,
} from "../../helpers/utils";
import { click, getFixture, hushConsole, nextTick, patchWithCleanup } from "../../helpers/utils";
import {
createWebClient,
doAction,
@@ -27,7 +18,6 @@ import { onWillStart } from "@odoo/owl";
let serverData;
let target;
// legacy stuff
const actionRegistry = registry.category("actions");
const actionHandlersRegistry = registry.category("action_handlers");
@@ -248,42 +238,6 @@ QUnit.module("ActionManager", (hooks) => {
assert.deepEqual(currentHash, { action: 8, id: 4, model: "pony", view_type: "form" });
});
QUnit.test(
"on_reverse_breadcrumb handler is correctly called (legacy)",
async function (assert) {
// This test can be removed as soon as we no longer support legacy actions as the new
// ActionManager doesn't support this option. Indeed, it is used to reload the previous
// action when coming back, but we won't need such an artefact to that with Wowl, as the
// controller will be re-instantiated with an (exported) state given in props.
assert.expect(5);
const ClientAction = AbstractAction.extend({
events: {
"click button": "_onClick",
},
start() {
this.$el.html('<button class="my_button">Execute another action</button>');
},
_onClick() {
this.do_action(4, {
on_reverse_breadcrumb: () => assert.step("on_reverse_breadcrumb"),
});
},
});
core.action_registry.add("ClientAction", ClientAction);
const webClient = await createWebClient({ serverData });
await doAction(webClient, "ClientAction");
assert.containsOnce(target, ".my_button");
await click(target.querySelector(".my_button"));
await legacyExtraNextTick();
assert.containsOnce(target, ".o_kanban_view");
await click(target, ".o_control_panel .breadcrumb a");
await legacyExtraNextTick();
assert.containsOnce(target, ".my_button");
assert.verifySteps(["on_reverse_breadcrumb"]);
delete core.action_registry.map.ClientAction;
}
);
QUnit.test('handles "history_back" event', async function (assert) {
assert.expect(4);
let list;
@@ -1,8 +1,6 @@
/** @odoo-module **/
import testUtils from "web.test_utils";
import core from "web.core";
import AbstractAction from "web.AbstractAction";
import { registry } from "@web/core/registry";
import { click, getFixture, patchWithCleanup, makeDeferred, nextTick } from "../../helpers/utils";
import { createWebClient, doAction, getActionManagerServerData } from "./../helpers";
@@ -165,32 +163,6 @@ QUnit.module("ActionManager", (hooks) => {
assert.containsNone(document.body, ".modal");
});
QUnit.test('on_attach_callback is called for actions in target="new"', async function (assert) {
assert.expect(3);
const ClientAction = AbstractAction.extend({
on_attach_callback: function () {
assert.step("on_attach_callback");
assert.containsOnce(
document.body,
".modal .o_test",
"should have rendered the client action in a dialog"
);
},
start: function () {
this.$el.addClass("o_test");
},
});
core.action_registry.add("test", ClientAction);
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
tag: "test",
target: "new",
type: "ir.actions.client",
});
assert.verifySteps(["on_attach_callback"]);
delete core.action_registry.map.test;
});
QUnit.test(
'footer buttons are updated when having another action in target "new"',
async function (assert) {
@@ -217,32 +189,6 @@ QUnit.module("ActionManager", (hooks) => {
}
);
QUnit.test(
'buttons of client action in target="new" and transition to MVC action',
async function (assert) {
const ClientAction = AbstractAction.extend({
renderButtons($target) {
const button = document.createElement("button");
button.setAttribute("class", "o_stagger_lee");
$target[0].appendChild(button);
},
});
core.action_registry.add("test", ClientAction);
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
tag: "test",
target: "new",
type: "ir.actions.client",
});
assert.containsOnce(target, ".modal footer button.o_stagger_lee");
assert.containsNone(target, '.modal footer button[special="save"]');
await doAction(webClient, 25);
assert.containsNone(target, ".modal footer button.o_stagger_lee");
assert.containsOnce(target, '.modal footer button[special="save"]');
delete core.action_registry.map.test;
}
);
QUnit.test(
'button with confirm attribute in act_window action in target="new"',
async function (assert) {
+1 -30
View File
@@ -20,9 +20,7 @@ import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { menuService } from "@web/webclient/menus/menu_service";
import { WebClient } from "@web/webclient/webclient";
import AbstractService from "web.AbstractService";
import ActionMenus from "web.ActionMenus";
import basicFields from "web.basic_fields";
import Registry from "web.Registry";
import core from "web.core";
import makeTestEnvironment from "web.test_env";
import { registerCleanup } from "../helpers/cleanup";
@@ -46,14 +44,13 @@ import session from "web.session";
import LegacyMockServer from "web.MockServer";
import Widget from "web.Widget";
import { uiService } from "@web/core/ui/ui_service";
import { ClientActionAdapter } from "@web/legacy/action_adapters";
import { commandService } from "@web/core/commands/command_service";
import { ConnectionAbortedError } from "@web/core/network/rpc_service";
import { CustomFavoriteItem } from "@web/search/custom_favorite_item/custom_favorite_item";
import { standaloneAdapter } from "web.OwlCompatibility";
import { overlayService } from "@web/core/overlay/overlay_service";
import { Component, onMounted, xml } from "@odoo/owl";
import { Component, xml } from "@odoo/owl";
import { fieldService } from "@web/core/field_service";
import { nameService } from "@web/core/name_service";
@@ -157,11 +154,6 @@ export async function addLegacyMockEnvironment(env, legacyParams = {}) {
legacyParams.dataManager
);
// clear the ActionMenus registry to prevent external code from doing unknown rpcs
const actionMenusRegistry = ActionMenus.registry;
ActionMenus.registry = new Registry();
registerCleanup(() => (ActionMenus.registry = actionMenusRegistry));
let localSession;
if (legacyParams && legacyParams.getTZOffset) {
patchWithCleanup(session, {
@@ -254,22 +246,6 @@ export async function addLegacyMockEnvironment(env, legacyParams = {}) {
export async function createWebClient(params) {
setupWebClientRegistries();
// With the compatibility layer, the action manager keeps legacy alive if they
// are still acessible from the breacrumbs. They are manually destroyed as soon
// as they are no longer referenced in the stack. This works fine in production,
// because the webclient is never destroyed. However, at the end of each test,
// we destroy the webclient and expect every legacy that has been instantiated
// to be destroyed. We thus need to manually destroy them here.
const controllers = [];
patchWithCleanup(ClientActionAdapter.prototype, {
setup() {
this._super();
onMounted(() => {
controllers.push(this.widget);
});
},
});
const legacyParams = params.legacyParams;
params.serverData = params.serverData || {};
const models = params.serverData.models;
@@ -298,11 +274,6 @@ export async function createWebClient(params) {
target.classList.add("o_web_client"); // necessary for the stylesheet
registerCleanup(() => {
target.classList.remove("o_web_client");
for (const controller of controllers) {
if (!controller.isDestroyed()) {
controller.destroy();
}
}
});
// Wait for visual changes caused by a potential loadState
await nextTick();