[REF] *: remove legacy views support
The last view in the odoo codebase has just been converted to owl, meaning that we no longer have legacy views. This allows us to remove a lot of legacy view related code: the abstract elements on which the legacy views were built, the compatibility layers that allowed to deal with both owl and legacy views uniformly, legacy widgets that were only used in those legacy views... closes odoo/odoo#114893 Related: odoo/upgrade#4420 Related: odoo/enterprise#38005 Signed-off-by: Géry Debongnie <ged@odoo.com>
This commit is contained in:
@@ -164,7 +164,6 @@
|
||||
'web/static/src/legacy/js/fields/relational_fields.js',
|
||||
'web/static/src/legacy/js/fields/field_registry.js',
|
||||
'web/static/src/legacy/js/fields/field_utils.js',
|
||||
'web/static/src/legacy/js/views/sample_server.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',
|
||||
|
||||
@@ -7,8 +7,6 @@ import { makeTestEnv } from "@web/../tests/helpers/mock_env";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { menuService } from "@web/webclient/menus/menu_service";
|
||||
import { actionService } from "@web/webclient/actions/action_service";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { viewService } from "@web/views/view_service";
|
||||
|
||||
const { Model } = spreadsheet;
|
||||
|
||||
@@ -114,8 +112,6 @@ QUnit.module(
|
||||
},
|
||||
};
|
||||
registry.category("services").add("menu", menuService).add("action", actionService);
|
||||
registry.category("services").add("view", viewService, { force: true }); // #action-serv-leg-compat-js-class
|
||||
registry.category("services").add("orm", ormService, { force: true }); // #action-serv-leg-compat-js-class
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import { createBasicChart } from "@spreadsheet/../tests/utils/commands";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { menuService } from "@web/webclient/menus/menu_service";
|
||||
import { actionService } from "@web/webclient/actions/action_service";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { viewService } from "@web/views/view_service";
|
||||
import { mountSpreadsheet } from "@spreadsheet/../tests/utils/ui";
|
||||
import { createModelWithDataSource } from "@spreadsheet/../tests/utils/model";
|
||||
|
||||
@@ -155,8 +153,6 @@ QUnit.module(
|
||||
};
|
||||
patchWithCleanup(session, { uid: 1 });
|
||||
registry.category("services").add("menu", menuService).add("action", actionService);
|
||||
registry.category("services").add("view", viewService, { force: true }); // #action-serv-leg-compat-js-class
|
||||
registry.category("services").add("orm", ormService, { force: true }); // #action-serv-leg-compat-js-class
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import { spreadsheetLinkMenuCellService } from "@spreadsheet/ir_ui_menu/index";
|
||||
import spreadsheet from "@spreadsheet/o_spreadsheet/o_spreadsheet_extended";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { actionService } from "@web/webclient/actions/action_service";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { viewService } from "@web/views/view_service";
|
||||
import { menuService } from "@web/webclient/menus/menu_service";
|
||||
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
|
||||
import { setCellContent } from "@spreadsheet/../tests/utils/commands";
|
||||
@@ -19,8 +17,6 @@ function beforeEach() {
|
||||
.add("menu", menuService)
|
||||
.add("action", actionService)
|
||||
.add("spreadsheetLinkMenuCell", spreadsheetLinkMenuCellService);
|
||||
registry.category("services").add("view", viewService, { force: true }); // #action-serv-leg-compat-js-class
|
||||
registry.category("services").add("orm", ormService, { force: true }); // #action-serv-leg-compat-js-class
|
||||
}
|
||||
|
||||
QUnit.module("spreadsheet > menu link cells", { beforeEach }, () => {
|
||||
|
||||
@@ -6,8 +6,6 @@ import { actionService } from "@web/webclient/actions/action_service";
|
||||
import { menuService } from "@web/webclient/menus/menu_service";
|
||||
import { spreadsheetLinkMenuCellService } from "@spreadsheet/ir_ui_menu/index";
|
||||
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
|
||||
import { viewService } from "@web/views/view_service";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { getMenuServerData } from "@spreadsheet/../tests/links/menu_data_utils";
|
||||
import { patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import { getEvaluatedCell } from "../utils/getters";
|
||||
@@ -21,8 +19,6 @@ function beforeEach() {
|
||||
.add("menu", menuService)
|
||||
.add("action", actionService)
|
||||
.add("spreadsheetLinkMenuCell", spreadsheetLinkMenuCellService);
|
||||
registry.category("services").add("view", viewService, { force: true }); // #action-serv-leg-compat-js-class
|
||||
registry.category("services").add("orm", ormService, { force: true }); // #action-serv-leg-compat-js-class
|
||||
}
|
||||
|
||||
QUnit.module("spreadsheet > link", { beforeEach });
|
||||
|
||||
@@ -19,7 +19,6 @@ This module provides the core of the Odoo Web Client.
|
||||
'views/report_templates.xml',
|
||||
'views/base_document_layout_views.xml',
|
||||
'views/speedscope_template.xml',
|
||||
'views/lazy_assets.xml',
|
||||
'views/neutralize_views.xml',
|
||||
'data/ir_attachment.xml',
|
||||
'data/report_layout.xml',
|
||||
@@ -171,8 +170,6 @@ This module provides the core of the Odoo Web Client.
|
||||
'web/static/src/legacy/js/core/smooth_scroll_on_drag.js',
|
||||
'web/static/src/legacy/js/widgets/colorpicker.js',
|
||||
'web/static/src/legacy/xml/colorpicker.xml',
|
||||
'web/static/src/legacy/js/widgets/translation_dialog.js',
|
||||
'web/static/src/legacy/xml/translation_dialog.xml',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
('include', 'web._assets_helpers'),
|
||||
@@ -230,7 +227,6 @@ This module provides the core of the Odoo Web Client.
|
||||
'web/static/src/legacy/legacy_client_actions.js',
|
||||
'web/static/src/legacy/legacy_dialog.js',
|
||||
'web/static/src/legacy/legacy_load_views.js',
|
||||
'web/static/src/legacy/legacy_views.js',
|
||||
'web/static/src/legacy/legacy_promise_error_handler.js',
|
||||
'web/static/src/legacy/legacy_rpc_error_handler.js',
|
||||
'web/static/src/legacy/root_widget.js',
|
||||
@@ -254,7 +250,6 @@ This module provides the core of the Odoo Web Client.
|
||||
'web/static/src/legacy/js/widgets/date_picker.js',
|
||||
'web/static/src/legacy/js/widgets/domain_selector_dialog.js',
|
||||
'web/static/src/legacy/js/widgets/domain_selector.js',
|
||||
'web/static/src/legacy/js/widgets/iframe_widget.js',
|
||||
'web/static/src/legacy/js/widgets/model_field_selector.js',
|
||||
'web/static/src/legacy/js/widgets/model_field_selector_popover.js',
|
||||
'web/static/src/legacy/js/env.js',
|
||||
@@ -264,15 +259,8 @@ This module provides the core of the Odoo Web Client.
|
||||
'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/search_panel.xml',
|
||||
'web/static/src/legacy/xml/week_days.xml',
|
||||
],
|
||||
"web.assets_backend_legacy_lazy": [
|
||||
("include", "web._assets_helpers"),
|
||||
('include', 'web._assets_backend_helpers'),
|
||||
'web/static/src/scss/pre_variables.scss',
|
||||
'web/static/lib/bootstrap/scss/_variables.scss',
|
||||
],
|
||||
'web.assets_frontend_minimal': [
|
||||
'web/static/src/legacy/js/promise_extension.js',
|
||||
'web/static/src/boot.js',
|
||||
@@ -415,8 +403,6 @@ This module provides the core of the Odoo Web Client.
|
||||
'web/static/src/legacy/js/core/smooth_scroll_on_drag.js',
|
||||
'web/static/src/legacy/js/widgets/colorpicker.js',
|
||||
'web/static/src/legacy/xml/colorpicker.xml',
|
||||
'web/static/src/legacy/js/widgets/translation_dialog.js',
|
||||
'web/static/src/legacy/xml/translation_dialog.xml',
|
||||
|
||||
'web/static/src/env.js',
|
||||
'web/static/src/core/utils/transitions.scss', # included early because used by other files
|
||||
|
||||
@@ -7,7 +7,6 @@ import { objectToQuery } from "../core/browser/router_service";
|
||||
import { useDebugCategory } from "../core/debug/debug_context";
|
||||
import { Dialog } from "../core/dialog/dialog";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { ViewNotFoundError } from "@web/views/view";
|
||||
import { cleanDomFromBootstrap, wrapSuccessOrFail, useLegacyRefs } from "./utils";
|
||||
import { mapDoActionOptionAPI } from "./backend_utils";
|
||||
|
||||
@@ -16,10 +15,8 @@ import {
|
||||
onMounted,
|
||||
onWillUnmount,
|
||||
onWillUpdateProps,
|
||||
status,
|
||||
useEffect,
|
||||
useExternalListener,
|
||||
useComponent,
|
||||
xml,
|
||||
} from "@odoo/owl";
|
||||
|
||||
@@ -245,179 +242,3 @@ export class ClientActionAdapter extends ActionAdapter {
|
||||
this.pushState(state);
|
||||
}
|
||||
}
|
||||
|
||||
const magicReloadSymbol = Symbol("magicReload");
|
||||
|
||||
function useMagicLegacyReload() {
|
||||
const comp = useComponent();
|
||||
if (comp.props.widget && comp.props.widget[magicReloadSymbol]) {
|
||||
return comp.props.widget[magicReloadSymbol];
|
||||
}
|
||||
let legacyReloadProm = null;
|
||||
const getReloadProm = () => legacyReloadProm;
|
||||
let manualReload;
|
||||
useEffect(
|
||||
() => {
|
||||
const widget = comp.widget;
|
||||
const controllerReload = widget.reload;
|
||||
widget.reload = function (...args) {
|
||||
manualReload = true;
|
||||
legacyReloadProm = controllerReload.call(widget, ...args);
|
||||
return legacyReloadProm.finally(() => {
|
||||
if (manualReload) {
|
||||
legacyReloadProm = null;
|
||||
manualReload = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
const controllerUpdate = widget.update;
|
||||
widget.update = function (...args) {
|
||||
const updateProm = controllerUpdate.call(widget, ...args);
|
||||
const manualUpdate = !manualReload;
|
||||
if (manualUpdate) {
|
||||
legacyReloadProm = updateProm;
|
||||
}
|
||||
return updateProm.finally(() => {
|
||||
if (manualUpdate) {
|
||||
legacyReloadProm = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
widget[magicReloadSymbol] = getReloadProm;
|
||||
},
|
||||
() => []
|
||||
);
|
||||
return getReloadProm;
|
||||
}
|
||||
|
||||
export class ViewAdapter extends ActionAdapter {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.actionService = useService("action");
|
||||
this.vm = useService("view");
|
||||
this.shouldUpdateWidget = true;
|
||||
this.magicReload = useMagicLegacyReload();
|
||||
const debugContext = {
|
||||
action: this.props.viewParams.action,
|
||||
component: this,
|
||||
};
|
||||
useDebugCategory("action", debugContext);
|
||||
useDebugCategory("view", debugContext);
|
||||
this.env = Component.env;
|
||||
}
|
||||
|
||||
get actionId() {
|
||||
return this.props.viewParams.action.jsId;
|
||||
}
|
||||
|
||||
async onWillStart() {
|
||||
if (this.props.widget) {
|
||||
this.widget = this.props.widget;
|
||||
this.widget.setParent(this);
|
||||
if (this.props.onReverseBreadcrumb) {
|
||||
await this.props.onReverseBreadcrumb();
|
||||
}
|
||||
return this.updateWidget(this.props.viewParams);
|
||||
} else {
|
||||
const view = new this.props.View(this.props.viewInfo, this.props.viewParams);
|
||||
this.widget = await view.getController(this);
|
||||
if (status(this) === "destroyed") {
|
||||
// the component might have been destroyed meanwhile, but if so, `this.widget` wasn't
|
||||
// destroyed by OwlCompatibility layer as it wasn't set yet, so destroy it now
|
||||
if (!this.actionService.__legacy__isActionInStack(this.actionId)) {
|
||||
this.widget.destroy();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this.widget._widgetRenderAndInsert(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async updateWidget(nextProps) {
|
||||
const shouldUpdateWidget = this.shouldUpdateWidget;
|
||||
this.shouldUpdateWidget = true;
|
||||
if (!shouldUpdateWidget) {
|
||||
return this.magicReload();
|
||||
}
|
||||
await this.widget.willRestore();
|
||||
const options = {
|
||||
...this.props.viewParams,
|
||||
...nextProps.viewParams,
|
||||
shouldUpdateSearchComponents: true,
|
||||
};
|
||||
if (!this.magicReload()) {
|
||||
this.widget.reload(options);
|
||||
}
|
||||
return this.magicReload();
|
||||
}
|
||||
|
||||
async renderWidget() {}
|
||||
|
||||
/**
|
||||
* Override to add the state of the legacy controller in the exported state.
|
||||
*/
|
||||
exportState() {
|
||||
const state = super.exportState();
|
||||
const widgetState = this.__widget.exportState();
|
||||
return Object.assign({}, state, widgetState);
|
||||
}
|
||||
|
||||
async loadViews(resModel, context, views) {
|
||||
return (await this.vm.loadViews({ resModel, views, context }, {})).__legacy__.fields_views;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
async _trigger_up(ev) {
|
||||
const payload = ev.data;
|
||||
if (ev.name === "switch_view") {
|
||||
if (payload.view_type === "form") {
|
||||
if (payload.res_id) {
|
||||
const activeIds = this.__widget.model.get(this.__widget.handle).res_ids;
|
||||
return this.props.selectRecord(payload.res_id, {
|
||||
mode: payload.mode,
|
||||
activeIds,
|
||||
});
|
||||
} else {
|
||||
return this.props.createRecord();
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await this.actionService.switchView(payload.view_type);
|
||||
} catch (e) {
|
||||
if (typeof e === "object" && e instanceof ViewNotFoundError) {
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else if (ev.name === "execute_action") {
|
||||
const buttonContext = new Context(payload.action_data.context).eval();
|
||||
const envContext = new Context(payload.env.context).eval();
|
||||
wrapSuccessOrFail(
|
||||
this.actionService.doActionButton({
|
||||
args: payload.action_data.args,
|
||||
buttonContext: buttonContext,
|
||||
context: envContext,
|
||||
close: payload.action_data.close,
|
||||
resModel: payload.env.model,
|
||||
name: payload.action_data.name,
|
||||
resId: payload.env.currentID || null,
|
||||
resIds: payload.env.resIDs,
|
||||
special: payload.action_data.special,
|
||||
type: payload.action_data.type,
|
||||
onClose: payload.on_closed,
|
||||
effect: payload.action_data.effect,
|
||||
}),
|
||||
payload
|
||||
);
|
||||
} else {
|
||||
super._trigger_up(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import ActionModel from "@web/legacy/js/views/action_model";
|
||||
import { makeContext } from "@web/core/context";
|
||||
|
||||
/**
|
||||
* @param {string} state
|
||||
@@ -17,13 +16,8 @@ function searchModelStateFromLegacy(state) {
|
||||
const newState = {};
|
||||
|
||||
if (parsedState.ControlPanelModelExtension) {
|
||||
const {
|
||||
query,
|
||||
filters,
|
||||
nextGroupId,
|
||||
nextGroupNumber,
|
||||
nextId,
|
||||
} = parsedState.ControlPanelModelExtension;
|
||||
const { query, filters, nextGroupId, nextGroupNumber, nextId } =
|
||||
parsedState.ControlPanelModelExtension;
|
||||
|
||||
newState.nextGroupId = nextGroupId;
|
||||
newState.nextGroupNumber = nextGroupNumber;
|
||||
@@ -112,97 +106,7 @@ function searchModelStateFromLegacy(state) {
|
||||
return JSON.stringify(newState);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} state
|
||||
* @returns {string}
|
||||
*/
|
||||
export function searchModelStateToLegacy(state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedState = JSON.parse(state);
|
||||
const query = [];
|
||||
for (const queryElem of parsedState.query) {
|
||||
const searchItemId = queryElem.searchItemId;
|
||||
const searchItem = parsedState.searchItems[searchItemId];
|
||||
const groupId = searchItem.groupId;
|
||||
const legacyQueryElem = { filterId: searchItemId, groupId };
|
||||
switch (searchItem.type) {
|
||||
case "dateFilter":
|
||||
legacyQueryElem.optionId = queryElem.generatorId;
|
||||
break;
|
||||
case "dateGroupBy":
|
||||
legacyQueryElem.optionId = queryElem.intervalId;
|
||||
break;
|
||||
case "comparison":
|
||||
legacyQueryElem.type = "comparison";
|
||||
legacyQueryElem.dateFilterId = searchItem.dateFilterId;
|
||||
break;
|
||||
case "field":
|
||||
legacyQueryElem.value = queryElem.autocompleteValue.value;
|
||||
legacyQueryElem.label = queryElem.autocompleteValue.label;
|
||||
legacyQueryElem.operator = queryElem.autocompleteValue.operator;
|
||||
break;
|
||||
}
|
||||
query.push(legacyQueryElem);
|
||||
}
|
||||
|
||||
const filters = {};
|
||||
for (const item of Object.values(parsedState.searchItems)) {
|
||||
const filter = Object.assign({}, item);
|
||||
switch (item.type) {
|
||||
case "dateGroupBy":
|
||||
filter.type = "groupBy";
|
||||
filter.hasOptions = true;
|
||||
filter.defaultOptionId = item.defaultIntervalId;
|
||||
delete filter.defaultIntervalId;
|
||||
break;
|
||||
case "dateFilter":
|
||||
filter.type = "filter";
|
||||
filter.isDateFilter = true;
|
||||
filter.hasOptions = true;
|
||||
filter.defaultOptionId = item.defaultGeneratorId;
|
||||
delete filter.defaultGeneratorId;
|
||||
break;
|
||||
case "favorite":
|
||||
filter.orderedBy = item.orderBy;
|
||||
delete filter.orderBy;
|
||||
break;
|
||||
case "filter": {
|
||||
let context = item.context;
|
||||
try {
|
||||
context = makeContext([context]);
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
filter.context = context;
|
||||
}
|
||||
}
|
||||
filters[item.id] = filter;
|
||||
}
|
||||
|
||||
const { nextGroupId, nextGroupNumber, nextId, sections, searchPanelInfo } = parsedState;
|
||||
const legacyState = {};
|
||||
legacyState.ControlPanelModelExtension = {
|
||||
query,
|
||||
filters,
|
||||
nextGroupId,
|
||||
nextGroupNumber,
|
||||
nextId,
|
||||
};
|
||||
legacyState.SearchPanelModelExtension = { sections, searchPanelInfo };
|
||||
|
||||
for (const [key, extension] of Object.entries(ActionModel.registry.entries())) {
|
||||
if (!["ControlPanel", "SearchPanel"].includes(key) && parsedState[key] !== undefined) {
|
||||
legacyState[extension.name] = parsedState[key];
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(legacyState);
|
||||
}
|
||||
|
||||
export function getGlobalState(legacyControllerState) {
|
||||
function getGlobalState(legacyControllerState) {
|
||||
const { resIds, searchModel, searchPanel } = legacyControllerState;
|
||||
const globalState = {};
|
||||
if (searchPanel) {
|
||||
@@ -218,18 +122,6 @@ export function getGlobalState(legacyControllerState) {
|
||||
return globalState;
|
||||
}
|
||||
|
||||
export function getLocalState(legacyControllerState) {
|
||||
const state = Object.assign({}, legacyControllerState);
|
||||
if ("currentId" in state) {
|
||||
state.resId = state.currentId;
|
||||
delete state.currentId;
|
||||
}
|
||||
delete state.searchModel;
|
||||
delete state.searchPanel;
|
||||
delete state.resIds;
|
||||
return state;
|
||||
}
|
||||
|
||||
export function mapDoActionOptionAPI(legacyOptions) {
|
||||
legacyOptions = legacyOptions || {};
|
||||
// use camelCase instead of snake_case for some keys
|
||||
|
||||
@@ -1,607 +0,0 @@
|
||||
/** @odoo-module alias=web.AbstractController **/
|
||||
|
||||
/**
|
||||
* The Controller class is the class coordinating the model and the renderer.
|
||||
* It is the C in MVC, and is what was formerly known in Odoo as a View.
|
||||
*
|
||||
* Its role is to listen to events bubbling up from the model/renderer, and call
|
||||
* the appropriate methods if necessary. It also render control panel buttons,
|
||||
* and react to changes in the search view. Basically, all interactions from
|
||||
* the renderer/model with the outside world (meaning server/reading in session/
|
||||
* reading localstorage, ...) has to go through the controller.
|
||||
*/
|
||||
|
||||
import ActionMixin from 'web.ActionMixin';
|
||||
import { loadCSS, loadJS } from "@web/core/assets";
|
||||
import concurrency from 'web.concurrency';
|
||||
import { ComponentWrapper } from 'web.OwlCompatibility';
|
||||
import mvc from 'web.mvc';
|
||||
import session from 'web.session';
|
||||
|
||||
var AbstractController = mvc.Controller.extend(ActionMixin, {
|
||||
custom_events: _.extend({}, ActionMixin.custom_events, {
|
||||
navigation_move: '_onNavigationMove',
|
||||
open_record: '_onOpenRecord',
|
||||
switch_view: '_onSwitchView',
|
||||
}),
|
||||
events: {
|
||||
'click a[type="action"]': '_onActionClicked',
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Object} param
|
||||
* @param {Object[]} params.actionViews
|
||||
* @param {string} params.activeActions
|
||||
* @param {string} params.bannerRoute
|
||||
* @param {Object} [params.controlPanel]
|
||||
* @param {string} params.controllerID an id to ease the communication with
|
||||
* upstream components
|
||||
* @param {string} params.displayName
|
||||
* @param {Object} params.initialState
|
||||
* @param {string} params.modelName
|
||||
* @param {ActionModel} [params.searchModel]
|
||||
* @param {string} [params.searchPanel]
|
||||
* @param {string} params.viewType
|
||||
* @param {boolean} [params.withControlPanel]
|
||||
* @param {boolean} [params.withSearchPanel]
|
||||
*/
|
||||
init: function (parent, model, renderer, params) {
|
||||
this._super.apply(this, arguments);
|
||||
this._title = params.displayName;
|
||||
this.modelName = params.modelName;
|
||||
this.activeActions = params.activeActions;
|
||||
this.controllerID = params.controllerID;
|
||||
this.initialState = params.initialState;
|
||||
this.bannerRoute = params.bannerRoute;
|
||||
this.actionViews = params.actionViews;
|
||||
this.viewType = params.viewType;
|
||||
// use a DropPrevious to correctly handle concurrent updates
|
||||
this.dp = new concurrency.DropPrevious();
|
||||
|
||||
this.withControlPanel = params.withControlPanel;
|
||||
this.withSearchPanel = params.withSearchPanel;
|
||||
if (params.searchModel) {
|
||||
this.searchModel = params.searchModel;
|
||||
}
|
||||
if (this.withControlPanel) {
|
||||
const { Component, props } = params.controlPanel;
|
||||
this.ControlPanel = Component;
|
||||
this.controlPanelProps = props;
|
||||
}
|
||||
if (this.withSearchPanel) {
|
||||
const { Component, props } = params.searchPanel;
|
||||
this.SearchPanel = Component;
|
||||
this.searchPanelProps = props;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Simply renders and updates the url.
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
start: async function () {
|
||||
this.$el.addClass('o_view_controller');
|
||||
this.renderButtons();
|
||||
const promises = [this._super(...arguments)];
|
||||
if (this.withControlPanel) {
|
||||
this._updateControlPanelProps(this.initialState);
|
||||
this._controlPanelWrapper = new ComponentWrapper(this, this.ControlPanel, this.controlPanelProps);
|
||||
this._controlPanelWrapper.env.bus.on('focus-view', this, () => this._giveFocus());
|
||||
promises.push(this._controlPanelWrapper.mount(this.el, { position: 'first-child' }));
|
||||
}
|
||||
if (this.withSearchPanel) {
|
||||
this._searchPanelWrapper = new ComponentWrapper(this, this.SearchPanel, this.searchPanelProps);
|
||||
const content = this.el.querySelector(':scope .o_content');
|
||||
content.classList.add('o_controller_with_searchpanel');
|
||||
promises.push(this._searchPanelWrapper.mount(content, { position: 'first-child' }));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
await this._update(this.initialState, { shouldUpdateSearchComponents: false });
|
||||
this.updateButtons();
|
||||
this.el.classList.toggle('o_legacy_view_sample_data', this.model.isInSampleMode());
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
destroy: function () {
|
||||
if (this.$buttons) {
|
||||
this.$buttons.off();
|
||||
}
|
||||
ActionMixin.destroy.call(this);
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Called each time the controller is attached into the DOM.
|
||||
*/
|
||||
on_attach_callback: function () {
|
||||
ActionMixin.on_attach_callback.call(this);
|
||||
this.searchModel.on('search', this, this._onSearch);
|
||||
if (this.withControlPanel) {
|
||||
this.searchModel.on('get-controller-query-params', this, this._onGetOwnedQueryParams);
|
||||
}
|
||||
if (!(this.renderer instanceof ComponentWrapper)) {
|
||||
this.renderer.on_attach_callback();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called each time the controller is detached from the DOM.
|
||||
*/
|
||||
on_detach_callback: function () {
|
||||
ActionMixin.on_detach_callback.call(this);
|
||||
this.searchModel.off('search', this);
|
||||
if (this.withControlPanel) {
|
||||
this.searchModel.off('get-controller-query-params', this);
|
||||
}
|
||||
if (!(this.renderer instanceof ComponentWrapper)) {
|
||||
this.renderer.on_detach_callback();
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discards the changes made on the record associated to the given ID, or
|
||||
* all changes made by the current controller if no recordID is given. For
|
||||
* example, when the user opens the 'home' screen, the action manager calls
|
||||
* this method on the active view to make sure it is ok to open the home
|
||||
* screen (and lose all current state).
|
||||
*
|
||||
* Note that it returns a Promise, because the view could choose to ask the
|
||||
* user if he agrees to discard.
|
||||
*
|
||||
* @param {string} [recordID]
|
||||
* if not given, we consider all the changes made by the controller
|
||||
* @param {Object} [options]
|
||||
* @returns {Promise} resolved if properly discarded, rejected otherwise
|
||||
*/
|
||||
discardChanges: function (recordID, options) {
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* Export the state of the controller containing information that is shared
|
||||
* between different controllers of a same action (like the current search
|
||||
* model state or the states of some components).
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
exportState() {
|
||||
const exported = {
|
||||
searchModel: this.searchModel.exportState(),
|
||||
};
|
||||
if (this.withSearchPanel) {
|
||||
const searchPanel = this._searchPanelWrapper.componentRef.comp;
|
||||
exported.searchPanel = searchPanel.exportState();
|
||||
}
|
||||
return exported;
|
||||
},
|
||||
/**
|
||||
* Parses and imports a previously exported state.
|
||||
*
|
||||
* @param {Object} state
|
||||
*/
|
||||
importState(state) {
|
||||
this.searchModel.importState(state.searchModel);
|
||||
if (this.withSearchPanel) {
|
||||
const searchPanel = this._searchPanelWrapper.componentRef.comp;
|
||||
searchPanel.importState(state.searchPanel);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The use of this method is discouraged. It is still snakecased, because
|
||||
* it currently is used in many templates, but we will move to a simpler
|
||||
* mechanism as soon as we can.
|
||||
*
|
||||
* @deprecated
|
||||
* @param {string} action type of action, such as 'create', 'read', ...
|
||||
* @returns {boolean}
|
||||
*/
|
||||
is_action_enabled: function (action) {
|
||||
return this.activeActions[action];
|
||||
},
|
||||
/**
|
||||
* Short helper method to reload the view
|
||||
*
|
||||
* @param {Object} [params={}]
|
||||
* @param {Object} [params.controllerState={}]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
reload: async function (params = {}) {
|
||||
if (params.controllerState) {
|
||||
this.importState(params.controllerState);
|
||||
Object.assign(params, this.searchModel.get('query'));
|
||||
}
|
||||
return this.update(params, {});
|
||||
},
|
||||
/**
|
||||
* This is the main entry point for the controller. Changes from the search
|
||||
* view arrive in this method, and internal changes can sometimes also call
|
||||
* this method. It is basically the way everything notifies the controller
|
||||
* that something has changed.
|
||||
*
|
||||
* The update method is responsible for fetching necessary data, then
|
||||
* updating the renderer and wait for the rendering to complete.
|
||||
*
|
||||
* @param {Object} params will be given to the model and to the renderer
|
||||
* @param {Object} [options={}]
|
||||
* @param {boolean} [options.reload=true] if true, the model will reload data
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async update(params, options = {}) {
|
||||
const shouldReload = 'reload' in options ? options.reload : true;
|
||||
if (shouldReload) {
|
||||
this.handle = await this.dp.add(this.model.reload(this.handle, params));
|
||||
}
|
||||
const localState = this.renderer.getLocalState();
|
||||
const state = this.model.get(this.handle, { withSampleData: true });
|
||||
const promises = [
|
||||
this._updateRendererState(state, params).then(() => {
|
||||
this.renderer.setLocalState(localState);
|
||||
}),
|
||||
this._update(this.model.get(this.handle), params)
|
||||
];
|
||||
await this.dp.add(Promise.all(promises));
|
||||
this.updateButtons();
|
||||
this.el.classList.toggle('o_legacy_view_sample_data', this.model.isInSampleMode());
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
|
||||
/**
|
||||
* Meant to be overriden to return a proper object.
|
||||
* @private
|
||||
* @param {Object} [state]
|
||||
* @return {(Object|null)}
|
||||
*/
|
||||
_getPagingInfo: function (state) {
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* Meant to be overriden to return a proper object.
|
||||
* @private
|
||||
* @param {Object} [state]
|
||||
* @return {(Object|null)}
|
||||
*/
|
||||
_getActionMenuItems: function (state) {
|
||||
return null;
|
||||
},
|
||||
/**
|
||||
* Gives the focus to the renderer if not in sample mode.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_giveFocus() {
|
||||
if (!this.model.isInSampleMode()) {
|
||||
this.renderer.giveFocus();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Hide an onboarding banner if present and visible
|
||||
* @private
|
||||
*/
|
||||
_hideBanner: function () {
|
||||
if (this._$banner.length) {
|
||||
const $bannerElement = this._$banner.filter('.o_onboarding_container.collapse.show');
|
||||
if ($bannerElement.length) {
|
||||
Collapse.getOrCreateInstance($bannerElement[0]).toggle();
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* This method is the way a view can notifies the outside world that
|
||||
* something has changed. The main use for this is to update the url, for
|
||||
* example with a new id.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_pushState: function () {
|
||||
this.trigger_up('push_state', {
|
||||
controllerID: this.controllerID,
|
||||
state: this.getState(),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {function} callback function to execute before removing classname
|
||||
* 'o_legacy_view_sample_data' (may be async). This allows to reload and/or
|
||||
* rerender before removing the className, thus preventing the view from
|
||||
* flickering.
|
||||
*/
|
||||
async _removeSampleData(callback) {
|
||||
this.model.leaveSampleMode();
|
||||
if (callback) {
|
||||
await callback();
|
||||
}
|
||||
this.el.classList.remove('o_legacy_view_sample_data');
|
||||
},
|
||||
/**
|
||||
* Renders the html provided by the route specified by the
|
||||
* bannerRoute attribute on the controller (banner_route in the template).
|
||||
* Renders it before the view output and add a css class 'o_has_banner' to it.
|
||||
* There can be only one banner displayed at a time.
|
||||
*
|
||||
* If the banner contains stylesheet links or js files, they are moved to <head>
|
||||
* (and will only be fetched once).
|
||||
*
|
||||
* Route example:
|
||||
* @http.route('/module/hello', auth='user', type='json')
|
||||
* def hello(self):
|
||||
* return {'html': '<h1>hello, world</h1>'}
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_renderBanner: async function () {
|
||||
if (this.bannerRoute !== undefined) {
|
||||
const response = await this._rpc({
|
||||
route: this.bannerRoute,
|
||||
params: {context: session.user_context},
|
||||
});
|
||||
if (!response.html) {
|
||||
this.$el.removeClass('o_has_banner');
|
||||
return Promise.resolve();
|
||||
}
|
||||
this.$el.addClass('o_has_banner');
|
||||
var $banner = $(response.html);
|
||||
// we should only display one banner at a time
|
||||
if (this._$banner && this._$banner.remove) {
|
||||
this._$banner.remove();
|
||||
}
|
||||
// Css and js are moved to <head>
|
||||
var defs = [];
|
||||
$('link[rel="stylesheet"]', $banner).each(function (i, link) {
|
||||
defs.push(loadCSS(link.href));
|
||||
link.remove();
|
||||
});
|
||||
$('script[type="text/javascript"]', $banner).each(function (i, js) {
|
||||
defs.push(loadJS(js.src));
|
||||
js.remove();
|
||||
});
|
||||
await Promise.all(defs);
|
||||
$banner.insertBefore(this.$('> .o_content'));
|
||||
this._$banner = $banner;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_startRenderer: function () {
|
||||
if (this.renderer instanceof ComponentWrapper) {
|
||||
return this.renderer.mount(this.$('.o_content')[0]);
|
||||
}
|
||||
return this.renderer.appendTo(this.$('.o_content'));
|
||||
},
|
||||
/**
|
||||
* This method is called after each update or when the start method is
|
||||
* completed.
|
||||
*
|
||||
* Its primary use is to be used as a hook to update all parts of the UI,
|
||||
* besides the renderer. For example, it may be used to enable/disable
|
||||
* some buttons in the control panel, such as the current graph type for a
|
||||
* graph view.
|
||||
*
|
||||
* FIXME: this hook should be synchronous, and called once async rendering
|
||||
* has been done.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} state the state given by the model
|
||||
* @param {Object} [params={}]
|
||||
* @param {Array} [params.breadcrumbs]
|
||||
* @param {Object} [params.shouldUpdateSearchComponents]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _update(state, params) {
|
||||
// AAB: update the control panel -> this will be moved elsewhere at some point
|
||||
if (!this.$buttons) {
|
||||
this.renderButtons();
|
||||
}
|
||||
const promises = [this._renderBanner()];
|
||||
if (params.shouldUpdateSearchComponents !== false) {
|
||||
if (this.withControlPanel) {
|
||||
this._updateControlPanelProps(state);
|
||||
if (params.breadcrumbs) {
|
||||
this.controlPanelProps.breadcrumbs = params.breadcrumbs;
|
||||
}
|
||||
promises.push(this.updateControlPanel());
|
||||
}
|
||||
if (this.withSearchPanel) {
|
||||
this._updateSearchPanel();
|
||||
}
|
||||
}
|
||||
this._pushState();
|
||||
await Promise.all(promises);
|
||||
},
|
||||
/**
|
||||
* Can be used to update the key 'cp_content'. This method is called in start and _update methods.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} state the state given by the model
|
||||
*/
|
||||
_updateControlPanelProps(state) {
|
||||
if (!this.controlPanelProps.cp_content) {
|
||||
this.controlPanelProps.cp_content = {};
|
||||
}
|
||||
if (this.$buttons) {
|
||||
this.controlPanelProps.cp_content.$buttons = this.$buttons;
|
||||
}
|
||||
Object.assign(this.controlPanelProps, {
|
||||
actionMenus: this._getActionMenuItems(state),
|
||||
pager: this._getPagingInfo(state),
|
||||
title: this.getTitle(),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} state
|
||||
* @param {Object} newProps
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_updatePaging: async function (state, newProps) {
|
||||
const pagingInfo = this._getPagingInfo(state);
|
||||
if (pagingInfo) {
|
||||
Object.assign(pagingInfo, newProps);
|
||||
return this.updateControlPanel({ pager: pagingInfo });
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Updates the state of the renderer (handle both Widget and Component
|
||||
* renderers).
|
||||
*
|
||||
* @private
|
||||
* @param {Object} state the model state
|
||||
* @param {Object} [params={}] will be given to the model and to the renderer
|
||||
* @return {Promise}
|
||||
*/
|
||||
_updateRendererState(state, params = {}) {
|
||||
if (this.renderer instanceof ComponentWrapper) {
|
||||
return this.renderer.update(state);
|
||||
}
|
||||
return this.renderer.updateState(state, params);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} [newProps={}]
|
||||
* @return {Promise}
|
||||
*/
|
||||
async _updateSearchPanel(newProps) {
|
||||
Object.assign(this.searchPanelProps, newProps);
|
||||
await this._searchPanelWrapper.update(this.searchPanelProps);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* When a user clicks on an <a> link with type="action", we need to actually
|
||||
* do the action. This kind of links is used a lot in no-content helpers.
|
||||
*
|
||||
* * if the link has both data-model and data-method attributes, the
|
||||
* corresponding method is called, chained to any action it would
|
||||
* return. An optional data-reload-on-close (set to a non-falsy value)
|
||||
* also causes th underlying view to be reloaded after the dialog is
|
||||
* closed.
|
||||
* * if the link has a name attribute, invoke the action with that
|
||||
* identifier (see :class:`ActionManager.doAction` to not get the
|
||||
* details)
|
||||
* * otherwise an *action descriptor* is built from the link's data-
|
||||
* attributes (model, res-id, views, domain and context)
|
||||
*
|
||||
* @private
|
||||
* @param ev
|
||||
*/
|
||||
_onActionClicked: function (ev) { // FIXME: maybe this should also work on <button> tags?
|
||||
ev.preventDefault();
|
||||
var $target = $(ev.currentTarget);
|
||||
var self = this;
|
||||
var data = $target.data();
|
||||
|
||||
if (data.method !== undefined && data.model !== undefined) {
|
||||
var options = {};
|
||||
if (data.reloadOnClose) {
|
||||
options.on_close = function () {
|
||||
self.trigger_up('reload');
|
||||
};
|
||||
}
|
||||
this.dp.add(this._rpc({
|
||||
model: data.model,
|
||||
method: data.method,
|
||||
context: session.user_context,
|
||||
})).then(function (action) {
|
||||
if (action !== undefined) {
|
||||
self.do_action(action, options);
|
||||
}
|
||||
});
|
||||
} else if ($target.attr('name')) {
|
||||
this.do_action(
|
||||
$target.attr('name'),
|
||||
data.context && {additional_context: data.context}
|
||||
);
|
||||
} else {
|
||||
this.do_action({
|
||||
name: $target.attr('title') || _.str.strip($target.text()),
|
||||
type: 'ir.actions.act_window',
|
||||
res_model: data.model || this.modelName,
|
||||
res_id: data.resId,
|
||||
target: 'current', // TODO: make customisable?
|
||||
views: data.views || (data.resId ? [[false, 'form']] : [[false, 'list'], [false, 'form']]),
|
||||
domain: data.domain || [],
|
||||
}, {
|
||||
additional_context: _.extend({}, data.context)
|
||||
});
|
||||
}
|
||||
if (data.oHideBanner) {
|
||||
self._hideBanner();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Called either from the control panel to focus the controller
|
||||
* or from the view to focus the search bar
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onNavigationMove: function (ev) {
|
||||
switch (ev.data.direction) {
|
||||
case 'up':
|
||||
ev.stopPropagation();
|
||||
this.searchModel.trigger('focus-control-panel');
|
||||
break;
|
||||
case 'down':
|
||||
ev.stopPropagation();
|
||||
this._giveFocus();
|
||||
break;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When an Odoo event arrives requesting a record to be opened, this method
|
||||
* gets the res_id, and request a switch view in the appropriate mode
|
||||
*
|
||||
* Note: this method seems wrong, it relies on the model being a basic model,
|
||||
* to get the res_id. It should receive the res_id in the event data
|
||||
* @todo move this to basic controller?
|
||||
*
|
||||
* @private
|
||||
* @param {OdooEvent} ev
|
||||
* @param {number} ev.data.id The local model ID for the record to be
|
||||
* opened
|
||||
* @param {string} [ev.data.mode='readonly']
|
||||
*/
|
||||
_onOpenRecord: function (ev) {
|
||||
ev.stopPropagation();
|
||||
var record = this.model.get(ev.data.id, {raw: true});
|
||||
this.trigger_up('switch_view', {
|
||||
view_type: 'form',
|
||||
res_id: record.res_id,
|
||||
mode: ev.data.mode || 'readonly',
|
||||
model: this.modelName,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Called when there is a change in the search view, so the current action's
|
||||
* environment needs to be updated with the new domain, context, groupby,...
|
||||
*
|
||||
* @private
|
||||
* @param {Object} searchQuery
|
||||
*/
|
||||
_onSearch: function (searchQuery) {
|
||||
this.reload(_.extend({ offset: 0, groupsOffset: 0 }, searchQuery));
|
||||
},
|
||||
/**
|
||||
* Intercepts the 'switch_view' event to add the controllerID into the data,
|
||||
* and lets the event bubble up.
|
||||
*
|
||||
* @param {OdooEvent} ev
|
||||
*/
|
||||
_onSwitchView: function (ev) {
|
||||
ev.data.controllerID = this.controllerID;
|
||||
},
|
||||
});
|
||||
|
||||
export default AbstractController;
|
||||
@@ -30,153 +30,46 @@
|
||||
|
||||
import fieldUtils from 'web.field_utils';
|
||||
import mvc from 'web.mvc';
|
||||
import SampleServer from 'web.SampleServer';
|
||||
|
||||
|
||||
var AbstractModel = mvc.Model.extend({
|
||||
/**
|
||||
* @param {Widget} parent
|
||||
* @param {Object} [params={}]
|
||||
* @param {Object} [params.fields]
|
||||
* @param {string} [params.modelName]
|
||||
* @param {boolean} [params.isSampleModel=false] if true, will fetch data
|
||||
* from a SampleServer instead of doing RPCs
|
||||
* @param {boolean} [params.useSampleModel=false] if true, will use a sample
|
||||
* model to generate sample data when there is no "real" data in database
|
||||
* @param {AbstractModel} [params.SampleModel] the AbstractModel class
|
||||
* to instantiate as sample model. This model won't do any rpc, but will
|
||||
* rather call a SampleServer that will generate sample data. This param
|
||||
* must be set when params.useSampleModel is true.
|
||||
*/
|
||||
init(parent, params = {}) {
|
||||
this._super(...arguments);
|
||||
this.useSampleModel = params.useSampleModel || false;
|
||||
if (params.isSampleModel) {
|
||||
this.isSampleModel = true;
|
||||
this.sampleServer = new SampleServer(params.modelName, params.fields);
|
||||
} else if (this.useSampleModel) {
|
||||
const sampleModelParams = Object.assign({}, params, {
|
||||
isSampleModel: true,
|
||||
SampleModel: null,
|
||||
useSampleModel: false,
|
||||
});
|
||||
this.sampleModel = new params.SampleModel(this, sampleModelParams);
|
||||
this._isInSampleMode = false;
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Override to call get on the sampleModel when we are in sample mode, and
|
||||
* option 'withSampleData' is set to true.
|
||||
*
|
||||
* @override
|
||||
* @param {any} _
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.withSampleData=false]
|
||||
*/
|
||||
get(_, options) {
|
||||
let state;
|
||||
if (options && options.withSampleData && this._isInSampleMode) {
|
||||
state = this.sampleModel.__get(...arguments);
|
||||
} else {
|
||||
state = this.__get(...arguments);
|
||||
}
|
||||
return state;
|
||||
return this.__get(...arguments);
|
||||
},
|
||||
/**
|
||||
* Under some conditions, the model is designed to generate sample data if
|
||||
* there is no real data in database. This function returns a boolean which
|
||||
* indicates the mode of the model: if true, we are in "sample" mode.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isInSampleMode() {
|
||||
return !!this._isInSampleMode;
|
||||
},
|
||||
/**
|
||||
* Disables the sample data (forever) on this model instance.
|
||||
*/
|
||||
leaveSampleMode() {
|
||||
if (this.useSampleModel) {
|
||||
this.useSampleModel = false;
|
||||
this._isInSampleMode = false;
|
||||
this.sampleModel.destroy();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Override to check if we need to call the sample model (and if so, to do
|
||||
* it) after loading the data, in the case where there is no real data to
|
||||
* display.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
async load(params) {
|
||||
load(params) {
|
||||
this.loadParams = params;
|
||||
const handle = await this.__load(...arguments);
|
||||
await this._callSampleModel('__load', handle, ...arguments);
|
||||
return handle;
|
||||
return this.__load(...arguments);
|
||||
},
|
||||
/**
|
||||
* When something changes, the data may need to be refetched. This is the
|
||||
* job for this method: reloading (only if necessary) all the data and
|
||||
* making sure that they are ready to be redisplayed.
|
||||
* Sometimes, we reload the data with the "same" params as the initial load
|
||||
* params (see '_haveParamsChanged'). When we do, if we were in "sample" mode,
|
||||
* we call again the sample server after the reload if there is still no data
|
||||
* to display. When the parameters change, we automatically leave "sample"
|
||||
* mode.
|
||||
*
|
||||
* @param {any} _
|
||||
* @param {Object} [params]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async reload(_, params) {
|
||||
const handle = await this.__reload(...arguments);
|
||||
if (this._isInSampleMode) {
|
||||
if (!this._haveParamsChanged(params)) {
|
||||
await this._callSampleModel('__reload', handle, ...arguments);
|
||||
} else {
|
||||
this.leaveSampleMode();
|
||||
}
|
||||
}
|
||||
return handle;
|
||||
return this.__reload(...arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} method
|
||||
* @param {any} handle
|
||||
* @param {...any} args
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _callSampleModel(method, handle, ...args) {
|
||||
if (this.useSampleModel && this._isEmpty(handle)) {
|
||||
try {
|
||||
if (method === '__load') {
|
||||
await this.sampleModel.__load(...args);
|
||||
} else if (method === '__reload') {
|
||||
await this.sampleModel.__reload(...args);
|
||||
}
|
||||
this._isInSampleMode = true;
|
||||
} catch (error) {
|
||||
if (error instanceof SampleServer.UnimplementedRouteError) {
|
||||
this.leaveSampleMode();
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.leaveSampleMode();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @returns {Object}
|
||||
@@ -184,18 +77,6 @@ var AbstractModel = mvc.Model.extend({
|
||||
__get() {
|
||||
return {};
|
||||
},
|
||||
/**
|
||||
* This function can be overriden to determine if the result of a load or
|
||||
* a reload is empty. In the affirmative, we will try to generate sample
|
||||
* data to prevent from having an empty state to display.
|
||||
*
|
||||
* @private
|
||||
* @params {any} handle, the value returned by a load or a reload
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isEmpty(/* handle */) {
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* To override to do the initial load of the data (this function is supposed
|
||||
* to be called only once).
|
||||
@@ -240,44 +121,6 @@ var AbstractModel = mvc.Model.extend({
|
||||
async __reload() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
/**
|
||||
* Determines whether or not the given params (reload params) differ from
|
||||
* the initial ones (this.loadParams). This is used to leave "sample" mode
|
||||
* as soon as a parameter (e.g. domain) changes.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} [params={}]
|
||||
* @param {Object} [params.context]
|
||||
* @param {Array[]} [params.domain]
|
||||
* @param {Object} [params.timeRanges]
|
||||
* @param {string[]} [params.groupBy]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_haveParamsChanged(params = {}) {
|
||||
for (const key of ['context', 'domain', 'timeRanges']) {
|
||||
if (key in params) {
|
||||
const diff = JSON.stringify(params[key]) !== JSON.stringify(this.loadParams[key]);
|
||||
if (diff) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.useSampleModel && 'groupBy' in params) {
|
||||
return JSON.stringify(params.groupBy) !== JSON.stringify(this.loadParams.groupedBy);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Override to redirect all rpcs to the SampleServer if this.isSampleModel
|
||||
* is true.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
async _rpc() {
|
||||
if (this.isSampleModel) {
|
||||
return this.sampleServer.mockRpc(...arguments);
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
export default AbstractModel;
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
/** @odoo-module alias=web.AbstractRenderer **/
|
||||
|
||||
/**
|
||||
* The renderer should not handle pagination, data loading, or coordination
|
||||
* with the control panel. It is only concerned with rendering.
|
||||
*
|
||||
*/
|
||||
|
||||
import { renderToString } from '@web/core/utils/render';
|
||||
import * as mvc from 'web.mvc';
|
||||
|
||||
// Renderers may display sample data when there is no real data to display. In
|
||||
// this case the data is displayed with opacity and can't be clicked. Moreover,
|
||||
// we also want to prevent the user from accessing DOM elements with TAB
|
||||
// navigation. This is the list of elements we won't allow to focus.
|
||||
const FOCUSABLE_ELEMENTS = [
|
||||
// focusable by default
|
||||
'a', 'button', 'input', 'select', 'textarea',
|
||||
// manually set
|
||||
'[tabindex="0"]'
|
||||
].map((sel) => `:scope ${sel}`).join(', ');
|
||||
|
||||
/**
|
||||
* @class AbstractRenderer
|
||||
*/
|
||||
export default mvc.Renderer.extend({
|
||||
// Defines the elements suppressed when in demo data. This must be a list
|
||||
// of DOM selectors matching view elements that will:
|
||||
// 1. receive the 'o_sample_data_disabled' class (greyd out & no user events)
|
||||
// 2. have themselves and any of their focusable children removed from the
|
||||
// tab navigation
|
||||
sampleDataTargets: [],
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @param {string} [params.noContentHelp]
|
||||
*/
|
||||
init: function (parent, state, params) {
|
||||
this._super.apply(this, arguments);
|
||||
this.arch = params.arch;
|
||||
this.noContentHelp = params.noContentHelp;
|
||||
this.withSearchPanel = params.withSearchPanel;
|
||||
},
|
||||
/**
|
||||
* The rendering is asynchronous. The start
|
||||
* method simply makes sure that we render the view.
|
||||
*
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async start() {
|
||||
this.$el.addClass(this.arch.attrs.class);
|
||||
if (this.withSearchPanel) {
|
||||
this.$el.addClass('o_renderer_with_searchpanel');
|
||||
}
|
||||
await Promise.all([this._render(), this._super()]);
|
||||
},
|
||||
/**
|
||||
* Called each time the renderer is attached into the DOM.
|
||||
*/
|
||||
on_attach_callback: function () {},
|
||||
/**
|
||||
* Called each time the renderer is detached from the DOM.
|
||||
*/
|
||||
on_detach_callback: function () {},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns any relevant state that the renderer might want to keep.
|
||||
*
|
||||
* The idea is that a renderer can be destroyed, then be replaced by another
|
||||
* one instantiated with the state from the model and the localState from
|
||||
* the renderer, and the end result should be the same.
|
||||
*
|
||||
* The kind of state that we expect the renderer to have is mostly DOM state
|
||||
* such as the scroll position, the currently active tab page, ...
|
||||
*
|
||||
* This method is called before each updateState, by the controller.
|
||||
*
|
||||
* @see setLocalState
|
||||
* @returns {any}
|
||||
*/
|
||||
getLocalState: function () {
|
||||
},
|
||||
/**
|
||||
* Order to focus to be given to the content of the current view
|
||||
*/
|
||||
giveFocus: function () {
|
||||
},
|
||||
/**
|
||||
* Resets state that renderer keeps, state may contains scroll position,
|
||||
* the currently active tab page, ...
|
||||
*
|
||||
* @see getLocalState
|
||||
* @see setLocalState
|
||||
*/
|
||||
resetLocalState() {
|
||||
},
|
||||
/**
|
||||
* This is the reverse operation from getLocalState. With this method, we
|
||||
* expect the renderer to restore all DOM state, if it is relevant.
|
||||
*
|
||||
* This method is called after each updateState, by the controller.
|
||||
*
|
||||
* @see getLocalState
|
||||
* @param {any} localState the result of a call to getLocalState
|
||||
*/
|
||||
setLocalState: function (localState) {
|
||||
},
|
||||
/**
|
||||
* Updates the state of the view. It retriggers a full rerender, unless told
|
||||
* otherwise (for optimization for example).
|
||||
*
|
||||
* @param {any} state
|
||||
* @param {Object} params
|
||||
* @param {boolean} [params.noRender=false]
|
||||
* if true, the method only updates the state without rerendering
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async updateState(state, params) {
|
||||
this._setState(state);
|
||||
if (!params.noRender) {
|
||||
await this._render();
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Renders the widget. This method can be overriden to perform actions
|
||||
* before or after the view has been rendered.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _render() {
|
||||
await this._renderView();
|
||||
this._suppressFocusableElements();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} [context]
|
||||
*/
|
||||
_renderNoContentHelper: function (context) {
|
||||
let templateName;
|
||||
if (!context && this.noContentHelp) {
|
||||
templateName = "web.ActionHelper";
|
||||
context = { noContentHelp: this.noContentHelp };
|
||||
} else {
|
||||
templateName = "web.NoContentHelper";
|
||||
}
|
||||
const innerHTML = renderToString(templateName, context);
|
||||
const template = Object.assign(document.createElement("template"), { innerHTML });
|
||||
this.el.append(template.content.firstChild);
|
||||
},
|
||||
/**
|
||||
* Actual rendering. This method is meant to be overridden by concrete
|
||||
* renderers.
|
||||
*
|
||||
* @abstract
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _renderView() { },
|
||||
/**
|
||||
* Assigns a new state to the renderer if not false.
|
||||
*
|
||||
* @private
|
||||
* @param {any} [state=false]
|
||||
*/
|
||||
_setState(state = false) {
|
||||
if (state !== false) {
|
||||
this.state = state;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Suppresses 'tabindex' property on any focusable element located inside
|
||||
* root elements defined in the `this.sampleDataTargets` object and assigns
|
||||
* the 'o_sample_data_disabled' class to these root elements.
|
||||
*
|
||||
* @private
|
||||
* @see sampleDataTargets
|
||||
*/
|
||||
_suppressFocusableElements() {
|
||||
if (!this.state.isSample || this.isEmbedded) {
|
||||
return;
|
||||
}
|
||||
const rootEls = [];
|
||||
for (const selector of this.sampleDataTargets) {
|
||||
rootEls.push(...this.el.querySelectorAll(`:scope ${selector}`));
|
||||
}
|
||||
const focusableEls = new Set(rootEls);
|
||||
for (const rootEl of rootEls) {
|
||||
rootEl.classList.add('o_sample_data_disabled');
|
||||
for (const focusableEl of rootEl.querySelectorAll(FOCUSABLE_ELEMENTS)) {
|
||||
focusableEls.add(focusableEl);
|
||||
}
|
||||
}
|
||||
for (const focusableEl of focusableEls) {
|
||||
focusableEl.setAttribute('tabindex', -1);
|
||||
if (focusableEl.classList.contains('dropdown-item')) {
|
||||
// Tells Bootstrap to ignore the dropdown item in keynav
|
||||
focusableEl.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
/** @odoo-module alias=web.AbstractRendererOwl **/
|
||||
|
||||
import { LegacyComponent } from "@web/legacy/legacy_component";
|
||||
import { onMounted, onPatched } from "@odoo/owl";
|
||||
|
||||
// Renderers may display sample data when there is no real data to display. In
|
||||
// this case the data is displayed with opacity and can't be clicked. Moreover,
|
||||
// we also want to prevent the user from accessing DOM elements with TAB
|
||||
// navigation. This is the list of elements we won't allow to focus.
|
||||
const FOCUSABLE_ELEMENTS = [
|
||||
// focusable by default
|
||||
'a', 'button', 'input', 'select', 'textarea',
|
||||
// manually set
|
||||
'[tabindex="0"]'
|
||||
].map((sel) => `:scope ${sel}`).join(', ');
|
||||
|
||||
class AbstractRenderer extends LegacyComponent {
|
||||
|
||||
setup() {
|
||||
// Defines the elements suppressed when in demo data. This must be a list
|
||||
// of DOM selectors matching view elements that will:
|
||||
// 1. receive the 'o_sample_data_disabled' class (greyd out & no user events)
|
||||
// 2. have themselves and any of their focusable children removed from the
|
||||
// tab navigation
|
||||
this.sampleDataTargets = [];
|
||||
|
||||
onMounted(() => {
|
||||
this._suppressFocusableElements();
|
||||
});
|
||||
onPatched(() => {
|
||||
this._suppressFocusableElements();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppresses 'tabindex' property on any focusable element located inside
|
||||
* root elements defined in the `this.sampleDataTargets` object and assigns
|
||||
* the 'o_sample_data_disabled' class to these root elements.
|
||||
*
|
||||
* @private
|
||||
* @see sampleDataTargets
|
||||
*/
|
||||
_suppressFocusableElements() {
|
||||
if (!this.props.isSample || this.props.isEmbedded) {
|
||||
const disabledEls = this.el.querySelectorAll(`.o_sample_data_disabled`);
|
||||
disabledEls.forEach(el => el.classList.remove('o_sample_data_disabled'));
|
||||
return;
|
||||
}
|
||||
const rootEls = [];
|
||||
for (const selector of this.sampleDataTargets) {
|
||||
rootEls.push(...this.el.querySelectorAll(`:scope ${selector}`));
|
||||
}
|
||||
const focusableEls = new Set(rootEls);
|
||||
for (const rootEl of rootEls) {
|
||||
rootEl.classList.add('o_sample_data_disabled');
|
||||
for (const focusableEl of rootEl.querySelectorAll(FOCUSABLE_ELEMENTS)) {
|
||||
focusableEls.add(focusableEl);
|
||||
}
|
||||
}
|
||||
for (const focusableEl of focusableEls) {
|
||||
focusableEl.setAttribute('tabindex', -1);
|
||||
if (focusableEl.classList.contains('dropdown-item')) {
|
||||
// Tells Bootstrap to ignore the dropdown item in keynav
|
||||
focusableEl.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default AbstractRenderer;
|
||||
@@ -1,435 +0,0 @@
|
||||
/** @odoo-module alias=web.AbstractView **/
|
||||
|
||||
/**
|
||||
* This is the base class inherited by all (JS) views. Odoo JS views are the
|
||||
* widgets used to display information in the main area of the web client
|
||||
* (note: the search view is not a "JS view" in that sense).
|
||||
*
|
||||
* The abstract view role is to take a set of fields, an arch (the xml
|
||||
* describing the view in db), and some params, and then, to create a
|
||||
* controller, a renderer and a model. This is the classical MVC pattern, but
|
||||
* the word 'view' has historical significance in Odoo code, so we replaced the
|
||||
* V in MVC by the 'renderer' word.
|
||||
*
|
||||
* JS views are supposed to be used like this:
|
||||
* 1. instantiate a view with some arch, fields and params
|
||||
* 2. call the getController method on the view instance. This returns a
|
||||
* controller (with a model and a renderer as sub widgets)
|
||||
* 3. append the controller somewhere
|
||||
*
|
||||
* Note that once a controller has been instantiated, the view class is no
|
||||
* longer useful (unless you want to create another controller), and will be
|
||||
* in most case discarded.
|
||||
*/
|
||||
|
||||
import ActionModel from "web.ActionModel";
|
||||
import AbstractModel from './abstract_model';
|
||||
import AbstractRenderer from './abstract_renderer';
|
||||
import AbstractController from './abstract_controller';
|
||||
import ControlPanel from 'web.ControlPanel';
|
||||
import SearchPanel from "web.searchPanel";
|
||||
import mvc from 'web.mvc';
|
||||
import viewUtils from 'web.viewUtils';
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
var Factory = mvc.Factory;
|
||||
|
||||
var AbstractView = Factory.extend({
|
||||
// name displayed in view switchers
|
||||
display_name: '',
|
||||
// indicates whether or not the view is mobile-friendly
|
||||
mobile_friendly: false,
|
||||
// icon is the font-awesome icon to display in the view switcher
|
||||
icon: 'fa-question',
|
||||
// multi_record is used to distinguish views displaying a single record
|
||||
// (e.g. FormView) from those that display several records (e.g. ListView)
|
||||
multi_record: true,
|
||||
// viewType is the type of the view, like 'form', 'kanban', 'list'...
|
||||
viewType: undefined,
|
||||
// determines if a search bar is available
|
||||
withSearchBar: true,
|
||||
// determines the search menus available and their orders
|
||||
searchMenuTypes: ['filter', 'groupBy', 'favorite'],
|
||||
// determines if a control panel should be instantiated
|
||||
withControlPanel: true,
|
||||
// determines if a search panel could be instantiated
|
||||
withSearchPanel: true,
|
||||
// determines the MVC components to use
|
||||
config: _.extend({}, Factory.prototype.config, {
|
||||
Model: AbstractModel,
|
||||
Renderer: AbstractRenderer,
|
||||
Controller: AbstractController,
|
||||
ControlPanel,
|
||||
SearchPanel,
|
||||
}),
|
||||
|
||||
/**
|
||||
* The constructor function is supposed to set 3 variables: rendererParams,
|
||||
* controllerParams and loadParams. These values will be used to initialize
|
||||
* the model, renderer and controllers.
|
||||
*
|
||||
* @constructs AbstractView
|
||||
*
|
||||
* @param {Object} viewInfo
|
||||
* @param {Object|string} viewInfo.arch
|
||||
* @param {Object} viewInfo.fields
|
||||
* @param {Object} viewInfo.fieldsInfo
|
||||
* @param {Object} params
|
||||
* @param {string} [params.modelName]
|
||||
* @param {Object} [params.action={}]
|
||||
* @param {Object} [params.context={}]
|
||||
* @param {string} [params.controllerID]
|
||||
* @param {number} [params.count]
|
||||
* @param {number} [params.currentId]
|
||||
* @param {Object} [params.controllerState]
|
||||
* @param {string} [params.displayName]
|
||||
* @param {Array[]} [params.domain=[]]
|
||||
* @param {Object[]} [params.dynamicFilters] transmitted to the
|
||||
* ControlPanel
|
||||
* @param {boolean} [params.editable]
|
||||
* @param {number[]} [params.ids]
|
||||
* @param {boolean} [params.isEmbedded=false]
|
||||
* @param {Object} [params.searchQuery={}]
|
||||
* @param {Object} [params.searchQuery.context={}]
|
||||
* @param {Array[]} [params.searchQuery.domain=[]]
|
||||
* @param {string[]} [params.searchQuery.groupBy=[]]
|
||||
* @param {Object} [params.userContext={}]
|
||||
* @param {boolean} [params.useSampleModel]
|
||||
* @param {boolean} [params.withControlPanel=AbstractView.prototype.withControlPanel]
|
||||
* @param {boolean} [params.withSearchPanel=AbstractView.prototype.withSearchPanel]
|
||||
*/
|
||||
init: function (viewInfo, params) {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
var action = params.action || {};
|
||||
params = _.defaults(params, this._extractParamsFromAction(action));
|
||||
|
||||
// in general, the fieldsView has to be processed by the View (e.g. the
|
||||
// arch is a string that needs to be parsed) ; the only exception is for
|
||||
// inline form views inside form views, as they are processed alongside
|
||||
// the main view, but they are opened in a FormViewDialog which
|
||||
// instantiates another FormView (unlike kanban or list subviews for
|
||||
// which only a Renderer is instantiated)
|
||||
if (typeof viewInfo.arch === 'string') {
|
||||
this.fieldsView = this._processFieldsView(viewInfo);
|
||||
} else {
|
||||
this.fieldsView = viewInfo;
|
||||
}
|
||||
this.arch = this.fieldsView.arch;
|
||||
this.fields = this.fieldsView.viewFields;
|
||||
this.userContext = params.userContext || {};
|
||||
|
||||
// the boolean parameter 'isEmbedded' determines if the view should be
|
||||
// considered as a subview. For now this is only used by the graph
|
||||
// controller that appends a 'Group By' button beside the 'Measures'
|
||||
// button when the graph view is embedded.
|
||||
var isEmbedded = params.isEmbedded || false;
|
||||
|
||||
this.rendererParams = {
|
||||
arch: this.arch,
|
||||
isEmbedded: isEmbedded,
|
||||
noContentHelp: params.noContentHelp,
|
||||
};
|
||||
|
||||
this.controllerParams = {
|
||||
actionViews: params.actionViews,
|
||||
activeActions: {
|
||||
edit: typeof params.editable === 'boolean' ? params.editable : this.arch.attrs.edit ? !!JSON.parse(this.arch.attrs.edit) : true,
|
||||
create: this.arch.attrs.create ? !!JSON.parse(this.arch.attrs.create) : true,
|
||||
delete: this.arch.attrs.delete ? !!JSON.parse(this.arch.attrs.delete) : true,
|
||||
duplicate: this.arch.attrs.duplicate ? !!JSON.parse(this.arch.attrs.duplicate) : true,
|
||||
},
|
||||
bannerRoute: this.arch.attrs.banner_route,
|
||||
controllerID: params.controllerID,
|
||||
displayName: params.displayName,
|
||||
isEmbedded: isEmbedded,
|
||||
modelName: params.modelName,
|
||||
viewType: this.viewType,
|
||||
};
|
||||
|
||||
var controllerState = params.controllerState || {};
|
||||
var currentId = controllerState.currentId || params.currentId;
|
||||
this.loadParams = {
|
||||
context: params.context,
|
||||
count: params.count || ((this.controllerParams.ids !== undefined) &&
|
||||
this.controllerParams.ids.length) || 0,
|
||||
domain: params.domain,
|
||||
modelName: params.modelName,
|
||||
res_id: currentId,
|
||||
res_ids: controllerState.resIds || params.ids || (currentId ? [currentId] : undefined),
|
||||
};
|
||||
|
||||
const useSampleModel = 'useSampleModel' in params ?
|
||||
params.useSampleModel :
|
||||
!!(this.arch.attrs.sample && JSON.parse(this.arch.attrs.sample));
|
||||
|
||||
this.modelParams = {
|
||||
fields: this.fields,
|
||||
modelName: params.modelName,
|
||||
useSampleModel,
|
||||
};
|
||||
if (useSampleModel) {
|
||||
this.modelParams.SampleModel = this.config.Model;
|
||||
}
|
||||
|
||||
var defaultOrder = this.arch.attrs.default_order;
|
||||
if (defaultOrder) {
|
||||
this.loadParams.orderedBy = _.map(defaultOrder.split(','), function (order) {
|
||||
order = order.trim().split(' ');
|
||||
return {name: order[0], asc: order[1] !== 'desc'};
|
||||
});
|
||||
}
|
||||
if (params.searchQuery) {
|
||||
this._updateMVCParams(params.searchQuery);
|
||||
}
|
||||
|
||||
this.withControlPanel = this.withControlPanel && params.withControlPanel;
|
||||
this.withSearchPanel = this.withSearchPanel &&
|
||||
this.multi_record && params.withSearchPanel &&
|
||||
!('search_panel' in params.context && !params.search_panel);
|
||||
|
||||
const searchModelParams = Object.assign({}, params, { action });
|
||||
if (this.withControlPanel || this.withSearchPanel) {
|
||||
const { arch, fields, favoriteFilters } = params.controlPanelFieldsView || {};
|
||||
const archInfo = ActionModel.extractArchInfo({ search: arch }, this.viewType);
|
||||
const controlPanelInfo = archInfo[this.config.ControlPanel.modelExtension];
|
||||
const searchPanelInfo = archInfo[this.config.SearchPanel.modelExtension];
|
||||
this.withSearchPanel = this.withSearchPanel && Boolean(searchPanelInfo);
|
||||
Object.assign(searchModelParams, {
|
||||
fields,
|
||||
favoriteFilters,
|
||||
controlPanelInfo,
|
||||
searchPanelInfo,
|
||||
});
|
||||
}
|
||||
const searchModel = this._createSearchModel(searchModelParams);
|
||||
this.controllerParams.searchModel = searchModel;
|
||||
if (this.controllerParams.controlPanel) {
|
||||
this.controllerParams.controlPanel.props.searchModel = searchModel;
|
||||
}
|
||||
if (this.controllerParams.searchPanel) {
|
||||
this.controllerParams.searchPanel.props.searchModel = searchModel;
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {Object} extraExtensions
|
||||
* @returns {ActionModel}
|
||||
*/
|
||||
_createSearchModel: function (params, extraExtensions) {
|
||||
// Search model + common config
|
||||
const { fields, favoriteFilters, controlPanelInfo, searchPanelInfo } = params;
|
||||
const extensions = Object.assign({}, extraExtensions);
|
||||
const importedState = params.controllerState || {};
|
||||
|
||||
// Control panel params
|
||||
if (this.withControlPanel) {
|
||||
// Control panel (Model)
|
||||
const ControlPanelComponent = this.config.ControlPanel;
|
||||
if (this.withSearchBar) {
|
||||
extensions[ControlPanelComponent.modelExtension] = {
|
||||
actionId: params.action.id,
|
||||
// control initialization
|
||||
activateDefaultFavorite: params.activateDefaultFavorite,
|
||||
archNodes: controlPanelInfo.children,
|
||||
dynamicFilters: params.dynamicFilters,
|
||||
favoriteFilters,
|
||||
withSearchBar: params.withSearchBar,
|
||||
};
|
||||
}
|
||||
this.controllerParams.withControlPanel = true;
|
||||
// Control panel (Component)
|
||||
const controlPanelProps = {
|
||||
action: params.action,
|
||||
breadcrumbs: params.breadcrumbs,
|
||||
fields,
|
||||
searchMenuTypes: params.searchMenuTypes,
|
||||
view: this.fieldsView,
|
||||
views: params.action.views && params.action.views.filter(
|
||||
v => v.multiRecord === this.multi_record
|
||||
),
|
||||
withBreadcrumbs: params.withBreadcrumbs,
|
||||
withSearchBar: params.withSearchBar,
|
||||
};
|
||||
this.controllerParams.controlPanel = {
|
||||
Component: ControlPanelComponent,
|
||||
props: controlPanelProps,
|
||||
};
|
||||
}
|
||||
|
||||
// Search panel params
|
||||
if (this.withSearchPanel) {
|
||||
// Search panel (Model)
|
||||
const SearchPanelComponent = this.config.SearchPanel;
|
||||
extensions[SearchPanelComponent.modelExtension] = {
|
||||
archNodes: searchPanelInfo.children,
|
||||
};
|
||||
this.controllerParams.withSearchPanel = true;
|
||||
this.rendererParams.withSearchPanel = true;
|
||||
// Search panel (Component)
|
||||
const searchPanelProps = {
|
||||
importedState: importedState.searchPanel,
|
||||
};
|
||||
if (searchPanelInfo.attrs.class) {
|
||||
searchPanelProps.className = searchPanelInfo.attrs.class;
|
||||
}
|
||||
this.controllerParams.searchPanel = {
|
||||
Component: SearchPanelComponent,
|
||||
props: searchPanelProps,
|
||||
};
|
||||
}
|
||||
|
||||
const searchModel = new ActionModel(extensions, {
|
||||
env: Component.env,
|
||||
modelName: params.modelName,
|
||||
context: Object.assign({}, this.loadParams.context),
|
||||
domain: this.loadParams.domain || [],
|
||||
importedState: importedState.searchModel,
|
||||
searchMenuTypes: params.searchMenuTypes,
|
||||
searchQuery: params.searchQuery,
|
||||
fields,
|
||||
});
|
||||
|
||||
return searchModel;
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
getController: async function () {
|
||||
const _super = this._super.bind(this);
|
||||
const { searchModel } = this.controllerParams;
|
||||
await searchModel.load();
|
||||
this._updateMVCParams(searchModel.get("query"));
|
||||
// get the parent of the model if it already exists, as _super will
|
||||
// set the new controller as parent, which we don't want
|
||||
const modelParent = this.model && this.model.getParent();
|
||||
const [controller] = await Promise.all([
|
||||
_super(...arguments),
|
||||
searchModel.isReady(),
|
||||
]);
|
||||
if (modelParent) {
|
||||
// if we already add a model, restore its parent
|
||||
this.model.setParent(modelParent);
|
||||
}
|
||||
return controller;
|
||||
},
|
||||
/**
|
||||
* Ensures that only one instance of AbstractModel is created
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
getModel: function () {
|
||||
if (!this.model) {
|
||||
this.model = this._super.apply(this, arguments);
|
||||
}
|
||||
return this.model;
|
||||
},
|
||||
/**
|
||||
* This is useful to customize the actual class to use before calling
|
||||
* createView.
|
||||
*
|
||||
* @param {Controller} Controller
|
||||
*/
|
||||
setController: function (Controller) {
|
||||
this.Controller = Controller;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} [action]
|
||||
* @param {Object} [action.context || {}]
|
||||
* @param {boolean} [action.context.no_breadcrumbs=false]
|
||||
* @param {integer} [action.context.active_id]
|
||||
* @param {integer[]} [action.context.active_ids]
|
||||
* @param {Object} [action.controlPanelFieldsView]
|
||||
* @param {string} [action.display_name]
|
||||
* @param {Array[]} [action.domain=[]]
|
||||
* @param {string} [action.help]
|
||||
* @param {integer} [action.id]
|
||||
* @param {integer} [action.limit]
|
||||
* @param {string} [action.name]
|
||||
* @param {string} [action.res_model]
|
||||
* @param {string} [action.target]
|
||||
* @param {boolean} [action.useSampleModel]
|
||||
* @returns {Object}
|
||||
*/
|
||||
_extractParamsFromAction: function (action) {
|
||||
action = action || {};
|
||||
var context = action.context || {};
|
||||
var inline = action.target === 'inline';
|
||||
const params = {
|
||||
actionId: action.id || false,
|
||||
actionViews: action.views || [],
|
||||
activateDefaultFavorite: !context.active_id && !context.active_ids,
|
||||
context: action.context || {},
|
||||
controlPanelFieldsView: action.controlPanelFieldsView,
|
||||
currentId: action.res_id ? action.res_id : undefined, // load returns 0
|
||||
displayName: action.display_name || action.name,
|
||||
domain: action.domain || [],
|
||||
limit: action.limit,
|
||||
modelName: action.res_model,
|
||||
noContentHelp: action.help,
|
||||
searchMenuTypes: inline ? [] : this.searchMenuTypes,
|
||||
withBreadcrumbs: 'no_breadcrumbs' in context ? !context.no_breadcrumbs : true,
|
||||
withControlPanel: this.withControlPanel,
|
||||
withSearchBar: inline ? false : this.withSearchBar,
|
||||
withSearchPanel: this.withSearchPanel,
|
||||
};
|
||||
if ('useSampleModel' in action) {
|
||||
params.useSampleModel = action.useSampleModel;
|
||||
}
|
||||
return params;
|
||||
},
|
||||
/**
|
||||
* Processes a fieldsView. In particular, parses its arch.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} fieldsView
|
||||
* @param {string} fieldsView.arch
|
||||
* @returns {Object} the processed fieldsView
|
||||
*/
|
||||
_processFieldsView: function (fieldsView) {
|
||||
var fv = _.extend({}, fieldsView);
|
||||
fv.arch = viewUtils.parseArch(fv.arch);
|
||||
fv.viewFields = _.defaults({}, fv.viewFields, fv.fields);
|
||||
return fv;
|
||||
},
|
||||
/**
|
||||
* Hook to update the renderer, controller and load params with the result
|
||||
* of a search (i.e. a context, a domain and a groupBy).
|
||||
*
|
||||
* @private
|
||||
* @param {Object} searchQuery
|
||||
* @param {Object} searchQuery.context
|
||||
* @param {Object} [searchQuery.timeRanges]
|
||||
* @param {Array[]} searchQuery.domain
|
||||
* @param {string[]} searchQuery.groupBy
|
||||
*/
|
||||
_updateMVCParams: function (searchQuery) {
|
||||
this.loadParams = _.extend(this.loadParams, {
|
||||
context: searchQuery.context,
|
||||
domain: searchQuery.domain,
|
||||
groupedBy: searchQuery.groupBy,
|
||||
});
|
||||
this.loadParams.orderedBy = Array.isArray(searchQuery.orderedBy) && searchQuery.orderedBy.length ?
|
||||
searchQuery.orderedBy :
|
||||
this.loadParams.orderedBy;
|
||||
if (searchQuery.timeRanges) {
|
||||
this.loadParams.timeRanges = searchQuery.timeRanges;
|
||||
this.rendererParams.timeRanges = searchQuery.timeRanges;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default AbstractView;
|
||||
@@ -1,13 +0,0 @@
|
||||
/** @odoo-module alias=web.RendererWrapper **/
|
||||
|
||||
|
||||
import { ComponentWrapper } from 'web.OwlCompatibility';
|
||||
|
||||
class RendererWrapper extends ComponentWrapper {
|
||||
getLocalState() { }
|
||||
setLocalState() { }
|
||||
giveFocus() { }
|
||||
resetLocalState() { }
|
||||
}
|
||||
|
||||
export default RendererWrapper;
|
||||
@@ -1,716 +0,0 @@
|
||||
/** @odoo-module alias=web.SampleServer **/
|
||||
|
||||
import session from 'web.session';
|
||||
import utils from 'web.utils';
|
||||
import Registry from 'web.Registry';
|
||||
import viewUtils from 'web.viewUtils';
|
||||
|
||||
class UnimplementedRouteError extends Error {}
|
||||
|
||||
/**
|
||||
* Helper function returning the value from a list of sample strings
|
||||
* corresponding to the given ID.
|
||||
* @param {number} id
|
||||
* @param {string[]} sampleTexts
|
||||
* @returns {string}
|
||||
*/
|
||||
function getSampleFromId(id, sampleTexts) {
|
||||
return sampleTexts[(id - 1) % sampleTexts.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function returning a regular expression specifically matching
|
||||
* a given 'term' in a fieldName. For example `fieldNameRegex('abc')`:
|
||||
* will match:
|
||||
* - "abc"
|
||||
* - "field_abc__def"
|
||||
* will not match:
|
||||
* - "aabc"
|
||||
* - "abcd_ef"
|
||||
* @param {...string} term
|
||||
* @returns {RegExp}
|
||||
*/
|
||||
function fieldNameRegex(...terms) {
|
||||
return new RegExp(`\\b((\\w+)?_)?(${terms.join('|')})(_(\\w+)?)?\\b`);
|
||||
}
|
||||
|
||||
const DESCRIPTION_REGEX = fieldNameRegex('description', 'label', 'title', 'subject', 'message');
|
||||
const EMAIL_REGEX = fieldNameRegex('email');
|
||||
const PHONE_REGEX = fieldNameRegex('phone');
|
||||
const URL_REGEX = fieldNameRegex('url');
|
||||
|
||||
/**
|
||||
* Sample server class
|
||||
*
|
||||
* Represents a static instance of the server used when a RPC call sends
|
||||
* empty values/groups while the attribute 'sample' is set to true on the
|
||||
* view.
|
||||
*
|
||||
* This server will generate fake data and send them in the adequate format
|
||||
* according to the route/method used in the RPC.
|
||||
*/
|
||||
class SampleServer {
|
||||
|
||||
/**
|
||||
* @param {string} modelName
|
||||
* @param {Object} fields
|
||||
*/
|
||||
constructor(modelName, fields) {
|
||||
this.mainModel = modelName;
|
||||
this.data = {};
|
||||
this.data[modelName] = {
|
||||
fields,
|
||||
records: [],
|
||||
};
|
||||
// Generate relational fields' co models
|
||||
for (const fieldName in fields) {
|
||||
const field = fields[fieldName];
|
||||
if (['many2one', 'one2many', 'many2many'].includes(field.type)) {
|
||||
this.data[field.relation] = this.data[field.relation] || {
|
||||
fields: {
|
||||
display_name: { type: 'char' },
|
||||
id: { type: 'integer' },
|
||||
color: { type: 'integer' },
|
||||
},
|
||||
records: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
// On some models, empty grouped Kanban or List view still contain
|
||||
// real (empty) groups. In this case, we re-use the result of the
|
||||
// web_read_group rpc to tweak sample data s.t. those real groups
|
||||
// contain sample records.
|
||||
this.existingGroups = null;
|
||||
// Sample records generation is only done if necessary, so we delay
|
||||
// it to the first "mockRPC" call. These flags allow us to know if
|
||||
// the records have been generated or not.
|
||||
this.populated = false;
|
||||
this.existingGroupsPopulated = false;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* This is the main entry point of the SampleServer. Mocks a request to
|
||||
* the server with sample data.
|
||||
* @param {Object} params
|
||||
* @returns {any} the result obtained with the sample data
|
||||
* @throws {Error} If called on a route/method we do not handle
|
||||
*/
|
||||
mockRpc(params) {
|
||||
if (!(params.model in this.data)) {
|
||||
throw new Error(`SampleServer: unknown model ${params.model}`);
|
||||
}
|
||||
this._populateModels();
|
||||
switch (params.method || params.route) {
|
||||
case '/web/dataset/search_read':
|
||||
return this._mockSearchReadController(params);
|
||||
case 'web_read_group':
|
||||
return this._mockWebReadGroup(params);
|
||||
case 'read_group':
|
||||
return this._mockReadGroup(params);
|
||||
case 'read_progress_bar':
|
||||
return this._mockReadProgressBar(params);
|
||||
case 'read':
|
||||
return this._mockRead(params);
|
||||
}
|
||||
// this rpc can't be mocked by the SampleServer itself, so check if there is an handler
|
||||
// in the mockRegistry: either specific for this model (with key 'model/method'), or
|
||||
// global (with key 'method')
|
||||
const method = params.method || params.route;
|
||||
const mockFunction = SampleServer.mockRegistry.get(`${params.model}/${method}`) ||
|
||||
SampleServer.mockRegistry.get(method);
|
||||
if (mockFunction) {
|
||||
return mockFunction.call(this, params);
|
||||
}
|
||||
console.log(`SampleServer: unimplemented route "${params.method || params.route}"`);
|
||||
throw new SampleServer.UnimplementedRouteError();
|
||||
}
|
||||
|
||||
setExistingGroups(groups) {
|
||||
this.existingGroups = groups;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object[]} measures, each measure has the form { fieldName, type }
|
||||
* @param {Object[]} records
|
||||
* @returns {Object}
|
||||
*/
|
||||
_aggregateFields(measures, records) {
|
||||
const values = {};
|
||||
for (const { fieldName, type } of measures) {
|
||||
if (['float', 'integer', 'monetary'].includes(type)) {
|
||||
if (records.length) {
|
||||
let value = 0;
|
||||
for (const record of records) {
|
||||
value += record[fieldName];
|
||||
}
|
||||
values[fieldName] = this._sanitizeNumber(value);
|
||||
} else {
|
||||
values[fieldName] = null;
|
||||
}
|
||||
}
|
||||
if (type === 'many2one') {
|
||||
const ids = new Set(records.map(r => r[fieldName]));
|
||||
values.fieldName = ids.size || null;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} value
|
||||
* @param {Object} options
|
||||
* @param {string} [options.interval]
|
||||
* @param {string} [options.relation]
|
||||
* @param {string} [options.type]
|
||||
* @returns {any}
|
||||
*/
|
||||
_formatValue(value, options) {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const { type, interval, relation } = options;
|
||||
if (['date', 'datetime'].includes(type)) {
|
||||
const fmt = SampleServer.FORMATS[interval];
|
||||
return moment(value).format(fmt);
|
||||
} else if (type === 'many2one') {
|
||||
const rec = this.data[relation].records.find(({id}) => id === value);
|
||||
return [value, rec.display_name];
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates field values based on heuristics according to field types
|
||||
* and names.
|
||||
*
|
||||
* @private
|
||||
* @param {string} modelName
|
||||
* @param {string} fieldName
|
||||
* @param {number} id the record id
|
||||
* @returns {any} the field value
|
||||
*/
|
||||
_generateFieldValue(modelName, fieldName, id) {
|
||||
const field = this.data[modelName].fields[fieldName];
|
||||
switch (field.type) {
|
||||
case "boolean":
|
||||
return fieldName === 'active' ? true : this._getRandomBool();
|
||||
case "char":
|
||||
case "text":
|
||||
if (["display_name", "name"].includes(fieldName)) {
|
||||
if (SampleServer.PEOPLE_MODELS.includes(modelName)) {
|
||||
return getSampleFromId(id, SampleServer.SAMPLE_PEOPLE);
|
||||
} else if (modelName === 'res.country') {
|
||||
return getSampleFromId(id, SampleServer.SAMPLE_COUNTRIES);
|
||||
}
|
||||
}
|
||||
if (fieldName === 'display_name') {
|
||||
return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
|
||||
} else if (["name", "reference"].includes(fieldName)) {
|
||||
return `REF${String(id).padStart(4, '0')}`;
|
||||
} else if (DESCRIPTION_REGEX.test(fieldName)) {
|
||||
return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
|
||||
} else if (EMAIL_REGEX.test(fieldName)) {
|
||||
const emailName = getSampleFromId(id, SampleServer.SAMPLE_PEOPLE)
|
||||
.replace(/ /, ".")
|
||||
.toLowerCase();
|
||||
return `${emailName}@sample.demo`;
|
||||
} else if (PHONE_REGEX.test(fieldName)) {
|
||||
return `+1 555 754 ${String(id).padStart(4, '0')}`;
|
||||
} else if (URL_REGEX.test(fieldName)) {
|
||||
return `http://sample${id}.com`;
|
||||
}
|
||||
return false;
|
||||
case "date":
|
||||
case "datetime": {
|
||||
const format = field.type === "date" ?
|
||||
"YYYY-MM-DD" :
|
||||
"YYYY-MM-DD HH:mm:ss";
|
||||
return this._getRandomDate(format);
|
||||
}
|
||||
case "float":
|
||||
return this._getRandomFloat(SampleServer.MAX_FLOAT);
|
||||
case "integer": {
|
||||
let max = SampleServer.MAX_INTEGER;
|
||||
if (fieldName.includes('color')) {
|
||||
max = this._getRandomBool() ? SampleServer.MAX_COLOR_INT : 0;
|
||||
}
|
||||
return this._getRandomInt(max);
|
||||
}
|
||||
case "monetary":
|
||||
return this._getRandomInt(SampleServer.MAX_MONETARY);
|
||||
case "many2one":
|
||||
if (field.relation === 'res.currency') {
|
||||
return session.company_currency_id;
|
||||
}
|
||||
if (field.relation === 'ir.attachment') {
|
||||
return false;
|
||||
}
|
||||
return this._getRandomSubRecordId();
|
||||
case "one2many":
|
||||
case "many2many": {
|
||||
const ids = [this._getRandomSubRecordId(), this._getRandomSubRecordId()];
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
case "selection": {
|
||||
// I hoped we wouldn't have to implement such special cases, but here it is.
|
||||
// If this (mail) field is set, 'Warning' is displayed instead of the last
|
||||
// activity, and we don't want to see a bunch of 'Warning's in a list. In the
|
||||
// future, if we have to implement several special cases like that, we'll setup
|
||||
// a proper hook to allow external modules to define extensions of this function.
|
||||
// For now, as we have only one use case, I guess that doing it here is fine.
|
||||
if (fieldName === 'activity_exception_decoration') {
|
||||
return false;
|
||||
}
|
||||
if (field.selection.length > 0) {
|
||||
return this._getRandomArrayEl(field.selection)[0];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {any[]} array
|
||||
* @returns {any}
|
||||
*/
|
||||
_getRandomArrayEl(array) {
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_getRandomBool() {
|
||||
return Math.random() < 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} format
|
||||
* @returns {moment}
|
||||
*/
|
||||
_getRandomDate(format) {
|
||||
const delta = Math.floor(
|
||||
(Math.random() - Math.random()) * SampleServer.DATE_DELTA
|
||||
);
|
||||
return new moment()
|
||||
.add(delta, "hour")
|
||||
.format(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {number} max
|
||||
* @returns {number} float in [O, max[
|
||||
*/
|
||||
_getRandomFloat(max) {
|
||||
return this._sanitizeNumber(Math.random() * max);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {number} max
|
||||
* @returns {number} int in [0, max[
|
||||
*/
|
||||
_getRandomInt(max) {
|
||||
return Math.floor(Math.random() * max);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {number} id in [1, SUB_RECORDSET_SIZE]
|
||||
*/
|
||||
_getRandomSubRecordId() {
|
||||
return Math.floor(Math.random() * SampleServer.SUB_RECORDSET_SIZE) + 1;
|
||||
}
|
||||
/**
|
||||
* Mocks calls to the read method.
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {Array[]} params.args (args[0] is the list of ids, args[1] is
|
||||
* the list of fields)
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
_mockRead(params) {
|
||||
const model = this.data[params.model];
|
||||
const ids = params.args[0];
|
||||
const fieldNames = params.args[1];
|
||||
const records = [];
|
||||
for (const r of model.records) {
|
||||
if (!ids.includes(r.id)) {
|
||||
continue;
|
||||
}
|
||||
const record = { id: r.id };
|
||||
for (const fieldName of fieldNames) {
|
||||
const field = model.fields[fieldName];
|
||||
if (!field) {
|
||||
record[fieldName] = false; // unknown field
|
||||
} else if (field.type === 'many2one') {
|
||||
const relModel = this.data[field.relation];
|
||||
const relRecord = relModel.records.find(
|
||||
relR => r[fieldName] === relR.id
|
||||
);
|
||||
record[fieldName] = relRecord ?
|
||||
[relRecord.id, relRecord.display_name] :
|
||||
false;
|
||||
} else {
|
||||
record[fieldName] = r[fieldName];
|
||||
}
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the read_group method.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {string[]} [params.fields] defaults to the list of all fields
|
||||
* @param {string[]} params.groupBy
|
||||
* @param {boolean} [params.lazy=true]
|
||||
* @returns {Object[]} Object with keys groups and length
|
||||
*/
|
||||
_mockReadGroup(params) {
|
||||
const lazy = 'lazy' in params ? params.lazy : true;
|
||||
const model = params.model;
|
||||
const fields = this.data[model].fields;
|
||||
const records = this.data[model].records;
|
||||
|
||||
const normalizedGroupBys = [];
|
||||
let groupBy = [];
|
||||
if (params.groupBy.length) {
|
||||
groupBy = lazy ? [params.groupBy[0]] : params.groupBy;
|
||||
}
|
||||
for (const groupBySpec of groupBy) {
|
||||
let [fieldName, interval] = groupBySpec.split(':');
|
||||
interval = interval || 'month';
|
||||
const { type, relation } = fields[fieldName];
|
||||
if (type) {
|
||||
const gb = { fieldName, type, interval, relation, alias: groupBySpec };
|
||||
normalizedGroupBys.push(gb);
|
||||
}
|
||||
}
|
||||
const groups = utils.groupBy(records, (record) => {
|
||||
const vals = {};
|
||||
for (const gb of normalizedGroupBys) {
|
||||
const { fieldName, type } = gb;
|
||||
let value;
|
||||
if (['date', 'datetime'].includes(type)) {
|
||||
value = this._formatValue(record[fieldName], gb);
|
||||
} else {
|
||||
value = record[fieldName];
|
||||
}
|
||||
vals[fieldName] = value;
|
||||
}
|
||||
return JSON.stringify(vals);
|
||||
});
|
||||
const measures = [];
|
||||
for (const measureSpec of (params.fields || Object.keys(fields))) {
|
||||
const [fieldName, aggregateFunction] = measureSpec.split(':');
|
||||
const { type } = fields[fieldName];
|
||||
if (!params.groupBy.includes(fieldName) && type &&
|
||||
(type !== 'many2one' || aggregateFunction !== 'count_distinct')) {
|
||||
measures.push({ fieldName, type });
|
||||
}
|
||||
}
|
||||
|
||||
let result = [];
|
||||
for (const id in groups) {
|
||||
const records = groups[id];
|
||||
const group = { __domain: [] };
|
||||
let countKey = `__count`;
|
||||
if (normalizedGroupBys.length && lazy) {
|
||||
countKey = `${normalizedGroupBys[0].fieldName}_count`;
|
||||
}
|
||||
group[countKey] = records.length;
|
||||
const firstElem = records[0];
|
||||
for (const gb of normalizedGroupBys) {
|
||||
const { alias, fieldName } = gb;
|
||||
group[alias] = this._formatValue(firstElem[fieldName], gb);
|
||||
}
|
||||
Object.assign(group, this._aggregateFields(measures, records));
|
||||
result.push(group);
|
||||
}
|
||||
if (normalizedGroupBys.length > 0) {
|
||||
const { alias, interval, type } = normalizedGroupBys[0];
|
||||
result = utils.sortBy(result, (group) => {
|
||||
const val = group[alias];
|
||||
if (['date', 'datetime'].includes(type)) {
|
||||
return moment(val, SampleServer.FORMATS[interval]);
|
||||
}
|
||||
return val;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the read_progress_bar method.
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {Object} params.kwargs
|
||||
* @return {Object}
|
||||
*/
|
||||
_mockReadProgressBar(params) {
|
||||
const groupByFieldName = viewUtils.getGroupByField(params.kwargs.group_by);
|
||||
const progress_bar = params.kwargs.progress_bar;
|
||||
const groupByField = this.data[params.model].fields[groupByFieldName];
|
||||
const data = {};
|
||||
for (const record of this.data[params.model].records) {
|
||||
let groupByValue = record[groupByFieldName];
|
||||
if (groupByField.type === "many2one") {
|
||||
const relatedRecords = this.data[groupByField.relation].records;
|
||||
const relatedRecord = relatedRecords.find(r => r.id === groupByValue);
|
||||
groupByValue = relatedRecord.display_name;
|
||||
}
|
||||
// special case for bool values: rpc call response with capitalized strings
|
||||
if (!(groupByValue in data)) {
|
||||
if (groupByValue === true) {
|
||||
groupByValue = "True";
|
||||
} else if (groupByValue === false) {
|
||||
groupByValue = "False";
|
||||
}
|
||||
}
|
||||
if (!(groupByValue in data)) {
|
||||
data[groupByValue] = {};
|
||||
for (const key in progress_bar.colors) {
|
||||
data[groupByValue][key] = 0;
|
||||
}
|
||||
}
|
||||
const fieldValue = record[progress_bar.field];
|
||||
if (fieldValue in data[groupByValue]) {
|
||||
data[groupByValue][fieldValue]++;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the /web/dataset/search_read route to return sample
|
||||
* records.
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {string[]} params.fields
|
||||
* @returns {{ records: Object[], length: number }}
|
||||
*/
|
||||
_mockSearchReadController(params) {
|
||||
const model = this.data[params.model];
|
||||
const rawRecords = model.records.slice(0, SampleServer.SEARCH_READ_LIMIT);
|
||||
const records = this._mockRead({
|
||||
model: params.model,
|
||||
args: [rawRecords.map(r => r.id), params.fields],
|
||||
});
|
||||
return { records, length: records.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the web_read_group method to return groups populated
|
||||
* with sample records. Only handles the case where the real call to
|
||||
* web_read_group returned groups, but none of these groups contain
|
||||
* records. In this case, we keep the real groups, and populate them
|
||||
* with sample records.
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {Object} [result] the result of a real call to web_read_group
|
||||
* @returns {{ groups: Object[], length: number }}
|
||||
*/
|
||||
_mockWebReadGroup(params) {
|
||||
let groups;
|
||||
if (this.existingGroups) {
|
||||
this._tweakExistingGroups(params);
|
||||
groups = this.existingGroups;
|
||||
} else {
|
||||
groups = this._mockReadGroup(params);
|
||||
}
|
||||
return {
|
||||
groups,
|
||||
length: groups.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the sample data such that the existing groups (in database)
|
||||
* also exists in the sample, and such that there are sample records in
|
||||
* those groups.
|
||||
* @private
|
||||
* @param {Object[]} groups empty groups returned by the server
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {string[]} params.groupBy
|
||||
*/
|
||||
_populateExistingGroups(params) {
|
||||
if (!this.existingGroupsPopulated) {
|
||||
const groups = this.existingGroups;
|
||||
this.groupsInfo = groups;
|
||||
const groupBy = params.groupBy[0];
|
||||
const groupByFieldName = viewUtils.getGroupByField(groupBy);
|
||||
const groupByField = this.data[params.model].fields[groupByFieldName];
|
||||
const values = groups.map(g => {
|
||||
if (['date', 'datetime'].includes(groupByField.type)) {
|
||||
// we arbitrarily take the first date of the group as a default value to populate
|
||||
// date/datetime groups
|
||||
return g.__range[groupBy] ? g.__range[groupBy].from : false;
|
||||
} else {
|
||||
return g[groupBy];
|
||||
}
|
||||
});
|
||||
const groupedByM2O = groupByField.type === 'many2one';
|
||||
if (groupedByM2O) { // re-populate co model with relevant records
|
||||
this.data[groupByField.relation].records = values.map(v => {
|
||||
return { id: v[0], display_name: v[1] };
|
||||
});
|
||||
}
|
||||
for (const r of this.data[params.model].records) {
|
||||
const value = getSampleFromId(r.id, values);
|
||||
r[groupByFieldName] = groupedByM2O ? value[0] : value;
|
||||
}
|
||||
this.existingGroupsPopulated = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates sample records for the models in this.data. Records will be
|
||||
* generated once, and subsequent calls to this function will be skipped.
|
||||
* @private
|
||||
*/
|
||||
_populateModels() {
|
||||
if (!this.populated) {
|
||||
for (const modelName in this.data) {
|
||||
const model = this.data[modelName];
|
||||
const fieldNames = Object.keys(model.fields).filter(f => f !== 'id');
|
||||
const size = modelName === this.mainModel ?
|
||||
SampleServer.MAIN_RECORDSET_SIZE :
|
||||
SampleServer.SUB_RECORDSET_SIZE;
|
||||
for (let id = 1; id <= size; id++) {
|
||||
const record = { id };
|
||||
for (const fieldName of fieldNames) {
|
||||
record[fieldName] = this._generateFieldValue(modelName, fieldName, id);
|
||||
}
|
||||
model.records.push(record);
|
||||
}
|
||||
}
|
||||
this.populated = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds the given number value according to the configured precision.
|
||||
* @private
|
||||
* @param {number} value
|
||||
* @returns {number}
|
||||
*/
|
||||
_sanitizeNumber(value) {
|
||||
return parseFloat(value.toFixed(SampleServer.FLOAT_PRECISION));
|
||||
}
|
||||
|
||||
/**
|
||||
* A real (web_)read_group call has been done, and it has returned groups,
|
||||
* but they are all empty. This function updates the sample data such
|
||||
* that those group values exist and those groups contain sample records.
|
||||
* @private
|
||||
* @param {Object[]} groups empty groups returned by the server
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {string[]} params.fields
|
||||
* @param {string[]} params.groupBy
|
||||
* @returns {Object[]} groups with count and aggregate values updated
|
||||
*
|
||||
* TODO: rename
|
||||
*/
|
||||
_tweakExistingGroups(params) {
|
||||
const groups = this.existingGroups;
|
||||
this._populateExistingGroups(params);
|
||||
|
||||
// update count and aggregates for each group
|
||||
const groupBy = params.groupBy[0];
|
||||
const groupByFieldName = viewUtils.getGroupByField(groupBy);
|
||||
const groupByField = this.data[params.model].fields[groupByFieldName];
|
||||
const records = this.data[params.model].records;
|
||||
for (const g of groups) {
|
||||
let groupValue = g[groupBy];
|
||||
if (groupByField.type === 'many2one') {
|
||||
groupValue = g[groupBy][0];
|
||||
} else if (['date', 'datetime'].includes(groupByField.type)) {
|
||||
// we arbitrarily take the first date of the group as a default value to tweak
|
||||
// date/datetime groups
|
||||
groupValue = g.__range[groupBy] ? g.__range[groupBy].from : false;
|
||||
}
|
||||
const recordsInGroup = records.filter(r => r[groupByFieldName] === groupValue);
|
||||
g[`${groupByFieldName}_count`] = recordsInGroup.length;
|
||||
for (const field of params.fields) {
|
||||
const fieldType = this.data[params.model].fields[field].type;
|
||||
if (['integer, float', 'monetary'].includes(fieldType)) {
|
||||
g[field] = recordsInGroup.reduce((acc, r) => acc + r[field], 0);
|
||||
}
|
||||
}
|
||||
g.__data = {
|
||||
records: this._mockRead({
|
||||
model: params.model,
|
||||
args: [recordsInGroup.map(r => r.id), params.fields],
|
||||
}),
|
||||
length: recordsInGroup.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SampleServer.FORMATS = {
|
||||
hour: 'HH[:00] DD MMM',
|
||||
day: 'YYYY-MM-DD',
|
||||
week: '[W]ww YYYY',
|
||||
month: 'MMMM YYYY',
|
||||
quarter: '[Q]Q YYYY',
|
||||
year: 'Y',
|
||||
};
|
||||
SampleServer.DISPLAY_FORMATS = Object.assign({}, SampleServer.FORMATS, { day: 'DD MMM YYYY' });
|
||||
|
||||
SampleServer.MAIN_RECORDSET_SIZE = 16;
|
||||
SampleServer.SUB_RECORDSET_SIZE = 5;
|
||||
SampleServer.SEARCH_READ_LIMIT = 10;
|
||||
|
||||
SampleServer.MAX_FLOAT = 100;
|
||||
SampleServer.MAX_INTEGER = 50;
|
||||
SampleServer.MAX_COLOR_INT = 7;
|
||||
SampleServer.MAX_MONETARY = 100000;
|
||||
SampleServer.DATE_DELTA = 24 * 60; // in hours -> 60 days
|
||||
SampleServer.FLOAT_PRECISION = 2;
|
||||
|
||||
SampleServer.SAMPLE_COUNTRIES = ["Belgium", "France", "Portugal", "Singapore", "Australia"];
|
||||
SampleServer.SAMPLE_PEOPLE = [
|
||||
"John Miller", "Henry Campbell", "Carrie Helle", "Wendi Baltz", "Thomas Passot",
|
||||
];
|
||||
SampleServer.SAMPLE_TEXTS = [
|
||||
"Laoreet id", "Volutpat blandit", "Integer vitae", "Viverra nam", "In massa",
|
||||
];
|
||||
SampleServer.PEOPLE_MODELS = [
|
||||
'res.users', 'res.partner', 'hr.employee', 'mail.followers', 'mailing.contact'
|
||||
];
|
||||
|
||||
SampleServer.UnimplementedRouteError = UnimplementedRouteError;
|
||||
|
||||
// mockRegistry allows to register mock version of methods or routes,
|
||||
// for all models:
|
||||
// SampleServer.mockRegistry.add('some_route', () => "abcd");
|
||||
// for a specific model (e.g. 'res.partner'):
|
||||
// SampleServer.mockRegistry.add('res.partner/some_method', () => 23);
|
||||
SampleServer.mockRegistry = new Registry();
|
||||
|
||||
export default SampleServer;
|
||||
@@ -1,222 +0,0 @@
|
||||
/** @odoo-module alias=web.searchPanel **/
|
||||
|
||||
import { Model, useModel } from "web.Model";
|
||||
import { LegacyComponent } from "@web/legacy/legacy_component";
|
||||
|
||||
const { onMounted, onWillStart, onWillUpdateProps, useRef, useState, useSubEnv } = owl;
|
||||
|
||||
/**
|
||||
* Search panel
|
||||
*
|
||||
* Represent an extension of the search interface located on the left side of
|
||||
* the view. It is divided in sections defined in a "<searchpanel>" node located
|
||||
* inside of a "<search>" arch. Each section is represented by a list of different
|
||||
* values (categories or ungrouped filters) or groups of values (grouped filters).
|
||||
* Its state is directly affected by its model (@see SearchPanelModelExtension).
|
||||
* @extends Component
|
||||
*/
|
||||
class SearchPanel extends LegacyComponent {
|
||||
setup() {
|
||||
useSubEnv({ searchModel: this.props.searchModel });
|
||||
|
||||
this.state = useState({
|
||||
active: {},
|
||||
expanded: {},
|
||||
});
|
||||
this.model = useModel("searchModel");
|
||||
this.scrollTop = 0;
|
||||
this.hasImportedState = false;
|
||||
|
||||
this.importState(this.props.importedState);
|
||||
|
||||
this.legacySearchPanelRef = useRef("legacySearchPanel");
|
||||
|
||||
onWillStart(async () => {
|
||||
this._expandDefaultValue();
|
||||
this._updateActiveValues();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
this._updateGroupHeadersChecked();
|
||||
if (this.hasImportedState && this.legacySearchPanelRef.el) {
|
||||
this.legacySearchPanelRef.el.scroll({ top: this.scrollTop });
|
||||
}
|
||||
});
|
||||
|
||||
onWillUpdateProps(async () => {
|
||||
this._updateActiveValues();
|
||||
});
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
get sections() {
|
||||
return this.model.get("sections", s => !s.empty);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
exportState() {
|
||||
const exported = {
|
||||
expanded: this.state.expanded,
|
||||
scrollTop: this.legacySearchPanelRef.el ? this.legacySearchPanelRef.el.scrollTop : 0,
|
||||
};
|
||||
return JSON.stringify(exported);
|
||||
}
|
||||
|
||||
importState(stringifiedState) {
|
||||
this.hasImportedState = Boolean(stringifiedState);
|
||||
if (this.hasImportedState) {
|
||||
const state = JSON.parse(stringifiedState);
|
||||
this.state.expanded = state.expanded;
|
||||
this.scrollTop = state.scrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Expands category values holding the default value of a category.
|
||||
* @private
|
||||
*/
|
||||
_expandDefaultValue() {
|
||||
if (this.hasImportedState) {
|
||||
return;
|
||||
}
|
||||
const categories = this.model.get("sections", s => s.type === "category");
|
||||
for (const category of categories) {
|
||||
this.state.expanded[category.id] = {};
|
||||
if (category.activeValueId) {
|
||||
const ancestorIds = this._getAncestorValueIds(category, category.activeValueId);
|
||||
for (const ancestorId of ancestorIds) {
|
||||
this.state.expanded[category.id][ancestorId] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {Object} category
|
||||
* @param {number} categoryValueId
|
||||
* @returns {number[]} list of ids of the ancestors of the given value in
|
||||
* the given category.
|
||||
*/
|
||||
_getAncestorValueIds(category, categoryValueId) {
|
||||
const { parentId } = category.values.get(categoryValueId);
|
||||
return parentId ? [...this._getAncestorValueIds(category, parentId), parentId] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent unnecessary calls to the model by ensuring a different category
|
||||
* is clicked.
|
||||
* @private
|
||||
* @param {Object} category
|
||||
* @param {Object} value
|
||||
*/
|
||||
async _toggleCategory(category, value) {
|
||||
if (value.childrenIds.length) {
|
||||
const categoryState = this.state.expanded[category.id];
|
||||
if (categoryState[value.id] && category.activeValueId === value.id) {
|
||||
delete categoryState[value.id];
|
||||
} else {
|
||||
categoryState[value.id] = true;
|
||||
}
|
||||
}
|
||||
if (category.activeValueId !== value.id) {
|
||||
this.state.active[category.id] = value.id;
|
||||
this.model.dispatch("toggleCategoryValue", category.id, value.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {number} filterId
|
||||
* @param {{ values: Map<Object> }} group
|
||||
*/
|
||||
_toggleFilterGroup(filterId, { values }) {
|
||||
const valueIds = [];
|
||||
const checked = [...values.values()].every(
|
||||
(value) => this.state.active[filterId][value.id]
|
||||
);
|
||||
values.forEach(({ id }) => {
|
||||
valueIds.push(id);
|
||||
this.state.active[filterId][id] = !checked;
|
||||
});
|
||||
this.model.dispatch("toggleFilterValues", filterId, valueIds, !checked);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {number} filterId
|
||||
* @param {Object} [group]
|
||||
* @param {number} valueId
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_toggleFilterValue(filterId, valueId, { currentTarget }) {
|
||||
this.state.active[filterId][valueId] = currentTarget.checked;
|
||||
this._updateGroupHeadersChecked();
|
||||
this.model.dispatch("toggleFilterValues", filterId, [valueId]);
|
||||
}
|
||||
|
||||
_updateActiveValues() {
|
||||
for (const section of this.model.get("sections")) {
|
||||
if (section.type === "category") {
|
||||
this.state.active[section.id] = section.activeValueId;
|
||||
} else {
|
||||
this.state.active[section.id] = {};
|
||||
if (section.groups) {
|
||||
for (const group of section.groups.values()) {
|
||||
for (const value of group.values.values()) {
|
||||
this.state.active[section.id][value.id] = value.checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (section && section.values) {
|
||||
for (const value of section.values.values()) {
|
||||
this.state.active[section.id][value.id] = value.checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the "checked" or "indeterminate" state of each of the group
|
||||
* headers according to the state of their values.
|
||||
* @private
|
||||
*/
|
||||
_updateGroupHeadersChecked() {
|
||||
if (!this.legacySearchPanelRef.el) {
|
||||
return;
|
||||
}
|
||||
const groups = this.legacySearchPanelRef.el.querySelectorAll(":scope .o_search_panel_filter_group");
|
||||
for (const group of groups) {
|
||||
const header = group.querySelector(":scope .o_search_panel_group_header input");
|
||||
const vals = [...group.querySelectorAll(":scope .o_search_panel_filter_value input")];
|
||||
header.checked = false;
|
||||
header.indeterminate = false;
|
||||
if (vals.every((v) => v.checked)) {
|
||||
header.checked = true;
|
||||
} else if (vals.some((v) => v.checked)) {
|
||||
header.indeterminate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchPanel.modelExtension = "SearchPanel";
|
||||
|
||||
SearchPanel.props = {
|
||||
className: { type: String, optional: 1 },
|
||||
importedState: { type: String, optional: 1 },
|
||||
searchModel: Model,
|
||||
};
|
||||
SearchPanel.template = "web.Legacy.SearchPanel";
|
||||
|
||||
export default SearchPanel;
|
||||
@@ -1,797 +0,0 @@
|
||||
/** @odoo-module alias=web.searchPanelModelExtension **/
|
||||
|
||||
|
||||
import ActionModel from "web.ActionModel";
|
||||
import { sortBy } from "web.utils";
|
||||
import Domain from "web.Domain";
|
||||
import pyUtils from "web.py_utils";
|
||||
|
||||
// DefaultViewTypes is the list of view types for which the searchpanel is
|
||||
// present by default (if not explicitly stated in the "view_types" attribute
|
||||
// in the arch).
|
||||
const DEFAULT_VIEW_TYPES = ["kanban", "tree"];
|
||||
const DEFAULT_LIMIT = 200;
|
||||
let nextSectionId = 1;
|
||||
|
||||
/**
|
||||
* @param {Filter} filter
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasDomain(filter) {
|
||||
return filter.domain !== "[]";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Section} section
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasValues({ errorMsg, groups, type, values }) {
|
||||
if (errorMsg) {
|
||||
return true;
|
||||
} else if (groups) {
|
||||
return [...groups.values()].some((g) => g.values.size);
|
||||
} else if (type === "category") {
|
||||
return values && values.size > 1; // false item ignored
|
||||
} else {
|
||||
return values && values.size > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a serialised array of the given map with its values being the
|
||||
* shallow copies of the original values.
|
||||
* @param {Map<any, Object>} map
|
||||
* @return {Array[]}
|
||||
*/
|
||||
function serialiseMap(map) {
|
||||
return [...map].map(([key, val]) => [key, Object.assign({}, val)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef Section
|
||||
* @prop {string} color
|
||||
* @prop {string} description
|
||||
* @prop {boolean} enableCounters
|
||||
* @prop {boolean} expand
|
||||
* @prop {string} fieldName
|
||||
* @prop {string} icon
|
||||
* @prop {number} id
|
||||
* @prop {number} index
|
||||
* @prop {number} limit
|
||||
* @prop {string} type
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Section} Category
|
||||
* @prop {boolean} hierarchize
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Section} Filter
|
||||
* @prop {string} domain
|
||||
* @prop {string} groupBy
|
||||
*/
|
||||
|
||||
/**
|
||||
* @function sectionPredicate
|
||||
* @param {Section} section
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @property {{ sections: Map<number, Section> }} state
|
||||
* @extends ActionModel.Extension
|
||||
*/
|
||||
class SearchPanelModelExtension extends ActionModel.Extension {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
this.categoriesToLoad = [];
|
||||
this.defaultValues = {};
|
||||
this.filtersToLoad = [];
|
||||
this.initialStateImport = false;
|
||||
this.searchDomain = [];
|
||||
for (const key in this.config.context) {
|
||||
const match = /^searchpanel_default_(.*)$/.exec(key);
|
||||
if (match) {
|
||||
this.defaultValues[match[1]] = this.config.context[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async callLoad(params) {
|
||||
const searchDomain = this._getExternalDomain();
|
||||
params.searchDomainChanged = (
|
||||
JSON.stringify(this.searchDomain) !== JSON.stringify(searchDomain)
|
||||
);
|
||||
if (!this.shouldLoad && !this.initialStateImport) {
|
||||
const isFetchable = (section) => section.enableCounters ||
|
||||
(params.searchDomainChanged && !section.expand);
|
||||
this.categoriesToLoad = this.categories.filter(isFetchable);
|
||||
this.filtersToLoad = this.filters.filter(isFetchable);
|
||||
this.shouldLoad = params.searchDomainChanged ||
|
||||
Boolean(this.categoriesToLoad.length + this.filtersToLoad.length);
|
||||
}
|
||||
this.searchDomain = searchDomain;
|
||||
this.initialStateImport = false;
|
||||
await super.callLoad(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
exportState() {
|
||||
const state = Object.assign({}, super.exportState());
|
||||
state.sections = serialiseMap(state.sections);
|
||||
for (const [/* id */ , section] of state.sections) {
|
||||
section.values = serialiseMap(section.values);
|
||||
if (section.groups) {
|
||||
section.groups = serialiseMap(section.groups);
|
||||
for (const [/* id */ , group] of section.groups) {
|
||||
group.values = serialiseMap(group.values);
|
||||
}
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {any}
|
||||
*/
|
||||
get(property, ...args) {
|
||||
switch (property) {
|
||||
case "domain": return this.getDomain();
|
||||
case "sections": return this.getSections(...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
importState(importedState) {
|
||||
this.initialStateImport = false;
|
||||
super.importState(...arguments);
|
||||
if (importedState) {
|
||||
this.state.sections = new Map(this.state.sections);
|
||||
for (const section of this.state.sections.values()) {
|
||||
section.values = new Map(section.values);
|
||||
if ([...section.values.keys()].some(e => e !== false)) {
|
||||
this.initialStateImport = true;
|
||||
}
|
||||
if (section.groups) {
|
||||
section.groups = new Map(section.groups);
|
||||
for (const group of section.groups.values()) {
|
||||
group.values = new Map(group.values);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async isReady() {
|
||||
await this.sectionsPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async load(params) {
|
||||
this.sectionsPromise = this._fetchSections(params.isInitialLoad);
|
||||
if (this._shouldWaitForData(params)) {
|
||||
await this.sectionsPromise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
prepareState() {
|
||||
Object.assign(this.state, { sections: new Map() });
|
||||
this._createSectionsFromArch();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Actions / Getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the concatenation of the category domain ad the filter
|
||||
* domain.
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
getDomain() {
|
||||
return Domain.prototype.normalizeArray([
|
||||
...this._getCategoryDomain(),
|
||||
...this._getFilterDomain(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a sorted list of a copy of all sections. This list can be
|
||||
* filtered by a given predicate.
|
||||
* @param {sectionPredicate} [predicate] used to determine
|
||||
* which subsets of sections is wanted
|
||||
* @returns {Section[]}
|
||||
*/
|
||||
getSections(predicate) {
|
||||
let sections = [...this.state.sections.values()].map((section) =>
|
||||
Object.assign({}, section, { empty: !hasValues(section) })
|
||||
);
|
||||
if (predicate) {
|
||||
sections = sections.filter(predicate);
|
||||
}
|
||||
return sections.sort((s1, s2) => s1.index - s2.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active value id of a given category.
|
||||
* @param {number} sectionId
|
||||
* @param {number} valueId
|
||||
*/
|
||||
toggleCategoryValue(sectionId, valueId) {
|
||||
const category = this.state.sections.get(sectionId);
|
||||
category.activeValueId = valueId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles a the filter value of a given section. The value will be set
|
||||
* to "forceTo" if provided, else it will be its own opposed value.
|
||||
* @param {number} sectionId
|
||||
* @param {number[]} valueIds
|
||||
* @param {boolean} [forceTo=null]
|
||||
*/
|
||||
toggleFilterValues(sectionId, valueIds, forceTo = null) {
|
||||
const section = this.state.sections.get(sectionId);
|
||||
for (const valueId of valueIds) {
|
||||
const value = section.values.get(valueId);
|
||||
value.checked = forceTo === null ? !value.checked : forceTo;
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Internal getters
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Shorthand access to sections of type "category".
|
||||
* @returns {Category[]}
|
||||
*/
|
||||
get categories() {
|
||||
return [...this.state.sections.values()].filter(s => s.type === "category");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand access to sections of type "filter".
|
||||
* @returns {Filter[]}
|
||||
*/
|
||||
get filters() {
|
||||
return [...this.state.sections.values()].filter(s => s.type === "filter");
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sets active values for each filter (coming from context). This needs
|
||||
* to be done only once at initialization.
|
||||
* @private
|
||||
*/
|
||||
_applyDefaultFilterValues() {
|
||||
for (const { fieldName, values } of this.filters) {
|
||||
const defaultValues = this.defaultValues[fieldName] || [];
|
||||
for (const valueId of defaultValues) {
|
||||
const value = values.get(valueId);
|
||||
if (value) {
|
||||
value.checked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} sectionId
|
||||
* @param {Object} result
|
||||
*/
|
||||
_createCategoryTree(sectionId, result) {
|
||||
const category = this.state.sections.get(sectionId);
|
||||
|
||||
let { error_msg, parent_field: parentField, values, } = result;
|
||||
if (error_msg) {
|
||||
category.errorMsg = error_msg;
|
||||
values = [];
|
||||
}
|
||||
if (category.hierarchize) {
|
||||
category.parentField = parentField;
|
||||
}
|
||||
for (const value of values) {
|
||||
category.values.set(
|
||||
value.id,
|
||||
Object.assign({}, value, {
|
||||
childrenIds: [],
|
||||
parentId: value[parentField] || false,
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const value of values) {
|
||||
const { parentId } = category.values.get(value.id);
|
||||
if (parentId && category.values.has(parentId)) {
|
||||
category.values.get(parentId).childrenIds.push(value.id);
|
||||
}
|
||||
}
|
||||
// collect rootIds
|
||||
category.rootIds = [false];
|
||||
for (const value of values) {
|
||||
const { parentId } = category.values.get(value.id);
|
||||
if (!parentId) {
|
||||
category.rootIds.push(value.id);
|
||||
}
|
||||
}
|
||||
// Set active value from context
|
||||
const valueIds = [false, ...values.map((val) => val.id)];
|
||||
this._ensureCategoryValue(category, valueIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {string} sectionId
|
||||
* @param {Object} result
|
||||
*/
|
||||
_createFilterTree(sectionId, result) {
|
||||
const filter = this.state.sections.get(sectionId);
|
||||
|
||||
let { error_msg, values, } = result;
|
||||
if (error_msg) {
|
||||
filter.errorMsg = error_msg;
|
||||
values = [];
|
||||
}
|
||||
|
||||
// restore checked property
|
||||
values.forEach((value) => {
|
||||
const oldValue = filter.values.get(value.id);
|
||||
value.checked = oldValue ? oldValue.checked : false;
|
||||
});
|
||||
|
||||
filter.values = new Map();
|
||||
const groupIds = [];
|
||||
if (filter.groupBy) {
|
||||
const groups = new Map();
|
||||
for (const value of values) {
|
||||
const groupId = value.group_id;
|
||||
if (!groups.has(groupId)) {
|
||||
if (groupId) {
|
||||
groupIds.push(groupId);
|
||||
}
|
||||
groups.set(groupId, {
|
||||
id: groupId,
|
||||
name: value.group_name,
|
||||
values: new Map(),
|
||||
tooltip: value.group_tooltip,
|
||||
sequence: value.group_sequence,
|
||||
hex_color: value.group_hex_color,
|
||||
});
|
||||
// restore former checked state
|
||||
const oldGroup =
|
||||
filter.groups && filter.groups.get(groupId);
|
||||
groups.get(groupId).state =
|
||||
(oldGroup && oldGroup.state) || false;
|
||||
}
|
||||
groups.get(groupId).values.set(value.id, value);
|
||||
}
|
||||
filter.groups = groups;
|
||||
filter.sortedGroupIds = sortBy(
|
||||
groupIds,
|
||||
(id) => groups.get(id).sequence || groups.get(id).name
|
||||
);
|
||||
for (const group of filter.groups.values()) {
|
||||
for (const [valueId, value] of group.values) {
|
||||
filter.values.set(valueId, value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const value of values) {
|
||||
filter.values.set(value.id, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a section in this.state.sections for each visible field found
|
||||
* in the search panel arch.
|
||||
* @private
|
||||
*/
|
||||
_createSectionsFromArch() {
|
||||
let hasCategoryWithCounters = false;
|
||||
let hasFilterWithDomain = false;
|
||||
this.config.archNodes.forEach(({ attrs, tag }, index) => {
|
||||
if (tag !== "field" || attrs.invisible === "1") {
|
||||
return;
|
||||
}
|
||||
const type = attrs.select === "multi" ? "filter" : "category";
|
||||
const section = {
|
||||
color: attrs.color,
|
||||
description:
|
||||
attrs.string || this.config.fields[attrs.name].string,
|
||||
enableCounters: !!pyUtils.py_eval(
|
||||
attrs.enable_counters || "0"
|
||||
),
|
||||
expand: !!pyUtils.py_eval(attrs.expand || "0"),
|
||||
fieldName: attrs.name,
|
||||
icon: attrs.icon,
|
||||
id: nextSectionId++,
|
||||
index,
|
||||
limit: pyUtils.py_eval(attrs.limit || String(DEFAULT_LIMIT)),
|
||||
type,
|
||||
values: new Map(),
|
||||
};
|
||||
if (type === "category") {
|
||||
section.activeValueId = this.defaultValues[attrs.name];
|
||||
section.icon = section.icon || "fa-folder";
|
||||
section.hierarchize = !!pyUtils.py_eval(
|
||||
attrs.hierarchize || "1"
|
||||
);
|
||||
section.values.set(false, {
|
||||
childrenIds: [],
|
||||
display_name: this.env._t("All"),
|
||||
id: false,
|
||||
bold: true,
|
||||
parentId: false,
|
||||
});
|
||||
hasCategoryWithCounters = hasCategoryWithCounters || section.enableCounters;
|
||||
} else {
|
||||
section.domain = attrs.domain || "[]";
|
||||
section.groupBy = attrs.groupby;
|
||||
section.icon = section.icon || "fa-filter";
|
||||
hasFilterWithDomain = hasFilterWithDomain || section.domain !== "[]";
|
||||
}
|
||||
this.state.sections.set(section.id, section);
|
||||
});
|
||||
/**
|
||||
* Category counters are automatically disabled if a filter domain is found
|
||||
* to avoid inconsistencies with the counters. The underlying problem could
|
||||
* actually be solved by reworking the search panel and the way the
|
||||
* counters are computed, though this is not the current priority
|
||||
* considering the time it would take, hence this quick "fix".
|
||||
*/
|
||||
if (hasCategoryWithCounters && hasFilterWithDomain) {
|
||||
// If incompatibilities are found -> disables all category counters
|
||||
for (const category of this.categories) {
|
||||
category.enableCounters = false;
|
||||
}
|
||||
// ... and triggers a warning
|
||||
console.warn(
|
||||
"Warning: categories with counters are incompatible with filters having a domain attribute.",
|
||||
"All category counters have been disabled to avoid inconsistencies.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the active value of a category is one of its own
|
||||
* existing values.
|
||||
* @private
|
||||
* @param {Category} category
|
||||
* @param {number[]} valueIds
|
||||
*/
|
||||
_ensureCategoryValue(category, valueIds) {
|
||||
if (!valueIds.includes(category.activeValueId)) {
|
||||
category.activeValueId = valueIds[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches values for each category at startup. At reload a category is
|
||||
* only fetched if needed.
|
||||
* @private
|
||||
* @param {Category[]} categories
|
||||
* @returns {Promise} resolved when all categories have been fetched
|
||||
*/
|
||||
async _fetchCategories(categories) {
|
||||
const filterDomain = this._getFilterDomain();
|
||||
await Promise.all(categories.map(async (category) => {
|
||||
const result = await this.env.services.rpc({
|
||||
method: "search_panel_select_range",
|
||||
model: this.config.modelName,
|
||||
args: [category.fieldName],
|
||||
kwargs: {
|
||||
category_domain: this._getCategoryDomain(category.id),
|
||||
enable_counters: category.enableCounters,
|
||||
expand: category.expand,
|
||||
filter_domain: filterDomain,
|
||||
hierarchize: category.hierarchize,
|
||||
limit: category.limit,
|
||||
search_domain: this.searchDomain,
|
||||
},
|
||||
params: {
|
||||
context: this.config.context,
|
||||
},
|
||||
});
|
||||
this._createCategoryTree(category.id, result);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches values for each filter. This is done at startup and at each
|
||||
* reload if needed.
|
||||
* @private
|
||||
* @param {Filter[]} filters
|
||||
* @returns {Promise} resolved when all filters have been fetched
|
||||
*/
|
||||
async _fetchFilters(filters) {
|
||||
const evalContext = {};
|
||||
for (const category of this.categories) {
|
||||
evalContext[category.fieldName] = category.activeValueId;
|
||||
}
|
||||
const categoryDomain = this._getCategoryDomain();
|
||||
await Promise.all(filters.map(async (filter) => {
|
||||
const result = await this.env.services.rpc({
|
||||
method: "search_panel_select_multi_range",
|
||||
model: this.config.modelName,
|
||||
args: [filter.fieldName],
|
||||
kwargs: {
|
||||
category_domain: categoryDomain,
|
||||
comodel_domain: Domain.prototype.stringToArray(
|
||||
filter.domain,
|
||||
evalContext
|
||||
),
|
||||
enable_counters: filter.enableCounters,
|
||||
filter_domain: this._getFilterDomain(filter.id),
|
||||
expand: filter.expand,
|
||||
group_by: filter.groupBy || false,
|
||||
group_domain: this._getGroupDomain(filter),
|
||||
limit: filter.limit,
|
||||
search_domain: this.searchDomain,
|
||||
},
|
||||
params: {
|
||||
context: this.config.context,
|
||||
},
|
||||
});
|
||||
this._createFilterTree(filter.id, result);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {boolean} isInitialLoad
|
||||
* @returns {Promise}
|
||||
*/
|
||||
async _fetchSections(isInitialLoad) {
|
||||
await this._fetchCategories(
|
||||
isInitialLoad ? this.categories : this.categoriesToLoad
|
||||
);
|
||||
await this._fetchFilters(
|
||||
isInitialLoad ? this.filters : this.filtersToLoad
|
||||
);
|
||||
if (isInitialLoad) {
|
||||
this._applyDefaultFilterValues();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes and returns the domain based on the current active
|
||||
* categories. If "excludedCategoryId" is provided, the category with
|
||||
* that id is not taken into account in the domain computation.
|
||||
* @private
|
||||
* @param {string} [excludedCategoryId]
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
_getCategoryDomain(excludedCategoryId) {
|
||||
const domain = [];
|
||||
for (const category of this.categories) {
|
||||
if (
|
||||
category.id === excludedCategoryId ||
|
||||
!category.activeValueId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const field = this.config.fields[category.fieldName];
|
||||
const operator =
|
||||
field.type === "many2one" && category.parentField ? "child_of" : "=";
|
||||
domain.push([
|
||||
category.fieldName,
|
||||
operator,
|
||||
category.activeValueId,
|
||||
]);
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the domain retrieved from the other model extensions.
|
||||
* @private
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
_getExternalDomain() {
|
||||
const domains = this.config.get("domain");
|
||||
const domain = domains.reduce((acc, dom) => [...acc, ...dom], []);
|
||||
return Domain.prototype.normalizeArray(domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes and returns the domain based on the current checked
|
||||
* filters. The values of a single filter are combined using a simple
|
||||
* rule: checked values within a same group are combined with an "OR"
|
||||
* operator (this is expressed as single condition using a list) and
|
||||
* groups are combined with an "AND" operator (expressed by
|
||||
* concatenation of conditions).
|
||||
* If a filter has no group, its checked values are implicitely
|
||||
* considered as forming a group (and grouped using an "OR").
|
||||
* If excludedFilterId is provided, the filter with that id is not
|
||||
* taken into account in the domain computation.
|
||||
* @private
|
||||
* @param {string} [excludedFilterId]
|
||||
* @returns {Array[]}
|
||||
*/
|
||||
_getFilterDomain(excludedFilterId) {
|
||||
const domain = [];
|
||||
|
||||
function addCondition(fieldName, valueMap) {
|
||||
const ids = [];
|
||||
for (const [valueId, value] of valueMap) {
|
||||
if (value.checked) {
|
||||
ids.push(valueId);
|
||||
}
|
||||
}
|
||||
if (ids.length) {
|
||||
domain.push([fieldName, "in", ids]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const filter of this.filters) {
|
||||
if (filter.id === excludedFilterId) {
|
||||
continue;
|
||||
}
|
||||
const { fieldName, groups, values } = filter;
|
||||
if (groups) {
|
||||
for (const group of groups.values()) {
|
||||
addCondition(fieldName, group.values);
|
||||
}
|
||||
} else {
|
||||
addCondition(fieldName, values);
|
||||
}
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a domain or an object of domains used to complement
|
||||
* the filter domains to accurately describe the constrains on
|
||||
* records when computing record counts associated to the filter
|
||||
* values (if a groupBy is provided). The idea is that the checked
|
||||
* values within a group should not impact the counts for the other
|
||||
* values in the same group.
|
||||
* @private
|
||||
* @param {string} filter
|
||||
* @returns {Object<string, Array[]> | Array[] | null}
|
||||
*/
|
||||
_getGroupDomain(filter) {
|
||||
const { fieldName, groups, enableCounters } = filter;
|
||||
const { type: fieldType } = this.config.fields[fieldName];
|
||||
|
||||
if (!enableCounters || !groups) {
|
||||
return {
|
||||
many2one: [],
|
||||
many2many: {},
|
||||
}[fieldType];
|
||||
}
|
||||
let groupDomain = null;
|
||||
if (fieldType === "many2one") {
|
||||
for (const group of groups.values()) {
|
||||
const valueIds = [];
|
||||
let active = false;
|
||||
for (const [valueId, value] of group.values) {
|
||||
const { checked } = value;
|
||||
valueIds.push(valueId);
|
||||
if (checked) {
|
||||
active = true;
|
||||
}
|
||||
}
|
||||
if (active) {
|
||||
if (groupDomain) {
|
||||
groupDomain = [[0, "=", 1]];
|
||||
break;
|
||||
} else {
|
||||
groupDomain = [[fieldName, "in", valueIds]];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (fieldType === "many2many") {
|
||||
const checkedValueIds = new Map();
|
||||
groups.forEach(({ values }, groupId) => {
|
||||
values.forEach(({ checked }, valueId) => {
|
||||
if (checked) {
|
||||
if (!checkedValueIds.has(groupId)) {
|
||||
checkedValueIds.set(groupId, []);
|
||||
}
|
||||
checkedValueIds.get(groupId).push(valueId);
|
||||
}
|
||||
});
|
||||
});
|
||||
groupDomain = {};
|
||||
for (const [gId, ids] of checkedValueIds.entries()) {
|
||||
for (const groupId of groups.keys()) {
|
||||
if (gId !== groupId) {
|
||||
const key = JSON.stringify(groupId);
|
||||
if (!groupDomain[key]) {
|
||||
groupDomain[key] = [];
|
||||
}
|
||||
groupDomain[key].push([fieldName, "in", ids]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return groupDomain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the query informations should be considered as ready
|
||||
* before or after having (re-)fetched the sections data.
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {boolean} params.isInitialLoad
|
||||
* @param {boolean} params.searchDomainChanged
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldWaitForData({ isInitialLoad, searchDomainChanged }) {
|
||||
if (isInitialLoad && Object.keys(this.defaultValues).length) {
|
||||
// Default values need to be checked on initial load
|
||||
return true;
|
||||
}
|
||||
if (this.categories.length && this.filters.some(hasDomain)) {
|
||||
// Selected category value might affect the filter values
|
||||
return true;
|
||||
}
|
||||
if (!this.searchDomain.length) {
|
||||
// No search domain -> no need to check for expand
|
||||
return false;
|
||||
}
|
||||
return [...this.state.sections.values()].some(
|
||||
(section) => !section.expand && searchDomainChanged
|
||||
);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Static
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {{ attrs: Object, children: Object[] } | null}
|
||||
*/
|
||||
static extractArchInfo(archs, viewType) {
|
||||
const { children } = archs.search;
|
||||
const spNode = children.find(c => c.tag === "searchpanel");
|
||||
const isObject = (obj) => typeof obj === "object" && obj !== null;
|
||||
if (spNode) {
|
||||
const actualType = viewType === "list" ? "tree" : viewType;
|
||||
const { view_types } = spNode.attrs;
|
||||
const viewTypes = view_types ?
|
||||
view_types.split(",") :
|
||||
DEFAULT_VIEW_TYPES;
|
||||
if (viewTypes.includes(actualType)) {
|
||||
return {
|
||||
attrs: spNode.attrs,
|
||||
children: spNode.children.filter(isObject),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
SearchPanelModelExtension.layer = 1;
|
||||
|
||||
ActionModel.registry.add("SearchPanel", SearchPanelModelExtension, 30);
|
||||
|
||||
export default SearchPanelModelExtension;
|
||||
@@ -1,115 +0,0 @@
|
||||
/** @odoo-module alias=web.signature_dialog **/
|
||||
|
||||
import core from 'web.core';
|
||||
import Dialog from 'web.Dialog';
|
||||
import { NameAndSignature } from 'web.name_and_signature';
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
// The goal of this dialog is to ask the user a signature request.
|
||||
// It uses @see SignNameAndSignature for the name and signature fields.
|
||||
var SignatureDialog = Dialog.extend({
|
||||
template: 'web.signature_dialog',
|
||||
custom_events: {
|
||||
'signature_changed': '_onChangeSignature',
|
||||
},
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {Widget} parent
|
||||
* @param {Object} options
|
||||
* @param {string} [options.title='Adopt Your Signature'] - modal title
|
||||
* @param {string} [options.size='medium'] - modal size
|
||||
* @param {Object} [options.nameAndSignatureOptions={}] - options for
|
||||
* @see NameAndSignature.init()
|
||||
*/
|
||||
init: function (parent, options) {
|
||||
var self = this;
|
||||
options = options || {};
|
||||
|
||||
options.title = options.title || _t("Adopt Your Signature");
|
||||
options.size = options.size || 'medium';
|
||||
options.technical = false;
|
||||
|
||||
if (!options.buttons) {
|
||||
options.buttons = [];
|
||||
options.buttons.push({text: _t("Adopt & Sign"), classes: "btn-primary", disabled: true, click: function (e) {
|
||||
self._onConfirm();
|
||||
}});
|
||||
options.buttons.push({text: _t("Cancel"), close: true});
|
||||
}
|
||||
|
||||
this._super(parent, options);
|
||||
|
||||
this.nameAndSignature = new NameAndSignature(this, options.nameAndSignatureOptions);
|
||||
},
|
||||
/**
|
||||
* Start the nameAndSignature widget and wait for it.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
willStart: function () {
|
||||
return Promise.all([
|
||||
this.nameAndSignature.appendTo($('<div>')),
|
||||
this._super.apply(this, arguments)
|
||||
]);
|
||||
},
|
||||
/**
|
||||
* Initialize the name and signature widget when the modal is opened.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var self = this;
|
||||
this.$primaryButton = this.$footer.find('.btn-primary');
|
||||
|
||||
this.opened().then(function () {
|
||||
self.$('.o_web_sign_name_and_signature').replaceWith(self.nameAndSignature.$el);
|
||||
// initialize the signature area
|
||||
self.nameAndSignature.resetSignature();
|
||||
});
|
||||
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Public
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns whether the drawing area is currently empty.
|
||||
*
|
||||
* @see NameAndSignature.isSignatureEmpty()
|
||||
* @returns {boolean} Whether the drawing area is currently empty.
|
||||
*/
|
||||
isSignatureEmpty: function () {
|
||||
return this.nameAndSignature.isSignatureEmpty();
|
||||
},
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Handlers
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Toggles the submit button depending on the signature state.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onChangeSignature: function () {
|
||||
var isEmpty = this.nameAndSignature.isSignatureEmpty();
|
||||
this.$primaryButton.prop('disabled', isEmpty);
|
||||
},
|
||||
/**
|
||||
* Upload the signature image when confirm.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onConfirm: function (fct) {
|
||||
this.trigger_up('upload_signature', {
|
||||
name: this.nameAndSignature.getName(),
|
||||
signatureImage: this.nameAndSignature.getSignatureImage(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default SignatureDialog;
|
||||
@@ -1,17 +0,0 @@
|
||||
odoo.define('web.view_registry', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This module defines the view_registry. Web views are added to the registry
|
||||
* in the 'web._view_registry' module to avoid cyclic dependencies.
|
||||
* Views defined in other addons should be added in this registry as well,
|
||||
* ideally in another module than the one defining the view, in order to
|
||||
* separate the declarative part of a module (the view definition) from its
|
||||
* 'side-effects' part.
|
||||
*/
|
||||
|
||||
var Registry = require('web.Registry');
|
||||
|
||||
return new Registry();
|
||||
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
odoo.define('web.IFrameWidget', function (require) {
|
||||
"use strict";
|
||||
|
||||
var Widget = require('web.Widget');
|
||||
|
||||
/**
|
||||
* Generic widget to create an iframe that listens for clicks
|
||||
*
|
||||
* It should be extended by overwriting the methods::
|
||||
*
|
||||
* init: function(parent) {
|
||||
* this._super(parent, <url_of_iframe>);
|
||||
* },
|
||||
* _onIFrameClicked: function(e){
|
||||
* filter the clicks you want to use and apply
|
||||
* an action on it
|
||||
* }
|
||||
*/
|
||||
var IFrameWidget = Widget.extend({
|
||||
tagName: 'iframe',
|
||||
/**
|
||||
* @constructor
|
||||
* @param {Widget} parent
|
||||
* @param {string} url
|
||||
*/
|
||||
init: function (parent, url) {
|
||||
this._super(parent);
|
||||
this.url = url;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @returns {Promise}
|
||||
*/
|
||||
start: function () {
|
||||
this.$el.css({height: '100%', width: '100%', border: 0});
|
||||
this.$el.attr({src: this.url});
|
||||
this.$el.on("load", this._bindEvents.bind(this));
|
||||
return this._super();
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Called when the iframe is ready
|
||||
*/
|
||||
_bindEvents: function (){
|
||||
this.$el.contents().click(this._onIFrameClicked.bind(this));
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
_onIFrameClicked: function (event){
|
||||
}
|
||||
});
|
||||
|
||||
return IFrameWidget;
|
||||
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
odoo.define('web.TranslationDialog', function (require) {
|
||||
'use strict';
|
||||
|
||||
var core = require('web.core');
|
||||
var Dialog = require('web.Dialog');
|
||||
var session = require('web.session');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
var TranslationDialog = Dialog.extend({
|
||||
template: 'web.TranslationDialogWidget',
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
* @param {Widget} parent
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.fieldName] the name of the field currently translated (from the model of the form view)
|
||||
* @param {integer} [options.resId] the ID of record currently translated
|
||||
* @param {string} [options.userLanguageValue] the value of the translation in the language of the user, as seen in the from view (might be empty)
|
||||
* @param {string} [options.dataPointID] the data point id of the record for which we do the translations
|
||||
* @param {boolean} [options.isComingFromTranslationAlert] the initiator of the dialog, might be a link on a field or the translation alert on top of the form
|
||||
*
|
||||
*/
|
||||
init: function (parent, options) {
|
||||
options = options || {};
|
||||
|
||||
this.fieldName = options.fieldName;
|
||||
this.resId = options.resId;
|
||||
this.userLanguageValue = options.userLanguageValue;
|
||||
this.dataPointModel = options.dataPointModel;
|
||||
this.dataPointID = options.dataPointID;
|
||||
this.isComingFromTranslationAlert = options.isComingFromTranslationAlert;
|
||||
this.currentInterfaceLanguage = session.user_context.lang;
|
||||
this.context = options.context;
|
||||
|
||||
this._super(parent, _.extend({
|
||||
size: 'large',
|
||||
title: _.str.sprintf(_t('Translate: %s'), this.fieldName),
|
||||
buttons: [
|
||||
{ text: _t('Save'), classes: 'btn-primary', close: true, click: this._onSave.bind(this) },
|
||||
{ text: _t('Discard'), close: true },
|
||||
],
|
||||
}, options));
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
willStart: function () {
|
||||
return Promise.all([
|
||||
this._super(),
|
||||
this._loadLanguages().then((l) => {
|
||||
this.languages = l;
|
||||
return this._loadTranslations().then((t) => {
|
||||
[this.translations, this.context] = t;
|
||||
let id = 1;
|
||||
this.translations.forEach((t) => t['id'] = id++);
|
||||
this.isText = this.context.translation_type === 'text';
|
||||
this.showSource = this.context.translation_show_source;
|
||||
});
|
||||
}),
|
||||
]).then(() => {
|
||||
this.data = this.translations.map((term) => {
|
||||
let relatedLanguage = this.languages.find((language) => language[0] === term.lang);
|
||||
return {
|
||||
id: term.id,
|
||||
lang: term.lang,
|
||||
langName: relatedLanguage[1],
|
||||
source: term.source,
|
||||
// we set the translation value coming from the database, except for the language
|
||||
// the user is currently utilizing. Then we set the translation value coming
|
||||
// from the value of the field in the form
|
||||
value: (term.lang === this.currentInterfaceLanguage &&
|
||||
!this.showSource &&
|
||||
!this.isComingFromTranslationAlert) ?
|
||||
this.userLanguageValue : term.value || ''
|
||||
};
|
||||
});
|
||||
this.data.sort((left, right) =>
|
||||
left.langName.localeCompare(right.langName));
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
/**
|
||||
* Load the translation terms for the installed language, for the current model and res_id
|
||||
* @private
|
||||
*/
|
||||
_loadTranslations: function () {
|
||||
return this._rpc({
|
||||
model: this.dataPointModel,
|
||||
method: 'get_field_translations',
|
||||
args: [[this.resId], this.fieldName],
|
||||
context: this.context,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Load the installed languages long names and code
|
||||
*
|
||||
* The result of the call is put in cache on the prototype of this dialog.
|
||||
* If any new language is installed, a full page refresh will happen,
|
||||
* so there is no need invalidate it.
|
||||
* @private
|
||||
*/
|
||||
_loadLanguages: function () {
|
||||
if (TranslationDialog.prototype.installedLanguagesCache)
|
||||
return Promise.resolve(TranslationDialog.prototype.installedLanguagesCache);
|
||||
|
||||
return this._rpc({
|
||||
model: 'res.lang',
|
||||
method: 'get_installed',
|
||||
fields: ['code', 'name', 'iso_code'],
|
||||
}).then((installedLanguages) => {
|
||||
TranslationDialog.prototype.installedLanguagesCache = installedLanguages;
|
||||
return TranslationDialog.prototype.installedLanguagesCache
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
/**
|
||||
* Save all the terms that have been updated
|
||||
* @private
|
||||
* @returns a promise that is resolved when all the save have occured
|
||||
*/
|
||||
_onSave: function () {
|
||||
var updatedTerm = {};
|
||||
var updateFormViewField;
|
||||
|
||||
this.el.querySelectorAll('input[type=text],textarea').forEach((t) => {
|
||||
var initialValue = this.data.find((d) => d.id == t.dataset.id);
|
||||
if (initialValue.value !== t.value) {
|
||||
updatedTerm[t.dataset.id] = {lang: initialValue.lang, source: initialValue.source, value: t.value, initialValue: initialValue.value};
|
||||
|
||||
if (initialValue.lang === this.currentInterfaceLanguage && !this.showSource) {
|
||||
// when the user has changed the term for the language he is
|
||||
// using in the interface, this change should be reflected
|
||||
// in the form view
|
||||
// partial translations being handled server side are
|
||||
// also ignored
|
||||
var changes = {};
|
||||
changes[this.fieldName] = t.value;
|
||||
updateFormViewField = {
|
||||
dataPointID: this.dataPointID,
|
||||
changes: changes,
|
||||
doNotSetDirty: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// updatedTerm only contains the id and values of the terms that
|
||||
// have been updated by the user
|
||||
const translations = {};
|
||||
if (this.showSource) { // model terms translation
|
||||
Object.entries(updatedTerm).forEach(([id, term]) => {
|
||||
if (!translations[term.lang]) {
|
||||
translations[term.lang] = {};
|
||||
}
|
||||
const source = term.initialValue ? term.initialValue : term.source;
|
||||
translations[term.lang][source] = term.value;
|
||||
});
|
||||
}
|
||||
else { // model translation
|
||||
Object.entries(updatedTerm).forEach(([id, term]) => translations[term.lang] = term.value);
|
||||
}
|
||||
return this._rpc({
|
||||
model: this.dataPointModel,
|
||||
method: 'update_field_translations',
|
||||
args: [[this.resId], this.fieldName, translations],
|
||||
}).then(() => {
|
||||
// we might have to update the value of the field on the form
|
||||
// view that opened the translation dialog
|
||||
if (updateFormViewField) {
|
||||
this.trigger_up('field_changed', updateFormViewField);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return TranslationDialog;
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import Context from "web.Context";
|
||||
import { browser } from "../core/browser/browser";
|
||||
import { registry } from "../core/registry";
|
||||
import { mapDoActionOptionAPI } from "./backend_utils";
|
||||
import { wrapSuccessOrFail } from "@web/legacy/utils";
|
||||
|
||||
export const legacyServiceProvider = {
|
||||
dependencies: ["effect", "action"],
|
||||
@@ -26,29 +25,6 @@ export const legacyServiceProvider = {
|
||||
const legacyOptions = mapDoActionOptionAPI(payload.options);
|
||||
services.action.doAction(payload.action, legacyOptions);
|
||||
});
|
||||
|
||||
browser.addEventListener("execute-action", (ev) => {
|
||||
const payload = ev.detail;
|
||||
const buttonContext = new Context(payload.action_data.context).eval();
|
||||
const envContext = new Context(payload.env.context).eval();
|
||||
wrapSuccessOrFail(
|
||||
services.action.doActionButton({
|
||||
args: payload.action_data.args,
|
||||
buttonContext: buttonContext,
|
||||
context: envContext,
|
||||
close: payload.action_data.close,
|
||||
resModel: payload.env.model,
|
||||
name: payload.action_data.name,
|
||||
resId: payload.env.currentID || null,
|
||||
resIds: payload.env.resIDs,
|
||||
special: payload.action_data.special,
|
||||
type: payload.action_data.type,
|
||||
onClose: payload.on_closed,
|
||||
effect: payload.action_data.effect,
|
||||
}),
|
||||
payload
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useSetupAction } from "../webclient/actions/action_hook";
|
||||
import { useLegacyRefs } from "./utils";
|
||||
import legacyViewRegistry from "web.view_registry";
|
||||
import { ViewAdapter } from "./action_adapters";
|
||||
import Widget from "web.Widget";
|
||||
import {
|
||||
breadcrumbsToLegacy,
|
||||
getGlobalState,
|
||||
getLocalState,
|
||||
searchModelStateToLegacy,
|
||||
} from "./backend_utils";
|
||||
import { getBundle, loadBundle } from "@web/core/assets";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { LegacyComponent } from "./legacy_component";
|
||||
|
||||
import { xml, onWillStart } from "@odoo/owl";
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
function getJsClassWidget(fieldsInfo) {
|
||||
const parsedXML = new DOMParser().parseFromString(fieldsInfo.arch, "text/xml");
|
||||
const key = parsedXML.documentElement.getAttribute("js_class");
|
||||
return legacyViewRegistry.get(key);
|
||||
}
|
||||
|
||||
const legacyViewTemplate = xml`
|
||||
<ViewAdapter Component="Widget" View="View" viewInfo="viewInfo" viewParams="viewParams"
|
||||
widget="widget" onReverseBreadcrumb="onReverseBreadcrumb" selectRecord="props.selectRecord" createRecord="props.createRecord"/>`;
|
||||
|
||||
// registers a view from the legacy view registry to the wowl one, but wrapped
|
||||
// into an Owl Component
|
||||
function registerView(name, LegacyView) {
|
||||
if (viewRegistry.contains(name)) {
|
||||
return;
|
||||
}
|
||||
class Controller extends LegacyComponent {
|
||||
setup() {
|
||||
this.vm = useService("view");
|
||||
this.Widget = Widget; // fool the ComponentAdapter with a simple Widget
|
||||
this.View = LegacyView;
|
||||
this.viewInfo = {};
|
||||
|
||||
let resIds;
|
||||
let searchModel;
|
||||
let searchPanel;
|
||||
const { globalState } = this.props;
|
||||
if (globalState) {
|
||||
resIds = globalState.resIds;
|
||||
searchModel = searchModelStateToLegacy(globalState.searchModel);
|
||||
searchPanel = globalState.searchPanel;
|
||||
}
|
||||
|
||||
// always add user context to the action context
|
||||
this.user = useService("user");
|
||||
const action = Object.assign({}, this.props.action);
|
||||
action.context = Object.assign({}, this.user.context, action.context);
|
||||
|
||||
const { actionFlags, breadcrumbs, noBreadcrumbs } = this.env.config;
|
||||
if (noBreadcrumbs) {
|
||||
action.context.no_breadcrumbs = true;
|
||||
}
|
||||
this.viewParams = Object.assign({}, actionFlags, {
|
||||
action,
|
||||
// legacy views automatically add the last part of the breadcrumbs
|
||||
breadcrumbs: breadcrumbsToLegacy(breadcrumbs),
|
||||
modelName: this.props.resModel,
|
||||
currentId: this.props.resId,
|
||||
controllerState: {
|
||||
currentId:
|
||||
"resId" in this.props
|
||||
? this.props.resId
|
||||
: this.props.state && this.props.state.currentId,
|
||||
resIds: this.props.resIds || resIds,
|
||||
searchModel,
|
||||
searchPanel,
|
||||
},
|
||||
});
|
||||
|
||||
// To open a new empty form view
|
||||
// Legacy demands undefined ids, not False
|
||||
if (this.viewParams.currentId === false) {
|
||||
this.viewParams.currentId = undefined;
|
||||
this.viewParams.controllerState.currentId = undefined;
|
||||
}
|
||||
|
||||
// Only add mode to viewParams if it is specified to avoid overwriting the default mode in some view (eg graph)
|
||||
if (this.props.mode) {
|
||||
this.viewParams.mode = this.props.mode;
|
||||
}
|
||||
const legacyRefs = useLegacyRefs();
|
||||
this.widget = this.props.state && this.props.state.__legacy_widget__;
|
||||
legacyRefs.widget = this.widget;
|
||||
this.onReverseBreadcrumb =
|
||||
this.props.state && this.props.state.__on_reverse_breadcrumb__;
|
||||
useSetupAction({
|
||||
rootRef: {
|
||||
get el() {
|
||||
return legacyRefs.widget && legacyRefs.widget.el;
|
||||
},
|
||||
},
|
||||
beforeLeave: () => legacyRefs.widget.canBeRemoved(),
|
||||
getGlobalState: () => getGlobalState(legacyRefs.component.exportState()),
|
||||
getLocalState: () => getLocalState(legacyRefs.component.exportState()),
|
||||
});
|
||||
onWillStart(this.onWillStart);
|
||||
}
|
||||
|
||||
async onWillStart() {
|
||||
const params = {
|
||||
resModel: this.props.resModel,
|
||||
views: this.props.views || this.env.config.views,
|
||||
context: this.props.context,
|
||||
};
|
||||
const options = {
|
||||
actionId: this.env.config.actionId,
|
||||
loadActionMenus: this.props.loadActionMenus,
|
||||
loadIrFilters: this.props.loadIrFilters,
|
||||
};
|
||||
const viewDescriptions = await this.vm.loadViews(params, options);
|
||||
const result = viewDescriptions.__legacy__;
|
||||
const fieldsInfo = result.fields_views[this.props.type || this.env.config.viewType];
|
||||
const jsClass = getJsClassWidget(fieldsInfo);
|
||||
this.View = jsClass || this.View;
|
||||
this.viewInfo = Object.assign({}, fieldsInfo, {
|
||||
fields: result.fields,
|
||||
viewFields: fieldsInfo.fields,
|
||||
type: this.props.type || this.env.config.viewType,
|
||||
});
|
||||
let controlPanelFieldsView;
|
||||
if (result.fields_views.search) {
|
||||
controlPanelFieldsView = Object.assign({}, result.fields_views.search, {
|
||||
favoriteFilters: result.filters,
|
||||
fields: result.fields,
|
||||
viewFields: result.fields_views.search.fields,
|
||||
});
|
||||
}
|
||||
const { viewSwitcherEntries = [] } = this.env.config;
|
||||
const _views = this.viewParams.action.views || this.env.config.views;
|
||||
const views = _views
|
||||
.filter(([, vtype]) => vtype !== "search")
|
||||
.map(([vid, vtype]) => {
|
||||
const view = viewSwitcherEntries.find((v) => v.type === vtype);
|
||||
if (view) {
|
||||
return Object.assign({}, view, { viewID: vid });
|
||||
} else {
|
||||
return {
|
||||
viewID: vid,
|
||||
type: vtype,
|
||||
multiRecord: !legacyView.multiRecord,
|
||||
};
|
||||
}
|
||||
});
|
||||
this.viewParams.action = Object.assign({}, this.viewParams.action, {
|
||||
controlPanelFieldsView,
|
||||
_views,
|
||||
views,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Controller.template = legacyViewTemplate;
|
||||
Controller.components = { ViewAdapter };
|
||||
Controller.isLegacy = true; // for action dialog
|
||||
|
||||
const legacyView = {
|
||||
type: LegacyView.prototype.viewType,
|
||||
display_name: LegacyView.prototype.display_name,
|
||||
icon: LegacyView.prototype.icon,
|
||||
isMobileFriendly: LegacyView.prototype.mobile_friendly,
|
||||
multiRecord: LegacyView.prototype.multi_record,
|
||||
isLegacy: true,
|
||||
Controller,
|
||||
};
|
||||
viewRegistry.add(name, legacyView);
|
||||
if (odoo.debug) {
|
||||
console.log(`Views: using legacy view: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// register views already in the legacy registry, and listens to future registrations
|
||||
for (const [name, action] of Object.entries(legacyViewRegistry.entries())) {
|
||||
registerView(name, action);
|
||||
}
|
||||
legacyViewRegistry.onAdd(registerView);
|
||||
|
||||
export async function loadLegacyViews() {
|
||||
const assets = await getBundle("web.assets_backend_legacy_lazy");
|
||||
await loadBundle(assets);
|
||||
}
|
||||
@@ -31,80 +31,3 @@
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.o_btn-link-as-button {
|
||||
padding: 2px;
|
||||
font-size:12px;
|
||||
|
||||
& > a {
|
||||
margin-bottom: -4px !important;
|
||||
margin-left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
// No content helper
|
||||
.o_view_nocontent {
|
||||
@include o-position-absolute(0, 0, 0, 0);
|
||||
pointer-events: none;
|
||||
z-index: var(--o-view-nocontent-zindex, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-image: radial-gradient(at 50% 50%, #{$o-view-background-color} 0px, #{rgba($o-view-background-color, 0.5)} 100%);
|
||||
|
||||
.o_nocontent_help {
|
||||
@include o-nocontent-empty;
|
||||
|
||||
.o_view_nocontent_smiling_face:before {
|
||||
@extend %o-nocontent-init-image;
|
||||
width: 120px;
|
||||
height: 140px;
|
||||
background: transparent url(/web/static/img/smiling_face.svg) no-repeat center;
|
||||
}
|
||||
|
||||
.o_view_nocontent_neutral_face:before {
|
||||
@extend %o-nocontent-init-image;
|
||||
width: 120px;
|
||||
height: 140px;
|
||||
background: transparent url(/web/static/img/neutral_face.svg) no-repeat center;
|
||||
}
|
||||
|
||||
.o_view_nocontent_empty_folder:before {
|
||||
@extend %o-nocontent-empty-document;
|
||||
}
|
||||
|
||||
.o_empty_custom_dashboard {
|
||||
min-height: 327px;
|
||||
margin-left: -$grid-gutter-width/2;
|
||||
margin-top: -$grid-gutter-width/2;
|
||||
padding: 100px 0 0 137px;
|
||||
background: transparent url(/web/static/img/graph_background.png) no-repeat 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_legacy_view_sample_data {
|
||||
.o_list_table {
|
||||
cursor: default !important;
|
||||
|
||||
& > thead .o_list_record_selector {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-check {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.o_nocontent_help {
|
||||
border-radius: 20%;
|
||||
background-color: $o-view-background-color;
|
||||
box-shadow: 0 0 120px 100px $o-view-background-color;
|
||||
}
|
||||
|
||||
.o_sample_data_disabled {
|
||||
opacity: 0.33;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +100,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<div t-name="web.signature_dialog">
|
||||
<div class="o_web_sign_name_and_signature"/>
|
||||
|
||||
<div class="mt16 small">By clicking Adopt & Sign, I agree that the chosen signature/initials will be a valid electronic representation of my hand-written signature/initials for all purposes when it is used on documents, including legally binding contracts.</div>
|
||||
</div>
|
||||
</templates>
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
|
||||
<t t-name="web.Legacy.SearchPanel" owl="1">
|
||||
<div class="o_search_panel flex-grow-0 flex-shrink-0 border-end pe-2 pb-5 ps-4 h-100 bg-view overflow-auto" t-att-class="props.className" t-ref="legacySearchPanel">
|
||||
<section t-foreach="model.get('sections', s => !s.empty)" t-as="section" t-key="section.id"
|
||||
t-attf-class="o_search_panel_section o_search_panel_{{ section.type }}"
|
||||
>
|
||||
<header class="o_search_panel_section_header pt-4 pb-2 text-uppercase cursor-default">
|
||||
<i t-attf-class="fa {{ section.icon }} o_search_panel_section_icon me-2 {{!section.color && section.type == 'filter' ? 'text-warning' : !section.color ? 'text-odoo': ''}}"
|
||||
t-att-style="section.color and ('color: ' + section.color)"
|
||||
/>
|
||||
<b t-esc="section.description"/>
|
||||
</header>
|
||||
<div t-if="section.errorMsg" class="alert alert-warning">
|
||||
<span><t t-esc="section.errorMsg"/></span>
|
||||
</div>
|
||||
<ul t-else="" class="list-group d-block o_search_panel_field">
|
||||
<t t-if="section.type === 'category'" t-call="web.Legacy.SearchPanel.Category">
|
||||
<t t-set="values" t-value="section.rootIds"/>
|
||||
</t>
|
||||
<t t-elif="section.groups">
|
||||
<li
|
||||
t-foreach="section.sortedGroupIds" t-as="groupId" t-key="groupId"
|
||||
class="o_search_panel_filter_group list-group-item p-0 border-0"
|
||||
t-att-class="groupId_last? 'mb-0' : 'mb-3'"
|
||||
>
|
||||
<!-- TODO: this is a workaround for issue https://github.com/odoo/owl/issues/695 (remove when solved) -->
|
||||
<t t-set="_section" t-value="section"/>
|
||||
<t t-set="group" t-value="section.groups.get(groupId)"/>
|
||||
<header class="o_search_panel_group_header pb-1">
|
||||
<div class="form-check w-100">
|
||||
<!-- TODO: "indeterminate" could not be set in the template and had to be set in
|
||||
JS manually. See https://github.com/odoo/owl/issues/713 (adapt when solved)
|
||||
-->
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
t-attf-id="{{ section.id }}_input_{{ groupId }})"
|
||||
t-on-click="() => this._toggleFilterGroup(section.id, group)"
|
||||
/>
|
||||
<label
|
||||
t-attf-for="{{ section.id }}_input_{{ groupId }})"
|
||||
class="o_search_panel_label form-check-label d-flex align-items-center justify-content-between w-100 o_cursor_pointer"
|
||||
t-att-class="{ o_with_counters: group.enableCounters }"
|
||||
t-att-title="group.tooltip or false"
|
||||
>
|
||||
<span class="o_search_panel_label_title text-truncate">
|
||||
<span t-if="group.hex_color" class="me-1" t-attf-style="color: {{ group.hex_color }};">●</span>
|
||||
<t t-esc="group.name"/>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
<ul class="list-group d-block">
|
||||
<t t-call="web.Legacy.SearchPanel.FiltersGroup">
|
||||
<t t-set="values" t-value="group.values"/>
|
||||
<t t-set="isChildList" t-value="true"/>
|
||||
<!-- TODO: this is a workaround for issue https://github.com/odoo/owl/issues/695 (remove when solved) -->
|
||||
<t t-set="section" t-value="_section"/>
|
||||
</t>
|
||||
</ul>
|
||||
</li>
|
||||
<ul t-if="section.groups.get(false)" class="list-group d-block">
|
||||
<t t-call="web.Legacy.SearchPanel.FiltersGroup">
|
||||
<t t-set="group" t-value="section.groups.get(false)"/>
|
||||
<t t-set="values" t-value="group.values"/>
|
||||
<!-- TODO: this is a workaround for issue https://github.com/odoo/owl/issues/695 (remove when solved) -->
|
||||
<t t-set="section" t-value="section"/>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-else="" t-call="web.Legacy.SearchPanel.FiltersGroup">
|
||||
<t t-set="values" t-value="section.values"/>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="web.Legacy.SearchPanel.Category" owl="1">
|
||||
<t t-foreach="values" t-as="valueId" t-key="valueId">
|
||||
<t t-set="value" t-value="section.values.get(valueId)"/>
|
||||
|
||||
<li class="o_search_panel_category_value list-group-item py-1 o_cursor_pointer border-0"
|
||||
t-att-class="isChildList ? 'o_treeEntry ps-4 pe-0' : 'ps-0 pe-2'"
|
||||
>
|
||||
<header
|
||||
class="list-group-item list-group-item-action d-flex align-items-center p-0 border-0"
|
||||
t-att-class="{'active text-900 fw-bold': state.active[section.id] === valueId}"
|
||||
t-on-click="() => this._toggleCategory(section, value)"
|
||||
>
|
||||
<div
|
||||
class="o_search_panel_label d-flex align-items-center overflow-hidden w-100 o_cursor_pointer mb-0"
|
||||
t-att-class="{'o_with_counters': section.enableCounters }"
|
||||
>
|
||||
<button class="o_toggle_fold btn p-0 flex-shrink-0 text-center">
|
||||
<i
|
||||
t-if="value.childrenIds.length"
|
||||
class="fa"
|
||||
t-att-class="{
|
||||
'fa-caret-down' : state.expanded[section.id][valueId],
|
||||
'fa-caret-right ms-1': !state.expanded[section.id][valueId]
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
class="o_search_panel_label_title text-truncate"
|
||||
t-att-class="{'fw-bold' : value.bold}"
|
||||
t-esc="value.display_name"
|
||||
/>
|
||||
</div>
|
||||
<small t-if="section.enableCounters and value.__count gt 0"
|
||||
class="o_search_panel_counter text-muted mx-2 fw-bold"
|
||||
t-esc="value.__count"
|
||||
/>
|
||||
</header>
|
||||
<ul t-if="value.childrenIds.length and state.expanded[section.id][valueId]"
|
||||
class="list-group d-block"
|
||||
>
|
||||
<t t-call="web.Legacy.SearchPanel.Category">
|
||||
<t t-set="values" t-value="value.childrenIds"/>
|
||||
<t t-set="isChildList" t-value="true"/>
|
||||
</t>
|
||||
</ul>
|
||||
</li>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-name="web.Legacy.SearchPanel.FiltersGroup" owl="1">
|
||||
<li t-foreach="[...values.keys()]" t-as="valueId" t-key="valueId"
|
||||
class="o_search_panel_filter_value list-group-item p-0 mb-1 border-0 o_cursor_pointer"
|
||||
t-att-class="{ 'ps-2' : isChildList }"
|
||||
>
|
||||
<t t-set="value" t-value="values.get(valueId)"/>
|
||||
<div class="form-check w-100">
|
||||
<input type="checkbox"
|
||||
t-attf-id="{{ section.id }}_input_{{ valueId }}"
|
||||
t-att-checked="state.active[section.id][valueId]"
|
||||
class="form-check-input"
|
||||
t-on-click="ev => this._toggleFilterValue(section.id, valueId, ev)"
|
||||
/>
|
||||
<label class="o_search_panel_label form-check-label d-flex align-items-center justify-content-between w-100 o_cursor_pointer"
|
||||
t-attf-for="{{ section.id }}_input_{{ valueId }}"
|
||||
t-att-title="(group and group.tooltip) or false">
|
||||
<span class="o_search_panel_label_title text-truncate" t-esc="value.display_name"/>
|
||||
<span t-if="section.enableCounters and value.__count gt 0"
|
||||
class="o_search_panel_counter text-muted mx-2 small"
|
||||
t-esc="value.__count"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<div t-name="web.TranslationDialogWidget" class="o_translation_dialog">
|
||||
<div class="row" t-foreach="widget.data" t-as="term">
|
||||
<div t-attf-class="col-12 #{widget.showSource ? 'col-lg-2' : 'col-lg-3'} #{widget.currentInterfaceLanguage == term.lang ? 'o_language_current' : ''}">
|
||||
<t t-esc="term.langName"></t>
|
||||
</div>
|
||||
<div t-if="widget.showSource" class="col-12 col-lg-3 source">
|
||||
<t t-esc="term.source"></t>
|
||||
</div>
|
||||
<div t-attf-class="col-12 #{widget.showSource ? 'col-lg-7' : 'col-lg-9'} translation">
|
||||
<input t-if="!widget.isText" type="text"
|
||||
class="o_field_char o_input"
|
||||
t-att-value="term.value"
|
||||
t-att-data-id="term.id"/>
|
||||
|
||||
<textarea t-if="widget.isText"
|
||||
t-esc="term.value"
|
||||
t-att-data-id="term.id"
|
||||
t-att-rows="widget.showSource ? 2 : 5"
|
||||
class="o_field_text o_field_translate o_field_widget o_input">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</templates>
|
||||
@@ -3,7 +3,7 @@
|
||||
* all views.
|
||||
*/
|
||||
|
||||
// Sample data global rules
|
||||
// Sample data global rules
|
||||
.o_view_sample_data {
|
||||
.form-check {
|
||||
pointer-events: none !important;
|
||||
@@ -15,3 +15,44 @@
|
||||
box-shadow: 0 0 120px 100px $o-view-background-color;
|
||||
}
|
||||
}
|
||||
|
||||
// No content helper
|
||||
.o_view_nocontent {
|
||||
@include o-position-absolute(0, 0, 0, 0);
|
||||
pointer-events: none;
|
||||
z-index: var(--o-view-nocontent-zindex, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-image: radial-gradient(at 50% 50%, #{$o-view-background-color} 0px, #{rgba($o-view-background-color, 0.5)} 100%);
|
||||
|
||||
.o_nocontent_help {
|
||||
@include o-nocontent-empty;
|
||||
|
||||
.o_view_nocontent_smiling_face:before {
|
||||
@extend %o-nocontent-init-image;
|
||||
width: 120px;
|
||||
height: 140px;
|
||||
background: transparent url(/web/static/img/smiling_face.svg) no-repeat center;
|
||||
}
|
||||
|
||||
.o_view_nocontent_neutral_face:before {
|
||||
@extend %o-nocontent-init-image;
|
||||
width: 120px;
|
||||
height: 140px;
|
||||
background: transparent url(/web/static/img/neutral_face.svg) no-repeat center;
|
||||
}
|
||||
|
||||
.o_view_nocontent_empty_folder:before {
|
||||
@extend %o-nocontent-empty-document;
|
||||
}
|
||||
|
||||
.o_empty_custom_dashboard {
|
||||
min-height: 327px;
|
||||
margin-left: -$grid-gutter-width/2;
|
||||
margin-top: -$grid-gutter-width/2;
|
||||
padding: 100px 0 0 137px;
|
||||
background: transparent url(/web/static/img/graph_background.png) no-repeat 0 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,17 +507,6 @@ function makeActionManager(env) {
|
||||
viewProps.resId = action.res_id;
|
||||
}
|
||||
|
||||
// LEGACY CODE COMPATIBILITY: remove when all views will be written in owl
|
||||
if (view.isLegacy) {
|
||||
const legacyActionInfo = { ...action, ...viewProps.action };
|
||||
Object.assign(viewProps, {
|
||||
action: legacyActionInfo,
|
||||
View: view,
|
||||
views: action.views,
|
||||
});
|
||||
}
|
||||
// END LEGACY CODE COMPATIBILITY
|
||||
|
||||
viewProps.noBreadcrumbs = action.context.no_breadcrumbs;
|
||||
delete action.context.no_breadcrumbs;
|
||||
return {
|
||||
@@ -712,16 +701,7 @@ function makeActionManager(env) {
|
||||
const toDestroy = new Set();
|
||||
for (const c of controllerStack) {
|
||||
if (!nextStackActionIds.includes(c.action.jsId)) {
|
||||
if (c.action.type === "ir.actions.act_window") {
|
||||
for (const viewType in c.action.controllers) {
|
||||
const controller = c.action.controllers[viewType];
|
||||
if (controller.view.isLegacy) {
|
||||
toDestroy.add(controller);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toDestroy.add(c);
|
||||
}
|
||||
toDestroy.add(c);
|
||||
}
|
||||
}
|
||||
for (const c of toDestroy) {
|
||||
@@ -877,42 +857,12 @@ function makeActionManager(env) {
|
||||
* @param {ActionOptions} options
|
||||
*/
|
||||
async function _executeActWindowAction(action, options) {
|
||||
// LEGACY CODE COMPATIBILITY: load views to determine js_class if any, s.t.
|
||||
// we know if the view to use is legacy or not
|
||||
// When all views will be converted, this will be done exclusively by View
|
||||
// #action-serv-leg-compat-js-class
|
||||
const loadViewParams = {
|
||||
context: action.context || {},
|
||||
views: action.views,
|
||||
resModel: action.res_model,
|
||||
};
|
||||
const loadViewOptions = {
|
||||
actionId: action.id,
|
||||
loadActionMenus: action.target !== "new" && action.target !== "inline",
|
||||
loadIrFilters: action.views.some((v) => v[1] === "search"),
|
||||
};
|
||||
const prom = env.services.view.loadViews(loadViewParams, loadViewOptions);
|
||||
const { views: viewDescriptions } = await keepLast.add(prom);
|
||||
const domParser = new DOMParser();
|
||||
const views = [];
|
||||
for (const [, type] of action.views) {
|
||||
if (type !== "search") {
|
||||
const arch = viewDescriptions[type].arch;
|
||||
const archDoc = domParser.parseFromString(arch, "text/xml").documentElement;
|
||||
const jsClass = archDoc.getAttribute("js_class");
|
||||
const view = viewRegistry.get(jsClass, false) || viewRegistry.get(type, false);
|
||||
if (view) {
|
||||
views.push(view);
|
||||
}
|
||||
if (type !== "search" && viewRegistry.contains(type)) {
|
||||
views.push(viewRegistry.get(type));
|
||||
}
|
||||
}
|
||||
// END LEGACY CODE COMPATIBILITY
|
||||
// const views = [];
|
||||
// for (const [, type] of action.views) {
|
||||
// if (type !== "search" && viewRegistry.contains(type)) {
|
||||
// views.push(viewRegistry.get(key));
|
||||
// }
|
||||
// }
|
||||
if (!views.length) {
|
||||
throw new Error(`No view found for act_window action ${action.id}`);
|
||||
}
|
||||
@@ -937,7 +887,7 @@ function makeActionManager(env) {
|
||||
|
||||
const controller = {
|
||||
jsId: `controller_${++id}`,
|
||||
Component: view.isLegacy ? view.Controller : View,
|
||||
Component: View,
|
||||
action,
|
||||
view,
|
||||
views,
|
||||
@@ -954,7 +904,7 @@ function makeActionManager(env) {
|
||||
if (lazyView) {
|
||||
updateUIOptions.lazyController = {
|
||||
jsId: `controller_${++id}`,
|
||||
Component: lazyView.isLegacy ? lazyView.Controller : View,
|
||||
Component: View,
|
||||
action,
|
||||
view: lazyView,
|
||||
views,
|
||||
@@ -1376,24 +1326,12 @@ function makeActionManager(env) {
|
||||
}
|
||||
const newController = controller.action.controllers[viewType] || {
|
||||
jsId: `controller_${++id}`,
|
||||
Component: view.isLegacy ? view.Controller : View,
|
||||
Component: View,
|
||||
action: controller.action,
|
||||
views: controller.views,
|
||||
view,
|
||||
};
|
||||
|
||||
// LEGACY CODE COMPATIBILITY: remove when controllers will be written in owl
|
||||
if (view.isLegacy && newController.jsId === controller.jsId) {
|
||||
// case where a legacy view is reloaded via the view switcher
|
||||
const { __legacy_widget__ } = controller.getLocalState();
|
||||
const params = {};
|
||||
if ("resId" in props) {
|
||||
params.currentId = props.resId;
|
||||
}
|
||||
return __legacy_widget__.reload(params);
|
||||
}
|
||||
// END LEGACY CODE COMPATIBILITY
|
||||
|
||||
const canProceed = await clearUncommittedChanges(env);
|
||||
if (!canProceed) {
|
||||
return;
|
||||
@@ -1540,7 +1478,6 @@ export const actionService = {
|
||||
"router",
|
||||
"rpc",
|
||||
"title",
|
||||
"view", // for legacy view compatibility #action-serv-leg-compat-js-class
|
||||
"ui",
|
||||
"user",
|
||||
],
|
||||
|
||||
@@ -47,18 +47,6 @@
|
||||
actionCount++;
|
||||
});
|
||||
|
||||
const AbstractController = odoo.__DEBUG__.services["web.AbstractController"];
|
||||
AbstractController.include({
|
||||
start() {
|
||||
this.$el.attr("data-view-type", this.viewType);
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
async update() {
|
||||
await this._super(...arguments);
|
||||
viewUpdateCount++;
|
||||
},
|
||||
});
|
||||
|
||||
const { patch } = odoo.__DEBUG__.services["@web/core/utils/patch"];
|
||||
const { WithSearch } = odoo.__DEBUG__.services["@web/search/with_search/with_search"];
|
||||
|
||||
|
||||
@@ -5,18 +5,13 @@ odoo.define('web.OwlCompatibilityTests', function (require) {
|
||||
ComponentAdapter,
|
||||
ComponentWrapper,
|
||||
WidgetAdapterMixin,
|
||||
standaloneAdapter,
|
||||
} = require('web.OwlCompatibility');
|
||||
const testUtils = require('web.test_utils');
|
||||
const Widget = require('web.Widget');
|
||||
const Dialog = require("web.Dialog");
|
||||
const { registry } = require("@web/core/registry");
|
||||
const { LegacyComponent } = require("@web/legacy/legacy_component");
|
||||
const { mapLegacyEnvToWowlEnv, useWowlService } = require("@web/legacy/utils");
|
||||
|
||||
const { legacyServiceProvider } = require("@web/legacy/legacy_service_provider");
|
||||
const { click } = require("@web/../tests/helpers/utils");
|
||||
|
||||
const makeTestEnvironment = require("web.test_env");
|
||||
const { makeTestEnv } = require("@web/../tests/helpers/mock_env");
|
||||
const { getFixture, mount, useLogLifeCycle, destroy } = require("@web/../tests/helpers/utils");
|
||||
@@ -808,69 +803,6 @@ odoo.define('web.OwlCompatibilityTests', function (require) {
|
||||
assert.verifySteps(["GED", "AAB", "MCM"]);
|
||||
});
|
||||
|
||||
QUnit.test("standaloneAdapter can trigger in the DOM and execute action", async (assert) => {
|
||||
assert.expect(3)
|
||||
const done = assert.async();
|
||||
|
||||
const MyDialog = Dialog.extend({
|
||||
async start() {
|
||||
const res = await this._super(...arguments);
|
||||
const btn = document.createElement("button");
|
||||
btn.classList.add("myButton");
|
||||
btn.addEventListener("click", () => {
|
||||
this.trigger_up("execute_action", {
|
||||
action_data: {},
|
||||
env: {},
|
||||
});
|
||||
});
|
||||
this.el.appendChild(btn);
|
||||
return res;
|
||||
}
|
||||
});
|
||||
|
||||
const dialogOpened = makeTestPromise();
|
||||
class MyComp extends Component {
|
||||
setup() {
|
||||
onWillDestroy(() => {
|
||||
this.dialog.destroy();
|
||||
})
|
||||
}
|
||||
async spawnDialog() {
|
||||
const parent = standaloneAdapter();
|
||||
this.dialog = new MyDialog(parent);
|
||||
await this.dialog.open();
|
||||
dialogOpened.resolve();
|
||||
}
|
||||
}
|
||||
MyComp.template = xml`<button class="spawnDialog" t-on-click="spawnDialog"/>`;
|
||||
|
||||
const actionService = {
|
||||
start() {
|
||||
return {
|
||||
async doActionButton() {
|
||||
assert.step("doActionButton");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("services").add("action", actionService);
|
||||
registry.category("services").add("legacy_service_provider", legacyServiceProvider);
|
||||
|
||||
const env = await makeTestEnv();
|
||||
await addMockEnvironmentOwl(Component);
|
||||
|
||||
const target = getFixture()
|
||||
await mount(MyComp, target, {env});
|
||||
await click(target.querySelector(".spawnDialog"));
|
||||
await dialogOpened;
|
||||
|
||||
assert.containsOnce(document.body, ".modal"); // legacy modal
|
||||
await click(document.body.querySelector(".modal .myButton"));
|
||||
assert.verifySteps(["doActionButton"]);
|
||||
done();
|
||||
})
|
||||
|
||||
QUnit.module('WidgetAdapterMixin and ComponentWrapper');
|
||||
|
||||
QUnit.test("widget with sub component", async function (assert) {
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
odoo.define('web.data_manager_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const config = require('web.config');
|
||||
const DataManager = require('web.DataManager');
|
||||
const MockServer = require('web.MockServer');
|
||||
const rpc = require('web.rpc');
|
||||
const testUtils = require('web.test_utils');
|
||||
|
||||
/**
|
||||
* Create a simple data manager with mocked functions:
|
||||
* - mockRPC -> rpc.query
|
||||
* - isDebug -> config.isDebug
|
||||
* @param {Object} params
|
||||
* @param {Object} params.archs
|
||||
* @param {Object} params.data
|
||||
* @param {Function} params.isDebug
|
||||
* @param {Function} params.mockRPC
|
||||
* @returns {DataManager}
|
||||
*/
|
||||
function createDataManager({ archs, data, isDebug, mockRPC }) {
|
||||
const dataManager = new DataManager();
|
||||
const server = new MockServer(data, { archs });
|
||||
|
||||
const serverMethods = {
|
||||
async get_views({ kwargs, model }) {
|
||||
const models = {};
|
||||
models[model] = server.fieldsGet(model);
|
||||
const views = {};
|
||||
for (const [viewId, viewType] of kwargs.views) {
|
||||
const arch = archs[[model, viewId || false, viewType].join()];
|
||||
views[viewType] = server.getView({ arch, model, viewId });
|
||||
for (const modelName of views[viewType].models) {
|
||||
models[modelName] = server.fieldsGet(modelName);
|
||||
}
|
||||
}
|
||||
const result = { models, views };
|
||||
if (kwargs.options.load_filters && views.search) {
|
||||
views.search.filters = data['ir.filters'].records.filter(r => r.model_id === model);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async get_filters({ args, model }) {
|
||||
return data[model].records.filter(r => r.model_id === args[0]);
|
||||
},
|
||||
async create_or_replace({ args }) {
|
||||
const id = data['ir.filters'].records.reduce((i, r) => Math.max(i, r.id), 0) + 1;
|
||||
const filter = Object.assign(args[0], { id });
|
||||
data['ir.filters'].records.push(filter);
|
||||
return id;
|
||||
},
|
||||
async unlink({ args }) {
|
||||
data['ir.filters'].records = data['ir.filters'].records.filter(
|
||||
r => r.id !== args[0]
|
||||
);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
testUtils.mock.patch(rpc, {
|
||||
async query({ method }) {
|
||||
this._super = serverMethods[method].bind(this, ...arguments);
|
||||
return mockRPC.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
testUtils.mock.patch(config, { isDebug });
|
||||
|
||||
return dataManager;
|
||||
}
|
||||
|
||||
QUnit.module("Services", {
|
||||
beforeEach() {
|
||||
this.archs = {
|
||||
'oui,10,kanban': '<kanban/>',
|
||||
'oui,20,search': '<search/>',
|
||||
};
|
||||
this.data = {
|
||||
oui: { fields: {}, records: [] },
|
||||
'ir.filters': {
|
||||
fields: {
|
||||
context: { type: "Text", string: "Context" },
|
||||
domain: { type: "Text", string: "Domain" },
|
||||
model_id: { type: "Selection", string: "Model" },
|
||||
name: { type: "Char", string: "Name" },
|
||||
},
|
||||
records: [{
|
||||
id: 2,
|
||||
context: '{}',
|
||||
domain: '[]',
|
||||
model_id: 'oui',
|
||||
name: "Favorite",
|
||||
}]
|
||||
}
|
||||
};
|
||||
this.loadViewsParams = {
|
||||
model: "oui",
|
||||
context: {},
|
||||
views_descr: [
|
||||
[10, 'kanban'],
|
||||
[20, 'search'],
|
||||
],
|
||||
};
|
||||
},
|
||||
afterEach() {
|
||||
testUtils.mock.unpatch(rpc);
|
||||
testUtils.mock.unpatch(config);
|
||||
},
|
||||
}, function () {
|
||||
|
||||
QUnit.module("Data manager");
|
||||
|
||||
QUnit.test("Load views with filters (non-debug mode)", async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const dataManager = createDataManager({
|
||||
archs: this.archs,
|
||||
data: this.data,
|
||||
isDebug() {
|
||||
return false;
|
||||
},
|
||||
async mockRPC({ method, model }) {
|
||||
assert.step([model, method].join('.'));
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
const firstLoad = await dataManager.load_views(this.loadViewsParams, {
|
||||
load_filters: true,
|
||||
});
|
||||
const secondLoad = await dataManager.load_views(this.loadViewsParams, {
|
||||
load_filters: true,
|
||||
});
|
||||
const filters = await dataManager.load_filters({ modelName: 'oui' });
|
||||
|
||||
assert.deepEqual(firstLoad, secondLoad,
|
||||
"query with same params and options should yield the same results");
|
||||
assert.deepEqual(firstLoad.search.favoriteFilters, filters,
|
||||
"load filters should yield the same result as the first load_views' filters");
|
||||
assert.verifySteps(['oui.get_views'],
|
||||
"only load once when not in assets debugging");
|
||||
});
|
||||
|
||||
QUnit.test("Load views with filters (debug mode)", async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
const dataManager = createDataManager({
|
||||
archs: this.archs,
|
||||
data: this.data,
|
||||
isDebug() {
|
||||
return true; // assets
|
||||
},
|
||||
async mockRPC({ method, model }) {
|
||||
assert.step([model, method].join('.'));
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
const firstLoad = await dataManager.load_views(this.loadViewsParams, {
|
||||
load_filters: true,
|
||||
});
|
||||
const secondLoad = await dataManager.load_views(this.loadViewsParams, {
|
||||
load_filters: true,
|
||||
});
|
||||
const filters = await dataManager.load_filters({ modelName: 'oui' });
|
||||
|
||||
assert.deepEqual(firstLoad, secondLoad,
|
||||
"query with same params and options should yield the same results");
|
||||
assert.deepEqual(firstLoad.search.favoriteFilters, filters,
|
||||
"load filters should yield the same result as the first load_views' filters");
|
||||
assert.verifySteps([
|
||||
'oui.get_views',
|
||||
'oui.get_views',
|
||||
'ir.filters.get_filters',
|
||||
], "reload each time when in assets debugging");
|
||||
});
|
||||
|
||||
QUnit.test("Cache invalidation and filters addition/deletion", async function (assert) {
|
||||
assert.expect(10);
|
||||
|
||||
const dataManager = createDataManager({
|
||||
archs: this.archs,
|
||||
data: this.data,
|
||||
isDebug() {
|
||||
return false; // Cache only works if 'debug !== assets'
|
||||
},
|
||||
async mockRPC({ method, model }) {
|
||||
assert.step([model, method].join('.'));
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
// A few unnecessary 'load_filters' are done in this test to assert
|
||||
// that the cache invalidation mechanics are working.
|
||||
let filters;
|
||||
|
||||
const firstLoad = await dataManager.load_views(this.loadViewsParams, {
|
||||
load_filters: true,
|
||||
});
|
||||
// Cache is valid -> should not trigger an RPC
|
||||
filters = await dataManager.load_filters({ modelName: 'oui' });
|
||||
assert.deepEqual(firstLoad.search.favoriteFilters, filters,
|
||||
"load_filters and load_views.search should return the same filters");
|
||||
|
||||
const filterId = await dataManager.create_filter({
|
||||
context: "{}",
|
||||
domain: "[]",
|
||||
model_id: 'oui',
|
||||
name: "Temp",
|
||||
});
|
||||
// Cache is not valid anymore -> triggers a 'get_filters'
|
||||
filters = await dataManager.load_filters({ modelName: 'oui' });
|
||||
// Cache is valid -> should not trigger an RPC
|
||||
filters = await dataManager.load_filters({ modelName: 'oui' });
|
||||
|
||||
assert.strictEqual(filters.length, 2,
|
||||
"A new filter should have been added");
|
||||
assert.ok(filters.find(f => f.id === filterId) === filters[filters.length - 1],
|
||||
"Create filter should return the id of the last created filter");
|
||||
|
||||
await dataManager.delete_filter(filterId);
|
||||
|
||||
// Views cache is valid but filters cache is not -> triggers a 'get_filters'
|
||||
const secondLoad = await dataManager.load_views(this.loadViewsParams, {
|
||||
load_filters: true,
|
||||
});
|
||||
filters = secondLoad.search.favoriteFilters;
|
||||
// Filters cache is once again valid -> no RPC
|
||||
const expectedFilters = await dataManager.load_filters({ modelName: 'oui' });
|
||||
|
||||
assert.deepEqual(filters, expectedFilters,
|
||||
"Filters loaded by the load_views should be equal to the result of a load_filters");
|
||||
|
||||
assert.verifySteps([
|
||||
'oui.get_views',
|
||||
'ir.filters.create_or_replace',
|
||||
'ir.filters.get_filters',
|
||||
'ir.filters.unlink',
|
||||
'ir.filters.get_filters',
|
||||
], "server should have been called only when needed");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
odoo.define('web.abstract_model_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const AbstractModel = require('web.AbstractModel');
|
||||
const Domain = require('web.Domain');
|
||||
|
||||
QUnit.module('LegacyViews', {}, function () {
|
||||
QUnit.module('AbstractModel');
|
||||
|
||||
QUnit.test('leave sample mode when unknown route is called on sample server', async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const Model = AbstractModel.extend({
|
||||
_isEmpty() {
|
||||
return true;
|
||||
},
|
||||
async __load() {
|
||||
if (this.isSampleModel) {
|
||||
await this._rpc({ model: 'partner', method: 'unknown' });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const model = new Model(null, {
|
||||
modelName: 'partner',
|
||||
fields: {},
|
||||
useSampleModel: true,
|
||||
SampleModel: Model,
|
||||
});
|
||||
|
||||
assert.ok(model.useSampleModel);
|
||||
assert.notOk(model._isInSampleMode);
|
||||
|
||||
await model.load({});
|
||||
|
||||
assert.notOk(model.useSampleModel);
|
||||
assert.notOk(model._isInSampleMode);
|
||||
|
||||
model.destroy();
|
||||
});
|
||||
|
||||
QUnit.test("don't cath general error on sample server in sample mode", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const error = new Error();
|
||||
|
||||
const Model = AbstractModel.extend({
|
||||
_isEmpty() {
|
||||
return true;
|
||||
},
|
||||
async __reload() {
|
||||
if (this.isSampleModel) {
|
||||
await this._rpc({ model: 'partner', method: 'read_group' });
|
||||
}
|
||||
},
|
||||
async _rpc() {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
const model = new Model(null, {
|
||||
modelName: 'partner',
|
||||
fields: {},
|
||||
useSampleModel: true,
|
||||
SampleModel: Model,
|
||||
});
|
||||
|
||||
assert.ok(model.useSampleModel);
|
||||
assert.notOk(model._isInSampleMode);
|
||||
|
||||
await model.load({});
|
||||
|
||||
assert.ok(model.useSampleModel);
|
||||
assert.ok(model._isInSampleMode);
|
||||
|
||||
async function reloadModel() {
|
||||
try {
|
||||
await model.reload();
|
||||
} catch (e) {
|
||||
assert.strictEqual(e, error);
|
||||
}
|
||||
}
|
||||
|
||||
await reloadModel();
|
||||
|
||||
model.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('fetch sample data: concurrency', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const Model = AbstractModel.extend({
|
||||
_isEmpty() {
|
||||
return true;
|
||||
},
|
||||
__get() {
|
||||
return { isSample: !!this.isSampleModel };
|
||||
},
|
||||
});
|
||||
|
||||
const model = new Model(null, {
|
||||
modelName: 'partner',
|
||||
fields: {},
|
||||
useSampleModel: true,
|
||||
SampleModel: Model,
|
||||
});
|
||||
|
||||
await model.load({ domain: Domain.FALSE_DOMAIN, });
|
||||
|
||||
const beforeReload = model.get(null, { withSampleData: true });
|
||||
|
||||
const reloaded = model.reload(null, { domain: Domain.TRUE_DOMAIN });
|
||||
const duringReload = model.get(null, { withSampleData: true });
|
||||
|
||||
await reloaded;
|
||||
|
||||
const afterReload = model.get(null, { withSampleData: true });
|
||||
|
||||
assert.strictEqual(beforeReload.isSample, true,
|
||||
"Sample data flag must be true before reload"
|
||||
);
|
||||
assert.strictEqual(duringReload.isSample, true,
|
||||
"Sample data flag must be true during reload"
|
||||
);
|
||||
assert.strictEqual(afterReload.isSample, false,
|
||||
"Sample data flag must be true after reload"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
odoo.define('web.abstract_view_banner_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
var AbstractRenderer = require('web.AbstractRenderer');
|
||||
var AbstractView = require('web.AbstractView');
|
||||
|
||||
var testUtils = require('web.test_utils');
|
||||
var createView = testUtils.createView;
|
||||
|
||||
var TestRenderer = AbstractRenderer.extend({
|
||||
_renderView: function () {
|
||||
this.$el.addClass('test_content');
|
||||
return this._super();
|
||||
},
|
||||
});
|
||||
|
||||
var TestView = AbstractView.extend({
|
||||
type: 'test',
|
||||
config: _.extend({}, AbstractView.prototype.config, {
|
||||
Renderer: TestRenderer
|
||||
}),
|
||||
});
|
||||
|
||||
var test_css_url = '/test_assetsbundle/static/src/css/test_cssfile1.css';
|
||||
|
||||
QUnit.module('LegacyViews', {
|
||||
beforeEach: function () {
|
||||
this.data = {
|
||||
test_model: {
|
||||
fields: {},
|
||||
records: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
afterEach: function () {
|
||||
$('head link[href$="' + test_css_url + '"]').remove();
|
||||
}
|
||||
}, function () {
|
||||
QUnit.module('BasicRenderer');
|
||||
|
||||
QUnit.test("The banner should be fetched from the route", function (assert) {
|
||||
var done = assert.async();
|
||||
assert.expect(6);
|
||||
|
||||
var banner_html =`
|
||||
<div class="modal o_onboarding_modal o_technical_modal" tabindex="-1" role="dialog"
|
||||
data-bs-backdrop="false">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-footer">
|
||||
<a type="action" class="btn btn-primary" data-bs-dismiss="modal"
|
||||
data-o-hide-banner="true">
|
||||
Remove
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_onboarding_container collapse show">
|
||||
<div class="o_onboarding_wrap">
|
||||
<a href="#" data-bs-toggle="modal" data-bs-target=".o_onboarding_modal"
|
||||
class="float-end o_onboarding_btn_close">
|
||||
<i class="fa fa-times" title="Close the onboarding panel" />
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<link type="text/css" href="` + test_css_url + `" rel="stylesheet">
|
||||
<div class="hello_banner">Here is the banner</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
createView({
|
||||
View: TestView,
|
||||
model: 'test_model',
|
||||
data: this.data,
|
||||
arch: '<test banner_route="/module/hello_banner"/>',
|
||||
mockRPC: function (route, args) {
|
||||
if (route === '/module/hello_banner') {
|
||||
assert.step(route);
|
||||
return Promise.resolve({html: banner_html});
|
||||
}
|
||||
return this._super(route, args);
|
||||
},
|
||||
}).then(async function (view) {
|
||||
var $banner = view.$('.hello_banner');
|
||||
assert.strictEqual($banner.length, 1,
|
||||
"The view should contain the response from the controller.");
|
||||
assert.verifySteps(['/module/hello_banner'], "The banner should be fetched.");
|
||||
|
||||
var $head_link = $('head link[href$="' + test_css_url + '"]');
|
||||
assert.strictEqual($head_link.length, 1,
|
||||
"The stylesheet should have been added to head.");
|
||||
|
||||
var $banner_link = $('link[href$="' + test_css_url + '"]', $banner);
|
||||
assert.strictEqual($banner_link.length, 0,
|
||||
"The stylesheet should have been removed from the banner.");
|
||||
|
||||
await testUtils.dom.click(view.$('.o_onboarding_btn_close')); // click on close to remove banner
|
||||
await testUtils.dom.click(view.$('.o_technical_modal .btn-primary:contains("Remove")')); // click on button remove from techinal modal
|
||||
assert.strictEqual(view.$('.o_onboarding_container.show').length, 0,
|
||||
"Banner should be removed from the view");
|
||||
|
||||
view.destroy();
|
||||
done();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,486 +0,0 @@
|
||||
odoo.define('web.sample_server_tests', function (require) {
|
||||
"use strict";
|
||||
|
||||
const SampleServer = require('web.SampleServer');
|
||||
const session = require('web.session');
|
||||
const { mock } = require('web.test_utils');
|
||||
|
||||
const {
|
||||
MAIN_RECORDSET_SIZE, SEARCH_READ_LIMIT, // Limits
|
||||
SAMPLE_COUNTRIES, SAMPLE_PEOPLE, SAMPLE_TEXTS, // Text values
|
||||
MAX_COLOR_INT, MAX_FLOAT, MAX_INTEGER, MAX_MONETARY, // Number values
|
||||
SUB_RECORDSET_SIZE, // Records sise
|
||||
} = SampleServer;
|
||||
|
||||
/**
|
||||
* Transforms random results into deterministic ones.
|
||||
*/
|
||||
class DeterministicSampleServer extends SampleServer {
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.arrayElCpt = 0;
|
||||
this.boolCpt = 0;
|
||||
this.subRecordIdCpt = 0;
|
||||
}
|
||||
_getRandomArrayEl(array) {
|
||||
return array[this.arrayElCpt++ % array.length];
|
||||
}
|
||||
_getRandomBool() {
|
||||
return Boolean(this.boolCpt++ % 2);
|
||||
}
|
||||
_getRandomSubRecordId() {
|
||||
return (this.subRecordIdCpt++ % SUB_RECORDSET_SIZE) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
QUnit.module("Sample Server", {
|
||||
beforeEach() {
|
||||
this.fields = {
|
||||
'res.users': {
|
||||
display_name: { string: "Name", type: 'char' },
|
||||
name: { string: "Reference", type: 'char' },
|
||||
email: { string: "Email", type: 'char' },
|
||||
phone_number: { string: "Phone number", type: 'char' },
|
||||
brol_machin_url_truc: { string: "URL", type: 'char' },
|
||||
urlemailphone: { string: "Whatever", type: 'char' },
|
||||
active: { string: "Active", type: 'boolean' },
|
||||
is_alive: { string: "Is alive", type: 'boolean' },
|
||||
description: { string: "Description", type: 'text' },
|
||||
birthday: { string: "Birthday", type: 'date' },
|
||||
arrival_date: { string: "Date of arrival", type: 'datetime' },
|
||||
height: { string: "Height", type: 'float' },
|
||||
color: { string: "Color", type: 'integer' },
|
||||
age: { string: "Age", type: 'integer' },
|
||||
salary: { string: "Salary", type: 'monetary' },
|
||||
currency: { string: "Currency", type: 'many2one', relation: 'res.currency' },
|
||||
manager_id: { string: "Manager", type: 'many2one', relation: 'res.users' },
|
||||
cover_image_id: { string: "Cover Image", type: 'many2one', relation: 'ir.attachment' },
|
||||
managed_ids: { string: "Managing", type: 'one2many', relation: 'res.users' },
|
||||
tag_ids: { string: "Tags", type: 'many2many', relation: 'tag' },
|
||||
type: { string: "Type", type: 'selection', selection: [
|
||||
['client', "Client"], ['partner', "Partner"], ['employee', "Employee"]
|
||||
] },
|
||||
},
|
||||
'res.country': {
|
||||
display_name: { string: "Name", type: 'char' },
|
||||
},
|
||||
'hobbit': {
|
||||
display_name: { string: "Name", type: 'char' },
|
||||
profession: { string: "Profession", type: 'selection', selection: [
|
||||
['gardener', "Gardener"], ['brewer', "Brewer"], ['adventurer', "Adventurer"]
|
||||
] },
|
||||
age: { string: "Age", type: 'integer' },
|
||||
},
|
||||
'ir.attachment': {
|
||||
display_name: { string: "Name", type: 'char' },
|
||||
}
|
||||
};
|
||||
},
|
||||
}, function () {
|
||||
|
||||
QUnit.module("Basic behaviour");
|
||||
|
||||
QUnit.test("Sample data: people type + all field names", async function (assert) {
|
||||
assert.expect(26);
|
||||
|
||||
mock.patch(session, {
|
||||
company_currency_id: 4,
|
||||
});
|
||||
|
||||
const allFieldNames = Object.keys(this.fields['res.users']);
|
||||
const server = new DeterministicSampleServer('res.users', this.fields['res.users']);
|
||||
const { records } = await server.mockRpc({
|
||||
method: '/web/dataset/search_read',
|
||||
model: 'res.users',
|
||||
fields: allFieldNames,
|
||||
});
|
||||
const rec = records[0];
|
||||
|
||||
function assertFormat(fieldName, regex) {
|
||||
if (regex instanceof RegExp) {
|
||||
assert.ok(
|
||||
regex.test(rec[fieldName].toString()),
|
||||
`Field "${fieldName}" has the correct format`
|
||||
);
|
||||
} else {
|
||||
assert.strictEqual(
|
||||
typeof rec[fieldName], regex,
|
||||
`Field "${fieldName}" is of type ${regex}`
|
||||
);
|
||||
}
|
||||
}
|
||||
function assertBetween(fieldName, min, max) {
|
||||
const val = rec[fieldName];
|
||||
assert.ok(
|
||||
min <= val && val < max && parseInt(val, 10) === val,
|
||||
`Field "${fieldName}" should be an integer between ${min} and ${max}: ${val}`
|
||||
);
|
||||
}
|
||||
|
||||
// Basic fields
|
||||
assert.ok(SAMPLE_PEOPLE.includes(rec.display_name));
|
||||
assert.ok(SAMPLE_PEOPLE.includes(rec.name));
|
||||
assert.strictEqual(rec.email,
|
||||
`${rec.display_name.replace(/ /, ".").toLowerCase()}@sample.demo`
|
||||
);
|
||||
assertFormat('phone_number', /\+1 555 754 000\d/);
|
||||
assertFormat('brol_machin_url_truc', /http:\/\/sample\d\.com/);
|
||||
assert.strictEqual(rec.urlemailphone, false);
|
||||
assert.strictEqual(rec.active, true);
|
||||
assertFormat('is_alive', 'boolean');
|
||||
assert.ok(SAMPLE_TEXTS.includes(rec.description));
|
||||
assertFormat('birthday', /\d{4}-\d{2}-\d{2}/);
|
||||
assertFormat('arrival_date', /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
|
||||
assert.ok(rec.height >= 0 && rec.height <= MAX_FLOAT, "Field height should be between 0 and 100");
|
||||
assertBetween('color', 0, MAX_COLOR_INT);
|
||||
assertBetween('age', 0, MAX_INTEGER);
|
||||
assertBetween('salary', 0, MAX_MONETARY);
|
||||
|
||||
// check float field have 2 decimal rounding
|
||||
assert.strictEqual(rec.height, parseFloat(parseFloat(rec.height).toFixed(2)));
|
||||
|
||||
const selectionValues = this.fields['res.users'].type.selection.map(
|
||||
(sel) => sel[0]
|
||||
);
|
||||
assert.ok(selectionValues.includes(rec.type));
|
||||
|
||||
// Relational fields
|
||||
assert.strictEqual(rec.currency[0], 4);
|
||||
// Currently we expect the currency name to be a latin string, which
|
||||
// is not important; in most case we only need the ID. The following
|
||||
// assertion can be removed if needed.
|
||||
assert.ok(SAMPLE_TEXTS.includes(rec.currency[1]));
|
||||
|
||||
assert.strictEqual(typeof rec.manager_id[0], 'number');
|
||||
assert.ok(SAMPLE_PEOPLE.includes(rec.manager_id[1]));
|
||||
|
||||
assert.strictEqual(rec.cover_image_id, false);
|
||||
|
||||
assert.strictEqual(rec.managed_ids.length, 2);
|
||||
assert.ok(rec.managed_ids.every(
|
||||
(id) => typeof id === 'number')
|
||||
);
|
||||
|
||||
assert.strictEqual(rec.tag_ids.length, 2);
|
||||
assert.ok(rec.tag_ids.every(
|
||||
(id) => typeof id === 'number')
|
||||
);
|
||||
|
||||
mock.unpatch(session);
|
||||
});
|
||||
|
||||
QUnit.test("Sample data: country type", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const server = new DeterministicSampleServer('res.country', this.fields['res.country']);
|
||||
const { records } = await server.mockRpc({
|
||||
method: '/web/dataset/search_read',
|
||||
model: 'res.country',
|
||||
fields: ['display_name'],
|
||||
});
|
||||
|
||||
assert.ok(SAMPLE_COUNTRIES.includes(records[0].display_name));
|
||||
});
|
||||
|
||||
QUnit.test("Sample data: any type", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const { records } = await server.mockRpc({
|
||||
method: '/web/dataset/search_read',
|
||||
model: 'hobbit',
|
||||
fields: ['display_name'],
|
||||
});
|
||||
|
||||
assert.ok(SAMPLE_TEXTS.includes(records[0].display_name));
|
||||
});
|
||||
|
||||
QUnit.module("RPC calls");
|
||||
|
||||
QUnit.test("Send 'search_read' RPC: valid field names", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: '/web/dataset/search_read',
|
||||
model: 'hobbit',
|
||||
fields: ['display_name'],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(result.records[0]),
|
||||
['id', 'display_name']
|
||||
);
|
||||
assert.strictEqual(result.length, SEARCH_READ_LIMIT);
|
||||
assert.ok(/\w+/.test(result.records[0].display_name),
|
||||
"Display name has been mocked"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'search_read' RPC: invalid field names", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: '/web/dataset/search_read',
|
||||
model: 'hobbit',
|
||||
fields: ['name'],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(result.records[0]),
|
||||
['id', 'name']
|
||||
);
|
||||
assert.strictEqual(result.length, SEARCH_READ_LIMIT);
|
||||
assert.strictEqual(result.records[0].name, false,
|
||||
`Field "name" doesn't exist => returns false`
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'web_read_group' RPC: no group", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
server.setExistingGroups([]);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'web_read_group',
|
||||
model: 'hobbit',
|
||||
groupBy: ['profession'],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { groups: [], length: 0 });
|
||||
});
|
||||
|
||||
QUnit.test("Send 'web_read_group' RPC: 2 groups", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
const existingGroups = [
|
||||
{ profession: 'gardener', profession_count: 0 },
|
||||
{ profession: 'adventurer', profession_count: 0 },
|
||||
];
|
||||
server.setExistingGroups(existingGroups);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'web_read_group',
|
||||
model: 'hobbit',
|
||||
groupBy: ['profession'],
|
||||
fields: [],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.length, 2);
|
||||
assert.strictEqual(result.groups.length, 2);
|
||||
|
||||
assert.deepEqual(
|
||||
result.groups.map((g) => g.profession),
|
||||
["gardener", "adventurer"]
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.groups.reduce((acc, g) => acc + g.profession_count, 0),
|
||||
MAIN_RECORDSET_SIZE
|
||||
);
|
||||
assert.ok(
|
||||
result.groups.every((g) => g.profession_count === g.__data.length)
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'web_read_group' RPC: all groups", async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
const existingGroups = [
|
||||
{ profession: 'gardener', profession_count: 0 },
|
||||
{ profession: 'brewer', profession_count: 0 },
|
||||
{ profession: 'adventurer', profession_count: 0 },
|
||||
];
|
||||
server.setExistingGroups(existingGroups);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'web_read_group',
|
||||
model: 'hobbit',
|
||||
groupBy: ['profession'],
|
||||
fields: [],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.length, 3);
|
||||
assert.strictEqual(result.groups.length, 3);
|
||||
|
||||
assert.deepEqual(
|
||||
result.groups.map((g) => g.profession),
|
||||
["gardener", "brewer", "adventurer"]
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.groups.reduce((acc, g) => acc + g.profession_count, 0),
|
||||
MAIN_RECORDSET_SIZE
|
||||
);
|
||||
assert.ok(
|
||||
result.groups.every((g) => g.profession_count === g.__data.length)
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read_group' RPC: no group", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read_group',
|
||||
model: 'hobbit',
|
||||
fields: [],
|
||||
groupBy: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, [{
|
||||
__count: MAIN_RECORDSET_SIZE,
|
||||
__domain: [],
|
||||
}]);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read_group' RPC: groupBy", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read_group',
|
||||
model: 'hobbit',
|
||||
fields: [],
|
||||
groupBy: ['profession'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.length, 3);
|
||||
assert.deepEqual(
|
||||
result.map((g) => g.profession),
|
||||
["adventurer", "brewer", "gardener"]
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.reduce((acc, g) => acc + g.profession_count, 0),
|
||||
MAIN_RECORDSET_SIZE,
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read_group' RPC: groupBy and field", async function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read_group',
|
||||
model: 'hobbit',
|
||||
fields: ['age'],
|
||||
groupBy: ['profession'],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.length, 3);
|
||||
assert.deepEqual(
|
||||
result.map((g) => g.profession),
|
||||
["adventurer", "brewer", "gardener"]
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.reduce((acc, g) => acc + g.profession_count, 0),
|
||||
MAIN_RECORDSET_SIZE,
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.reduce((acc, g) => acc + g.age, 0),
|
||||
server.data.hobbit.records.reduce((acc, g) => acc + g.age, 0)
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read_group' RPC: multiple groupBys and lazy", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read_group',
|
||||
model: 'hobbit',
|
||||
fields: [],
|
||||
groupBy: ['profession', 'age'],
|
||||
});
|
||||
|
||||
assert.ok('profession' in result[0]);
|
||||
assert.notOk('age' in result[0]);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read_group' RPC: multiple groupBys and not lazy", async function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read_group',
|
||||
model: 'hobbit',
|
||||
fields: [],
|
||||
groupBy: ['profession', 'age'],
|
||||
lazy: false,
|
||||
});
|
||||
|
||||
assert.ok('profession' in result[0]);
|
||||
assert.ok('age' in result[0]);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read' RPC: no id", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read',
|
||||
model: 'hobbit',
|
||||
args: [
|
||||
[], ['display_name']
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read' RPC: one id", async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const result = await server.mockRpc({
|
||||
method: 'read',
|
||||
model: 'hobbit',
|
||||
args: [
|
||||
[1], ['display_name']
|
||||
],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.length, 1);
|
||||
assert.ok(
|
||||
/\w+/.test(result[0].display_name),
|
||||
"Display name has been mocked"
|
||||
);
|
||||
assert.strictEqual(result[0].id, 1);
|
||||
});
|
||||
|
||||
QUnit.test("Send 'read' RPC: more than all available ids", async function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
const server = new DeterministicSampleServer('hobbit', this.fields.hobbit);
|
||||
|
||||
const amount = MAIN_RECORDSET_SIZE + 3;
|
||||
const ids = new Array(amount).fill().map((_, i) => i + 1);
|
||||
const result = await server.mockRpc({
|
||||
method: 'read',
|
||||
model: 'hobbit',
|
||||
args: [
|
||||
ids, ['display_name']
|
||||
],
|
||||
});
|
||||
|
||||
assert.strictEqual(result.length, MAIN_RECORDSET_SIZE);
|
||||
});
|
||||
|
||||
// To be implemented if needed
|
||||
// QUnit.test("Send 'read_progress_bar' RPC", async function (assert) { ... });
|
||||
});
|
||||
});
|
||||
@@ -1,404 +0,0 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { getFixture, legacyExtraNextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
|
||||
import {
|
||||
getFacetTexts,
|
||||
removeFacet,
|
||||
saveFavorite,
|
||||
setupControlPanelFavoriteMenuRegistry,
|
||||
setupControlPanelServiceRegistry,
|
||||
switchView,
|
||||
toggleFavoriteMenu,
|
||||
toggleMenu,
|
||||
toggleMenuItem,
|
||||
toggleSaveFavorite,
|
||||
} from "@web/../tests/search/helpers";
|
||||
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { ControlPanel } from "@web/search/control_panel/control_panel";
|
||||
import { SearchModel } from "@web/search/search_model";
|
||||
import AbstractView from "web.AbstractView";
|
||||
import ActionModel from "web.ActionModel";
|
||||
import { mock } from "web.test_utils";
|
||||
import legacyViewRegistry from "web.view_registry";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { LegacyComponent } from "@web/legacy/legacy_component";
|
||||
|
||||
import { xml } from "@odoo/owl";
|
||||
const viewRegistry = registry.category("views");
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
QUnit.module("LegacyViews", (hooks) => {
|
||||
hooks.beforeEach(async () => {
|
||||
target = getFixture();
|
||||
|
||||
serverData = {
|
||||
models: {
|
||||
foo: {
|
||||
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",
|
||||
store: true,
|
||||
sortable: true,
|
||||
},
|
||||
},
|
||||
records: [],
|
||||
},
|
||||
},
|
||||
views: {
|
||||
"foo,false,legacy_toy": `<legacy_toy/>`,
|
||||
"foo,false,toy": `<toy/>`,
|
||||
"foo,false,search": `
|
||||
<search>
|
||||
<field name="foo" operator="="/>
|
||||
<filter name="true_domain" string="True Domain" domain="[(1, '=', 1)]"/>
|
||||
<filter name="date_domain" string="Date Filter" date="date_field" domain="[]"/>
|
||||
<filter name="group_by_bar" string="Bar" context="{ 'group_by': 'bar' }"/>
|
||||
<filter name="group_by_date_field" string="Date GroupBy" context="{ 'group_by': 'date_field' }"/>
|
||||
</search>
|
||||
`,
|
||||
},
|
||||
};
|
||||
setupControlPanelFavoriteMenuRegistry();
|
||||
setupControlPanelServiceRegistry();
|
||||
|
||||
class ToyController extends LegacyComponent {}
|
||||
ToyController.template = xml`<div class="o_toy_view"><ControlPanel /></div>`;
|
||||
ToyController.components = { ControlPanel};
|
||||
|
||||
viewRegistry.add("toy", {
|
||||
type: "toy",
|
||||
display_name: _lt("Toy view"),
|
||||
multiRecord: true,
|
||||
searchMenuTypes: ["filter", "groupBy", "comparison", "favorite"],
|
||||
Controller: ToyController,
|
||||
});
|
||||
|
||||
const LegacyToyView = AbstractView.extend({
|
||||
display_name: _lt("Legacy toy view"),
|
||||
icon: "fa fa-bars",
|
||||
multiRecord: true,
|
||||
viewType: "legacy_toy",
|
||||
searchMenuTypes: ["filter", "groupBy", "comparison", "favorite"],
|
||||
});
|
||||
|
||||
legacyViewRegistry.add("legacy_toy", LegacyToyView);
|
||||
});
|
||||
|
||||
QUnit.module("State Mappings");
|
||||
|
||||
QUnit.test(
|
||||
"legacy and new views can share search model state (no favorite)",
|
||||
async function (assert) {
|
||||
assert.expect(10);
|
||||
|
||||
const unpatchDate = mock.patchDate(2021, 6, 1, 10, 0, 0);
|
||||
|
||||
const webClient = await createWebClient({ serverData });
|
||||
|
||||
await doAction(webClient, {
|
||||
name: "Action name",
|
||||
res_model: "foo",
|
||||
type: "ir.actions.act_window",
|
||||
views: [
|
||||
[false, "toy"],
|
||||
[false, "legacy_toy"],
|
||||
],
|
||||
context: {
|
||||
search_default_foo: "ABC",
|
||||
search_default_true_domain: 1,
|
||||
search_default_date_domain: 1,
|
||||
search_default_group_by_bar: 50,
|
||||
search_default_group_by_date_field: 1,
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_toy.active");
|
||||
assert.deepEqual(
|
||||
getFacetTexts(target).map((s) => s.replace(/\s/, "")),
|
||||
[
|
||||
"FooABC",
|
||||
"TrueDomainorDate Filter: July 2021",
|
||||
"DateGroupBy: Month>Bar",
|
||||
]
|
||||
);
|
||||
|
||||
await toggleMenu(target, "Comparison");
|
||||
await toggleMenuItem(target, "Date Filter: Previous Period");
|
||||
|
||||
assert.deepEqual(
|
||||
getFacetTexts(target).map((s) => s.replace(/\s/, "")),
|
||||
[
|
||||
"FooABC",
|
||||
"TrueDomainorDate Filter: July 2021",
|
||||
"DateGroupBy: Month>Bar",
|
||||
"DateFilter: Previous Period",
|
||||
]
|
||||
);
|
||||
|
||||
await switchView(target, "legacy_toy");
|
||||
await legacyExtraNextTick();
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_legacy_toy.active");
|
||||
assert.deepEqual(
|
||||
getFacetTexts(target).map((s) => s.replace(/\s/, "")),
|
||||
[
|
||||
"FooABC",
|
||||
"TrueDomainorDate Filter: July 2021",
|
||||
"DateGroupBy: Month>Bar",
|
||||
"DateFilter: Previous Period",
|
||||
]
|
||||
);
|
||||
|
||||
await removeFacet(target, 1);
|
||||
|
||||
assert.deepEqual(
|
||||
getFacetTexts(target).map((s) => s.replace(/\s/, "")),
|
||||
[
|
||||
"FooABC",
|
||||
"DateGroupBy: Month>Bar",
|
||||
]
|
||||
);
|
||||
|
||||
await switchView(target, "toy");
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_toy.active");
|
||||
assert.deepEqual(
|
||||
getFacetTexts(target).map((s) => s.replace(/\s/, "")),
|
||||
[
|
||||
"FooABC",
|
||||
"DateGroupBy: Month>Bar",
|
||||
]
|
||||
);
|
||||
|
||||
// Check if update works
|
||||
await removeFacet(target, 1);
|
||||
|
||||
assert.deepEqual(
|
||||
getFacetTexts(target).map((s) => s.replace(/\s/, "")),
|
||||
[
|
||||
"FooABC",
|
||||
]
|
||||
);
|
||||
|
||||
await switchView(target, "legacy_toy");
|
||||
|
||||
assert.deepEqual(
|
||||
getFacetTexts(target).map((s) => s.replace(/\s/, "")),
|
||||
[
|
||||
"FooABC",
|
||||
]
|
||||
);
|
||||
|
||||
unpatchDate();
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"legacy and new views can share search model state (favorite)",
|
||||
async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
serverData.models.foo.filters = [
|
||||
{
|
||||
context: "{}",
|
||||
domain: "[['foo', '=', 'qsdf']]",
|
||||
id: 7,
|
||||
is_default: true,
|
||||
name: "My favorite",
|
||||
sort: "[]",
|
||||
user_id: [2, "Mitchell Admin"],
|
||||
},
|
||||
];
|
||||
|
||||
const webClient = await createWebClient({ serverData });
|
||||
|
||||
await doAction(webClient, {
|
||||
name: "Action name",
|
||||
res_model: "foo",
|
||||
type: "ir.actions.act_window",
|
||||
views: [
|
||||
[false, "toy"],
|
||||
[false, "legacy_toy"],
|
||||
],
|
||||
});
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_toy.active");
|
||||
assert.deepEqual(getFacetTexts(target), ["My favorite"]);
|
||||
|
||||
await switchView(target, "legacy_toy");
|
||||
await legacyExtraNextTick();
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_legacy_toy.active");
|
||||
assert.deepEqual(getFacetTexts(target), ["My favorite"]);
|
||||
|
||||
await switchView(target, "toy");
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_toy.active");
|
||||
assert.deepEqual(getFacetTexts(target), ["My favorite"]);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"newly created favorite in a new view can be used in a legacy view",
|
||||
async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
patchWithCleanup(browser, { setTimeout: (fn) => fn() });
|
||||
const webClient = await createWebClient({
|
||||
serverData,
|
||||
mockRPC(_, args) {
|
||||
if (args.method === "create_or_replace") {
|
||||
assert.ok(typeof args.args[0].domain === "string");
|
||||
return 7; // fake serverId to simulate the creation of
|
||||
// the favorite in db.
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await doAction(webClient, {
|
||||
name: "Action name",
|
||||
res_model: "foo",
|
||||
type: "ir.actions.act_window",
|
||||
views: [
|
||||
[false, "toy"],
|
||||
[false, "legacy_toy"],
|
||||
],
|
||||
});
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_toy.active");
|
||||
|
||||
await toggleFavoriteMenu(target);
|
||||
await toggleSaveFavorite(target);
|
||||
await saveFavorite(target);
|
||||
|
||||
assert.deepEqual(getFacetTexts(target), ["Action name"]);
|
||||
|
||||
await switchView(target, "legacy_toy");
|
||||
await legacyExtraNextTick();
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_legacy_toy.active");
|
||||
assert.deepEqual(getFacetTexts(target), ["Action name"]);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"legacy and new views with search model extensions can share search model state",
|
||||
async function (assert) {
|
||||
assert.expect(16);
|
||||
|
||||
serverData.views[
|
||||
"foo,1,legacy_toy"
|
||||
] = `<legacy_toy js_class="legacy_toy_with_extension"/>`;
|
||||
|
||||
class SearchModelExtension extends SearchModel {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.toyExtension = { locationId: "Grand-Rosière" };
|
||||
}
|
||||
|
||||
exportState() {
|
||||
const exportedState = super.exportState(...arguments);
|
||||
exportedState.toyExtension = this.toyExtension;
|
||||
return exportedState;
|
||||
}
|
||||
|
||||
_importState(state) {
|
||||
super._importState(state);
|
||||
this.toyExtension = state.toyExtension;
|
||||
assert.step(JSON.stringify(state.toyExtension));
|
||||
}
|
||||
}
|
||||
|
||||
const ToyView = viewRegistry.get("toy");
|
||||
ToyView.SearchModel = SearchModelExtension;
|
||||
|
||||
class ToyExtension extends ActionModel.Extension {
|
||||
importState(state) {
|
||||
super.importState(state); // done even if state is undefined in legacy code
|
||||
assert.step(JSON.stringify(state) || "no state");
|
||||
}
|
||||
prepareState() {
|
||||
Object.assign(this.state, {
|
||||
locationId: "The place to be",
|
||||
});
|
||||
}
|
||||
}
|
||||
ActionModel.registry.add("toyExtension", ToyExtension);
|
||||
|
||||
const LegacyToyView = legacyViewRegistry.get("legacy_toy");
|
||||
|
||||
const LegacyToyViewWithExtension = LegacyToyView.extend({
|
||||
_createSearchModel(params, extraExtensions = {}) {
|
||||
Object.assign(extraExtensions, { toyExtension: {} });
|
||||
return this._super(params, extraExtensions);
|
||||
},
|
||||
});
|
||||
legacyViewRegistry.add("legacy_toy_with_extension", LegacyToyViewWithExtension);
|
||||
|
||||
const webClient = await createWebClient({ serverData });
|
||||
|
||||
await doAction(webClient, {
|
||||
name: "Action name",
|
||||
res_model: "foo",
|
||||
type: "ir.actions.act_window",
|
||||
views: [
|
||||
[false, "toy"],
|
||||
[1, "legacy_toy"],
|
||||
],
|
||||
});
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_toy.active");
|
||||
|
||||
await switchView(target, "legacy_toy");
|
||||
await legacyExtraNextTick();
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_legacy_toy.active");
|
||||
assert.verifySteps([`{"locationId":"Grand-Rosière"}`]);
|
||||
|
||||
await switchView(target, "toy");
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_toy.active");
|
||||
assert.verifySteps([`{"locationId":"Grand-Rosière"}`]);
|
||||
|
||||
await doAction(webClient, {
|
||||
name: "Action name",
|
||||
res_model: "foo",
|
||||
type: "ir.actions.act_window",
|
||||
views: [
|
||||
[1, "legacy_toy"],
|
||||
[false, "toy"],
|
||||
],
|
||||
});
|
||||
await legacyExtraNextTick();
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_legacy_toy.active");
|
||||
assert.verifySteps([`no state`]);
|
||||
|
||||
await switchView(target, "toy");
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_toy.active");
|
||||
assert.verifySteps([`{"locationId":"The place to be"}`]);
|
||||
|
||||
await switchView(target, "legacy_toy");
|
||||
await legacyExtraNextTick();
|
||||
|
||||
assert.containsOnce(target, ".o_switch_view.o_legacy_toy.active");
|
||||
assert.verifySteps([`{"locationId":"The place to be"}`]);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -50,7 +50,7 @@ import session from "web.session";
|
||||
import LegacyMockServer from "web.MockServer";
|
||||
import Widget from "web.Widget";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import { ClientActionAdapter, ViewAdapter } from "@web/legacy/action_adapters";
|
||||
import { ClientActionAdapter } from "@web/legacy/action_adapters";
|
||||
import { commandService } from "@web/core/commands/command_service";
|
||||
import { ConnectionAbortedError } from "@web/core/network/rpc_service";
|
||||
import { CustomFavoriteItem } from "@web/search/favorite_menu/custom_favorite_item";
|
||||
@@ -267,14 +267,6 @@ export async function createWebClient(params) {
|
||||
});
|
||||
},
|
||||
});
|
||||
patchWithCleanup(ViewAdapter.prototype, {
|
||||
setup() {
|
||||
this._super();
|
||||
onMounted(() => {
|
||||
controllers.push(this.widget);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const legacyParams = params.legacyParams;
|
||||
params.serverData = params.serverData || {};
|
||||
|
||||
@@ -4,9 +4,7 @@ import { browser } from "@web/core/browser/browser";
|
||||
import { notificationService } from "@web/core/notifications/notification_service";
|
||||
import { menuService } from "@web/webclient/menus/menu_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import { viewService } from "@web/views/view_service";
|
||||
import { actionService } from "@web/webclient/actions/action_service";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
import { NavBar } from "@web/webclient/navbar/navbar";
|
||||
@@ -39,8 +37,6 @@ QUnit.module("Navbar", {
|
||||
serviceRegistry.add("notification", notificationService);
|
||||
serviceRegistry.add("hotkey", hotkeyService);
|
||||
serviceRegistry.add("ui", uiService);
|
||||
serviceRegistry.add("view", viewService); // #action-serv-leg-compat-js-class
|
||||
serviceRegistry.add("orm", ormService); // #action-serv-leg-compat-js-class
|
||||
systrayRegistry.add("addon.myitem", { Component: MySystrayItem });
|
||||
patchWithCleanup(browser, {
|
||||
setTimeout: (handler, delay, ...args) => handler(...args),
|
||||
|
||||
@@ -4,9 +4,7 @@ import { dialogService } from "@web/core/dialog/dialog_service";
|
||||
import { notificationService } from "@web/core/notifications/notification_service";
|
||||
import { popoverService } from "@web/core/popover/popover_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { ormService } from "@web/core/orm_service";
|
||||
import { uiService } from "@web/core/ui/ui_service";
|
||||
import { viewService } from "@web/views/view_service";
|
||||
import { legacyServiceProvider } from "@web/legacy/legacy_service_provider";
|
||||
import { actionService } from "@web/webclient/actions/action_service";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
@@ -34,9 +32,7 @@ QUnit.module("WebClient", {
|
||||
.add("notification", notificationService)
|
||||
.add("popover", popoverService)
|
||||
.add("title", fakeTitleService)
|
||||
.add("ui", uiService)
|
||||
.add("view", viewService) // #action-serv-leg-compat-js-class
|
||||
.add("orm", ormService); // #action-serv-leg-compat-js-class
|
||||
.add("ui", uiService);
|
||||
baseConfig = { activateMockServer: true };
|
||||
target = getFixture();
|
||||
},
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="assets_backend_legacy_lazy" name="Lazy assets for legacy Views" groups="base.group_user">
|
||||
<t t-call-assets="web.assets_backend_legacy_lazy" />
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -247,8 +247,6 @@ Featuring
|
||||
'web/static/src/legacy/js/core/smooth_scroll_on_drag.js',
|
||||
'web/static/src/legacy/js/widgets/colorpicker.js',
|
||||
'web/static/src/legacy/xml/colorpicker.xml',
|
||||
'web/static/src/legacy/js/widgets/translation_dialog.js',
|
||||
'web/static/src/legacy/xml/translation_dialog.xml',
|
||||
'web/static/src/core/**/*.js',
|
||||
'web/static/src/env.js',
|
||||
'web/static/src/legacy/js/services/session.js',
|
||||
|
||||
Reference in New Issue
Block a user