[REF] *: remove legacy search models/components

There is no action that uses the search models/components available in
the legacy control panel. We remove those.

closes odoo/odoo#121433

Related: odoo/enterprise#41078
Signed-off-by: Géry Debongnie <ged@odoo.com>
This commit is contained in:
Mathieu Duckerts-Antoine
2023-05-16 15:55:24 +02:00
parent 43e87ea1cf
commit 5304d8fc64
36 changed files with 4 additions and 7752 deletions
@@ -1,158 +0,0 @@
/** @odoo-module alias=board.AddToBoardMenu **/
import Context from "web.Context";
import Domain from "web.Domain";
import { Dropdown } from "@web/core/dropdown/dropdown";
import FavoriteMenu from "web.FavoriteMenu";
import { sprintf } from "web.utils";
import { useAutofocus } from "@web/core/utils/hooks";
const { Component, useState } = owl;
/**
* 'Add to board' menu
*
* Component consisiting of a toggle button, a text input and an 'Add' button.
* The first button is simply used to toggle the component and will determine
* whether the other elements should be rendered.
* The input will be given the name (or title) of the view that will be added.
* Finally, the last button will send the name as well as some of the action
* properties to the server to add the current view (and its context) to the
* user's dashboard.
* This component is only available in actions of type 'ir.actions.act_window'.
*/
class AddToBoardMenu extends Component {
setup() {
this.interactive = true;
this.state = useState({
name: this.env.action.name || "",
open: false,
});
useAutofocus();
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* This is the main function for actually saving the dashboard. This method
* is supposed to call the route /board/add_to_dashboard with proper
* information.
* @private
*/
async addToBoard() {
const searchQuery = this.env.searchModel.get("query");
const context = new Context(this.env.action.context);
context.add(searchQuery.context);
context.add({
group_by: searchQuery.groupBy,
orderedBy: searchQuery.orderedBy,
});
if (
searchQuery.timeRanges &&
Object.prototype.hasOwnProperty.call(searchQuery.timeRanges, "fieldName")
) {
context.add({
comparison: searchQuery.timeRanges,
});
}
let controllerQueryParams;
this.env.searchModel.trigger("get-controller-query-params", (params) => {
controllerQueryParams = params || {};
});
controllerQueryParams.context = controllerQueryParams.context || {};
const queryContext = controllerQueryParams.context;
delete controllerQueryParams.context;
context.add(Object.assign(controllerQueryParams, queryContext));
const domainArray = new Domain(this.env.action.domain || []);
const domain = Domain.prototype.normalizeArray(
domainArray.toArray().concat(searchQuery.domain)
);
const evalutatedContext = context.eval();
for (const key in evalutatedContext) {
if (
Object.prototype.hasOwnProperty.call(evalutatedContext, key) &&
/^search_default_/.test(key)
) {
delete evalutatedContext[key];
}
}
evalutatedContext.dashboard_merge_domains_contexts = false;
Object.assign(this.state, {
name: $(".o_input").val() || "",
open: false,
});
const result = await this.env.services.rpc({
route: "/board/add_to_dashboard",
params: {
action_id: this.env.action.id || false,
context_to_save: evalutatedContext,
domain: domain,
view_mode: this.env.view.type,
name: this.state.name,
},
});
if (result) {
this.env.services.notification.notify({
title: sprintf(this.env._t("'%s' added to dashboard"), this.state.name),
message: this.env._t(
"Please refresh your browser for the changes to take effect."
),
type: "warning",
});
} else {
this.env.services.notification.notify({
message: this.env._t("Could not add filter to dashboard"),
type: "danger",
});
}
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* @private
* @param {KeyboardEvent} ev
*/
onInputKeydown(ev) {
switch (ev.key) {
case "Enter":
ev.preventDefault();
this.addToBoard();
break;
case "Escape":
// Gives the focus back to the component.
ev.preventDefault();
ev.target.blur();
break;
}
}
//---------------------------------------------------------------------
// Static
//---------------------------------------------------------------------
/**
* @param {Object} env
* @returns {boolean}
*/
static shouldBeDisplayed(env) {
return env.action.type === "ir.actions.act_window";
}
}
AddToBoardMenu.props = {};
AddToBoardMenu.template = "board.AddToBoard";
AddToBoardMenu.components = { Dropdown };
FavoriteMenu.registry.add("add-to-board-menu", AddToBoardMenu, 10);
export default AddToBoardMenu;
@@ -19,8 +19,6 @@ import {
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import LegacyAddToBoard from "board.AddToBoardMenu";
import LegacyFavoriteMenu from "web.FavoriteMenu";
import testUtils from "web.test_utils";
import { makeFakeUserService } from "@web/../tests/helpers/mock_services";
@@ -76,7 +74,6 @@ QUnit.module("Board", (hooks) => {
},
};
LegacyFavoriteMenu.registry.add("add-to-board-menu", LegacyAddToBoard, 10);
favoriteMenuRegistry.add("add-to-board", addToBoardItem, { sequence: 10 });
serverData = { models };
target = getFixture();
@@ -1,21 +0,0 @@
/** @odoo-module **/
import CustomFilterItem from "web.CustomFilterItem";
import { patch } from "web.utils";
patch(CustomFilterItem.prototype, "mail.CustomFilterItem", {
/**
* With the `mail` module installed, we want to filter out some of the
* available fields in 'Add custom filter' menu (@see CustomFilterItem).
* @override
*/
_validateField(field) {
return (
this._super(...arguments) &&
field.relation !== "mail.message" &&
field.name !== "message_ids"
);
},
});
export default CustomFilterItem;
-1
View File
@@ -148,7 +148,6 @@
('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/js/views/action_model.js',
'web/static/src/legacy/legacy_load_views.js',
'web/static/src/legacy/legacy_service_provider.js',
'web/static/src/legacy/utils.js',
-3
View File
@@ -69,7 +69,6 @@
'project/static/src/utils/**/*',
'project/static/src/components/**/*',
'project/static/src/views/**/*',
'project/static/src/js/project_control_panel.js',
'project/static/src/js/project_graph_view.js',
'project/static/src/js/project_pivot_view.js',
'project/static/src/js/project_rating_graph_view.js',
@@ -174,7 +173,6 @@
'web/static/src/legacy/js/fields/field_utils.js',
'web/static/src/legacy/js/views/abstract_model.js',
'web/static/src/legacy/js/views/basic/basic_model.js',
'web/static/src/legacy/js/views/action_model.js',
'web/static/src/legacy/js/views/view_utils.js',
'web/static/src/legacy/js/services/data_manager.js',
'web/static/src/legacy/js/services/session.js',
@@ -186,7 +184,6 @@
'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/model.js',
'web/static/src/legacy/js/owl_compatibility.js',
'web_editor/static/src/components/**/*',
@@ -1,42 +0,0 @@
/** @odoo-module **/
import ControlPanel from 'web.ControlPanel';
import session from 'web.session';
const { onWillStart, onWillUpdateProps } = owl;
export class ProjectControlPanel extends ControlPanel {
setup() {
super.setup();
this.show_project_update = this.props.view.type === "form" || this.props.action.context.show_project_update;
this.project_id = this.show_project_update ? this.props.action.context.active_id : false;
onWillStart(() => this._loadWidgetData());
onWillUpdateProps(() => this._loadWidgetData());
}
async _loadWidgetData() {
if (this.show_project_update) {
this.data = await this.rpc({
model: 'project.project',
method: 'get_last_update_or_default',
args: [this.project_id],
});
this.is_project_user = await session.user_has_group('project.group_project_user');
}
}
async onStatusClick(ev) {
ev.preventDefault();
await this.trigger('do-action', {
action: "project.project_update_all_action",
options: {
additional_context: {
default_project_id: this.project_id,
active_id: this.project_id
}
}
});
}
}
@@ -1,35 +0,0 @@
/** @odoo-module **/
import FavoriteMenuLegacy from 'web.FavoriteMenu';
import CustomFavoriteItemLegacy from 'web.CustomFavoriteItem';
import { registry } from "@web/core/registry";
/**
* Remove all components contained in the favorite menu registry except the CustomFavoriteItem
* component for only the project sharing feature.
*/
export function prepareFavoriteMenuRegister() {
let customFavoriteItemKey = 'favorite-generator-menu';
const keys = FavoriteMenuLegacy.registry.keys().filter(key => key !== customFavoriteItemKey);
FavoriteMenuLegacy.registry = Object.assign(FavoriteMenuLegacy.registry, {
map: {},
_scoreMapping: {},
_sortedKeys: null,
});
FavoriteMenuLegacy.registry.add(customFavoriteItemKey, CustomFavoriteItemLegacy, 0);
// notify the listeners, we keep only one key in this registry.
for (const key of keys) {
for (const callback of FavoriteMenuLegacy.registry.listeners) {
callback(key, undefined);
}
}
customFavoriteItemKey = 'custom-favorite-item';
const favoriteMenuRegistry = registry.category("favoriteMenu");
for (const [key] of favoriteMenuRegistry.getEntries()) {
if (key !== customFavoriteItemKey) {
favoriteMenuRegistry.remove(key);
}
}
}
@@ -1,7 +1,5 @@
/** @odoo-module **/
import { startWebClient } from '@web/start';
import { ProjectSharingWebClient } from './project_sharing';
import { prepareFavoriteMenuRegister } from './components/favorite_menu_registry';
prepareFavoriteMenuRegister();
startWebClient(ProjectSharingWebClient);
-2
View File
@@ -248,11 +248,9 @@ This module provides the core of the Odoo Web Client.
'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/model.js',
'web/static/src/legacy/js/owl_compatibility.js',
'web/static/src/legacy/xml/base.xml',
'web/static/src/legacy/xml/control_panel.xml',
'web/static/src/legacy/xml/fields.xml',
'web/static/src/legacy/xml/week_days.xml',
],
+1 -111
View File
@@ -1,113 +1,7 @@
/** @odoo-module **/
import ActionModel from "@web/legacy/js/views/action_model";
/**
* @param {string} state
* @returns {string}
*/
function searchModelStateFromLegacy(state) {
/**
* Possible problem if only one of ControlPanelModelExtension or SearchPanelModelExtension is installed.
* We might need to do something in SearchModel.
* @todo (DAM) check when search panel is reworked.
*/
const parsedState = JSON.parse(state);
const newState = {};
if (parsedState.ControlPanelModelExtension) {
const { query, filters, nextGroupId, nextGroupNumber, nextId } =
parsedState.ControlPanelModelExtension;
newState.nextGroupId = nextGroupId;
newState.nextGroupNumber = nextGroupNumber;
newState.nextId = nextId;
newState.query = [];
newState.searchItems = {};
for (const queryElem of query) {
const filterId = queryElem.filterId;
const filter = filters[filterId];
const newQueryElem = { searchItemId: filterId };
switch (filter.type) {
case "filter":
if (filter.hasOptions) {
newQueryElem.generatorId = queryElem.optionId;
}
break;
case "groupBy":
if (filter.hasOptions) {
newQueryElem.intervalId = queryElem.optionId;
}
break;
case "field":
newQueryElem.autocompleteValue = {
value: queryElem.value,
label: queryElem.label,
operator: queryElem.operator,
};
break;
}
newState.query.push(newQueryElem);
}
for (const filter of Object.values(filters)) {
const item = Object.assign({}, filter);
switch (item.type) {
case "groupBy":
if (filter.hasOptions) {
item.type = "dateGroupBy";
item.defaultIntervalId = item.defaultOptionId;
delete item.hasOptions;
delete item.defaultOptionId;
}
break;
case "filter":
if (filter.hasOptions) {
item.type = "dateFilter";
item.defaultGeneratorId = item.defaultOptionId;
delete item.hasOptions;
delete item.isDateFilter;
delete item.defaultOptionId;
}
break;
case "favorite":
item.orderBy = item.orderedBy;
delete item.orderedBy;
break;
}
newState.searchItems[filter.id] = item;
}
}
if (parsedState.SearchPanelModelExtension) {
const { sections, searchPanelInfo } = parsedState.SearchPanelModelExtension;
newState.sections = sections;
//! Can be undefined. See search_model.__legacyParseSearchPanelArchAnyway
newState.searchPanelInfo = searchPanelInfo;
if (newState.searchPanelInfo) {
newState.searchPanelInfo.loaded = true;
}
}
for (const [key, extension] of Object.entries(ActionModel.registry.entries())) {
if (
!["ControlPanel", "SearchPanel"].includes(key) &&
parsedState[extension.name] !== undefined
) {
newState[key] = parsedState[extension.name];
}
}
if (!Object.keys(newState).length) {
return;
}
return JSON.stringify(newState);
}
function getGlobalState(legacyControllerState) {
const { resIds, searchModel, searchPanel } = legacyControllerState;
const { resIds, searchPanel } = legacyControllerState;
const globalState = {};
if (searchPanel) {
globalState.searchPanel = searchPanel;
@@ -115,10 +9,6 @@ function getGlobalState(legacyControllerState) {
if (resIds) {
globalState.resIds = resIds;
}
const newSearchModel = searchModelStateFromLegacy(searchModel);
if (newSearchModel) {
globalState.searchModel = newSearchModel;
}
return globalState;
}
@@ -8,12 +8,10 @@
*/
import ActionMixin from "web.ActionMixin";
import ActionModel from "web.ActionModel";
import ControlPanel from "web.ControlPanel";
import Widget from "web.Widget";
import { ComponentWrapper } from "web.OwlCompatibility";
import { Component } from "@odoo/owl";
const AbstractAction = Widget.extend(ActionMixin, {
config: {
ControlPanel: ControlPanel,
@@ -27,44 +25,6 @@ const AbstractAction = Widget.extend(ActionMixin, {
*/
hasControlPanel: false,
/**
* If true, this flag indicates that the client action should automatically
* fetch the <arch> of a search view (or control panel view). Note that
* to do that, it also needs a specific modelName.
*
* For example, the Discuss application adds the following line in its
* constructor::
*
* this.searchModelConfig.modelName = 'mail.message';
*
* @type boolean
*/
loadControlPanel: false,
/**
* A client action might want to use a search bar in its control panel, or
* it could choose not to use it.
*
* Note that it only makes sense if hasControlPanel is set to true.
*
* @type boolean
*/
withSearchBar: false,
/**
* This parameter can be set to customize the available sub menus in the
* controlpanel (Filters/Group By/Favorites). This is basically a list of
* the sub menus that we want to use.
*
* Note that it only makes sense if hasControlPanel is set to true.
*
* For example, set ['filter', 'favorite'] to enable the Filters and
* Favorites menus.
*
* @type string[]
*/
searchMenuTypes: [],
/**
* @override
*
@@ -76,67 +36,13 @@ const AbstractAction = Widget.extend(ActionMixin, {
this._super(parent);
this._title = action.display_name || action.name;
this.searchModelConfig = {
context: Object.assign({}, action.context),
domain: action.domain || [],
env: Component.env,
searchMenuTypes: this.searchMenuTypes,
};
this.extensions = {};
if (this.hasControlPanel) {
this.extensions.ControlPanel = {
actionId: action.id,
withSearchBar: this.withSearchBar,
};
this.viewId = action.search_view_id && action.search_view_id[0];
this.controlPanelProps = {
action,
breadcrumbs: options && options.breadcrumbs,
withSearchBar: this.withSearchBar,
searchMenuTypes: this.searchMenuTypes,
};
}
},
/**
* The willStart method is actually quite complicated if the client action
* has a controlPanel, because it needs to prepare it.
*
* @override
*/
willStart: async function () {
const superPromise = this._super(...arguments);
if (this.hasControlPanel) {
if (this.loadControlPanel) {
const { context, modelName } = this.searchModelConfig;
const options = { load_filters: this.searchMenuTypes.includes('favorite') };
const { arch, fields, favoriteFilters } = await this.loadFieldView(
modelName,
context || {},
this.viewId,
'search',
options
);
const archs = { search: arch };
const { ControlPanel: controlPanelInfo } = ActionModel.extractArchInfo(archs);
Object.assign(this.extensions.ControlPanel, {
archNodes: controlPanelInfo.children,
favoriteFilters,
fields,
});
this.controlPanelProps.fields = fields;
}
}
this.searchModel = new ActionModel(this.extensions, this.searchModelConfig);
if (this.hasControlPanel) {
this.controlPanelProps.searchModel = this.searchModel;
}
return Promise.all([
superPromise,
this.searchModel.load(),
]);
},
/**
* @override
*/
@@ -164,27 +70,13 @@ const AbstractAction = Widget.extend(ActionMixin, {
*/
on_attach_callback: function () {
ActionMixin.on_attach_callback.call(this);
this.searchModel.on('search', this, this._onSearch);
if (this.hasControlPanel) {
this.searchModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams);
}
},
/**
* @override
*/
on_detach_callback: function () {
ActionMixin.on_detach_callback.call(this);
this.searchModel.off('search', this);
if (this.hasControlPanel) {
this.searchModel.off('get-controller-query-params', this);
}
},
/**
* @private
* @param {Object} [searchQuery]
*/
_onSearch: function () {},
});
export default AbstractAction;
@@ -35,16 +35,6 @@
*/
contentTemplate: null,
/**
* Events built by and managed by Odoo Framework
*
* It is expected that any Widget Class implementing this mixin
* will also implement the ParentedMixin which actually manages those
*/
custom_events: {
get_controller_query_params: '_onGetOwnedQueryParams',
},
/**
* 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
@@ -103,30 +93,6 @@
return Promise.resolve();
},
/**
* This function is called when the current state of the action
* should be known. For instance, if the action is a view controller,
* this may be useful to reinstantiate a view in the same state.
*
* Typically the state can (and should) be encoded in a query object of
* the form::
*
* {
* context: {...},
* groupBy: [...],
* domain = [...],
* orderedBy = [...],
* }
*
* where the context key can contain many information.
* This method is mainly called during the creation of a custom filter.
*
* @returns {Object}
*/
getOwnedQueryParams: function () {
return {};
},
/**
* Returns a serializable state that will be pushed in the URL by
* the action manager, allowing the action to be restarted correctly
@@ -210,24 +176,6 @@
_setTitle: function (title) {
this._title = title;
},
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* FIXME: this logic should be rethought
*
* Handles a context request: provides to the caller the state of the
* current controller.
*
* @private
* @param {function} callback used to send the requested state
*/
_onGetOwnedQueryParams: function (callback) {
const state = this.getOwnedQueryParams();
callback(state || {});
},
});
export default ActionMixin;
@@ -1,289 +0,0 @@
/** @odoo-module alias=web.DatePickerOwl **/
import config from "web.config";
import field_utils from "web.field_utils";
import time from "web.time";
import { useAutofocus } from "@web/core/utils/hooks";
import { LegacyComponent } from "@web/legacy/legacy_component";
const {
onMounted,
onPatched,
onWillUnmount,
onWillUpdateProps,
useExternalListener,
useState,
} = owl;
let datePickerId = 0;
/**
* Date picker
*
* This component exposes the API of the tempusdominus datepicker library.
* As such, its template is a simple input that will open the TD datepicker
* when clicked on. The component will also synchronize any user-input value
* with the library widget and vice-vera.
*
* For further details regarding the implementation of the picker itself, please
* refer to the official tempusdominus documentation (note: all props given
* to this component will be passed as arguments to instantiate the picker widget).
* @extends Component
*/
class DatePicker extends LegacyComponent {
setup() {
this.state = useState({ warning: false });
this.datePickerId = `o_datepicker_${datePickerId++}`;
this.typeOfDate = 'date';
this.inputRef = useAutofocus();
useExternalListener(window, 'scroll', this._onWindowScroll);
onMounted(() => {
$(this.el).on('show.datetimepicker', this._onDateTimePickerShow.bind(this));
$(this.el).on('hide.datetimepicker', this._onDateTimePickerHide.bind(this));
$(this.el).on('error.datetimepicker', () => false);
const pickerOptions = Object.assign({ format: this.defaultFormat }, this.props);
this._datetimepicker(pickerOptions);
this.inputRef.el.value = this._formatDate(this.props.date);
});
onWillUnmount(() => {
$(this.el).off('show.datetimepicker hide.datetimepicker error.datetimepicker');
this._datetimepicker('destroy');
});
let newDateReceived = false;
onWillUpdateProps((nextProps) => {
for (const prop in nextProps) {
if (prop == "onDateTimeChanged") {
continue;
}
this._datetimepicker(prop, nextProps[prop]);
}
if (nextProps.date) {
newDateReceived = true;
}
});
onPatched(() => {
if (newDateReceived) {
newDateReceived = false;
this.inputRef.el.value = this._formatDate(this.props.date);
}
});
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* @returns {string}
*/
get defaultFormat() {
return time.getLangDateFormat();
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* Handle bootstrap datetimepicker calls.
* @private
* @param {...any} args anything that will be passed to the datetimepicker function.
*/
_datetimepicker(...args) {
this.ignoreBootstrapEvents = true;
$(this.el).datetimepicker(...args);
this.ignoreBootstrapEvents = false;
}
/**
* @private
* @param {moment} date
* @returns {string}
*/
_formatDate(date) {
try {
return field_utils.format[this.typeOfDate](date, null, { timezone: false });
} catch {
return false;
}
}
/**
* @private
* @param {string|false} value
* @returns {moment}
*/
_parseInput(inputValue) {
try {
return field_utils.parse[this.typeOfDate](inputValue, null, { timezone: false });
} catch {
return false;
}
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* Reacts to the datetimepicker being hidden
* Used to unbind the scroll event from the datetimepicker
* @private
*/
_onDateTimePickerHide() {
if (this.ignoreBootstrapEvents) {
return;
}
const date = this._parseInput(this.inputRef.el.value);
this.state.warning = date.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD');
this.props.onDateTimeChanged(date);
}
/**
* Reacts to the datetimepicker being shown
* Could set/verify our widget value
* And subsequently update the datetimepicker
* @private
*/
_onDateTimePickerShow() {
if (this.ignoreBootstrapEvents) {
return;
}
this.inputRef.el.select();
}
/**
* @private
*/
_onInputClick() {
this._datetimepicker('toggle');
}
/**
* @private
*/
_onInputChange() {
const date = this._parseInput(this.inputRef.el.value);
if (date) {
this.state.warning = date.format('YYYY-MM-DD') > moment().format('YYYY-MM-DD');
this.props.onDateTimeChanged(date);
} else {
this.inputRef.el.value = this._formatDate(this.props.date);
}
}
/**
* @private
*/
_onWindowScroll(ev) {
if (!config.device.isIOS && ev.target !== this.inputRef.el) {
this._datetimepicker('hide');
}
}
}
DatePicker.defaultProps = {
calendarWeeks: true,
icons: {
clear: 'fa fa-delete',
close: 'fa fa-check primary',
date: 'fa fa-calendar',
down: 'oi oi-chevron-down',
next: 'oi oi-chevron-right',
previous: 'oi oi-chevron-left',
time: 'fa fa-clock-o',
today: 'fa fa-calendar-check-o',
up: 'oi oi-chevron-up',
},
get locale() {return moment.locale();},
maxDate: moment({ y: 9999, M: 11, d: 31 }),
minDate: moment({ y: 1000 }),
useCurrent: false,
widgetParent: 'body',
};
DatePicker.props = {
// Actual date value
date: moment,
// function to call when the date/time changed
onDateTimeChanged: Function,
// Other props
buttons: {
type: Object,
shape: {
showClear: Boolean,
showClose: Boolean,
showToday: Boolean,
},
optional: 1,
},
calendarWeeks: { type: Boolean, optional: true },
format: { type: String, optional: 1 },
icons: {
type: Object,
shape: {
clear: String,
close: String,
date: String,
down: String,
next: String,
previous: String,
time: String,
today: String,
up: String,
},
optional: true,
},
keyBinds: { validate: kb => typeof kb === 'object' || kb === null, optional: 1 },
locale: { type: String, optional: true },
maxDate: { type: moment, optional: true },
minDate: { type: moment, optional: true },
readonly: { type: Boolean, optional: 1 },
useCurrent: { type: Boolean, optional: true },
widgetParent: { type: String, optional: true },
};
DatePicker.template = "web.Legacy.DatePicker";
/**
* Date/time picker
*
* Similar to the DatePicker component, adding the handling of more specific
* time values: hour-minute-second.
*
* Once again, refer to the tempusdominus documentation for implementation
* details.
* @extends DatePicker
*/
class DateTimePicker extends DatePicker {
setup() {
super.setup();
this.typeOfDate = 'datetime';
}
/**
* @override
*/
get defaultFormat() {
return time.getLangDatetimeFormat();
}
}
DateTimePicker.defaultProps = Object.assign(Object.create(DatePicker.defaultProps), {
buttons: {
showClear: false,
showClose: true,
showToday: false,
},
});
export default {
DatePicker,
DateTimePicker,
};
@@ -1,33 +0,0 @@
/** @odoo-module alias=web.ComparisonMenu **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item";
import { FACET_ICONS } from "web.searchUtils";
import { useModel } from "web.Model";
import { LegacyComponent } from "@web/legacy/legacy_component";
class ComparisonMenu extends LegacyComponent {
setup() {
this.icon = FACET_ICONS.comparison;
this.model = useModel('searchModel');
}
/**
* @override
*/
get items() {
return this.model.get('filters', f => f.type === 'comparison');
}
/**
* @private
* @param {number} itemId
*/
onComparisonSelected(itemId) {
this.model.dispatch("toggleComparison", itemId);
}
}
ComparisonMenu.template = "web.legacy.ComparisonMenu";
ComparisonMenu.components = { Dropdown, SearchDropdownItem };
export default ComparisonMenu;
@@ -1,16 +1,9 @@
/** @odoo-module alias=web.ControlPanel **/
import ActionMenus from "web.ActionMenus";
import ComparisonMenu from "web.ComparisonMenu";
import ActionModel from "web.ActionModel";
import FavoriteMenu from "web.FavoriteMenu";
import FilterMenu from "web.FilterMenu";
import GroupByMenu from "web.GroupByMenu";
import Pager from "web.Pager";
import SearchBar from "web.SearchBar";
import { useModel } from "web.Model";
import { LegacyComponent } from "@web/legacy/legacy_component";
import { sprintf } from "@web/core/utils/strings";
import { sprintf } from "@web/core/utils/strings";
const {
onMounted,
@@ -53,46 +46,7 @@ import { sprintf } from "@web/core/utils/strings";
* Control panel
*
* The control panel of the action|view. In its standard form, it is composed of
* several sections/subcomponents. Here is a simplified graph representing the
* action|view and its control panel:
*
* ┌ View Controller | Action ----------------------------------------------------------┐
* | ┌ Control Panel ──────────────┬──────────────────────────────────────────────────┐ |
* | │ ┌ Breadcrumbs ────────────┐ │ ┌ Search View ─────────────────────────────────┐ │ |
* | │ │ [1] / [2] │ │ │ [3] [ ================ 4 ================= ] │ │ |
* | │ └─────────────────────────┘ │ └──────────────────────────────────────────────┘ │ |
* | ├─────────────────────────────┼──────────────────────────────────────────────────┤ |
* | │ ┌ Buttons ┐ ┌ ActionMenus ┐ │ ┌ Search Menus ─────┐ ┌ Pager ┐┌ View switcher ┐ │ |
* | │ │ [5] │ │ [6] │ │ │ [7] [8] [9] [10] │ │ [11] ││ [12] │ │ |
* | │ └─────────┘ └─────────────┘ │ └───────────────────┘ └───────┘└───────────────┘ │ |
* | └─────────────────────────────┴──────────────────────────────────────────────────┘ |
* | ┌ View Renderer | Action content ────────────────────────────────────────────────┐ |
* | │ │ |
* | │ ... │ |
* | │ │ |
* | │ │ |
* | │ │ |
* | └────────────────────────────────────────────────────────────────────────────────┘ |
* └------------------------------------------------------------------------------------┘
*
* 1. Breadcrumbs: list of links composed by the `props.breadcrumbs` collection.
* 2. Title: the title of the action|view. Can be empty and will yield 'Unnamed'.
* 3. Search facets: a collection of facet components generated by the `ControlPanelModel`
* and handled by the `SearchBar` component. @see SearchFacet
* 4. SearchBar: @see SearchBar
* 5. Buttons: section in which the action|controller is meant to inject its control
* buttons. The template provides a slot for this purpose.
* 6. Action menus: @see ActionMenus
* 7. Filter menu: @see FilterMenu
* 8. Group by menu: @see GroupByMenu
* 9. Comparison menu: @see ComparisonMenu
* 10. Favorite menu: @see FavoriteMenu
* 11. Pager: @see Pager
* 12. View switcher buttons: list of buttons composed by the `props.views` collection.
*
* Subcomponents (especially in the [Search Menus] section) will call
* the ControlPanelModel to get processed information about the current view|action.
* @see ControlPanelModel for more details.
* 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:
@@ -111,18 +65,11 @@ import { sprintf } from "@web/core/utils/strings";
let subEnvView = this.props.view;
useSubEnv({
action: this.props.action,
searchModel: this.props.searchModel,
get view() {
return subEnvView;
},
});
// Connect to the model
// TODO: move this in enterprise whenever possible
if (this.env.searchModel) {
this.model = useModel('searchModel');
}
// Reference hooks
this.contentRefs = {
buttons: useRef('buttons'),
@@ -208,35 +155,26 @@ import { sprintf } from "@web/core/utils/strings";
return formattedFields;
}
}
ControlPanel.modelExtension = "ControlPanel";
ControlPanel.components = {
SearchBar,
ActionMenus, Pager,
ComparisonMenu, FilterMenu, GroupByMenu, FavoriteMenu,
};
ControlPanel.defaultProps = {
breadcrumbs: [],
fields: {},
searchMenuTypes: [],
views: [],
withBreadcrumbs: true,
withSearchBar: true,
};
ControlPanel.props = {
action: Object,
breadcrumbs: { type: Array, optional: true },
searchModel: ActionModel,
cp_content: { type: Object, optional: 1 },
fields: { type: Object, optional: true },
pager: { validate: p => typeof p === 'object' || p === null, optional: 1 },
searchMenuTypes: { type: Array, optional: true },
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 },
withSearchBar: { type: Boolean, optional: true },
};
ControlPanel.template = 'web.Legacy.ControlPanel';
File diff suppressed because it is too large Load Diff
@@ -1,149 +0,0 @@
/** @odoo-module alias=web.CustomFavoriteItem **/
import { CheckBox } from "@web/core/checkbox/checkbox";
import { Dropdown } from "@web/core/dropdown/dropdown";
import FavoriteMenu from "web.FavoriteMenu";
import { useModel } from "web.Model";
import { LegacyComponent } from "@web/legacy/legacy_component";
const { useState, useRef } = owl;
let favoriteId = 0;
/**
* Favorite generator menu
*
* This component is used to add a new favorite linked which will take every
* information out of the current context and save it to a new `ir.filter`.
*
* There are 3 additional inputs to modify the filter:
* - a text input (mandatory): the name of the favorite (must be unique)
* - 'use by default' checkbox: if checked, the favorite will be the default
* filter of the current model (and will bypass
* any existing default filter). Cannot be checked
* along with 'share with all users' checkbox.
* - 'share with all users' checkbox: if checked, the favorite will be available
* with all users instead of the current
* one.Cannot be checked along with 'use
* by default' checkbox.
* Finally, there is a 'Save' button used to apply the current configuration
* and save the context to a new filter.
*/
class CustomFavoriteItem extends LegacyComponent {
setup() {
const favId = favoriteId++;
this.useByDefaultId = `o_favorite_use_by_default_${favId}`;
this.shareAllUsersId = `o_favorite_share_all_users_${favId}`;
this.descriptionRef = useRef("description");
this.model = useModel("searchModel");
this.interactive = true;
this.state = useState({
description: this.env.action.name || "",
isDefault: false,
isShared: false,
});
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* @private
*/
saveFavorite() {
if (!this.state.description) {
this.env.services.notification.notify({
message: this.env._t("A name for your favorite filter is required."),
type: 'danger',
});
return this.descriptionRef.el.focus();
}
const favorites = this.model.get('filters', f => f.type === 'favorite');
if (favorites.some(f => f.description === this.state.description)) {
this.env.services.notification.notify({
message: this.env._t("Filter with same name already exists."),
type: 'danger',
});
return this.descriptionRef.el.focus();
}
this.model.dispatch('createNewFavorite', {
type: 'favorite',
description: this.state.description,
isDefault: this.state.isDefault,
isShared: this.state.isShared,
});
// Reset state
Object.assign(this.state, {
description: this.env.action.name || "",
isDefault: false,
isShared: false,
open: false,
});
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* @private
* @param {boolean} checked
*/
onDefaultCheckboxChange(checked) {
this.state.isDefault = checked;
if (checked) {
this.state.isShared = false;
}
}
/**
* @private
* @param {boolean} checked
*/
onShareCheckboxChange(checked) {
this.state.isShared = checked;
if (checked) {
this.state.isDefault = false;
}
}
/**
* @private
* @param {jQueryEvent} ev
*/
onInputKeydown(ev) {
switch (ev.key) {
case 'Enter':
ev.preventDefault();
this.saveFavorite();
break;
case 'Escape':
// Gives the focus back to the component.
ev.preventDefault();
ev.target.blur();
break;
}
}
//---------------------------------------------------------------------
// Static
//---------------------------------------------------------------------
/**
* @param {Object} env
* @returns {boolean}
*/
static shouldBeDisplayed(env) {
return true;
}
}
CustomFavoriteItem.props = {};
CustomFavoriteItem.template = "web.CustomFavoriteItem";
CustomFavoriteItem.components = { CheckBox, Dropdown };
CustomFavoriteItem.groupNumber = 3; // have 'Save Current Search' in its own group
FavoriteMenu.registry.add('favorite-generator-menu', CustomFavoriteItem, 0);
export default CustomFavoriteItem;
@@ -1,281 +0,0 @@
/** @odoo-module alias=web.CustomFilterItem **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DatePicker, DateTimePicker } from "web.DatePickerOwl";
import Domain from "web.Domain";
import { FIELD_OPERATORS, FIELD_TYPES } from "web.searchUtils";
import field_utils from "web.field_utils";
import { useModel } from "web.Model";
import { LegacyComponent } from "@web/legacy/legacy_component";
const { useState } = owl;
/**
* Filter generator menu
*
* Component which purpose is to generate new filters from a set of inputs.
* It is made of one or several `condition` objects that will be used to generate
* filters which will be added in the filter menu. Each condition is composed
* of 2, 3 or 4 inputs:
*
* 1. FIELD (select): the field used to form the base of the domain condition;
*
* 2. OPERATOR (select): the symbol determining the operator(s) of the domain
* condition, linking the field to one or several value(s).
* Some operators can have pre-defined values that will replace user inputs.
* @see searchUtils for the list of operators.
*
* 3. [VALUE] (input|select): the value of the domain condition. it will be parsed
* according to the selected field's type. Note that
* it is optional as some operators have defined values.
* The generated condition domain will be as following:
* [
* [field, operator, (operator_value|input_value)]
* ]
*
* 4. [VALUE] (input): for now, only date-typed fields with the 'between' operator
* allow for a second value. The given input values will then
* be taken as the borders of the date range (between x and y)
* and will be translated as the following domain form:
* [
* [date_field, '>=', x],
* [date_field, '<=', y],
* ]
*/
class CustomFilterItem extends LegacyComponent {
setup() {
this.model = useModel('searchModel');
this.conditions = useState([]);
// Format, filter and sort the fields props
this.fields = Object.values(this.props.fields)
.filter(field => this._validateField(field))
.concat({ string: 'ID', type: 'id', name: 'id' })
.sort(({ string: a }, { string: b }) => a > b ? 1 : a < b ? -1 : 0);
// Give access to constants variables to the template.
this.DECIMAL_POINT = this.env._t.database.parameters.decimal_point;
this.OPERATORS = FIELD_OPERATORS;
this.FIELD_TYPES = FIELD_TYPES;
// Add first condition
this.addNewCondition();
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* Populate the conditions list with a new condition having as properties:
* - the last condition or the first available field
* - the last condition or the first available operator
* - a null or empty array value
* @private
*/
addNewCondition() {
const lastCondition = [...this.conditions].pop();
const condition = lastCondition
? Object.assign({}, lastCondition)
: {
field: 0,
operator: 0,
};
this._setDefaultValue(condition);
this.conditions.push(condition);
}
/**
* @private
* @param {Object} field
* @returns {boolean}
*/
_validateField(field) {
return !field.deprecated &&
field.searchable &&
FIELD_TYPES[field.type] &&
field.name !== 'id';
}
/**
* @private
* @param {Object} condition
*/
_setDefaultValue(condition) {
const fieldType = this.fields[condition.field].type;
const genericType = FIELD_TYPES[fieldType];
const operator = FIELD_OPERATORS[genericType][condition.operator];
// Logical value
switch (genericType) {
case 'id':
case 'number':
condition.value = 0;
break;
case 'date':
condition.value = [moment()];
if (operator.symbol === 'between') {
condition.value.push(moment());
}
break;
case 'datetime':
condition.value = [moment('00:00:00', 'hh:mm:ss').utcOffset(0, true)];
if (operator.symbol === 'between') {
condition.value.push(moment('23:59:59', 'hh:mm:ss').utcOffset(0, true));
}
break;
case 'selection':
if (this.fields[condition.field].selection.length) {
const [firstValue] = this.fields[condition.field].selection[0];
condition.value = firstValue;
}
else {
condition.value = "";
}
break;
default:
condition.value = "";
}
// Displayed value
if (["float", "monetary"].includes(fieldType)) {
condition.displayedValue = `0${this.DECIMAL_POINT}0`;
} else {
condition.displayedValue = String(condition.value);
}
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* Convert all conditions to prefilters.
* @private
*/
onApply() {
const preFilters = this.conditions.map(condition => {
const field = this.fields[condition.field];
const type = this.FIELD_TYPES[field.type];
const operator = this.OPERATORS[type][condition.operator];
const descriptionArray = [field.string, operator.description];
const domainArray = [];
let domainValue;
// Field type specifics
if ('value' in operator) {
domainValue = [operator.value];
// No description to push here
} else if (['date', 'datetime'].includes(type)) {
domainValue = condition.value.map(
val => field_utils.parse[type](val, { type }, { timezone: true })
);
const dateValue = condition.value.map(
val => field_utils.format[type](val, { type }, { timezone: false })
);
descriptionArray.push(`"${dateValue.join(" " + this.env._t("and") + " ")}"`);
} else if (type === "selection") {
domainValue = [condition.value];
const formattedValue = field_utils.format[type](condition.value, field);
descriptionArray.push(`"${formattedValue}"`);
} else {
domainValue = [condition.value];
descriptionArray.push(`"${condition.value}"`);
}
// Operator specifics
if (operator.symbol === 'between') {
domainArray.push(
[field.name, '>=', domainValue[0]],
[field.name, '<=', domainValue[1]]
);
} else {
domainArray.push([field.name, operator.symbol, domainValue[0]]);
}
const preFilter = {
description: descriptionArray.join(" "),
domain: Domain.prototype.arrayToString(domainArray),
type: 'filter',
};
return preFilter;
});
this.model.dispatch('createNewFilters', preFilters);
// remove conditions
this.conditions.splice(0, this.conditions.length);
this.addNewCondition();
}
/**
* @private
* @param {Object} condition
* @param {number} valueIndex
* @param {Date} date
*/
onDateTimeChanged(condition, valueIndex, date) {
condition.value[valueIndex] = date;
}
/**
* @private
* @param {Object} condition
* @param {Event} ev
*/
onFieldSelect(condition, ev) {
Object.assign(condition, {
field: ev.target.selectedIndex,
operator: 0,
});
this._setDefaultValue(condition);
}
/**
* @private
* @param {Object} condition
* @param {Event} ev
*/
onOperatorSelect(condition, ev) {
condition.operator = ev.target.selectedIndex;
this._setDefaultValue(condition);
}
/**
* @private
* @param {Object} condition
*/
onRemoveCondition(conditionIndex) {
this.conditions.splice(conditionIndex, 1);
}
/**
* @private
* @param {Object} condition
* @param {Event} ev
*/
onValueChange(condition, ev) {
if (!ev.target.value) {
return this._setDefaultValue(condition);
}
let { type } = this.fields[condition.field];
if (type === "id") {
type = "integer";
}
if (FIELD_TYPES[type] === "number") {
try {
// Write logical value into the 'value' property
condition.value = field_utils.parse[type](ev.target.value);
// Write displayed value in the input and 'displayedValue' property
condition.displayedValue = ev.target.value;
} catch {
// Parsing error: reverts to previous value
ev.target.value = condition.displayedValue;
}
} else {
condition.value = condition.displayedValue = ev.target.value;
}
}
}
CustomFilterItem.components = { DatePicker, DateTimePicker, Dropdown };
CustomFilterItem.props = { fields: Object };
CustomFilterItem.template = "web.Legacy.CustomFilterItem";
export default CustomFilterItem;
@@ -1,88 +0,0 @@
/** @odoo-module alias=web.FavoriteMenu **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item";
import Dialog from "web.OwlDialog";
import { FACET_ICONS } from "web.searchUtils";
import Registry from "web.Registry";
import { useModel } from "web.Model";
import { LegacyComponent } from "@web/legacy/legacy_component";
const { useState } = owl;
/**
* 'Favorites' menu
*
* Simple rendering of the filters of type `favorites` given by the control panel
* model. It uses most of the behaviours implemented by the dropdown menu Component,
* with the addition of a submenu registry used to display additional components.
* Only the favorite generator (@see CustomFavoriteItem) is registered in
* the `web` module.
*/
class FavoriteMenu extends LegacyComponent {
setup() {
this.icon = FACET_ICONS.favorite;
this.model = useModel('searchModel');
this.state = useState({ deletedFavorite: false });
}
//---------------------------------------------------------------------
// Getters
//---------------------------------------------------------------------
/**
* @override
*/
get items() {
const favorites = this.model.get('filters', f => f.type === 'favorite');
const registryMenus = this.constructor.registry.values().reduce(
(menus, Component) => {
if (Component.shouldBeDisplayed(this.env)) {
menus.push({
key: Component.name,
groupNumber: Component.groupNumber,
Component,
});
}
return menus;
},
[]
);
return [...favorites, ...registryMenus];
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* @private
* @param {int} id
*/
openConfirmationDialog(id) {
const favorite = this.items.find(fav => fav.id === id);
this.state.deletedFavorite = favorite;
}
/**
* @private
* @param {number} itemId
*/
onFavoriteSelected(itemId) {
this.model.dispatch('toggleFilter', itemId);
}
/**
* @private
*/
async _onRemoveFavorite() {
this.model.dispatch('deleteFavorite', this.state.deletedFavorite.id);
this.state.deletedFavorite = false;
}
}
FavoriteMenu.registry = new Registry();
FavoriteMenu.components = { Dialog, Dropdown, SearchDropdownItem };
FavoriteMenu.template = 'web.Legacy.FavoriteMenu';
export default FavoriteMenu;
@@ -1,64 +0,0 @@
/** @odoo-module alias=web.FilterMenu **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item";
import CustomFilterItem from "web.CustomFilterItem";
import { FACET_ICONS } from "web.searchUtils";
import { useModel } from "web.Model";
import { LegacyComponent } from "@web/legacy/legacy_component";
/**
* 'Filters' menu
*
* Simple rendering of the filters of type `filter` given by the control panel
* model. It uses most of the behaviours implemented by the dropdown menu Component,
* with the addition of a filter generator (@see CustomFilterItem).
*/
class FilterMenu extends LegacyComponent {
setup() {
this.icon = FACET_ICONS.filter;
this.model = useModel('searchModel');
}
//---------------------------------------------------------------------
// Getters
//---------------------------------------------------------------------
/**
* @override
*/
get items() {
return this.model.get('filters', f => f.type === 'filter');
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* @private
* @param {Object} param0
* @param {number} param0.itemId
* @param {number} [param0.optionId]
*/
onFilterSelected({ itemId, optionId }) {
if (optionId) {
this.model.dispatch('toggleFilterWithOptions', itemId, optionId);
} else {
this.model.dispatch('toggleFilter', itemId);
}
}
}
FilterMenu.defaultProps = {
class: "",
};
FilterMenu.props = {
fields: Object,
class: { String, optional: true },
};
FilterMenu.template = "web.legacy.FilterMenu";
FilterMenu.components = { CustomFilterItem, Dropdown, SearchDropdownItem };
export default FilterMenu;
@@ -1,76 +0,0 @@
/** @odoo-module alias=web.GroupByMenu **/
import { Dropdown } from "@web/core/dropdown/dropdown";
import { SearchDropdownItem } from "@web/search/search_dropdown_item/search_dropdown_item";
import { CustomGroupByItem } from "@web/search/custom_group_by_item/custom_group_by_item";
import { FACET_ICONS, GROUPABLE_TYPES } from "web.searchUtils";
import { useModel } from "web.Model";
import { LegacyComponent } from "@web/legacy/legacy_component";
class GroupByMenu extends LegacyComponent {
setup() {
this.icon = FACET_ICONS.groupBy;
this.fields = Object.values(this.props.fields)
.filter(field => this._validateField(field))
.sort(({ string: a }, { string: b }) => a > b ? 1 : a < b ? -1 : 0);
this.model = useModel('searchModel');
}
//---------------------------------------------------------------------
// Getters
//---------------------------------------------------------------------
/**
* @override
*/
get items() {
return this.model.get('filters', f => f.type === 'groupBy');
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* @see {@link odoo/fields.py} Field._description_sortable
* @see {@link odoo/fields.py} Many2Many.groupable
* @private
* @param {Object} field
* @returns {boolean}
*/
_validateField(field) {
return (field.type === "many2many" ? field.store : field.sortable) &&
field.name !== "id" &&
GROUPABLE_TYPES.includes(field.type);
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* @param {Object} param0
* @param {number} param0.itemId
* @param {number} [param0.optionId]
*/
onGroupBySelected({ itemId, optionId }) {
if (optionId) {
this.model.dispatch('toggleFilterWithOptions', itemId, optionId);
} else {
this.model.dispatch('toggleFilter', itemId);
}
}
onAddCustomGroup(fieldName) {
const field = this.props.fields[fieldName];
this.model.dispatch("createNewGroupBy", field);
}
}
GroupByMenu.components = { CustomGroupByItem, Dropdown, SearchDropdownItem };
GroupByMenu.props = { fields: Object };
GroupByMenu.template = "web.legacy.GroupByMenu";
export default GroupByMenu;
@@ -1,491 +0,0 @@
/** @odoo-module alias=web.SearchBar **/
import Domain from "web.Domain";
import field_utils from "web.field_utils";
import { useAutofocus } from "@web/core/utils/hooks";
import { useModel } from "web.Model";
import { fuzzyTest } from "@web/core/utils/search";
import { LegacyComponent } from "@web/legacy/legacy_component";
const { onMounted, onWillUnmount, toRaw, useExternalListener, useState } = owl;
const CHAR_FIELDS = ['char', 'html', 'many2many', 'many2one', 'one2many', 'text'];
let sourceId = 0;
/**
* Search bar
*
* This component has two main roles:
* 1) Display the current search facets
* 2) Create new search filters using an input and an autocompletion values
* generator.
*
* For the first bit, the core logic can be found in the XML template of this
* component, searchfacet components or in the ControlPanelModel itself.
*
* The autocompletion mechanic works with transient subobjects called 'sources'.
* Sources contain the information that will be used to generate new search facets.
* A source is generated either:
* a. From an undetermined user input: the user will give a string and select
* a field from the autocompletion dropdown > this will search the selected
* field records with the given pattern (with an 'ilike' operator);
* b. From a given selection: when given an input by the user, the searchbar
* will pre-fetch 'many2one' field records matching the input value and filter
* 'select' fields with the same value. If the user clicks on one of these
* fetched/filtered values, it will generate a matching search facet targeting
* records having this exact value.
* @extends Component
*/
class SearchBar extends LegacyComponent {
setup() {
this.inputRef = useAutofocus();
this.model = useModel('searchModel');
this.state = useState({
sources: [],
focusedItem: 0,
inputValue: "",
});
this.autoCompleteSources = this.model.get('filters', f => f.type === 'field').map(
filter => this._createSource(filter)
);
this.noResultItem = [null, this.env._t("(no result)")];
useExternalListener(window, 'click', this._onWindowClick);
useExternalListener(window, 'keydown', this._onWindowKeydown);
onMounted(() => {
// 'search' will always patch the search bar, 'focus' will never.
this.model.on('search', this, () => {
this.inputRef.el.focus();
});
this.model.on('focus-control-panel', this, () => {
this.inputRef.el.focus();
});
});
onWillUnmount(() => {
this.model.off('search', this);
this.model.off('focus-control-panel', this);
});
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* @private
*/
_closeAutoComplete() {
this.state.sources = [];
this.state.focusedItem = 0;
this.state.inputValue = "";
this.inputRef.el.value = "";
this.inputRef.el.focus();
}
/**
* @private
* @param {Object} filter
* @returns {Object}
*/
_createSource(filter) {
const field = toRaw(this.props.fields)[filter.fieldName];
const type = field.type === "reference" ? "char" : field.type;
const source = {
active: true,
description: filter.description,
filterId: filter.id,
filterOperator: filter.operator,
id: sourceId ++,
operator: CHAR_FIELDS.includes(type) ? 'ilike' : '=',
parent: false,
type,
};
switch (type) {
case 'selection': {
source.active = false;
source.selection = field.selection || [];
break;
}
case 'boolean': {
source.active = false;
source.selection = [
[true, this.env._t("Yes")],
[false, this.env._t("No")],
];
break;
}
case 'many2one': {
source.expand = true;
source.expanded = false;
source.context = field.context;
source.relation = field.relation;
if (filter.domain) {
source.domain = filter.domain;
}
}
}
return source;
}
/**
* @private
* @param {Object} source
* @param {[any, string]} values
* @param {boolean} [active=true]
*/
_createSubSource(source, [value, label], active = true) {
const subSource = {
active,
filterId: source.filterId,
filterOperator: source.filterOperator,
id: sourceId ++,
label,
operator: '=',
parent: source,
value,
};
return subSource;
}
/**
* @private
* @param {Object} source
* @param {boolean} shouldExpand
*/
async _expandSource(source, shouldExpand) {
source.expanded = shouldExpand;
if (shouldExpand) {
let args = source.domain;
if (typeof args === 'string') {
try {
args = Domain.prototype.stringToArray(args);
} catch {
args = [];
}
}
const results = await this.rpc({
kwargs: {
args,
context: { ...this.model.config.context, ...source.context },
limit: 8,
name: this.state.inputValue.trim(),
},
method: 'name_search',
model: source.relation,
});
const options = results.map(result => this._createSubSource(source, result));
const parentIndex = this.state.sources.indexOf(source);
if (!options.length) {
options.push(this._createSubSource(source, this.noResultItem, false));
}
this.state.sources.splice(parentIndex + 1, 0, ...options);
} else {
this.state.sources = this.state.sources.filter(src => src.parent !== source);
}
}
/**
* @private
* @param {string} query
*/
_filterSources(query) {
return this.autoCompleteSources.reduce(
(sources, source) => {
// Field selection or boolean.
if (source.selection) {
const options = [];
source.selection.forEach(result => {
if (fuzzyTest(query, result[1].toLowerCase())) {
options.push(this._createSubSource(source, result));
}
});
if (options.length) {
sources.push(...options);
}
// Any other type.
} else if (this._validateSource(query, source)) {
sources.push(source);
}
// Fold any expanded item.
if (source.expanded) {
source.expanded = false;
}
return sources;
},
[]
);
}
/**
* Focus the search facet at the designated index if any.
* @private
*/
_focusFacet(index) {
const facets = this.el.getElementsByClassName('o_searchview_facet');
if (facets.length) {
facets[index].focus();
}
}
/**
* Try to parse the given rawValue according to the type of the given
* source field type. The returned formatted value is the one that will
* supposedly be sent to the server.
* @private
* @param {string} rawValue
* @param {Object} source
* @returns {string}
*/
_parseWithSource(rawValue, { type }) {
const parser = field_utils.parse[type];
let parsedValue;
switch (type) {
case 'date':
case 'datetime': {
const parsedDate = parser(rawValue, { type }, { timezone: true });
const dateFormat = type === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD';
const momentValue = moment(parsedDate, dateFormat);
if (!momentValue.isValid()) {
throw new Error('Invalid date');
}
parsedValue = parsedDate.toJSON();
break;
}
case 'many2one': {
parsedValue = rawValue;
break;
}
default: {
parsedValue = parser(rawValue);
}
}
return parsedValue;
}
/**
* @private
* @param {Object} source
*/
_selectSource(source) {
// Inactive sources are:
// - Selection sources
// - "no result" items
if (source.active) {
const labelValue = source.label || this.state.inputValue.trim();
this.model.dispatch('addAutoCompletionValues', {
filterId: source.filterId,
value: "value" in source ? source.value : this._parseWithSource(labelValue, source),
label: labelValue,
operator: source.filterOperator || source.operator,
});
}
this._closeAutoComplete();
}
/**
* @private
* @param {string} query
* @param {Object} source
* @returns {boolean}
*/
_validateSource(query, source) {
try {
this._parseWithSource(query, source);
} catch {
return false;
}
return true;
}
//---------------------------------------------------------------------
// Handlers
//---------------------------------------------------------------------
/**
* @private
* @param {Object} facet
* @param {number} facetIndex
* @param {KeyboardEvent} ev
*/
_onFacetKeydown(facet, facetIndex, ev) {
switch (ev.key) {
case 'ArrowLeft':
if (facetIndex === 0) {
this.inputRef.el.focus();
} else {
this._focusFacet(facetIndex - 1);
}
break;
case 'ArrowRight':
const facets = this.el.getElementsByClassName('o_searchview_facet');
if (facetIndex === facets.length - 1) {
this.inputRef.el.focus();
} else {
this._focusFacet(facetIndex + 1);
}
break;
case 'Backspace':
this._onFacetRemove(facet);
break;
}
}
/**
* @private
* @param {Object} facet
*/
_onFacetRemove(facet) {
this.model.dispatch('deactivateGroup', facet.groupId);
}
/**
* @private
* @param {KeyboardEvent} ev
*/
_onSearchKeydown(ev) {
if (ev.isComposing) {
// This case happens with an IME for example: we let it handle all key events.
return;
}
const currentItem = this.state.sources[this.state.focusedItem] || {};
switch (ev.key) {
case 'ArrowDown':
ev.preventDefault();
if (Object.keys(this.state.sources).length) {
let nextIndex = this.state.focusedItem + 1;
if (nextIndex >= this.state.sources.length) {
nextIndex = 0;
}
this.state.focusedItem = nextIndex;
} else {
this.env.bus.trigger('focus-view');
}
break;
case 'ArrowLeft':
if (currentItem.expanded) {
// Priority 1: fold expanded item.
ev.preventDefault();
this._expandSource(currentItem, false);
} else if (currentItem.parent) {
// Priority 2: focus parent item.
ev.preventDefault();
this.state.focusedItem = this.state.sources.indexOf(currentItem.parent);
// Priority 3: Do nothing (navigation inside text).
} else if (ev.target.selectionStart === 0) {
// Priority 4: navigate to rightmost facet.
this._focusFacet(this.model.get("facets").length - 1);
}
break;
case 'ArrowRight':
if (ev.target.selectionStart === this.state.inputValue.length) {
// Priority 1: Do nothing (navigation inside text).
if (currentItem.expand) {
// Priority 2: go to first child or expand item.
ev.preventDefault();
if (currentItem.expanded) {
this.state.focusedItem ++;
} else {
this._expandSource(currentItem, true);
}
} else if (ev.target.selectionStart === this.state.inputValue.length) {
// Priority 3: navigate to leftmost facet.
this._focusFacet(0);
}
}
break;
case 'ArrowUp':
ev.preventDefault();
let previousIndex = this.state.focusedItem - 1;
if (previousIndex < 0) {
previousIndex = this.state.sources.length - 1;
}
this.state.focusedItem = previousIndex;
break;
case 'Backspace':
if (!this.state.inputValue.length) {
const facets = this.model.get("facets");
if (facets.length) {
this._onFacetRemove(facets[facets.length - 1]);
}
}
break;
case 'Enter':
if (!this.state.inputValue.length) {
this.model.dispatch('search');
break;
}
/* falls through */
case 'Tab':
if (this.state.inputValue.length) {
ev.preventDefault(); // keep the focus inside the search bar
this._selectSource(currentItem);
}
break;
case 'Escape':
if (this.state.sources.length) {
this._closeAutoComplete();
}
break;
}
}
/**
* @private
* @param {InputEvent} ev
*/
_onSearchInput(ev) {
this.state.inputValue = ev.target.value;
const wasVisible = this.state.sources.length;
const query = this.state.inputValue.trim().toLowerCase();
if (query.length) {
this.state.sources = this._filterSources(query);
} else if (wasVisible) {
this._closeAutoComplete();
}
}
/**
* Only handled if the user has moved its cursor at least once after the
* results are loaded and displayed.
* @private
* @param {number} resultIndex
*/
_onSourceMousemove(resultIndex) {
this.state.focusedItem = resultIndex;
}
/**
* @private
* @param {MouseEvent} ev
*/
_onWindowClick(ev) {
if (this.state.sources.length && !this.el.contains(ev.target)) {
this._closeAutoComplete();
}
}
/**
* @private
* @param {KeyboardEvent} ev
*/
_onWindowKeydown(ev) {
if (ev.key === 'Escape' && this.state.sources.length) {
ev.preventDefault();
ev.stopPropagation();
this._closeAutoComplete();
}
}
}
SearchBar.defaultProps = {
fields: {},
};
SearchBar.props = {
fields: { type: Object, optional: true },
};
SearchBar.template = 'web.Legacy.SearchBar';
export default SearchBar;
-478
View File
@@ -1,478 +0,0 @@
/** @odoo-module alias=web.Model **/
import { groupBy } from "web.utils";
import Registry from "web.Registry";
import { useBus } from "@web/core/utils/hooks";
const { EventBus, onWillRender, useComponent } = owl;
const isNotNull = (val) => val !== null && val !== undefined;
/**
* Feature extension of the class Model.
* @see {Model}
*/
class ModelExtension {
/**
* @param {Object} config
* @param {Object} config.env
*/
constructor(config) {
this.config = config;
this.env = this.config.env;
this.shouldLoad = true;
this.state = {};
}
//---------------------------------------------------------------------
// Public
//---------------------------------------------------------------------
/**
* Used by the parent model to initiate a load action. The actual
* loading of the extension is determined by the "shouldLoad" property.
* @param {Object} params
*/
async callLoad(params) {
if (this.shouldLoad) {
this.shouldLoad = false;
await this.load(params);
}
}
/**
* @param {string} method
* @param {...any} args
*/
dispatch(method, ...args) {
if (method in this) {
this[method](...args);
}
}
/**
* Exports the current state of the extension.
* @returns {Object}
*/
exportState() {
return this.state;
}
/**
* Meant to return the result of the appropriate getter or do nothing
* if not concerned by the given property.
* @abstract
* @param {string} property
* @param {...any} args
* @returns {null}
*/
get() {
return null;
}
/**
* Imports the given state after parsing it. If no state is given the
* extension will prepare a new state and will need to be loaded.
* @param {Object} [state]
*/
importState(state) {
this.shouldLoad = !state;
if (this.shouldLoad) {
this.prepareState();
} else {
Object.assign(this.state, state);
}
}
/**
* Called and awaited on initial model load.
* @abstract
* @param {Object} params
* @returns {Promise}
*/
async load(/* params */) {
/* ... */
}
/**
* Called on initialization if no imported state for the extension is
* found.
* @abstract
*/
prepareState() {
/* ... */
}
}
/**
* The layer of an extension indicates with which other extensions this one
* will be loaded. This property must be overridden in case the model
* depends on other extensions to be loaded first.
*/
ModelExtension.layer = 0;
/**
* Model
*
* The purpose of the class Model and the associated hook useModel
* is to offer something similar to an owl store but with no automatic
* notification (and rendering) of components when the 'state' used in the
* model would change. Instead, one should call the "__notifyComponents"
* function whenever it is useful to alert registered component.
* Nevertheless, when calling a method through the 'dispatch' method, a
* notification does take place automatically, and registered components
* (via useModel) are rendered.
*
* It is highly expected that this class will change in a near future. We
* don't have the necessary hindsight to be sure its actual form is good.
*
* The following snippets show a typical use case of the model system: a
* search model with a control panel extension feature.
*
*-------------------------------------------------------------------------
* MODEL AND EXTENSIONS DEFINITION
*-------------------------------------------------------------------------
*
* 1. Definition of the main model
* @see Model
* ```
* class ActionModel extends Model {
* // ...
* }
* ```
*
* 2. Definition of the model extension
* @see ModelExtension
* ```
* class ControlPanelModelExtension extends ActionModel.Extension {
* // ...
* }
* ```
*
* 3. Registration of the extension into the main model
* @see Registry()
* ```
* ActionModel.registry.add("ControlPanel", ControlPanelModelExtension, 10);
* ```
*
*-------------------------------------------------------------------------
* ON VIEW/ACTION INIT
*-------------------------------------------------------------------------
*
* 4. Creation of the core model and its extensions
* @see Model.prototype.constructor()
* ```
* const extensions = {
* SearchPanel: {
* // ...
* }
* }
* const searchModelConfig = {
* // ...
* };
* const actionModel = new ActionModel(extensions, searchModelConfig);
* ```
*
* 5. Loading of all extensions' asynchronous data
* @see Model.prototype.load()
* ```
* await actionModel.load();
* ```
*
* 6. Subscribing to the model changes
* @see useModel()
* ```
* class ControlPanel extends Component {
* constructor() {
* super(...arguments);
* // env must contain the actionModel
* this.actionModel = useModel('actionModel');
* }
* }
* ```
*
*-------------------------------------------------------------------------
* MODEL USAGE ON RUNTIME
*-------------------------------------------------------------------------
*
* Case: dispatch an action
* @see Model.prototype.dispatch()
* ```
* actionModel.dispatch("updateProperty", value);
* ```
*
* Case: call a getter
* @see Model.prototype.get()
* ```
* const result = actionModel.get("property");
* ```
*
* @abstract
* @extends EventBus
*/
class Model extends EventBus {
/**
* Instantiated extensions are determined by the `extensions` argument:
* - keys are the extensions names as added in the registry
* - values are the local configurations given to each extension
* The extensions are grouped by the sequence number they where
* registered with in the registry. Extensions being on the same level
* will be loaded in parallel; this means that all extensions belonging
* to the same group are awaited before loading the next group.
* @param {Object<string, any>} [extensions={}]
* @param {Object} [globalConfig={}] global configuration: can be
* accessed by itself and each of the added extensions.
* @param {Object} [globalConfig.env]
* @param {string} [globalConfig.importedState]
*/
constructor(extensions = {}, globalConfig = {}) {
super();
this.config = globalConfig;
this.env = this.config.env;
this.dispatching = false;
this.extensions = [];
this.externalState = {};
this.mapping = new WeakMap();
this.rev = 1;
const { name, registry } = this.constructor;
if (!registry || !(registry instanceof Registry)) {
throw new Error(`Unimplemented registry on model "${name}".`);
}
// Order, group and sequencially instantiate all extensions
const registryExtensions = Object.entries(registry.entries());
const extensionNameLayers = registryExtensions.map(
([name, { layer }]) => ({ name, layer })
);
const groupedNameLayers = groupBy(extensionNameLayers, "layer");
for (const groupNameLayers of Object.values(groupedNameLayers)) {
for (const { name } of groupNameLayers) {
if (name in extensions) {
this.addExtension(name, extensions[name]);
}
}
}
this.importState(this.config.importedState);
}
//---------------------------------------------------------------------
// Public
//---------------------------------------------------------------------
/**
* Method used internally to instantiate all extensions. Can also be
* called externally to add extensions after model instantiation.
* @param {string} extensionName
* @param {Object} extensionConfig
*/
addExtension(extensionName, extensionConfig) {
const { name, registry } = this.constructor;
const Extension = registry.get(extensionName);
if (!Extension) {
throw new Error(`Unknown model extension "${extensionName}" in model "${name}"`);
}
// Extension config = this.config ∪ extension.config
const get = this.__get.bind(this, Extension.name);
const trigger = this.trigger.bind(this);
const config = Object.assign({ get, trigger }, this.config, extensionConfig);
const extension = new Extension(config);
if (!(Extension.layer in this.extensions)) {
this.extensions[Extension.layer] = [];
}
this.extensions[Extension.layer].push(extension);
}
/**
* Returns the result of the first related method on any instantiated
* extension. This method must be overridden if multiple extensions
* return a value with a common method (and dispatchAll does not
* suffice). After the dispatch of the action, all models are partially
* reloaded and components are notified afterwards.
* @param {string} method
* @param {...any} args
*/
dispatch(method, ...args) {
const isInitialDispatch = !this.dispatching;
this.dispatching = true;
for (const extension of this.extensions.flat()) {
extension.dispatch(method, ...args);
}
if (!isInitialDispatch) {
return;
}
this.dispatching = false;
let rev = this.rev;
// Calls 'after dispatch' hooks
// Purpose: fetch updated data from the server. This is considered
// a loading action and is thus performed by groups instead of
// loading all extensions at once.
this._loadExtensions({ isInitialLoad: false }).then(() => {
// Notifies subscribed components
// Purpose: re-render components bound by 'useModel'
if (rev === this.rev) {
this._notifyComponents();
}
});
}
/**
* Stringifies and exports an object holding the exported state of each
* active extension.
* @returns {string}
*/
exportState() {
const exported = {};
for (const extension of this.extensions.flat()) {
exported[extension.constructor.name] = extension.exportState();
}
const fullState = Object.assign({}, this.externalState, exported);
return JSON.stringify(fullState);
}
/**
* Returns the result of the first related getter on any instantiated
* extension. This method must be overridden if multiple extensions
* share a common getter (and getAll does not make the job).
* @param {string} property
* @param {...any} args
* @returns {any}
*/
get(property, ...args) {
for (const extension of this.extensions.flat()) {
const result = extension.get(property, ...args);
if (isNotNull(result)) {
return result;
}
}
return null;
}
/**
* Parses the given stringified state object and imports each state
* part to its related extension.
* @param {string} [stringifiedState="null"]
*/
importState(stringifiedState = "null") {
const state = JSON.parse(stringifiedState) || {};
Object.assign(this.externalState, state);
for (const extension of this.extensions.flat()) {
extension.importState(state[extension.constructor.name]);
}
}
/**
* Must be called after construction and state preparation/import.
* Waits for all asynchronous work needed by the model extensions to be
* ready.
* /!\ The current model extensions do not require a smarter system at
* the moment (therefore using layers instead of dependencies). It
* should be changed if at some point an extension needs another
* specific extension to be loaded instead of a whole batch (with the
* current system some promises will be waited needlessly).
* @returns {Promise}
*/
async load() {
await this._loadExtensions({ isInitialLoad: true });
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* Returns the list of the results of all extensions providing a getter
* for the given property returning a non-null value, excluding the
* extension whose name is equal to "excluded". This method is given to
* each extension in the "config" object bound to the model scope and
* having the extension name bound as the first argument.
* @private
* @param {string} excluded
* @param {string} property
* @param {...any} args
* @returns {any[]}
*/
__get(excluded, property, ...args) {
const results = [];
for (const extension of this.extensions.flat()) {
if (extension.constructor.name !== excluded) {
const result = extension.get(property, ...args);
if (isNotNull(result)) {
results.push(result);
}
}
}
return results;
}
/**
* Private handler to loop over all extension layers sequencially and
* wait for a given callback to be completed on all extensions of a
* same layer.
* @private
* @param {Object} params
* @param {boolean} params.isInitialLoad whether this call comes
* from the initial load.
* @returns {Promise}
*/
async _loadExtensions(params) {
for (let layer = 0; layer < this.extensions.length; layer++) {
await Promise.all(this.extensions[layer].map(
(extension) => extension.callLoad(params)
));
}
}
/**
* @see Context.__notifyComponents() in owl.js for explanation
* @private
*/
_notifyComponents() {
const rev = ++this.rev;
this.trigger("update", { rev });
}
}
Model.Extension = ModelExtension;
/**
* This is more or less the hook 'useContextWithCB' from owl only slightly
* simplified.
*
* @param {string} modelName
* @returns {model}
*/
function useModel(modelName) {
const component = useComponent();
const model = component.env[modelName];
if (!(model instanceof Model)) {
throw new Error(`No Model found when connecting '${component.constructor.name}'`);
}
model.mapping.set(component, 0);
const __render = component.render.bind(component);
component.render = function () {
if (model.rev > model.mapping.get(component)) {
return;
}
__render();
};
onWillRender(() => {
model.mapping.set(component, model.rev);
});
useBus(model, "update", (ev) => {
const { rev } = ev.detail;
if (model.mapping.get(component) < rev) {
model.mapping.set(component, rev);
component.render();
}
});
return model;
}
export default {
Model,
useModel,
};
@@ -1,234 +0,0 @@
/** @odoo-module alias=web.ActionModel **/
import Domain from "web.Domain";
import { FACET_ICONS } from "web.searchUtils";
import { Model } from "web.Model";
import { parseArch } from "web.viewUtils";
import pyUtils from "web.py_utils";
import Registry from "web.Registry";
const isNotNull = (value) => value !== null && value !== undefined;
const isObject = (obj) => typeof obj === "object" && obj !== null;
/**
* @extends Model.Extension
*/
class ActionModelExtension extends Model.Extension {
//---------------------------------------------------------------------
// Public
//---------------------------------------------------------------------
/**
* Initiates the asynchronous tasks of the extension and returns a
* promise resolved as soon as all the informations necessary to build
* the search query are ready.
* @returns {Promise}
*/
async callLoad() {
this.loadPromise = super.callLoad(...arguments);
await this.loadPromise;
}
/**
* Returns a promise resolved when the extension is completely ready.
* @returns {Promise}
*/
async isReady() {
await this.loadPromise;
}
//---------------------------------------------------------------------
// Static
//---------------------------------------------------------------------
/**
* @abstract
* @param {Object} archs
* @param {string | null} [viewType=null]
* @returns {null}
*/
static extractArchInfo() {
return null;
}
}
/**
* @extends Model
*/
class ActionModel extends Model {
//---------------------------------------------------------------------
// Public
//---------------------------------------------------------------------
/**
* @override
*/
get(property) {
switch (property) {
case "query": return this.config.searchQuery || this._getQuery();
case "facets": return this._getFacets();
}
return super.get(...arguments);
}
/**
* Returns a promise resolved when all extensions are completely ready.
* @returns {Promise}
*/
async isReady() {
await this._awaitExtensions();
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* @private
* @returns {Promise}
*/
async _awaitExtensions() {
await Promise.all(this.extensions.flat().map(
(extension) => extension.isReady()
));
}
/**
* @override
*/
__get(excluded, property) {
const results = super.__get(...arguments);
switch (property) {
case "domain": return [this.config.domain, ...results];
case "context": return [this.config.context, ...results];
}
return results;
}
/**
* Validates and formats all facets given by the extensions. This is
* done here rather than in the search bar because the searchMenuTypes
* are available only to the model.
* @private
* @returns {Object[]}
*/
_getFacets() {
const types = this.config.searchMenuTypes || [];
const isValidType = (type) => (
!['groupBy', 'comparison'].includes(type) || types.includes(type)
);
const facets = [];
for (const extension of this.extensions.flat()) {
for (const facet of extension.get("facets") || []) {
if (!isValidType(facet.type)) {
continue;
}
facet.separator = facet.type === 'groupBy' ? ">" : this.env._t("or");
if (facet.type in FACET_ICONS) {
facet.icon = FACET_ICONS[facet.type];
}
facets.push(facet);
}
}
return facets;
}
/**
* @typedef TimeRanges
* @property {string} fieldName
* @property {string} comparisonRangeId
* @property {Array[]} range
* @property {string} rangeDescription
* @property {Array[]} comparisonRange
* @property {string} comparisonRangeDescription
*/
/**
* @typedef Query
* @property {Object} context
* @property {Array[]} domain
* @property {string[]} groupBy
* @property {string[]} orderedBy
* @property {TimeRanges?} timeRanges
*/
/**
* @private
* @returns {Query}
*/
_getQuery() {
const evalContext = this.env.session.user_context;
const contexts = this.__get(null, "context");
const domains = this.__get(null, "domain");
const query = {
context: pyUtils.eval("contexts", contexts, evalContext),
domain: Domain.prototype.normalizeArray(
pyUtils.eval("domains", domains, evalContext)
),
orderedBy: this.get("orderedBy") || [],
};
const searchMenuTypes = this.config.searchMenuTypes || [];
if (searchMenuTypes.includes("groupBy")) {
query.groupBy = this.get("groupBy") || [];
} else {
query.groupBy = [];
}
if (searchMenuTypes.includes("comparison")) {
query.timeRanges = this.get("timeRanges") || {};
}
return query;
}
/**
* Overridden to trigger a "search" event as soon as the query data
* are ready.
* @override
*/
async _loadExtensions({ isInitialLoad }) {
await super._loadExtensions(...arguments);
if (!isInitialLoad) {
this.trigger("search", this.get("query"));
await this._awaitExtensions();
}
}
//---------------------------------------------------------------------
// Static
//---------------------------------------------------------------------
/**
* @param {Object} archs
* @param {string | null} [viewType=null]
* @returns {Object}
*/
static extractArchInfo(archs, viewType = null) {
const parsedArchs = {};
if (!archs.search) {
archs.search = "<search/>";
}
for (const key in archs) {
const { attrs, children } = parseArch(archs[key]);
const objectChildren = children.filter(isObject);
parsedArchs[key] = {
attrs,
children: objectChildren,
};
}
const archInfo = {};
for (const key of this.registry.keys()) {
const extension = this.registry.get(key);
const result = extension.extractArchInfo(parsedArchs, viewType);
if (isNotNull(result)) {
archInfo[key] = result;
}
}
return archInfo;
}
}
ActionModel.Extension = ActionModelExtension;
ActionModel.registry = new Registry(null,
(value) => value.prototype instanceof ActionModel.Extension
);
export default ActionModel;
-330
View File
@@ -31,14 +31,6 @@
role="search"
t-ref="searchView"
>
<div t-if="props.withSearchBar" class="o_searchview" role="search" aria-autocomplete="list" >
<i class="o_searchview_icon oi oi-search"
title="Search..."
role="img"
aria-label="Search..."
/>
<SearchBar t-if="props.withSearchBar" fields="fields"/>
</div>
</div>
</div>
</div>
@@ -56,16 +48,6 @@
role="search"
t-ref="searchViewButtons"
>
<t t-if="props.withSearchBar">
<FilterMenu t-if="props.searchMenuTypes.includes('filter')"
fields="fields"
/>
<GroupByMenu t-if="props.searchMenuTypes.includes('groupBy')"
fields="fields"
/>
<ComparisonMenu t-if="props.searchMenuTypes.includes('comparison') and model.get('filters', f => f.type === 'comparison').length"/>
<FavoriteMenu t-if="props.searchMenuTypes.includes('favorite')"/>
</t>
</div>
<div class="o_cp_pager" role="search" t-ref="pager">
<Pager t-if="props.pager and props.pager.limit" t-props="props.pager"/>
@@ -95,24 +77,6 @@
</span>
</t>
<t t-name="web.Legacy.DatePicker" owl="1">
<div class="o_datepicker" aria-atomic="true" t-att-id="datePickerId" data-target-input="nearest">
<input type="text" class="o_datepicker_input o_input datetimepicker-input"
t-att-name="props.name"
t-att-placeholder="props.placeholder"
t-attf-data-target="#{ '#' + datePickerId }"
t-att-readonly="props.readonly"
t-ref="autofocus"
t-on-change="_onInputChange"
t-on-click="_onInputClick"
/>
<span t-if="props.warn_future and state.warning" class="fa fa-exclamation-triangle text-danger o_tz_warning o_datepicker_warning">
<t>This date is on the future. Make sure it is what you expected.</t>
</span>
<span class="o_datepicker_button"/>
</div>
</t>
<t t-name="web.DialogButton.tooltip" owl="1">
<div class="oe_tooltip_string" role="tooltip">
<div class="tooltip-inner">
@@ -306,177 +270,6 @@
</nav>
</t>
<t t-name="web.Legacy.SearchBar" owl="1">
<div class="o_searchview_input_container">
<div t-foreach="model.get('facets')" t-as="facet" t-key="facet_index"
tabindex="0"
class="o_searchview_facet"
role="img"
aria-label="search"
t-on-keydown="(ev) => this._onFacetKeydown(facet, facet_index, ev)">
<span t-if="facet.icon" t-attf-class="o_searchview_facet_label text-bg-odoo {{ facet.icon }}"/>
<span t-else="" class="o_searchview_facet_label text-bg-odoo" t-esc="facet.title"/>
<div class="o_facet_values border border-odoo border-start-0">
<t t-foreach="facet.values" t-as="facetValue" t-key="facetValue_index">
<span t-if="!facetValue_first" class="o_facet_values_sep" t-esc="facet.separator"/>
<span class="o_facet_value" t-esc="facetValue"/>
</t>
</div>
<i class="o_facet_remove oi oi-close ms-1"
role="img"
aria-label="Remove"
title="Remove"
t-on-click="()=>this._onFacetRemove(facet)"
/>
</div>
<input type="text"
class="o_searchview_input"
t-att-autofocus="!env.device.isMobileDevice"
accesskey="Q"
placeholder="Search..."
role="searchbox"
title="Search for records"
t-ref="autofocus"
t-on-keydown="_onSearchKeydown"
t-on-input="_onSearchInput"
/>
<ul t-if="state.sources.length" class="dropdown-menu o_searchview_autocomplete dropdown-menu show" role="menu">
<li t-foreach="state.sources" t-as="src" t-key="src.id"
t-att-id="src.id"
class="o_menu_item dropdown-item"
t-att-class="{ o_indent: src.parent and !src.parent.selection, focus: src_index === state.focusedItem}"
t-on-click="()=>this._selectSource(src)"
t-on-mousemove="()=>this._onSourceMousemove(src_index)"
>
<a t-if="src.expand"
href="#"
class="o_expand"
t-on-click.stop.prevent="()=>this._expandSource(src, !src.expanded)"
>
<i t-attf-class="fa fa-caret-{{ src.expanded ? 'down' : 'right' }}"/>
</a>
<a href="#" t-on-click.prevent="">
<t t-if="src.parent.selection">
Search <b t-esc="src.parent.description"/>: <b class="fst-italic text-primary" t-esc="src.label"/></t>
<t t-elif="src.label" t-esc="src.label"/>
<t t-elif="['date', 'datetime'].includes(src.type)">
<t>Search <b t-esc="src.description"/> at: <b class="fst-italic text-primary" t-esc="state.inputValue"/></t>
</t>
<t t-else="">Search <b t-esc="src.description"/> for: <b class="fst-italic text-primary" t-esc="state.inputValue"/></t>
</a>
</li>
</ul>
</div>
</t>
<t t-name="web.Legacy.CustomFilterItem" owl="1">
<Dropdown class="'o_add_custom_filter_menu'">
<t t-set-slot="toggler">
Add Custom Filter
</t>
<t t-foreach="conditions" t-as="condition" t-key="condition_index">
<div class=" o_filter_condition dropdown-item-text position-relative">
<t t-set="fieldType" t-value="fields[condition.field].type"/>
<t t-set="selectedOperator" t-value="OPERATORS[FIELD_TYPES[fieldType]][condition.operator]"/>
<span t-if="!condition_first" class="o_or_filter">or</span>
<select class="o_input o_generator_menu_field"
t-on-change="ev => this.onFieldSelect(condition, ev)"
>
<option t-foreach="fields" t-as="field" t-key="field_index"
t-att-value="field.name"
t-att-selected="field_index === condition.field"
t-esc="field.string"
/>
</select>
<select class="o_input o_generator_menu_operator"
t-on-change="ev => this.onOperatorSelect(condition, ev)"
>
<option t-foreach="OPERATORS[FIELD_TYPES[fieldType]]" t-as="operator" t-key="operator_index"
t-att-value="operator.symbol"
t-att-selected="operator_index === condition.operator"
t-esc="operator.description"
/>
</select>
<span t-if="!('value' in selectedOperator)" class="o_generator_menu_value">
<t t-if="fieldType === 'date'">
<DatePicker
date="condition.value[0]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 0, date)"
/>
<DatePicker t-if="selectedOperator.symbol === 'between'"
date="condition.value[1]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 1, date)"
/>
</t>
<t t-elif="fieldType === 'datetime'">
<DateTimePicker
date="condition.value[0]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 0, date)"
/>
<DateTimePicker t-if="selectedOperator.symbol === 'between'"
date="condition.value[1]"
onDateTimeChanged="date => this.onDateTimeChanged(condition, 1, date)"
/>
</t>
<select t-elif="fieldType === 'selection'" class="o_input"
t-on-change="ev => this.onValueChange(condition, ev)"
>
<option t-foreach="fields[condition.field].selection" t-as="option" t-key="option_index"
t-att-value="option[0]"
t-esc="option[1]"
/>
</select>
<!-- @todo (DAM) I think that the localization should be better consisered below -->
<input t-elif="fieldType === 'float'"
class="o_input"
step="0.01"
t-att-type="DECIMAL_POINT === '.' ? 'number' : 'text'"
t-attf-title="Number using {{ DECIMAL_POINT }} as decimal separator."
t-attf-pattern="[0-9]+([\\{{ DECIMAL_POINT }}][0-9]+)?"
t-model="condition.displayedValue"
t-on-change="ev => this.onValueChange(condition, ev)"
/>
<input t-elif="['integer', 'id'].includes(fieldType)"
class="o_input"
step="1"
type="number"
t-model="condition.displayedValue"
t-on-change="ev => this.onValueChange(condition, ev)"
/>
<input t-else=""
type="text"
class="o_input"
t-model="condition.displayedValue"
t-on-change="ev => this.onValueChange(condition, ev)"
/>
</span>
<i t-if="conditions.length gt 1"
class="fa fa-trash-o o_generator_menu_delete"
role="image"
aria-label="Delete"
title="Delete"
t-on-click="() => this.onRemoveCondition(condition_index)"
/>
</div>
</t>
<div class="px-3 py-2">
<button type="button"
class="btn btn-primary o_apply_filter me-2"
t-on-click="onApply"
>
Apply
</button>
<button type="button"
class="btn btn-secondary o_add_condition"
t-on-click.stop="addNewCondition"
>
<i class="fa fa-plus-circle"/>
<t>Add a condition</t>
</button>
</div>
</Dropdown>
</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"
@@ -512,133 +305,10 @@
<!-- Legacy Templates -->
<t t-name="WidgetLabel.tooltip">
<div class="oe_tooltip_string" t-if="widget.string" role="tooltip">
<t t-esc="widget.string"/> <t t-if="debug and widget.nolabel">(nolabel)</t>
</div>
<p t-if="help" class="oe_tooltip_help" role="tooltip">
<t t-esc="help"/>
</p>
<ul t-if="debug" class="oe_tooltip_technical" role="tooltip">
<li data-item="field" t-if="widget.name">
<span class="oe_tooltip_technical_title">Field:</span>
<t t-esc="widget.name"/>
</li>
<li data-item="object">
<span class="oe_tooltip_technical_title">Object:</span>
<t t-esc="widget.model"/>
</li>
<li data-item="type">
<span class="oe_tooltip_technical_title">Type:</span>
<t t-esc="widget.field.type"/>
</li>
<li t-if="widget.attrs.widget" data-item="widget">
<span class="oe_tooltip_technical_title">Widget:</span>
<t t-set="description" t-value="widget.attrs.Widget.prototype.description"/>
<t t-if="description">
<t t-esc="description"/> (<t t-esc="widget.attrs.widget"/>)
</t>
<t t-else="1">
<t t-esc="widget.attrs.widget"/>
</t>
</li>
<li t-if="widget.attrs.size || widget.field.size" data-item="size">
<span class="oe_tooltip_technical_title">Size:</span>
<t t-esc="widget.attrs.size || widget.field.size"/>
</li>
<li t-if="widget.attrs.context || widget.field.context" data-item="context">
<span class="oe_tooltip_technical_title">Context:</span>
<t t-esc="widget.attrs.context || JSON.stringify(widget.field.context)"/>
</li>
<li t-if="widget.attrs.domain || widget.field.domain" data-item="domain">
<span class="oe_tooltip_technical_title">Domain:</span>
<t t-esc="widget.attrs.domain || JSON.stringify(widget.field.domain)"/>
</li>
<li t-if="widget.attrs.modifiers and Object.keys(widget.attrs.modifiers || {}).length > 0" data-item="modifiers">
<span class="oe_tooltip_technical_title">Modifiers:</span>
<t t-esc="JSON.stringify(widget.attrs.modifiers)"/>
</li>
<li t-if="widget.field and widget.field.change_default" data-item="change_default">
<span class="oe_tooltip_technical_title">Change default:</span>
Yes
</li>
<li t-if="widget.attrs.on_change" data-item="on_change">
<span class="oe_tooltip_technical_title">On change:</span>
<t t-esc="widget.attrs.on_change"/>
</li>
<li t-if="widget.field and widget.field.relation" data-item="relation">
<span class="oe_tooltip_technical_title">Relation:</span>
<t t-esc="widget.field.relation"/>
</li>
<li t-if="widget.field and widget.field.selection" data-item="selection">
<span class="oe_tooltip_technical_title">Selection:</span>
<ul class="oe_tooltip_technical">
<li t-foreach="widget.field.selection" t-as="option" t-key="option_index">
[<t t-esc="option[0]"/>]
<t t-if="option[1]"> - </t>
<t t-esc="option[1]"/>
</li>
</ul>
</li>
</ul>
</t>
<t t-name="WidgetButton.tooltip">
<div class="oe_tooltip_string" t-if="debug || node.attrs.string" role="tooltip">
<t t-if="debug">
Button
<t t-if="node.attrs.string">: </t>
<t t-if="!node.attrs.string"> (no string)</t>
</t>
<t t-esc="node.attrs.string"/>
</div>
<p t-if="node.attrs.help" class="oe_tooltip_help" role="tooltip">
<t t-esc="node.attrs.help"/>
</p>
<ul t-if="debug" class="oe_tooltip_technical" role="tooltip">
<li data-item="object">
<span class="oe_tooltip_technical_title">Object:</span>
<t t-esc="state.model"/>
</li>
<li t-if="node.attrs.context" data-item="context">
<span class="oe_tooltip_technical_title">Context:</span>
<t t-esc="node.attrs.context || widget.field.context"/>
</li>
<li t-if="node.attrs.modifiers and Object.keys(node.attrs.modifiers || {}).length > 0" data-item="modifiers">
<span class="oe_tooltip_technical_title">Modifiers:</span>
<t t-esc="JSON.stringify(node.attrs.modifiers)"/>
</li>
<li t-if="node.attrs.special" data-item="special">
<span class="oe_tooltip_technical_title">Special:</span>
<t t-esc="node.attrs.special"/>
</li>
<t t-set="button_type" t-value="node.attrs.type"/>
<li t-if="button_type" data-item="button_type">
<span class="oe_tooltip_technical_title">Button Type:</span>
<t t-esc="button_type"/>
</li>
<li t-if="button_type === 'object'" data-item="button_method">
<span class="oe_tooltip_technical_title">Method:</span>
<t t-esc="node.attrs.name"/>
</li>
<li t-if="button_type === 'action'" data-item="button_action">
<span class="oe_tooltip_technical_title">Action ID:</span>
<t t-esc="node.attrs.name"/>
</li>
</ul>
</t>
<div t-name="Action" class="o_action">
<div class="o_content"/>
</div>
<t t-name="CreateButton.tooltip">
<div class="oe_tooltip_string" role="tooltip">
<div class="tooltip-inner">
Hit ENTER to CREATE<br/>
Hit DOWN to navigate to the list below
</div>
</div>
</t>
<t t-name="FormButton.tooltip">
<div class="oe_tooltip_string" role="tooltip">
<div class="tooltip-inner">
@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web.Legacy.FavoriteMenu" owl="1">
<Dropdown class="'o_favorite_menu btn-group'" togglerClass="'btn btn-light'">
<t t-set-slot="toggler">
<i class="me-1" t-att-class="icon"/>
<span class="o_dropdown_title">Favorites</span>
<Dialog t-if="state.deletedFavorite"
title="'Warning'"
size="'medium'"
onClosed="() => { state.deletedFavorite = false }"
>
<t t-if="state.deletedFavorite.userId">Are you sure that you want to remove this filter?</t>
<t t-else="">This filter is global and will be removed for everybody if you continue.</t>
<t t-set-slot="buttons">
<button type="button" class="btn btn-primary" t-on-click="_onRemoveFavorite">
<t>Ok</t>
</button>
<button type="button" class="btn btn-secondary" t-on-click.stop="() => { state.deletedFavorite = false }">
<t>Cancel</t>
</button>
</t>
</Dialog>
</t>
<t t-set="currentGroup" t-value="null"/>
<t t-foreach="this.items" t-as="item" t-key="item.id or item.key">
<t t-if="currentGroup !== null and currentGroup !== item.groupNumber">
<div role="separator" class="dropdown-divider"/>
</t>
<t t-if="item.type ==='favorite'">
<SearchDropdownItem class="{ o_menu_item: true, selected: item.isActive }"
checked="item.isActive"
parentClosingMode="'none'"
onSelected="() => this.onFavoriteSelected(item.id)"
>
<span class="d-flex p-0 align-items-center justify-content-between">
<t t-esc="item.description"/>
<i class="ms-1 o_icon_right fa fa-trash-o"
title="Delete item"
t-on-click.stop="() => this.openConfirmationDialog(item.id)"
/>
</span>
</SearchDropdownItem>
</t>
<t t-else="">
<t t-component="item.Component" t-props="item.props"/>
</t>
<t t-set="currentGroup" t-value="item.groupNumber"/>
</t>
</Dropdown>
</t>
<t t-name="web.legacy.FilterMenu" owl="1">
<Dropdown class="'o_filter_menu btn-group ' + props.class" togglerClass="'btn btn-light'">
<t t-set-slot="toggler">
<i class="me-1" t-att-class="icon"/>
<span class="o_dropdown_title">Filters</span>
</t>
<t t-set="currentGroup" t-value="null"/>
<t t-foreach="items" t-as="item" t-key="item.id">
<t t-if="currentGroup !== null and currentGroup !== item.groupNumber">
<div class="dropdown-divider" role="separator"/>
</t>
<t t-if="item.options">
<Dropdown togglerClass="'o_menu_item' + (item.isActive ? ' selected' : '')">
<t t-set-slot="toggler">
<t t-esc="item.description"/>
</t>
<t t-set="subGroup" t-value="null"/>
<t t-foreach="item.options" t-as="option" t-key="option.id">
<t t-if="subGroup !== null and subGroup !== option.groupNumber">
<div class="dropdown-divider" role="separator"/>
</t>
<SearchDropdownItem class="{ o_item_option: true, selected: option.isActive }"
t-esc="option.description"
checked="option.isActive"
parentClosingMode="'none'"
onSelected="() => this.onFilterSelected({ itemId: item.id, optionId: option.id })"
/>
<t t-set="subGroup" t-value="option.groupNumber"/>
</t>
</Dropdown>
</t>
<t t-else="">
<SearchDropdownItem class="{ o_menu_item: true, selected: item.isActive }"
checked="item.isActive"
parentClosingMode="'none'"
t-esc="item.description"
onSelected="() => this.onFilterSelected({ itemId: item.id })"
/>
</t>
<t t-set="currentGroup" t-value="item.groupNumber"/>
</t>
<t t-if="items.length">
<div role="separator" class="dropdown-divider"/>
</t>
<CustomFilterItem fields="props.fields"/>
</Dropdown>
</t>
<t t-name="web.legacy.ComparisonMenu" owl="1">
<Dropdown class="'o_comparison_menu btn-group'" togglerClass="'btn btn-light'">
<t t-set-slot="toggler">
<i class="me-1" t-att-class="icon"/>
<span class="o_dropdown_title">Comparison</span>
</t>
<t t-foreach="items" t-as="item" t-key="item.id">
<SearchDropdownItem class="{ o_menu_item: true, selected: item.isActive }"
checked="item.isActive"
parentClosingMode="'none'"
t-esc="item.description"
onSelected="() => this.onComparisonSelected(item.id)"
/>
</t>
</Dropdown>
</t>
<t t-name="web.legacy.GroupByMenu" owl="1">
<Dropdown class="'o_group_by_menu btn-group'"
togglerClass="'btn btn-light'"
t-props="dropdownProps"
>
<t t-set-slot="toggler">
<i class="me-1" t-att-class="icon"/>
<span class="o_dropdown_title">Group By<t t-if="props.showCaretDown"> <i class="fa fa-caret-down ms-1"/></t></span>
</t>
<t t-set="currentGroup" t-value="null"/>
<t t-foreach="items" t-as="item" t-key="item.id">
<t t-if="currentGroup !== null and currentGroup !== item.groupNumber">
<div class="dropdown-divider" role="separator"/>
</t>
<t t-if="item.options">
<Dropdown togglerClass="'o_menu_item' + (item.isActive ? ' selected' : '')">
<t t-set-slot="toggler">
<t t-esc="item.description"/>
</t>
<t t-set="subGroup" t-value="null"/>
<t t-foreach="item.options" t-as="option" t-key="option.id">
<t t-if="subGroup !== null and subGroup !== option.groupNumber">
<div class="dropdown-divider" role="separator"/>
</t>
<SearchDropdownItem class="{ o_item_option: true, selected: option.isActive }"
checked="option.isActive ? true : false"
parentClosingMode="'none'"
t-esc="option.description"
onSelected="() => this.onGroupBySelected({ itemId: item.id, optionId: option.id})"
/>
<t t-set="subGroup" t-value="option.groupNumber"/>
</t>
</Dropdown>
</t>
<t t-else="">
<SearchDropdownItem class="{ o_menu_item: true, selected: item.isActive }"
checked="item.isActive"
parentClosingMode="'none'"
t-esc="item.description"
onSelected="() => this.onGroupBySelected({ itemId: item.id })"
/>
</t>
<t t-set="currentGroup" t-value="item.groupNumber"/>
</t>
<t t-if="!hideCustomGroupBy and fields.length">
<div t-if="items.length" role="separator" class="dropdown-divider"/>
<CustomGroupByItem fields="fields" onAddCustomGroup.bind="onAddCustomGroup"/>
</t>
</Dropdown>
</t>
</templates>
@@ -9,9 +9,6 @@ import core from "web.core";
import makeTestEnvironment from "web.test_env";
import { registerCleanup } from "./cleanup";
import { makeTestEnv } from "./mock_env";
import { patchWithCleanup } from "./utils";
import * as FavoriteMenu from "web.FavoriteMenu";
import * as CustomFavoriteItem from "web.CustomFavoriteItem";
const serviceRegistry = registry.category("services");
@@ -45,21 +42,3 @@ export async function makeLegacyDialogMappingTestEnv() {
env,
};
}
function clearLegacyRegistryWithCleanup(r) {
const patch = {
// To improve? Initial data in registry is ignored.
map: Object.create(null),
// Preserve onAdd listeners
listeners: [...r.listeners],
_scoreMapping: Object.create(null),
_sortedKeys: null,
};
patchWithCleanup(r, patch);
}
export function prepareLegacyRegistriesWithCleanup() {
// Clear FavoriteMenu registry and add the "Save favorite" item.
clearLegacyRegistryWithCleanup(FavoriteMenu.registry);
FavoriteMenu.registry.add("favorite-generator-menu", CustomFavoriteItem, 0);
}
@@ -1,331 +0,0 @@
/** @odoo-module **/
import { DatePicker, DateTimePicker } from "web.DatePickerOwl";
import testUtils from "web.test_utils";
import time from "web.time";
const { createComponent } = testUtils;
QUnit.module('Components', {}, function () {
QUnit.module('DatePicker (legacy)');
QUnit.test("basic rendering", async function (assert) {
assert.expect(8);
const picker = await createComponent(DatePicker, {
props: { date: moment('1997-01-09'), onDateTimeChanged: () => {} },
});
assert.containsOnce(picker, 'input.o_input.o_datepicker_input');
assert.containsOnce(picker, 'span.o_datepicker_button');
assert.containsNone(document.body, 'div.bootstrap-datetimepicker-widget');
const input = picker.el.querySelector('input.o_input.o_datepicker_input');
assert.strictEqual(input.value, '01/09/1997',
"Value should be the one given")
;
assert.strictEqual(input.dataset.target, `#${picker.el.id}`,
"DatePicker id should match its input target");
await testUtils.dom.click(input);
assert.containsOnce(document.body, 'div.bootstrap-datetimepicker-widget .datepicker');
assert.containsNone(document.body, 'div.bootstrap-datetimepicker-widget .timepicker');
assert.strictEqual(
document.querySelector('.datepicker .day.active').dataset.day,
'01/09/1997',
"Datepicker should have set the correct day"
);
});
QUnit.test("pick a date", async function (assert) {
assert.expect(5);
const picker = await createComponent(DatePicker, {
props: {
date: moment('1997-01-09'),
onDateTimeChanged: date => {
assert.step('datetime-changed');
assert.strictEqual(date.format('MM/DD/YYYY'), '02/08/1997',
"Event should transmit the correct date");
},
}
});
const input = picker.el.querySelector('.o_datepicker_input');
await testUtils.dom.click(input);
await testUtils.dom.click(document.querySelector('.datepicker th.next')); // next month
assert.verifySteps([]);
await testUtils.dom.click(document.querySelectorAll('.datepicker table td')[15]); // previous day
assert.strictEqual(input.value, '02/08/1997');
assert.verifySteps(['datetime-changed']);
});
QUnit.test("pick a date with locale", async function (assert) {
assert.expect(4);
// weird shit of moment https://github.com/moment/moment/issues/5600
// When month regex returns undefined, january is taken (first month of the default "nameless" locale)
const originalLocale = moment.locale();
// Those parameters will make Moment's internal compute stuff that are relevant to the bug
const months = 'janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre'.split('_');
const monthsShort = 'janv._févr._mars_avr._mai_juin_juil._août_custSept._oct._nov._déc.'.split('_');
moment.defineLocale('frenchForTests', { months, monthsShort, code: 'frTest' , monthsParseExact: true});
const hasChanged = testUtils.makeTestPromise();
const picker = await createComponent(DatePicker, {
translateParameters: {
date_format: "%d %b, %Y", // Those are important too
time_format: "%H:%M:%S",
},
props: {
date: moment('09/01/1997', 'MM/DD/YYYY'),
onDateTimeChanged: date => {
assert.step('datetime-changed');
assert.strictEqual(date.format('MM/DD/YYYY'), '09/02/1997',
"Event should transmit the correct date");
hasChanged.resolve();
},
}
});
const input = picker.el.querySelector('.o_datepicker_input');
await testUtils.dom.click(input);
await testUtils.dom.click(document.querySelectorAll('.datepicker table td')[3]); // next day
assert.strictEqual(input.value, '02 custSept., 1997');
assert.verifySteps(['datetime-changed']);
moment.locale(originalLocale);
moment.updateLocale('frenchForTests', null);
});
QUnit.test("enter a date value", async function (assert) {
assert.expect(5);
const picker = await createComponent(DatePicker, {
props: {
date: moment('1997-01-09'),
onDateTimeChanged: date => {
assert.step('datetime-changed');
assert.strictEqual(date.format('MM/DD/YYYY'), '02/08/1997',
"Event should transmit the correct date");
},
}
});
const input = picker.el.querySelector('.o_datepicker_input');
assert.verifySteps([]);
await testUtils.fields.editAndTrigger(input, '02/08/1997', ['change']);
assert.verifySteps(['datetime-changed']);
await testUtils.dom.click(input);
assert.strictEqual(
document.querySelector('.datepicker .day.active').dataset.day,
'02/08/1997',
"Datepicker should have set the correct day"
);
});
QUnit.test("Date format is correctly set", async function (assert) {
assert.expect(2);
testUtils.mock.patch(time, { getLangDateFormat: () => "YYYY/MM/DD" });
const picker = await createComponent(DatePicker, {
props: { date: moment('1997-01-09'), onDateTimeChanged: () => {} },
});
const input = picker.el.querySelector('.o_datepicker_input');
assert.strictEqual(input.value, '1997/01/09');
// Forces an update to assert that the registered format is the correct one
await testUtils.dom.click(input);
assert.strictEqual(input.value, '1997/01/09');
testUtils.mock.unpatch(time);
});
QUnit.module('DateTimePicker (legacy)');
QUnit.test("basic rendering", async function (assert) {
assert.expect(11);
const picker = await createComponent(DateTimePicker, {
props: { date: moment('1997-01-09 12:30:01'), onDateTimeChanged: () => {} },
});
assert.containsOnce(picker, 'input.o_input.o_datepicker_input');
assert.containsOnce(picker, 'span.o_datepicker_button');
assert.containsNone(document.body, 'div.bootstrap-datetimepicker-widget');
const input = picker.el.querySelector('input.o_input.o_datepicker_input');
assert.strictEqual(input.value, '01/09/1997 12:30:01', "Value should be the one given");
assert.strictEqual(input.dataset.target, `#${picker.el.id}`,
"DateTimePicker id should match its input target");
await testUtils.dom.click(input);
assert.containsOnce(document.body, 'div.bootstrap-datetimepicker-widget .datepicker');
assert.containsOnce(document.body, 'div.bootstrap-datetimepicker-widget .timepicker');
assert.strictEqual(
document.querySelector('.datepicker .day.active').dataset.day,
'01/09/1997',
"Datepicker should have set the correct day");
assert.strictEqual(document.querySelector('.timepicker .timepicker-hour').innerText.trim(), '12',
"Datepicker should have set the correct hour");
assert.strictEqual(document.querySelector('.timepicker .timepicker-minute').innerText.trim(), '30',
"Datepicker should have set the correct minute");
assert.strictEqual(document.querySelector('.timepicker .timepicker-second').innerText.trim(), '01',
"Datepicker should have set the correct second");
});
QUnit.test("pick a date and time", async function (assert) {
assert.expect(5);
const picker = await createComponent(DateTimePicker, {
props: {
date: moment('1997-01-09 12:30:01'),
onDateTimeChanged: date => {
assert.step('datetime-changed');
assert.strictEqual(date.format('MM/DD/YYYY HH:mm:ss'), '02/08/1997 15:45:05',
"Event should transmit the correct date");
},
}
});
const input = picker.el.querySelector('input.o_input.o_datepicker_input');
await testUtils.dom.click(input);
await testUtils.dom.click(document.querySelector('.datepicker th.next')); // February
await testUtils.dom.click(document.querySelectorAll('.datepicker table td')[15]); // 08
await testUtils.dom.click(document.querySelector('a[title="Select Time"]'));
await testUtils.dom.click(document.querySelector('.timepicker .timepicker-hour'));
await testUtils.dom.click(document.querySelectorAll('.timepicker .hour')[15]); // 15h
await testUtils.dom.click(document.querySelector('.timepicker .timepicker-minute'));
await testUtils.dom.click(document.querySelectorAll('.timepicker .minute')[9]); // 45m
await testUtils.dom.click(document.querySelector('.timepicker .timepicker-second'));
assert.verifySteps([]);
await testUtils.dom.click(document.querySelectorAll('.timepicker .second')[1]); // 05s
assert.strictEqual(input.value, '02/08/1997 15:45:05');
assert.verifySteps(['datetime-changed']);
});
QUnit.test("pick a date and time with locale", async function (assert) {
assert.expect(5);
// weird shit of moment https://github.com/moment/moment/issues/5600
// When month regex returns undefined, january is taken (first month of the default "nameless" locale)
const originalLocale = moment.locale();
// Those parameters will make Moment's internal compute stuff that are relevant to the bug
const months = 'janvier_février_mars_avril_mai_juin_juillet_août_septembre_octobre_novembre_décembre'.split('_');
const monthsShort = 'janv._févr._mars_avr._mai_juin_juil._août_custSept._oct._nov._déc.'.split('_');
moment.defineLocale('frenchForTests', { months, monthsShort, code: 'frTest' , monthsParseExact: true});
const hasChanged = testUtils.makeTestPromise();
const picker = await createComponent(DateTimePicker, {
translateParameters: {
date_format: "%d %b, %Y", // Those are important too
time_format: "%H:%M:%S",
},
props: {
date: moment('09/01/1997 12:30:01', 'MM/DD/YYYY HH:mm:ss'),
onDateTimeChanged: date => {
assert.step('datetime-changed');
assert.strictEqual(date.format('MM/DD/YYYY HH:mm:ss'), '09/02/1997 15:45:05',
"Event should transmit the correct date");
hasChanged.resolve();
},
}
});
const input = picker.el.querySelector('input.o_input.o_datepicker_input');
await testUtils.dom.click(input);
await testUtils.dom.click(document.querySelectorAll('.datepicker table td')[3]); // next day
await testUtils.dom.click(document.querySelector('a[title="Select Time"]'));
await testUtils.dom.click(document.querySelector('.timepicker .timepicker-hour'));
await testUtils.dom.click(document.querySelectorAll('.timepicker .hour')[15]); // 15h
await testUtils.dom.click(document.querySelector('.timepicker .timepicker-minute'));
await testUtils.dom.click(document.querySelectorAll('.timepicker .minute')[9]); // 45m
await testUtils.dom.click(document.querySelector('.timepicker .timepicker-second'));
assert.verifySteps([]);
await testUtils.dom.click(document.querySelectorAll('.timepicker .second')[1]); // 05s
assert.strictEqual(input.value, '02 custSept., 1997 15:45:05');
assert.verifySteps(['datetime-changed']);
await hasChanged;
moment.locale(originalLocale);
moment.updateLocale('frenchForTests', null);
});
QUnit.test("enter a datetime value", async function (assert) {
assert.expect(9);
const picker = await createComponent(DateTimePicker, {
props: {
date: moment('1997-01-09 12:30:01'),
onDateTimeChanged: date => {
assert.step('datetime-changed');
assert.strictEqual(date.format('MM/DD/YYYY HH:mm:ss'), '02/08/1997 15:45:05',
"Event should transmit the correct date");
},
}
});
const input = picker.el.querySelector('.o_datepicker_input');
assert.verifySteps([]);
await testUtils.fields.editAndTrigger(input, '02/08/1997 15:45:05', ['change']);
assert.verifySteps(['datetime-changed']);
await testUtils.dom.click(input);
assert.strictEqual(input.value, '02/08/1997 15:45:05');
assert.strictEqual(
document.querySelector('.datepicker .day.active').dataset.day,
'02/08/1997',
"Datepicker should have set the correct day"
);
assert.strictEqual(document.querySelector('.timepicker .timepicker-hour').innerText.trim(), '15',
"Datepicker should have set the correct hour");
assert.strictEqual(document.querySelector('.timepicker .timepicker-minute').innerText.trim(), '45',
"Datepicker should have set the correct minute");
assert.strictEqual(document.querySelector('.timepicker .timepicker-second').innerText.trim(), '05',
"Datepicker should have set the correct second");
});
QUnit.test("Date time format is correctly set", async function (assert) {
assert.expect(2);
testUtils.mock.patch(time, { getLangDatetimeFormat: () => "hh:mm:ss YYYY/MM/DD" });
const picker = await createComponent(DateTimePicker, {
props: { date: moment('1997-01-09 12:30:01'), onDateTimeChanged: () => {} },
});
const input = picker.el.querySelector('.o_datepicker_input');
assert.strictEqual(input.value, '12:30:01 1997/01/09');
// Forces an update to assert that the registered format is the correct one
await testUtils.dom.click(input);
assert.strictEqual(input.value, '12:30:01 1997/01/09');
testUtils.mock.unpatch(time);
});
});
@@ -1,456 +0,0 @@
/** @odoo-module **/
import ActionModel from "web.ActionModel";
import makeTestEnvironment from "web.test_env";
function createModel(params = {}) {
const archs = (params.arch && { search: params.arch, }) || {};
const { ControlPanel: controlPanelInfo, } = ActionModel.extractArchInfo(archs);
const extensions = {
ControlPanel: {
context: params.context,
archNodes: controlPanelInfo.children,
dynamicFilters: params.dynamicFilters,
favoriteFilters: params.favoriteFilters,
env: makeTestEnvironment(),
fields: params.fields,
},
};
const model = new ActionModel(extensions);
return model;
}
function sanitizeFilters(model) {
const cpme = model.extensions[0].find(
(ext) => ext.constructor.name === "ControlPanelModelExtension"
);
const filters = Object.values(cpme.state.filters);
return filters.map(filter => {
const copy = Object.assign({}, filter);
delete copy.groupId;
delete copy.groupNumber;
delete copy.id;
return copy;
});
}
QUnit.module('ControlPanelModelExtension', {
beforeEach() {
this.fields = {
display_name: { string: "Displayed name", type: 'char' },
foo: { string: "Foo", type: "char", default: "My little Foo Value", store: true, sortable: true },
date_field: { string: "Date", type: "date", store: true, sortable: true },
float_field: { string: "Float", type: "float" },
bar: { string: "Bar", type: "many2one", relation: 'partner' },
};
}
}, function () {
QUnit.module('Arch parsing');
QUnit.test('empty arch', async function (assert) {
assert.expect(1);
const model = createModel();
assert.deepEqual(sanitizeFilters(model), []);
});
QUnit.test('one field tag', async function (assert) {
assert.expect(1);
const arch = `
<search>
<field name="bar"/>
</search>`;
const fields = this.fields;
const model = createModel({ arch, fields, });
assert.deepEqual(sanitizeFilters(model), [
{
description: "Bar",
fieldName: "bar",
fieldType: "many2one",
type: "field"
},
]);
});
QUnit.test('one separator tag', async function (assert) {
assert.expect(1);
const arch = `
<search>
<separator/>
</search>`;
const fields = this.fields;
const model = createModel({ arch, fields, });
assert.deepEqual(sanitizeFilters(model), []);
});
QUnit.test('one separator tag and one field tag', async function (assert) {
assert.expect(1);
const arch = `
<search>
<separator/>
<field name="bar"/>
</search>`;
const fields = this.fields;
const model = createModel({ arch, fields, });
assert.deepEqual(sanitizeFilters(model), [
{
description: "Bar",
fieldName: "bar",
fieldType: "many2one",
type: "field"
},
]);
});
QUnit.test('one filter tag', async function (assert) {
assert.expect(1);
const arch = `
<search>
<filter name="filter" string="Hello" domain="[]"/>
</search>`;
const fields = this.fields;
const model = createModel({ arch, fields, });
assert.deepEqual(sanitizeFilters(model), [
{
description: "Hello",
domain: "[]",
name: "filter",
type: "filter",
},
]);
});
QUnit.test('one filter tag with date attribute', async function (assert) {
assert.expect(1);
const arch = `
<search>
<filter name="date_filter" string="Date" date="date_field"/>
</search>`;
const fields = this.fields;
const model = createModel({ arch, fields, });
const dateFilterId = Object.values(model.get('filters'))[0].id;
assert.deepEqual(sanitizeFilters(model), [
{
defaultOptionId: "this_month",
description: "Date",
fieldName: "date_field",
fieldType: "date",
isDateFilter: true,
hasOptions: true,
name: "date_filter",
type: "filter"
},
{
comparisonOptionId: "previous_period",
dateFilterId,
description: "Date: Previous Period",
type: "comparison"
},
{
comparisonOptionId: "previous_year",
dateFilterId,
description: "Date: Previous Year",
type: "comparison"
}
]);
});
QUnit.test('one groupBy tag', async function (assert) {
assert.expect(1);
const arch = `
<search>
<filter name="groupby" string="Hi" context="{ 'group_by': 'date_field:day'}"/>
</search>`;
const fields = this.fields;
const model = createModel({ arch, fields, });
assert.deepEqual(sanitizeFilters(model), [
{
defaultOptionId: "day",
description: "Hi",
fieldName: "date_field",
fieldType: "date",
hasOptions: true,
name: "groupby",
type: "groupBy",
},
]);
});
QUnit.test('two filter tags', async function (assert) {
assert.expect(1);
const arch = `
<search>
<filter name="filter_1" string="Hello One" domain="[]"/>
<filter name="filter_2" string="Hello Two" domain="[('bar', '=', 3)]"/>
</search>`;
const fields = this.fields;
const model = createModel({ arch, fields, });
assert.deepEqual(sanitizeFilters(model), [
{
description: "Hello One",
domain: "[]",
name: "filter_1",
type: "filter",
},
{
description: "Hello Two",
domain: "[('bar', '=', 3)]",
name: "filter_2",
type: "filter",
},
]);
});
QUnit.test('two filter tags separated by a separator', async function (assert) {
assert.expect(1);
const arch = `
<search>
<filter name="filter_1" string="Hello One" domain="[]"/>
<separator/>
<filter name="filter_2" string="Hello Two" domain="[('bar', '=', 3)]"/>
</search>`;
const fields = this.fields;
const model = createModel({ arch, fields, });
assert.deepEqual(sanitizeFilters(model), [
{
description: "Hello One",
domain: "[]",
name: "filter_1",
type: "filter",
},
{
description: "Hello Two",
domain: "[('bar', '=', 3)]",
name: "filter_2",
type: "filter",
},
]);
});
QUnit.test('one filter tag and one field', async function (assert) {
assert.expect(1);
const arch = `
<search>
<filter name="filter" string="Hello" domain="[]"/>
<field name="bar"/>
</search>`;
const fields = this.fields;
const model = createModel({ arch, fields, });
assert.deepEqual(sanitizeFilters(model), [
{
description: "Hello",
domain: "[]",
name: "filter",
type: "filter",
},
{
description: "Bar",
fieldName: "bar",
fieldType: "many2one",
type: "field",
},
]);
});
QUnit.test('two field tags', async function (assert) {
assert.expect(1);
const arch = `
<search>
<field name="foo"/>
<field name="bar"/>
</search>`;
const fields = this.fields;
const model = createModel({ arch, fields, });
assert.deepEqual(sanitizeFilters(model), [
{
description: "Foo",
fieldName: "foo",
fieldType: "char",
type: "field"
},
{
description: "Bar",
fieldName: "bar",
fieldType: "many2one",
type: "field"
},
]);
});
QUnit.module('Preparing initial state');
QUnit.test('process favorite filters', async function (assert) {
assert.expect(1);
const favoriteFilters = [{
user_id: [2, "Mitchell Admin"],
name: 'Sorted filter',
id: 5,
context: {
group_by: ['foo', 'bar']
},
sort: '["foo", "-bar"]',
domain: "[('user_id', '=', uid)]",
}];
const model = createModel({ favoriteFilters });
assert.deepEqual(sanitizeFilters(model), [
{
context: {},
description: "Sorted filter",
domain: "[('user_id', '=', uid)]",
groupBys: ['foo', 'bar'],
orderedBy: [
{
asc: true,
name: "foo"
},
{
asc: false,
name: "bar"
}
],
removable: true,
serverSideId: 5,
type: "favorite",
userId: 2
},
]);
});
QUnit.test('process dynamic filters', async function (assert) {
assert.expect(1);
const dynamicFilters = [{
description: 'Quick search',
domain: [['id', 'in', [1, 3, 4]]]
}];
const model = createModel({ dynamicFilters });
assert.deepEqual(sanitizeFilters(model), [
{
description: 'Quick search',
domain: "[[\"id\",\"in\",[1,3,4]]]",
isDefault: true,
type: 'filter'
},
]);
});
QUnit.test('ir.filter values', async function (assert) {
assert.expect(1);
const context = {
search_default_filter: true,
search_default_bar: 0,
search_default_groupby: 2,
};
const arch = `
<search>
<filter name="filter" string="Hello" domain="[['foo', '=', 'hello']]"/>
<filter name="groupby" string="Goodbye" context="{'group_by': 'foo'}"/>
<field name="bar"/>
</search>`;
const fields = this.fields;
const model = createModel({ arch, fields, context });
assert.deepEqual(model.get("irFilterValues"), {
action_id: undefined,
context: {
group_by: ["foo"],
},
domain: '[["foo", "=", "hello"]]',
model_id: undefined,
sort: "[]",
user_id: undefined
});
});
QUnit.test('falsy search defaults are not activated', async function (assert) {
assert.expect(1);
const context = {
search_default_filter: false,
search_default_bar: 0,
search_default_groupby: 2,
};
const arch = `
<search>
<filter name="filter" string="Hello" domain="[]"/>
<filter name="groupby" string="Goodbye" context="{'group_by': 'foo'}"/>
<field name="bar"/>
</search>`;
const fields = this.fields;
const model = createModel({ arch, fields, context });
// only the truthy filter 'groupby' has isDefault true
assert.deepEqual(sanitizeFilters(model), [
{
description: 'Hello',
domain: "[]",
name: "filter",
type: 'filter',
},
{
description: 'Bar',
fieldName: 'bar',
fieldType: 'many2one',
type: 'field',
},
{
defaultRank: 2,
description: 'Goodbye',
fieldName: 'foo',
fieldType: 'char',
isDefault: true,
name: "groupby",
type: 'groupBy',
},
]);
});
QUnit.test('search defaults on X2M fields', async function (assert) {
assert.expect(1);
const context = {
search_default_otom: [1, 2],
search_default_mtom: [1, 2]
};
const fields = this.fields;
fields.otom = { string: "O2M", type: "one2many", relation: 'partner' };
fields.mtom = { string: "M2M", type: "many2many", relation: 'partner' };
const arch = `
<search>
<field name="otom"/>
<field name="mtom"/>
</search>`;
const model = createModel({ arch, fields, context });
assert.deepEqual(sanitizeFilters(model), [
{
"defaultAutocompleteValue": {
"label": [1, 2],
"operator": "ilike",
"value": [1, 2]
},
"defaultRank": -10,
"description": "O2M",
"fieldName": "otom",
"fieldType": "one2many",
"isDefault": true,
"type": "field"
},
{
"defaultAutocompleteValue": {
"label": [1, 2],
"operator": "ilike",
"value": [1, 2]
},
"defaultRank": -10,
"description": "M2M",
"fieldName": "mtom",
"fieldType": "many2many",
"isDefault": true,
"type": "field"
}
]);
});
});
@@ -1,305 +0,0 @@
/** @odoo-module **/
import testUtils from "web.test_utils";
import * as cpHelpers from "@web/../tests/search/helpers";
const { createControlPanel } = testUtils;
QUnit.module('ControlPanel', {
beforeEach() {
this.fields = {
display_name: { string: "Displayed name", type: 'char', searchable: true },
foo: { string: "Foo", type: "char", default: "My little Foo Value", store: true, sortable: true, searchable: true },
date_field: { string: "Date", type: "date", store: true, sortable: true, searchable: true },
float_field: { string: "Float", type: "float", searchable: true },
bar: { string: "Bar", type: "many2one", relation: 'partner', searchable: true },
};
}
}, function () {
QUnit.test('default field operator', async function (assert) {
assert.expect(2);
const fields = {
foo_op: { string: "Foo Op", type: "char", store: true, sortable: true, searchable: true },
foo: { string: "Foo", type: "char", store: true, sortable: true, searchable: true },
bar_op: { string: "Bar Op", type: "many2one", relation: 'partner', searchable: true },
bar: { string: "Bar", type: "many2one", relation: 'partner', searchable: true },
selec: { string: "Selec", type: "selection", selection: [['red', "Red"], ['black', "Black"]] },
};
const arch = `
<search>
<field name="bar"/>
<field name="bar_op" operator="child_of"/>
<field name="foo"/>
<field name="foo_op" operator="="/>
<field name="selec"/>
</search>`;
const searchMenuTypes = [];
const params = {
cpModelConfig: {
arch,
fields,
context: {
show_filterC: true,
search_default_bar: 10,
search_default_bar_op: 10,
search_default_foo: "foo",
search_default_foo_op: "foo_op",
search_default_selec: 'red',
},
searchMenuTypes,
},
cpProps: { fields, searchMenuTypes },
env: {
session: {
async rpc() {
return [[10, "Deco Addict"]];
},
},
},
};
const controlPanel = await createControlPanel(params);
assert.deepEqual(
cpHelpers.getFacetTexts(controlPanel).map(t => t.replace(/\s/g, "")),
[
"BarDecoAddict",
"BarOpDecoAddict",
"Foofoo",
"FooOpfoo_op",
"SelecRed"
]
);
assert.deepEqual(
controlPanel.getQuery().domain,
[
"&", "&", "&", "&",
["bar", "=", 10],
["bar_op", "child_of", 10],
["foo", "ilike", "foo"],
["foo_op", "=", "foo_op"],
["selec", "=", "red"],
]
);
});
QUnit.module('Keyboard navigation');
QUnit.test('remove a facet with backspace', async function (assert) {
assert.expect(2);
const params = {
cpModelConfig: {
arch: `<search> <field name="foo"/></search>`,
fields: this.fields,
context: { search_default_foo: "a" },
searchMenuTypes: ['filter'],
},
cpProps: { fields: this.fields },
};
const controlPanel = await createControlPanel(params);
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Foo\na']);
// delete a facet
const searchInput = controlPanel.el.querySelector('input.o_searchview_input');
await testUtils.dom.triggerEvent(searchInput, 'keydown', { key: 'Backspace' });
assert.containsNone(controlPanel, 'div.o_searchview div.o_searchview_facet');
// delete nothing (should not crash)
await testUtils.dom.triggerEvent(searchInput, 'keydown', { key: 'Backspace' });
});
QUnit.test('fields and filters with groups/invisible attribute', async function (assert) {
// navigation and automatic menu closure don't work here (i don't know why yet) -->
// should be tested separatly
assert.expect(16);
const arch = `
<search>
<field name="display_name" string="Foo B" invisible="1"/>
<field name="foo" string="Foo A"/>
<filter name="filterA" string="FA" domain="[]"/>
<filter name="filterB" string="FB" invisible="1" domain="[]"/>
<filter name="filterC" string="FC" invisible="not context.get('show_filterC')" domain="[]"/>
<filter name="groupByA" string="GA" context="{ 'group_by': 'date_field:day' }"/>
<filter name="groupByB" string="GB" context="{ 'group_by': 'date_field:day' }" invisible="1"/>
</search>`;
const searchMenuTypes = ['filter', 'groupBy'];
const params = {
cpModelConfig: {
arch,
fields: this.fields,
context: {
show_filterC: true,
search_default_display_name: 'value',
search_default_filterB: true,
search_default_groupByB: true
},
searchMenuTypes
},
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
function selectorContainsValue(selector, value, shouldContain) {
const elements = [...controlPanel.el.querySelectorAll(selector)];
const regExp = new RegExp(value);
const matches = elements.filter(el => regExp.test(el.innerText.replace(/\s/g, "")));
assert.strictEqual(matches.length, shouldContain ? 1 : 0,
`${selector} in the control panel should${shouldContain ? '' : ' not'} contain "${value}".`
);
}
// default filters/fields should be activated even if invisible
assert.containsN(controlPanel, 'div.o_searchview_facet', 3);
selectorContainsValue('.o_searchview_facet', "FooBvalue", true);
selectorContainsValue('.o_searchview_facet .o_facet_values', "FB", true);
selectorContainsValue('.o_searchview_facet .o_facet_values', "GB", true);
await cpHelpers.toggleFilterMenu(controlPanel);
selectorContainsValue('.o_menu_item', "FA", true);
selectorContainsValue('.o_menu_item', "FB", false);
selectorContainsValue('.o_menu_item', "FC", true);
await cpHelpers.toggleGroupByMenu(controlPanel);
selectorContainsValue('.o_menu_item', "GA", true);
selectorContainsValue('.o_menu_item', "GB", false);
// 'a' to filter nothing on bar
await cpHelpers.editSearch(controlPanel, 'a');
// the only item in autocomplete menu should be FooA: a
selectorContainsValue('.o_searchview_autocomplete', "SearchFooAfor:a", true);
await cpHelpers.validateSearch(controlPanel);
selectorContainsValue('.o_searchview_facet', "FooAa", true);
// The items in the Filters menu and the Group By menu should be the same as before
await cpHelpers.toggleFilterMenu(controlPanel);
selectorContainsValue('.o_menu_item', "FA", true);
selectorContainsValue('.o_menu_item', "FB", false);
selectorContainsValue('.o_menu_item', "FC", true);
await cpHelpers.toggleGroupByMenu(controlPanel);
selectorContainsValue('.o_menu_item', "GA", true);
selectorContainsValue('.o_menu_item', "GB", false);
});
QUnit.test('invisible fields and filters with unknown related fields should not be rendered', async function (assert) {
assert.expect(2);
// This test case considers that the current user is not a member of
// the "base.group_system" group and both "bar" and "date_field" fields
// have field-level access control that limit access to them only from
// that group.
//
// As MockServer currently does not support "groups" access control, we:
//
// - emulate field-level access control of fields_get() by removing
// "bar" and "date_field" from the model fields
// - set filters with groups="base.group_system" as `invisible=1` in
// view to emulate the behavior of fields_view_get()
// [see ir.ui.view `_apply_group()`]
delete this.fields.bar;
delete this.fields.date_field;
const searchMenuTypes = [];
const params = {
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
assert.containsNone(controlPanel.el, 'div.o_search_options div.o_filter_menu',
"there should not be filter dropdown");
assert.containsNone(controlPanel.el, 'div.o_search_options div.o_group_by_menu',
"there should not be groupby dropdown");
});
QUnit.test('groupby menu is not rendered if searchMenuTypes does not have groupBy', async function (assert) {
assert.expect(2);
const arch = `<search/>`;
const searchMenuTypes = ['filter'];
const params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes,
},
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
assert.containsOnce(controlPanel.el, 'div.o_search_options div.o_filter_menu');
assert.containsNone(controlPanel.el, 'div.o_search_options div.o_group_by_menu');
});
QUnit.test('search field should be autofocused', async function (assert) {
assert.expect(2);
const controlPanel = await createControlPanel({
model: 'partner',
arch: '<search/>',
data: this.data,
env: {
device: {
isMobileDevice: false,
},
},
});
assert.containsOnce(controlPanel, '.o_searchview_input', "has a search field");
assert.containsOnce(controlPanel, '.o_searchview_input:focus-within',
"has autofocused search field");
});
QUnit.test("search field's autofocus should be disabled on mobile device", async function (assert) {
assert.expect(2);
const controlPanel = await createControlPanel({
model: 'partner',
arch: '<search/>',
data: this.data,
env: {
device: {
isMobileDevice: true,
},
},
});
assert.containsOnce(controlPanel, '.o_searchview_input', "has a search field");
assert.containsNone(controlPanel, '.o_searchview_input:focus-within',
"hasn't autofocused search field");
});
QUnit.test("dynamic domains evaluation using global context", async function (assert) {
const arch = `
<search>
<filter name="filter" domain="[('date_deadline', '&lt;', context.get('my_date'))]"/>
</search>
`;
const context = {
search_default_filter: true,
my_date: "2021-09-17",
};
const fields = this.fields;
const searchMenuTypes = ['filter'];
const controlPanel = await createControlPanel({
cpModelConfig: { arch, fields, searchMenuTypes, context },
cpProps: { fields, searchMenuTypes },
});
assert.deepEqual(
controlPanel.getQuery().domain,
[['date_deadline', '<', "2021-09-17"]]
);
});
});
@@ -1,622 +0,0 @@
/** @odoo-module **/
import Domain from "web.Domain";
import CustomFilterItem from "web.CustomFilterItem";
import ActionModel from "web.ActionModel";
import pyUtils from "web.py_utils";
import testUtils from "web.test_utils";
import { getFixture } from "@web/../tests/helpers/utils";
import * as cpHelpers from "@web/../tests/search/helpers";
const { createComponent } = testUtils;
const toggleAddCustomFilterStandalone = async (el) => {
await cpHelpers.toggleMenu(el, "Add Custom Filter");
};
let target;
QUnit.module('Components', {
beforeEach: function () {
this.fields = {
date_field: { name: 'date_field', string: "A date", type: 'date', searchable: true },
date_time_field: { name: 'date_time_field', string: "DateTime", type: 'datetime', searchable: true },
boolean_field: { name: 'boolean_field', string: "Boolean Field", type: 'boolean', default: true, searchable: true },
binary_field: { name: 'binary_field', string: "Binary Field", type: 'binary', searchable: true },
char_field: { name: 'char_field', string: "Char Field", type: 'char', default: "foo", trim: true, searchable: true },
float_field: { name: 'float_field', string: "Floaty McFloatface", type: 'float', searchable: true },
color: { name: 'color', string: "Color", type: 'selection', selection: [['black', "Black"], ['white', "White"]], searchable: true },
};
target = getFixture();
},
}, function () {
QUnit.module('CustomFilterItem (legacy)');
QUnit.test('basic rendering', async function (assert) {
assert.expect(16);
await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
env: {
searchModel: new ActionModel(),
},
});
assert.strictEqual(target.querySelector(".dropdown").innerText.trim(), "Add Custom Filter");
assert.hasClass(target.querySelector(".dropdown"), "o_add_custom_filter_menu");
assert.strictEqual(target.querySelector(".dropdown").children.length, 1);
await toggleAddCustomFilterStandalone(target);
// Single condition
assert.containsOnce(target, '.o_filter_condition');
assert.containsOnce(target, '.o_filter_condition select.o_generator_menu_field');
assert.containsOnce(target, '.o_filter_condition select.o_generator_menu_operator');
assert.containsOnce(target, '.o_filter_condition span.o_generator_menu_value');
assert.containsNone(target, '.o_filter_condition .o_or_filter');
assert.containsNone(target, '.o_filter_condition .o_generator_menu_delete');
// no deletion allowed on single condition
assert.containsNone(target, '.o_filter_condition > i.o_generator_menu_delete');
// Buttons
assert.containsOnce(target, 'button.o_apply_filter');
assert.containsOnce(target, 'button.o_add_condition');
assert.containsOnce(target, '.o_filter_condition');
await cpHelpers.addCondition(target);
assert.containsN(target, '.o_filter_condition', 2);
assert.containsOnce(target, '.o_filter_condition .o_or_filter');
assert.containsN(target, '.o_filter_condition .o_generator_menu_delete', 2);
});
QUnit.test('custom OR filter presets new condition from preceding', async function (assert) {
assert.expect(4);
const searchModel = new ActionModel();
await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
env: { searchModel },
});
// Open custom filter form
await toggleAddCustomFilterStandalone(target);
// Retrieve second selectable values for field and operator dropdowns
const fieldSecondValue = target.querySelector('.o_generator_menu_field option:nth-of-type(2)').value;
const operatorSecondValue = target.querySelector('.o_generator_menu_operator option:nth-of-type(2)').value;
// Check if they really exist…
assert.ok(!!fieldSecondValue);
assert.ok(!!operatorSecondValue);
// Add first filter condition
await testUtils.fields.editSelect(target.querySelector('.o_generator_menu_field'), fieldSecondValue);
await cpHelpers.editConditionOperator(target, 0, operatorSecondValue);
// Add a second conditon on the filter being created
await cpHelpers.addCondition(target);
// Check the defaults for field and operator dropdowns
assert.strictEqual(
target.querySelector('.o_filter_condition:nth-of-type(2) .o_generator_menu_field').value,
fieldSecondValue
);
assert.strictEqual(
target.querySelector('.o_filter_condition:nth-of-type(2) .o_generator_menu_operator').value,
operatorSecondValue
);
});
QUnit.test('binary field: basic search', async function (assert) {
assert.expect(4);
let expectedFilters;
class MockedSearchModel extends ActionModel {
dispatch(method, ...args) {
assert.strictEqual(method, 'createNewFilters');
const preFilters = args[0];
assert.deepEqual(preFilters, expectedFilters);
}
}
const searchModel = new MockedSearchModel();
await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
env: { searchModel },
});
// Default value
expectedFilters = [{
description: 'Binary Field is set',
domain: '[["binary_field","!=",False]]',
type: 'filter',
}];
await toggleAddCustomFilterStandalone(target);
await testUtils.fields.editSelect(target.querySelector('.o_generator_menu_field'), 'binary_field');
await cpHelpers.applyFilter(target);
// Updated value
expectedFilters = [{
description: 'Binary Field is not set',
domain: '[["binary_field","=",False]]',
type: 'filter',
}];
await testUtils.fields.editSelect(target.querySelector('.o_generator_menu_field'), 'binary_field');
await cpHelpers.editConditionOperator(target, 0, '=');
await cpHelpers.applyFilter(target);
});
QUnit.test('selection field: default and updated value', async function (assert) {
assert.expect(4);
let expectedFilters;
class MockedSearchModel extends ActionModel {
dispatch(method, ...args) {
assert.strictEqual(method, 'createNewFilters');
const preFilters = args[0];
assert.deepEqual(preFilters, expectedFilters);
}
}
const searchModel = new MockedSearchModel();
await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
env: { searchModel },
});
// Default value
expectedFilters = [{
description: 'Color is "Black"',
domain: '[["color","=","black"]]',
type: 'filter',
}];
await toggleAddCustomFilterStandalone(target);
await testUtils.fields.editSelect(target.querySelector('.o_generator_menu_field'), 'color');
await cpHelpers.applyFilter(target);
// Updated value
expectedFilters = [{
description: 'Color is "White"',
domain: '[["color","=","white"]]',
type: 'filter',
}];
await testUtils.fields.editSelect(target.querySelector('.o_generator_menu_field'), 'color');
await testUtils.fields.editSelect(target.querySelector('.o_generator_menu_value select'), 'white');
await cpHelpers.applyFilter(target);
});
QUnit.test('selection field: no value', async function (assert) {
assert.expect(2);
this.fields.color.selection = [];
let expectedFilters;
class MockedSearchModel extends ActionModel {
dispatch(method, ...args) {
assert.strictEqual(method, 'createNewFilters');
const preFilters = args[0];
assert.deepEqual(preFilters, expectedFilters);
}
}
const searchModel = new MockedSearchModel();
const cfi = await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
env: { searchModel },
});
// Default value
expectedFilters = [{
description: 'Color is ""',
domain: '[["color","=",""]]',
type: 'filter',
}];
await toggleAddCustomFilterStandalone(cfi);
await testUtils.fields.editSelect(cfi.el.querySelector('.o_generator_menu_field'), 'color');
await cpHelpers.applyFilter(cfi);
})
QUnit.test('adding a simple filter works', async function (assert) {
assert.expect(7);
delete this.fields.date_field;
class MockedSearchModel extends ActionModel {
dispatch(method, ...args) {
assert.strictEqual(method, 'createNewFilters');
const preFilters = args[0];
const preFilter = preFilters[0];
assert.strictEqual(preFilter.type, 'filter');
assert.strictEqual(preFilter.description, 'Boolean Field is Yes');
assert.strictEqual(preFilter.domain, '[["boolean_field","=",True]]');
}
}
const searchModel = new MockedSearchModel();
await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
env: { searchModel },
});
await toggleAddCustomFilterStandalone(target);
await testUtils.fields.editSelect(target.querySelector('.o_generator_menu_field'), 'boolean_field');
await cpHelpers.applyFilter(target);
// The only things visible should be the button 'Add Custom Filter' and the menu;
assert.strictEqual(target.querySelector(".dropdown").children.length, 2);
assert.containsOnce(target, 'button.dropdown-toggle');
assert.containsOnce(target, '.dropdown-menu');
});
QUnit.test('filtering by ID interval works', async function (assert) {
assert.expect(4);
this.fields.id_field = { name: 'id_field', string: "ID", type: "id", searchable: true };
const expectedDomains = [
[['id_field','>', 10]],
[['id_field','<=', 20]],
];
class MockedSearchModel extends ActionModel {
dispatch(method, ...args) {
assert.strictEqual(method, 'createNewFilters');
const preFilters = args[0];
const preFilter = preFilters[0];
// this step combine a tokenization/parsing followed by a string formatting
let domain = pyUtils.assembleDomains([preFilter.domain]);
domain = Domain.prototype.stringToArray(domain);
assert.deepEqual(domain, expectedDomains.shift());
}
}
const searchModel = new MockedSearchModel();
await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
env: { searchModel },
});
async function testValue(operator, value) {
// open filter menu generator, select ID field, switch operator, type value, then click apply
await cpHelpers.editConditionField(target, 0, 'id_field');
await cpHelpers.editConditionOperator(target, 0, operator);
await cpHelpers.editConditionValue(target, 0,
value
);
await cpHelpers.applyFilter(target);
}
await toggleAddCustomFilterStandalone(target);
for (const domain of [...expectedDomains]) {
await testValue(domain[0][1], domain[0][2]);
}
});
QUnit.test('commit search with an extended proposition with field char does not cause a crash', async function (assert) {
assert.expect(12);
this.fields.many2one_field = { name: 'many2one_field', string: "Trululu", type: "many2one", searchable: true };
const expectedDomains = [
[['many2one_field', 'ilike', `a`]],
[['many2one_field', 'ilike', `"a"`]],
[['many2one_field', 'ilike', `'a'`]],
[['many2one_field', 'ilike', `'`]],
[['many2one_field', 'ilike', `"`]],
[['many2one_field', 'ilike', `\\`]],
];
const testedValues = [`a`, `"a"`, `'a'`, `'`, `"`, `\\`];
class MockedSearchModel extends ActionModel {
dispatch(method, ...args) {
assert.strictEqual(method, 'createNewFilters');
const preFilters = args[0];
const preFilter = preFilters[0];
// this step combine a tokenization/parsing followed by a string formatting
let domain = pyUtils.assembleDomains([preFilter.domain]);
domain = Domain.prototype.stringToArray(domain);
assert.deepEqual(domain, expectedDomains.shift());
}
}
const searchModel = new MockedSearchModel();
await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
env: { searchModel },
});
async function testValue(value) {
// open filter menu generator, select trululu field and enter string `a`, then click apply
await cpHelpers.editConditionField(target, 0, 'many2one_field');
await cpHelpers.editConditionValue(target, 0,
value
);
await cpHelpers.applyFilter(target);
}
await toggleAddCustomFilterStandalone(target);
for (const value of testedValues) {
await testValue(value);
}
delete ActionModel.registry.map.testExtension;
});
QUnit.test('custom filter datetime with equal operator', async function (assert) {
assert.expect(5);
class MockedSearchModel extends ActionModel {
dispatch(method, ...args) {
assert.strictEqual(method, 'createNewFilters');
const preFilters = args[0];
const preFilter = preFilters[0];
assert.strictEqual(preFilter.description,
'DateTime is equal to "02/22/2017 11:00:00"',
"description should be in localized format");
assert.deepEqual(preFilter.domain,
'[["date_time_field","=","2017-02-22 15:00:00"]]',
"domain should be in UTC format");
}
}
const searchModel = new MockedSearchModel();
await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
session: {
getTZOffset() {
return -240;
},
},
env: { searchModel },
});
await toggleAddCustomFilterStandalone(target);
await testUtils.fields.editSelect(target.querySelector('.o_generator_menu_field'), 'date_time_field');
assert.strictEqual(target.querySelector('.o_generator_menu_field').value, 'date_time_field');
assert.strictEqual(target.querySelector('.o_generator_menu_operator').value, 'between');
await cpHelpers.editConditionOperator(target, 0, '=');
await testUtils.fields.editSelect(target.querySelector('.o_filter_condition span.o_generator_menu_value input'), '02/22/2017 11:00:00'); // in TZ
await cpHelpers.applyFilter(target);
});
QUnit.test('custom filter datetime between operator', async function (assert) {
assert.expect(5);
class MockedSearchModel extends ActionModel {
dispatch(method, ...args) {
assert.strictEqual(method, 'createNewFilters');
const preFilters = args[0];
const preFilter = preFilters[0];
assert.strictEqual(preFilter.description,
'DateTime is between "02/22/2017 11:00:00 and 02/22/2017 17:00:00"',
"description should be in localized format");
assert.deepEqual(preFilter.domain,
'[["date_time_field",">=","2017-02-22 15:00:00"]' +
',["date_time_field","<=","2017-02-22 21:00:00"]]',
"domain should be in UTC format");
}
}
const searchModel = new MockedSearchModel();
await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
session: {
getTZOffset() {
return -240;
},
},
env: { searchModel },
});
await toggleAddCustomFilterStandalone(target);
await testUtils.fields.editSelect(target.querySelector('.o_generator_menu_field'), 'date_time_field');
assert.strictEqual(target.querySelector('.o_generator_menu_field').value, 'date_time_field');
assert.strictEqual(target.querySelector('.o_generator_menu_operator').value, 'between');
const valueInputs = target.querySelectorAll('.o_generator_menu_value .o_input');
await testUtils.fields.editSelect(valueInputs[0], '02/22/2017 11:00:00'); // in TZ
await testUtils.fields.editSelect(valueInputs[1], '02-22-2017 17:00:00'); // in TZ
await cpHelpers.applyFilter(target);
});
QUnit.test('default custom filter datetime', async function (assert) {
assert.expect(5);
class MockedSearchModel extends ActionModel {
dispatch(method, ...args) {
assert.strictEqual(method, 'createNewFilters');
const domain = JSON.parse(args[0][0].domain);
assert.strictEqual(domain[0][2].split(' ')[1],
'04:00:00',
"domain should be in UTC format");
assert.strictEqual(domain[1][2].split(' ')[1],
'03:59:59',
"domain should be in UTC format");
}
}
const searchModel = new MockedSearchModel();
await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
session: {
getTZOffset() {
return -240;
},
},
env: { searchModel },
});
await toggleAddCustomFilterStandalone(target);
await testUtils.fields.editSelect(target.querySelector('.o_generator_menu_field'), 'date_time_field');
assert.strictEqual(target.querySelector('.o_generator_menu_field').value, 'date_time_field');
assert.strictEqual(target.querySelector('.o_generator_menu_operator').value, 'between');
await cpHelpers.applyFilter(target);
});
QUnit.test('input value parsing', async function (assert) {
assert.expect(7);
await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
env: {
searchModel: new ActionModel(),
},
});
await toggleAddCustomFilterStandalone(target);
await cpHelpers.addCondition(target);
await cpHelpers.editConditionField(target, 0, "float_field");
await cpHelpers.editConditionField(target, 1, "id");
const [floatInput, idInput] = target.querySelectorAll('.o_generator_menu_value .o_input');
// Default values
assert.strictEqual(floatInput.value, "0.0");
assert.strictEqual(idInput.value, "0");
// Float parsing
await cpHelpers.editConditionValue(target, 0, "4.2");
assert.strictEqual(floatInput.value, "4.2");
await cpHelpers.editConditionValue(target, 0, "DefinitelyValidFloat");
// String input in a number input gives "", which is parsed as 0
assert.strictEqual(floatInput.value, "0.0");
// Number parsing
await cpHelpers.editConditionValue(target, 1, "4");
assert.strictEqual(idInput.value, "4");
await cpHelpers.editConditionValue(target, 1, "4.2");
assert.strictEqual(idInput.value, "4");
await cpHelpers.editConditionValue(target, 1, "DefinitelyValidID");
// String input in a number input gives "", which is parsed as 0
assert.strictEqual(idInput.value, "0");
});
QUnit.test('input value parsing with language', async function (assert) {
assert.expect(5);
await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
env: {
searchModel: new ActionModel(),
_t: Object.assign(s => s, { database: { parameters: { decimal_point: "," } }}),
},
translateParameters: {
decimal_point: ",",
thousands_sep: "",
grouping: [3, 0],
},
});
await toggleAddCustomFilterStandalone(target);
await cpHelpers.addCondition(target);
await cpHelpers.editConditionField(target, 0, "float_field");
const [floatInput] = target.querySelectorAll('.o_generator_menu_value .o_input');
// Default values
assert.strictEqual(floatInput.value, "0,0");
// Float parsing
await cpHelpers.editConditionValue(target, 0, '4,');
assert.strictEqual(floatInput.value, "4,");
await cpHelpers.editConditionValue(target, 0, '4,2');
assert.strictEqual(floatInput.value, "4,2");
await cpHelpers.editConditionValue(target, 0, '4,2,');
assert.strictEqual(floatInput.value, "4,2");
await cpHelpers.editConditionValue(target, 0, "DefinitelyValidFloat");
// The input here is a string, resulting in a parsing error instead of 0
assert.strictEqual(floatInput.value, "4,2");
});
QUnit.test('add custom filter with multiple values', async function (assert) {
assert.expect(2);
class MockedSearchModel extends ActionModel {
dispatch(method, ...args) {
assert.strictEqual(method, 'createNewFilters');
const preFilters = args[0];
const expected = [
{
description: 'A date is equal to "01/09/1997"',
domain: '[["date_field","=","1997-01-09"]]',
type: "filter",
},
{
description: 'Boolean Field is No',
domain: '[["boolean_field","!=",True]]',
type: "filter",
},
{
description: 'Floaty McFloatface is equal to "7.2"',
domain: '[["float_field","=",7.2]]',
type: "filter",
},
{
description: 'ID is "9"',
domain: '[["id","=",9]]',
type: "filter",
},
];
assert.deepEqual(preFilters, expected,
"Conditions should be in the correct order witht the right values.");
}
}
const searchModel = new MockedSearchModel();
await createComponent(CustomFilterItem, {
props: {
fields: this.fields,
},
env: { searchModel },
});
await toggleAddCustomFilterStandalone(target);
await cpHelpers.addCondition(target);
await cpHelpers.addCondition(target);
await cpHelpers.addCondition(target);
await cpHelpers.addCondition(target);
await cpHelpers.editConditionField(target, 0, 'date_field');
await cpHelpers.editConditionValue(target, 0, '01/09/1997');
await cpHelpers.editConditionField(target, 1, 'boolean_field');
await cpHelpers.editConditionOperator(target, 1, '!=');
await cpHelpers.editConditionField(target, 2, 'char_field');
await cpHelpers.editConditionValue(target, 2, "I will be deleted anyway");
await cpHelpers.editConditionField(target, 3, 'float_field');
await cpHelpers.editConditionValue(target, 3, 7.2);
await cpHelpers.editConditionField(target, 4, 'id');
await cpHelpers.editConditionValue(target, 4, 9);
await cpHelpers.removeCondition(target, 2);
await cpHelpers.applyFilter(target);
});
});
@@ -1,487 +0,0 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import testUtils from "web.test_utils";
import * as cpHelpers from "@web/../tests/search/helpers";
import { escapeHTML } from "@web/core/utils/strings";
const { createControlPanel, mock } = testUtils;
const { patchDate } = mock;
const searchMenuTypes = ['filter'];
QUnit.module('Components', {
beforeEach: function () {
this.fields = {
date_field: { string: "Date", type: "date", store: true, sortable: true, searchable: true },
foo: { string: "Foo", type: "char", store: true, sortable: true },
};
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
});
},
}, function () {
QUnit.module('FilterMenu (legacy)');
QUnit.test('simple rendering with no filter', async function (assert) {
assert.expect(2);
const params = {
cpModelConfig: { searchMenuTypes },
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleFilterMenu(controlPanel);
assert.containsNone(controlPanel, '.o_menu_item, .dropdown-divider');
assert.containsOnce(controlPanel, '.o_add_custom_filter_menu');
});
QUnit.test('simple rendering with a single filter', async function (assert) {
const arch = `
<search>
<filter string="Foo" name="foo" domain="[]"/>
</search>`;
const params = {
cpModelConfig: { arch, fields: this.fields, searchMenuTypes },
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleFilterMenu(controlPanel);
assert.containsOnce(controlPanel, '.o_menu_item');
assert.containsOnce(controlPanel, ".o_menu_item[role=menuitemcheckbox]");
assert.deepEqual(controlPanel.el.querySelector(".o_menu_item").ariaChecked, "false");
assert.containsOnce(controlPanel, '.dropdown-divider');
assert.containsOnce(controlPanel, 'div.o_add_custom_filter_menu');
});
QUnit.test('should have Date and ID field proposed in that order in "Add custom Filter" submenu', async function (assert) {
assert.expect(2);
const params = {
cpModelConfig: { fields: this.fields, searchMenuTypes },
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleFilterMenu(controlPanel);
await cpHelpers.toggleAddCustomFilter(controlPanel);
const optionEls = controlPanel.el.querySelectorAll('.o_filter_condition select.o_generator_menu_field option');
assert.strictEqual(optionEls[0].innerText.trim(), 'Date');
assert.strictEqual(optionEls[1].innerText.trim(), 'ID');
});
QUnit.test('toggle a "simple" filter in filter menu works', async function (assert) {
assert.expect(12);
const domains = [
[['foo', '=', 'qsdf']],
[]
];
const arch = `
<search>
<filter string="Foo" name="foo" domain="[['foo', '=', 'qsdf']]"/>
</search>`;
const params = {
cpModelConfig: { arch, searchMenuTypes },
cpProps: { fields: {}, searchMenuTypes },
search: function (searchQuery) {
const { domain } = searchQuery;
assert.deepEqual(domain, domains.shift());
}
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleFilterMenu(controlPanel);
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), []);
assert.notOk(cpHelpers.isItemSelected(controlPanel, 0));
assert.containsOnce(controlPanel, ".o_menu_item[role=menuitemcheckbox]");
assert.deepEqual(controlPanel.el.querySelector(".o_menu_item").ariaChecked, "false");
await cpHelpers.toggleMenuItem(controlPanel, "Foo");
assert.deepEqual(controlPanel.el.querySelector(".o_menu_item").ariaChecked, "true");
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Foo']);
assert.containsOnce(controlPanel.el.querySelector('.o_searchview .o_searchview_facet'),
'span.fa.fa-filter.o_searchview_facet_label');
assert.ok(cpHelpers.isItemSelected(controlPanel, "Foo"));
await cpHelpers.toggleMenuItem(controlPanel, "Foo");
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), []);
assert.notOk(cpHelpers.isItemSelected(controlPanel, "Foo"));
});
QUnit.test('add a custom filter works', async function (assert) {
assert.expect(1);
const params = {
cpModelConfig: { fields: this.fields, searchMenuTypes },
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleFilterMenu(controlPanel);
await cpHelpers.toggleAddCustomFilter(controlPanel);
// choose ID field in 'Add Custome filter' menu and value 1
await cpHelpers.editConditionField(controlPanel, 0, 'id');
await cpHelpers.editConditionValue(controlPanel, 0, 1);
await cpHelpers.applyFilter(controlPanel);
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['ID is "1"']);
});
QUnit.test('deactivate a new custom filter works', async function (assert) {
assert.expect(4);
const unpatchDate = patchDate(2020, 1, 5, 12, 20, 0);
const params = {
cpModelConfig: { fields: this.fields, searchMenuTypes },
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleFilterMenu(controlPanel);
await cpHelpers.toggleAddCustomFilter(controlPanel);
await cpHelpers.applyFilter(controlPanel);
assert.ok(cpHelpers.isItemSelected(controlPanel, 'Date is equal to "02/05/2020"'));
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Date is equal to "02/05/2020"']);
await cpHelpers.toggleMenuItem(controlPanel, 'Date is equal to "02/05/2020"');
assert.notOk(cpHelpers.isItemSelected(controlPanel, 'Date is equal to "02/05/2020"'));
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), []);
unpatchDate();
});
QUnit.test('filter by a date field using period works', async function (assert) {
assert.expect(56);
const unpatchDate = patchDate(2017, 2, 22, 1, 0, 0);
const basicDomains = [
["&", ["date_field", ">=", "2017-01-01"], ["date_field", "<=", "2017-12-31"]],
["&", ["date_field", ">=", "2017-02-01"], ["date_field", "<=", "2017-02-28"]],
["&", ["date_field", ">=", "2017-01-01"], ["date_field", "<=", "2017-12-31"]],
["&", ["date_field", ">=", "2017-01-01"], ["date_field", "<=", "2017-01-31"]],
["|",
"&", ["date_field", ">=", "2017-01-01"], ["date_field", "<=", "2017-01-31"],
"&", ["date_field", ">=", "2017-10-01"], ["date_field", "<=", "2017-12-31"]
],
["&", ["date_field", ">=", "2017-10-01"], ["date_field", "<=", "2017-12-31"]],
["&", ["date_field", ">=", "2017-01-01"], ["date_field", "<=", "2017-12-31"]],
["&", ["date_field", ">=", "2017-01-01"], ["date_field", "<=", "2017-03-31"]],
["&", ["date_field", ">=", "2017-01-01"], ["date_field", "<=", "2017-12-31"]],
["&", ["date_field", ">=", "2017-01-01"], ["date_field", "<=", "2017-12-31"]],
["|",
"&", ["date_field", ">=", "2016-01-01"], ["date_field", "<=", "2016-12-31"],
"&", ["date_field", ">=", "2017-01-01"], ["date_field", "<=", "2017-12-31"]
],
["|",
"|",
"&", ["date_field", ">=", "2015-01-01"], ["date_field", "<=", "2015-12-31"],
"&", ["date_field", ">=", "2016-01-01"], ["date_field", "<=", "2016-12-31"],
"&", ["date_field", ">=", "2017-01-01"], ["date_field", "<=", "2017-12-31"]
],
["|",
"|",
"&", ["date_field", ">=", "2015-03-01"], ["date_field", "<=", "2015-03-31"],
"&", ["date_field", ">=", "2016-03-01"], ["date_field", "<=", "2016-03-31"],
"&", ["date_field", ">=", "2017-03-01"], ["date_field", "<=", "2017-03-31"]
]
];
const arch = `
<search>
<filter string="Date" name="date_field" date="date_field"/>
</search>`;
const params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes,
context: { search_default_date_field: 1 },
},
cpProps: { fields: this.fields, searchMenuTypes },
search: function (searchQuery) {
// we inspect query domain
const { domain } = searchQuery;
if (domain.length) {
assert.deepEqual(domain, basicDomains.shift());
}
},
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleFilterMenu(controlPanel);
await cpHelpers.toggleMenuItem(controlPanel, "Date");
const optionEls = controlPanel.el.querySelectorAll('span.o_item_option');
// default filter should be activated with the global default period 'this_month'
const { domain } = controlPanel.getQuery();
assert.deepEqual(
domain,
["&", ["date_field", ">=", "2017-03-01"], ["date_field", "<=", "2017-03-31"]]
);
assert.ok(cpHelpers.isItemSelected(controlPanel, "Date"));
assert.ok(cpHelpers.isOptionSelected(controlPanel, "Date", 0));
// check option descriptions
const optionDescriptions = [...optionEls].map(e => e.innerText.trim());
const expectedDescriptions = [
'March', 'February', 'January',
'Q4', 'Q3', 'Q2', 'Q1',
'2017', '2016', '2015'
];
assert.deepEqual(optionDescriptions, expectedDescriptions);
// check generated domains
const steps = [
{ description: 'March', facetContent: 'Date: 2017', selectedoptions: [7] },
{ description: 'February', facetContent: 'Date: February 2017', selectedoptions: [1, 7] },
{ description: 'February', facetContent: 'Date: 2017', selectedoptions: [7] },
{ description: 'January', facetContent: 'Date: January 2017', selectedoptions: [2, 7] },
{ description: 'Q4', facetContent: 'Date: January 2017/Q4 2017', selectedoptions: [2, 3, 7] },
{ description: 'January', facetContent: 'Date: Q4 2017', selectedoptions: [3, 7] },
{ description: 'Q4', facetContent: 'Date: 2017', selectedoptions: [7] },
{ description: 'Q1', facetContent: 'Date: Q1 2017', selectedoptions: [6, 7] },
{ description: 'Q1', facetContent: 'Date: 2017', selectedoptions: [7] },
{ description: '2017', selectedoptions: [] },
{ description: '2017', facetContent: 'Date: 2017', selectedoptions: [7] },
{ description: '2016', facetContent: 'Date: 2016/2017', selectedoptions: [7, 8] },
{ description: '2015', facetContent: 'Date: 2015/2016/2017', selectedoptions: [7, 8, 9] },
{ description: 'March', facetContent: 'Date: March 2015/March 2016/March 2017', selectedoptions: [0, 7, 8, 9] }
];
for (const s of steps) {
const index = expectedDescriptions.indexOf(s.description);
await cpHelpers.toggleMenuItemOption(controlPanel, 0, index);
if (s.facetContent) {
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), [s.facetContent]);
} else {
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), []);
}
s.selectedoptions.forEach(index => {
assert.ok(cpHelpers.isOptionSelected(controlPanel, 0, index),
`at step ${steps.indexOf(s) + 1}, option ${expectedDescriptions[index]} should be selected`);
});
}
unpatchDate();
});
QUnit.test('filter by a date field using period works even in January', async function (assert) {
assert.expect(5);
const unpatchDate = patchDate(2017, 0, 7, 3, 0, 0);
const arch = `
<search>
<filter string="Date" name="some_filter" date="date_field" default_period="last_month"/>
</search>`;
const params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes,
context: { search_default_some_filter: 1 },
},
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
const { domain } = controlPanel.getQuery();
assert.deepEqual(domain, [
'&',
["date_field", ">=", "2016-12-01"],
["date_field", "<=", "2016-12-31"]
]);
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ["Date: December 2016"]);
await cpHelpers.toggleFilterMenu(controlPanel);
await cpHelpers.toggleMenuItem(controlPanel, "Date");
assert.ok(cpHelpers.isItemSelected(controlPanel, "Date"));
assert.ok(cpHelpers.isOptionSelected(controlPanel, "Date", 'December'));
assert.ok(cpHelpers.isOptionSelected(controlPanel, "Date", '2016'));
unpatchDate();
});
QUnit.test('`context` key in <filter> is used', async function (assert) {
assert.expect(1);
const arch = `
<search>
<filter string="Filter" name="some_filter" domain="[]" context="{'coucou_1': 1}"/>
</search>`;
const params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes
},
cpProps: { fields: this.fields, searchMenuTypes },
search: function (searchQuery) {
// we inspect query context
const { context } = searchQuery;
assert.deepEqual(context, { coucou_1: 1 });
},
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleFilterMenu(controlPanel);
await cpHelpers.toggleMenuItem(controlPanel, 0);
});
QUnit.test('Filter with JSON-parsable domain works', async function (assert) {
assert.expect(1);
const originalDomain = [['foo', '=', 'Gently Weeps']];
const xml_domain = JSON.stringify(originalDomain);
const arch =
`<search>
<filter string="Foo" name="gently_weeps" domain="${escapeHTML(xml_domain)}"/>
</search>`;
const params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes,
},
cpProps: { fields: this.fields, searchMenuTypes },
search: function (searchQuery) {
const { domain } = searchQuery;
assert.deepEqual(domain, originalDomain,
'A JSON parsable xml domain should be handled just like any other'
);
},
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleFilterMenu(controlPanel);
await cpHelpers.toggleMenuItem(controlPanel, 0);
});
QUnit.test('filter with date attribute set as search_default', async function (assert) {
assert.expect(1);
const unpatchDate = patchDate(2019, 6, 31, 13, 43, 0);
const arch =
`<search>
<filter string="Date" name="date_field" date="date_field" default_period="last_month"/>
</search>`,
params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes,
context: {
search_default_date_field: true
}
},
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ["Date: June 2019"]);
unpatchDate();
});
QUnit.test('filter domains are correcly combined by OR and AND', async function (assert) {
assert.expect(2);
const arch =
`<search>
<filter string="Filter Group 1" name="f_1_g1" domain="[['foo', '=', 'f1_g1']]"/>
<separator/>
<filter string="Filter 1 Group 2" name="f1_g2" domain="[['foo', '=', 'f1_g2']]"/>
<filter string="Filter 2 GROUP 2" name="f2_g2" domain="[['foo', '=', 'f2_g2']]"/>
</search>`,
params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes,
context: {
search_default_f_1_g1: true,
search_default_f1_g2: true,
search_default_f2_g2: true,
}
},
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
const { domain } = controlPanel.getQuery();
assert.deepEqual(domain, [
'&',
['foo', '=', 'f1_g1'],
'|',
['foo', '=', 'f1_g2'],
['foo', '=', 'f2_g2']
]);
assert.deepEqual(
cpHelpers.getFacetTexts(controlPanel),
["Filter Group 1", "Filter 1 Group 2orFilter 2 GROUP 2"]
);
});
QUnit.test('arch order of groups of filters preserved', async function (assert) {
assert.expect(12);
const arch =
`<search>
<filter string="1" name="coolName1" date="date_field"/>
<separator/>
<filter string="2" name="coolName2" date="date_field"/>
<separator/>
<filter string="3" name="coolName3" domain="[]"/>
<separator/>
<filter string="4" name="coolName4" domain="[]"/>
<separator/>
<filter string="5" name="coolName5" domain="[]"/>
<separator/>
<filter string="6" name="coolName6" domain="[]"/>
<separator/>
<filter string="7" name="coolName7" domain="[]"/>
<separator/>
<filter string="8" name="coolName8" domain="[]"/>
<separator/>
<filter string="9" name="coolName9" domain="[]"/>
<separator/>
<filter string="10" name="coolName10" domain="[]"/>
<separator/>
<filter string="11" name="coolName11" domain="[]"/>
</search>`,
params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes,
},
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleFilterMenu(controlPanel);
assert.containsN(controlPanel, '.o_filter_menu .o_menu_item', 11);
const menuItemEls = controlPanel.el.querySelectorAll('.o_filter_menu .o_menu_item');
[...menuItemEls].forEach((e, index) => {
assert.strictEqual(e.innerText.trim(), String(index + 1));
});
});
});
@@ -1,470 +0,0 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { patchWithCleanup } from "@web/../tests/helpers/utils";
import testUtils from "web.test_utils";
import * as cpHelpers from "@web/../tests/search/helpers";
const { createControlPanel } = testUtils;
const searchMenuTypes = ['groupBy'];
QUnit.module('Components', {
beforeEach: function () {
this.fields = {
bar: { string: "Bar", type: "many2one", relation: 'partner' },
birthday: { string: "Birthday", type: "date", store: true, sortable: true },
date_field: { string: "Date", type: "date", store: true, sortable: true },
float_field: { string: "Float", type: "float", group_operator: 'sum' },
foo: { string: "Foo", type: "char", store: true, sortable: true },
m2m: { string: "Many2Many", type: "many2many", store: true},
m2m_not_stored: { string: "Many2Many not stored", type: "many2many" },
};
patchWithCleanup(browser, {
setTimeout: (fn) => fn(),
});
},
}, function () {
QUnit.module('GroupByMenu (legacy)');
QUnit.test('simple rendering with neither groupbys nor groupable fields', async function (assert) {
assert.expect(1);
const params = {
cpModelConfig: { searchMenuTypes },
cpProps: { fields: {}, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
assert.containsNone(controlPanel, '.o_menu_item, .dropdown-divider, .o_add_custom_group_menu');
});
QUnit.test('simple rendering with no groupby', async function (assert) {
assert.expect(3);
// Manually make m2m_not_stored to be sortable.
// Even if it's sortable, it should not be included in the add custom groupby options.
this.fields.m2m_not_stored.sortable = true;
const params = {
cpModelConfig: { searchMenuTypes },
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleGroupByMenu(controlPanel);
assert.containsNone(controlPanel, '.o_menu_item, .dropdown-divider');
assert.containsOnce(controlPanel, '.o_add_custom_group_menu');
await cpHelpers.toggleAddCustomGroup(controlPanel);
const optionEls = controlPanel.el.querySelectorAll('.o_add_custom_group_menu select option');
assert.deepEqual(
[...optionEls].map((el) => el.innerText.trim()),
['Birthday', 'Date', 'Foo', 'Many2Many']
);
});
QUnit.test('simple rendering with a single groupby', async function (assert) {
const arch = `
<search>
<filter string="Groupby Foo" name="gb_foo" context="{'group_by': 'foo'}"/>
</search>`;
const params = {
cpModelConfig: { arch, fields: this.fields, searchMenuTypes },
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleGroupByMenu(controlPanel);
assert.containsOnce(controlPanel, '.o_menu_item');
const menuItem = controlPanel.el.querySelector(".o_menu_item");
assert.strictEqual(menuItem.innerText.trim(), "Groupby Foo");
assert.strictEqual(menuItem.getAttribute("role"), "menuitemcheckbox");
assert.strictEqual(menuItem.ariaChecked, "false");
assert.containsOnce(controlPanel, '.dropdown-divider');
assert.containsOnce(controlPanel, '.o_add_custom_group_menu');
});
QUnit.test('toggle a "simple" groupby in groupby menu works', async function (assert) {
assert.expect(13);
const groupBys = [['foo'], []];
const arch = `
<search>
<filter string="Groupby Foo" name="gb_foo" context="{'group_by': 'foo'}"/>
</search>`;
const params = {
cpModelConfig: {arch, fields: this.fields, searchMenuTypes },
cpProps: { fields: this.fields, searchMenuTypes },
search: function (searchQuery) {
const { groupBy } = searchQuery;
assert.deepEqual(groupBy, groupBys.shift());
},
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleGroupByMenu(controlPanel);
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), []);
assert.notOk(cpHelpers.isItemSelected(controlPanel, 0));
const menuItem = controlPanel.el.querySelector(".o_menu_item");
assert.strictEqual(menuItem.innerText.trim(), "Groupby Foo");
assert.strictEqual(menuItem.getAttribute("role"), "menuitemcheckbox");
assert.strictEqual(menuItem.ariaChecked, "false");
await cpHelpers.toggleMenuItem(controlPanel, 0);
assert.strictEqual(menuItem.ariaChecked, "true");
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Groupby Foo']);
assert.containsOnce(controlPanel.el.querySelector('.o_searchview .o_searchview_facet'),
'span.oi.oi-group.o_searchview_facet_label');
assert.ok(cpHelpers.isItemSelected(controlPanel, 0));
await cpHelpers.toggleMenuItem(controlPanel, 0);
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), []);
assert.notOk(cpHelpers.isItemSelected(controlPanel, 0));
});
QUnit.test('toggle a "simple" groupby quickly does not crash', async function (assert) {
assert.expect(1);
const arch = `
<search>
<filter string="Groupby Foo" name="gb_foo" context="{'group_by': 'foo'}"/>
</search>`;
const params = {
cpModelConfig: { arch, fields: this.fields, searchMenuTypes },
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleGroupByMenu(controlPanel);
cpHelpers.toggleMenuItem(controlPanel, 0);
cpHelpers.toggleMenuItem(controlPanel, 0);
assert.ok(true);
});
QUnit.test('remove a "Group By" facet properly unchecks groupbys in groupby menu', async function (assert) {
assert.expect(5);
const arch = `
<search>
<filter string="Groupby Foo" name="gb_foo" context="{'group_by': 'foo'}"/>
</search>`;
const params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes,
context: { search_default_gb_foo: 1 }
},
cpProps: { fields: this.fields, searchMenuTypes },
search: function (searchQuery) {
const { groupBy } = searchQuery;
assert.deepEqual(groupBy, []);
},
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleGroupByMenu(controlPanel);
const facetEl = controlPanel.el.querySelector('.o_searchview .o_searchview_facet');
assert.strictEqual(facetEl.innerText.trim(), "Groupby Foo");
assert.ok(cpHelpers.isItemSelected(controlPanel, 0));
await testUtils.dom.click(facetEl.querySelector('i.o_facet_remove'));
assert.containsNone(controlPanel, '.o_searchview .o_searchview_facet');
await cpHelpers.toggleGroupByMenu(controlPanel);
assert.notOk(cpHelpers.isItemSelected(controlPanel, 0));
});
QUnit.test('group by a date field using interval works', async function (assert) {
assert.expect(21);
const groupBys = [
['date_field:year', 'date_field:week' ],
['date_field:year', 'date_field:month', 'date_field:week'],
['date_field:year', 'date_field:month'],
['date_field:year'],
[]
];
const arch = `
<search>
<filter string="Date" name="date" context="{'group_by': 'date_field:week'}"/>
</search>`;
const params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes,
context: { search_default_date: 1 }
},
cpProps: { fields: this.fields, searchMenuTypes },
search: function (searchQuery) {
const { groupBy } = searchQuery;
assert.deepEqual(groupBy, groupBys.shift());
},
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleGroupByMenu(controlPanel);
await cpHelpers.toggleMenuItem(controlPanel, 0);
const optionEls = controlPanel.el.querySelectorAll('span.o_item_option');
// default groupby should be activated with the default inteval 'week'
const { groupBy } = controlPanel.getQuery();
assert.deepEqual(groupBy, ['date_field:week']);
assert.ok(cpHelpers.isOptionSelected(controlPanel, 0, 3));
// check option descriptions
const optionDescriptions = [...optionEls].map(e => e.innerText.trim());
const expectedDescriptions = ['Year', 'Quarter', 'Month', 'Week', 'Day'];
assert.deepEqual(optionDescriptions, expectedDescriptions);
const steps = [
{ description: 'Year', facetContent: 'Date: Year>Date: Week', selectedoptions: [0, 3] },
{ description: 'Month', facetContent: 'Date: Year>Date: Month>Date: Week', selectedoptions: [0, 2, 3] },
{ description: 'Week', facetContent: 'Date: Year>Date: Month', selectedoptions: [0, 2] },
{ description: 'Month', facetContent: 'Date: Year', selectedoptions: [0] },
{ description: 'Year', selectedoptions: [] },
];
for (const s of steps) {
const index = expectedDescriptions.indexOf(s.description);
await cpHelpers.toggleMenuItemOption(controlPanel, 0, index);
if (s.facetContent) {
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), [s.facetContent]);
} else {
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), []);
}
s.selectedoptions.forEach(index => {
assert.ok(cpHelpers.isOptionSelected(controlPanel, 0, index));
});
}
});
QUnit.test('interval options are correctly grouped and ordered', async function (assert) {
assert.expect(8);
const arch = `
<search>
<filter string="Bar" name="bar" context="{'group_by': 'bar'}"/>
<filter string="Date" name="date" context="{'group_by': 'date_field'}"/>
<filter string="Foo" name="foo" context="{'group_by': 'foo'}"/>
</search>`;
const params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes,
context: { search_default_bar: 1 }
},
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Bar']);
// open menu 'Group By'
await cpHelpers.toggleGroupByMenu(controlPanel);
// Open the groupby 'Date'
await cpHelpers.toggleMenuItem(controlPanel, 'Date');
// select option 'week'
await cpHelpers.toggleMenuItemOption(controlPanel, 'Date', 'Week');
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Bar>Date: Week']);
// select option 'day'
await cpHelpers.toggleMenuItemOption(controlPanel, 'Date', 'Day');
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Bar>Date: Week>Date: Day']);
// select option 'year'
await cpHelpers.toggleMenuItemOption(controlPanel, 'Date', 'Year');
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Bar>Date: Year>Date: Week>Date: Day']);
// select 'Foo'
await cpHelpers.toggleMenuItem(controlPanel, 'Foo');
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Bar>Date: Year>Date: Week>Date: Day>Foo']);
// select option 'quarter'
await cpHelpers.toggleMenuItem(controlPanel, 'Date');
await cpHelpers.toggleMenuItemOption(controlPanel, 'Date', 'Quarter');
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Bar>Date: Year>Date: Quarter>Date: Week>Date: Day>Foo']);
// unselect 'Bar'
await cpHelpers.toggleMenuItem(controlPanel, 'Bar');
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Date: Year>Date: Quarter>Date: Week>Date: Day>Foo']);
// unselect option 'week'
await cpHelpers.toggleMenuItem(controlPanel, 'Date');
await cpHelpers.toggleMenuItemOption(controlPanel, 'Date', 'Week');
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Date: Year>Date: Quarter>Date: Day>Foo']);
});
QUnit.test('the ID field should not be proposed in "Add Custom Group" menu', async function (assert) {
assert.expect(2);
const fields = {
foo: { string: "Foo", type: "char", store: true, sortable: true },
id: { sortable: true, string: 'ID', type: 'integer' }
};
const params = {
cpModelConfig: { searchMenuTypes },
cpProps: { fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleGroupByMenu(controlPanel);
await cpHelpers.toggleAddCustomGroup(controlPanel);
const optionEls = controlPanel.el.querySelectorAll('.o_add_custom_group_menu select option');
assert.strictEqual(optionEls.length, 1);
assert.strictEqual(optionEls[0].innerText.trim(), "Foo");
});
QUnit.test('add a date field in "Add Custome Group" activate a groupby with global default option "month"', async function (assert) {
assert.expect(4);
const fields = {
date_field: { string: "Date", type: "date", store: true, sortable: true },
id: { sortable: true, string: 'ID', type: 'integer' }
};
const params = {
cpModelConfig: { fields, searchMenuTypes },
cpProps: { fields, searchMenuTypes },
search: function (searchQuery) {
const { groupBy } = searchQuery;
assert.deepEqual(groupBy, ['date_field:month']);
}
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleGroupByMenu(controlPanel);
await cpHelpers.toggleAddCustomGroup(controlPanel);
await cpHelpers.applyGroup(controlPanel);
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Date: Month']);
assert.ok(cpHelpers.isItemSelected(controlPanel, "Date"));
await cpHelpers.toggleMenuItem(controlPanel, "Date");
assert.ok(cpHelpers.isOptionSelected(controlPanel, "Date", "Month"));
});
QUnit.test('default groupbys can be ordered', async function (assert) {
assert.expect(2);
const arch = `
<search>
<filter string="Birthday" name="birthday" context="{'group_by': 'birthday'}"/>
<filter string="Date" name="date" context="{'group_by': 'date_field:week'}"/>
</search>`;
const params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes,
context: { search_default_birthday: 2, search_default_date: 1 }
},
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
// the defautl groupbys should be activated in the right order
const { groupBy } = controlPanel.getQuery();
assert.deepEqual(groupBy, ['date_field:week', 'birthday:month']);
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Date: Week>Birthday: Month']);
});
QUnit.test('a separator in groupbys does not cause problems', async function (assert) {
assert.expect(23);
const arch = `
<search>
<filter string="Date" name="coolName" context="{'group_by': 'date_field'}"/>
<separator/>
<filter string="Bar" name="superName" context="{'group_by': 'bar'}"/>
</search>`;
const params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes,
},
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
await cpHelpers.toggleGroupByMenu(controlPanel);
await cpHelpers.toggleMenuItem(controlPanel, 0);
await cpHelpers.toggleMenuItemOption(controlPanel, 0, 4);
assert.ok(cpHelpers.isItemSelected(controlPanel, 0));
assert.notOk(cpHelpers.isItemSelected(controlPanel, 1));
assert.ok(cpHelpers.isOptionSelected(controlPanel, 0, 4), 'selected');
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Date: Day']);
await cpHelpers.toggleMenuItem(controlPanel, 1);
await cpHelpers.toggleMenuItem(controlPanel, 0);
assert.ok(cpHelpers.isItemSelected(controlPanel, 0));
assert.ok(cpHelpers.isItemSelected(controlPanel, 1));
assert.ok(cpHelpers.isOptionSelected(controlPanel, 0, 4), 'selected');
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Date: Day>Bar']);
await cpHelpers.toggleMenuItemOption(controlPanel, 0, 1);
assert.ok(cpHelpers.isItemSelected(controlPanel, 0));
assert.ok(cpHelpers.isItemSelected(controlPanel, 1));
assert.ok(cpHelpers.isOptionSelected(controlPanel, 0, 1), 'selected');
assert.ok(cpHelpers.isOptionSelected(controlPanel, 0, 4), 'selected');
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Date: Quarter>Date: Day>Bar']);
await cpHelpers.toggleMenuItem(controlPanel, 1);
await cpHelpers.toggleMenuItem(controlPanel, 0);
assert.ok(cpHelpers.isItemSelected(controlPanel, 0));
assert.notOk(cpHelpers.isItemSelected(controlPanel, 1));
assert.ok(cpHelpers.isOptionSelected(controlPanel, 0, 1), 'selected');
assert.ok(cpHelpers.isOptionSelected(controlPanel, 0, 4), 'selected');
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), ['Date: Quarter>Date: Day']);
await cpHelpers.removeFacet(controlPanel);
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), []);
await cpHelpers.toggleGroupByMenu(controlPanel);
await cpHelpers.toggleMenuItem(controlPanel, 0);
assert.notOk(cpHelpers.isItemSelected(controlPanel, 0));
assert.notOk(cpHelpers.isItemSelected(controlPanel, 1));
assert.notOk(cpHelpers.isOptionSelected(controlPanel, 0, 1), 'selected');
assert.notOk(cpHelpers.isOptionSelected(controlPanel, 0, 4), 'selected');
});
QUnit.test('falsy search default groupbys are not activated', async function (assert) {
assert.expect(2);
const arch = `
<search>
<filter string="Birthday" name="birthday" context="{'group_by': 'birthday'}"/>
<filter string="Date" name="date" context="{'group_by': 'foo'}"/>
</search>`;
const params = {
cpModelConfig: {
arch,
fields: this.fields,
searchMenuTypes,
context: { search_default_birthday: false, search_default_foo: 0 }
},
cpProps: { fields: this.fields, searchMenuTypes },
};
const controlPanel = await createControlPanel(params);
const { groupBy } = controlPanel.getQuery();
assert.deepEqual(groupBy, []);
assert.deepEqual(cpHelpers.getFacetTexts(controlPanel), []);
});
});
@@ -240,7 +240,6 @@ odoo.define('web.test_utils', ["web.session", "web.test_utils_create", "web.test
},
createComponent: testUtilsCreate.createComponent,
createControlPanel: testUtilsCreate.createControlPanel,
createAsyncView: testUtilsCreate.createView,
createCalendarView: testUtilsCreate.createCalendarView,
createView: testUtilsCreate.createView,
@@ -11,11 +11,8 @@
import ActionMenus from "web.ActionMenus";
import concurrency from "web.concurrency";
import ControlPanel from "web.ControlPanel";
import { useListener } from "@web/core/utils/hooks";
import dom from "web.dom";
import makeTestEnvironment from "web.test_env";
import ActionModel from "web.ActionModel";
import Registry from "web.Registry";
import testUtilsMock from "web.test_utils_mock";
import Widget from "web.Widget";
@@ -23,7 +20,7 @@
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { LegacyComponent } from "@web/legacy/legacy_component";
const { Component, onMounted, onWillStart, useState, xml } = owl;
const { Component, useState, xml } = owl;
/**
* Similar as createView, but specific for calendar views. Some calendar
@@ -104,95 +101,6 @@
return parent.child;
}
/**
* Create a Control Panel instance, with an extensible environment and
* its related Control Panel Model. Event interception is done through
* params['get-controller-query-params'] and params.search, for the two
* available event handlers respectively.
* @param {Object} [params={}]
* @param {Object} [params.cpProps]
* @param {Object} [params.cpModelConfig]
* @param {boolean} [params.debug]
* @param {Object} [params.env]
* @returns {Object} useful control panel testing elements:
* - controlPanel: the control panel instance
* - el: the control panel HTML element
* - helpers: a suite of bound helpers (see above functions for all
* available helpers)
*/
async function createControlPanel(params = {}) {
const env = makeTestEnvironment(params.env || {});
const props = Object.assign({
action: {},
fields: {},
}, params.cpProps);
const globalConfig = Object.assign({
context: {},
domain: [],
}, params.cpModelConfig);
if (globalConfig.arch && globalConfig.fields) {
const model = "__mockmodel__";
const serverParams = {
model,
data: { [model]: { fields: globalConfig.fields, records: [] } },
};
const mockServer = await testUtilsMock.addMockEnvironment(
new Widget(),
serverParams,
);
const { arch } = testUtilsMock.getView(mockServer, {
arch: globalConfig.arch,
fields: globalConfig.fields,
model,
viewOptions: { context: globalConfig.context },
});
Object.assign(globalConfig, { arch });
}
globalConfig.env = env;
const archs = (globalConfig.arch && { search: globalConfig.arch, }) || {};
const { ControlPanel: controlPanelInfo, } = ActionModel.extractArchInfo(archs);
const extensions = {
ControlPanel: { archNodes: controlPanelInfo.children, },
};
class Parent extends LegacyComponent {
setup() {
this.searchModel = new ActionModel(extensions, globalConfig);
this.state = useState(props);
useChild();
onWillStart(async () => {
await this.searchModel.load();
});
onMounted(() => {
if (params['get-controller-query-params']) {
this.searchModel.on('get-controller-query-params', this,
params['get-controller-query-params']);
}
if (params.search) {
this.searchModel.on('search', this, params.search);
}
});
}
}
Parent.components = { ControlPanel };
Parent.template = xml`
<ControlPanel
t-props="state"
searchModel="searchModel"
/>`;
const target = getFixture();
const parent = await mount(Parent, target, { env });
const controlPanel = parent.child;
controlPanel.getQuery = () => parent.searchModel.get("query");
registerCleanup(() => {
destroy(parent);
});
return controlPanel;
}
/**
* Create a model from given parameters.
*
@@ -393,7 +301,6 @@
export default {
createCalendarView,
createComponent,
createControlPanel,
createModel,
createParent,
createView,
-2
View File
@@ -11,7 +11,6 @@ import { legacyProm } from "web.test_legacy";
import { registerCleanup } from "./helpers/cleanup";
import { utils } from "./helpers/mock_env";
import { session as sessionInfo } from "@web/session";
import { prepareLegacyRegistriesWithCleanup } from "./helpers/legacy_env_utils";
import { config as transitionConfig } from "@web/core/transition";
import { loadLanguages } from "@web/core/l10n/translation";
@@ -380,7 +379,6 @@ export async function setupTests() {
QUnit.testStart(() => {
checkGlobalObjectsIntegrity();
prepareRegistriesWithCleanup();
prepareLegacyRegistriesWithCleanup();
forceLocaleAndTimezoneWithCleanup();
cleanLoadedLanguages();
patchBrowserWithCleanup();