[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:
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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;
|
||||
@@ -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;
|
||||
@@ -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', '<', 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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user