odoo18/addons/web/static/src/views/view.js

459 lines
15 KiB
JavaScript

import { useDebugCategory } from "@web/core/debug/debug_context";
import { evaluateBooleanExpr } from "@web/core/py_js/py";
import { registry } from "@web/core/registry";
import { KeepLast } from "@web/core/utils/concurrency";
import { useService } from "@web/core/utils/hooks";
import { deepCopy, pick } from "@web/core/utils/objects";
import { nbsp } from "@web/core/utils/strings";
import { parseXML } from "@web/core/utils/xml";
import { extractLayoutComponents } from "@web/search/layout";
import { WithSearch } from "@web/search/with_search/with_search";
import { useActionLinks } from "@web/views/view_hook";
import { computeViewClassName } from "./utils";
import { loadBundle } from "@web/core/assets";
import { cookie } from "@web/core/browser/cookie";
import {
Component,
markRaw,
onWillUpdateProps,
onWillStart,
toRaw,
useSubEnv,
reactive,
} from "@odoo/owl";
import { session } from "@web/session";
const viewRegistry = registry.category("views");
viewRegistry.addValidation({
type: { validate: (t) => t in session.view_info },
Controller: { validate: (c) => c.prototype instanceof Component },
"*": true,
});
/** @typedef {Object} Config
* @property {integer|false} actionId
* @property {string|false} actionType
* @property {Object} actionFlags
* @property {() => []} breadcrumbs
* @property {() => string} getDisplayName
* @property {(string) => void} setDisplayName
* @property {() => Object} getPagerProps
* @property {Object[]} viewSwitcherEntry
* @property {Object[]} viewSwitcherEntry
* @property {Component} Banner
*/
/**
* Returns the default config to use if no config, or an incomplete config has
* been provided in the env, which can happen with standalone views.
* @returns {Config}
*/
export function getDefaultConfig() {
let displayName;
const config = {
actionId: false,
actionType: false,
embeddedActions: [],
currentEmbeddedActionId: false,
parentActionId: false,
actionFlags: {},
breadcrumbs: reactive([
{
get name() {
return displayName;
},
},
]),
disableSearchBarAutofocus: false,
getDisplayName: () => displayName,
historyBack: () => {},
pagerProps: {},
setDisplayName: (newDisplayName) => {
displayName = newDisplayName;
// This is a hack to force the reactivity when a new displayName is set
config.breadcrumbs.push(undefined);
config.breadcrumbs.pop();
},
viewSwitcherEntries: [],
views: [],
};
return config;
}
/** @typedef {import("./utils").OrderTerm} OrderTerm */
/** @typedef {Object} ViewProps
* @property {string} resModel
* @property {string} type
*
* @property {string} [arch] if given, fields must be given too /\ no post processing is done (evaluation of "groups" attribute,...)
* @property {Object} [fields] if given, arch must be given too
* @property {number|false} [viewId]
* @property {Object} [actionMenus]
* @property {boolean} [loadActionMenus=false]
*
* @property {string} [searchViewArch] if given, searchViewFields must be given too
* @property {Object} [searchViewFields] if given, searchViewArch must be given too
* @property {number|false} [searchViewId]
* @property {Object[]} [irFilters]
* @property {boolean} [loadIrFilters=false]
*
* @property {Object} [comparison]
* @property {Object} [context={}]
* @property {DomainRepr} [domain]
* @property {string[]} [groupBy]
* @property {OrderTerm[]} [orderBy]
*
* @property {boolean} [useSampleModel]
* @property {string} [noContentHelp]
*
* @property {Object} [display={}] to rework
*
* manipulated by withSearch
*
* @property {boolean} [activateFavorite]
* @property {Object[]} [dynamicFilters]
* @property {boolean} [hideCustomGroupBy]
* @property {string[]} [searchMenuTypes]
* @property {Object} [globalState]
*/
export class ViewNotFoundError extends Error {}
const CALLBACK_RECORDER_NAMES = [
"__beforeLeave__",
"__getGlobalState__",
"__getLocalState__",
"__getContext__",
"__getOrderBy__",
];
const STANDARD_PROPS = [
"resModel",
"type",
"jsClass",
"arch",
"fields",
"relatedModels",
"viewId",
"views",
"actionMenus",
"loadActionMenus",
"searchViewArch",
"searchViewFields",
"searchViewId",
"irFilters",
"loadIrFilters",
"comparison",
"context",
"domain",
"groupBy",
"orderBy",
"useSampleModel",
"noContentHelp",
"className",
"display",
"globalState",
"activateFavorite",
"dynamicFilters",
"hideCustomGroupBy",
"searchMenuTypes",
...CALLBACK_RECORDER_NAMES,
// LEGACY: remove this later (clean when mappings old state <-> new state are established)
"searchPanel",
"searchModel",
];
const ACTIONS = ["create", "delete", "edit", "group_create", "group_delete", "group_edit"];
export class View extends Component {
static _download = async function () {};
static template = "web.View";
static components = { WithSearch };
static searchMenuTypes = ["filter", "groupBy", "favorite"];
static canOrderByCount = false;
static defaultProps = {
display: {},
context: {},
loadActionMenus: false,
loadIrFilters: false,
className: "",
};
static props = {
"*": true,
};
setup() {
const { arch, fields, resModel, searchViewArch, searchViewFields, type } = this.props;
if (!resModel) {
throw Error(`View props should have a "resModel" key`);
}
if (!type) {
throw Error(`View props should have a "type" key`);
}
if ((arch && !fields) || (!arch && fields)) {
throw new Error(`"arch" and "fields" props must be given together`);
}
if ((searchViewArch && !searchViewFields) || (!searchViewArch && searchViewFields)) {
throw new Error(`"searchViewArch" and "searchViewFields" props must be given together`);
}
this.viewService = useService("view");
this.withSearchProps = null;
useSubEnv({
keepLast: new KeepLast(),
config: {
...getDefaultConfig(),
...this.env.config,
},
...Object.fromEntries(
CALLBACK_RECORDER_NAMES.map((name) => [name, this.props[name] || null])
),
});
this.handleActionLinks = useActionLinks({ resModel });
onWillStart(() => this.loadView(this.props));
onWillUpdateProps((nextProps) => this.onWillUpdateProps(nextProps));
useDebugCategory("view", { component: this });
}
async loadView(props) {
const type = props.type;
if (!session.view_info[type]) {
throw new Error(`Invalid view type: ${type}`);
}
// determine views for which descriptions should be obtained
let { viewId, searchViewId } = props;
const views = deepCopy(props.views || this.env.config.views);
const view = views.find((v) => v[1] === type) || [];
if (view.length) {
view[0] = viewId !== undefined ? viewId : view[0];
viewId = view[0];
} else {
view.push(viewId || false, type);
views.push(view); // viewId will remain undefined if not specified and loadView=false
}
const searchView = views.find((v) => v[1] === "search");
if (searchView) {
searchView[0] = searchViewId !== undefined ? searchViewId : searchView[0];
searchViewId = searchView[0];
} else if (searchViewId !== undefined) {
views.push([searchViewId, "search"]);
}
// searchViewId will remains undefined if loadSearchView=false
// prepare view description
const { context, resModel, loadActionMenus, loadIrFilters } = props;
let {
arch,
fields,
relatedModels,
searchViewArch,
searchViewFields,
irFilters,
actionMenus,
} = props;
const loadView = !arch || (!actionMenus && loadActionMenus);
const loadSearchView =
(searchViewId !== undefined && !searchViewArch) || (!irFilters && loadIrFilters);
let viewDescription = { viewId, resModel, type };
let searchViewDescription;
if (loadView || loadSearchView) {
// view description (or search view description if required) is incomplete
// a loadViews is done to complete the missing information
const result = await this.viewService.loadViews(
{ context, resModel, views },
{
actionId: this.env.config.actionId,
embeddedActionId: this.env.config.currentEmbeddedActionId,
embeddedParentResId: context.active_id,
loadActionMenus,
loadIrFilters,
}
);
// Note: if props.views is different from views, the cached descriptions
// will certainly not be reused! (but for the standard flow this will work as
// before)
viewDescription = result.views[type];
searchViewDescription = result.views.search;
if (loadSearchView) {
searchViewId = searchViewId || searchViewDescription.id;
if (!searchViewArch) {
searchViewArch = searchViewDescription.arch;
searchViewFields = result.fields;
}
if (!irFilters) {
irFilters = searchViewDescription.irFilters;
}
}
this.env.config.views = views;
fields = fields || markRaw(result.fields);
relatedModels = relatedModels || markRaw(result.relatedModels);
}
if (!arch) {
arch = viewDescription.arch;
}
if (!actionMenus) {
actionMenus = viewDescription.actionMenus;
}
const archXmlDoc = parseXML(arch.replace(/&amp;nbsp;/g, nbsp));
for (const action of ACTIONS) {
if (action in this.props.context && !this.props.context[action]) {
archXmlDoc.setAttribute(action, "0");
}
}
const jsClass = archXmlDoc.hasAttribute("js_class")
? archXmlDoc.getAttribute("js_class")
: props.jsClass || type;
if (!viewRegistry.contains(jsClass)) {
await loadBundle(
cookie.get("color_scheme") === "dark"
? "web.assets_backend_lazy_dark"
: "web.assets_backend_lazy"
);
}
const descr = viewRegistry.get(jsClass);
const sample = archXmlDoc.getAttribute("sample");
const className = computeViewClassName(type, archXmlDoc, [
"o_view_controller",
...(props.className || "").split(" "),
]);
Object.assign(this.env.config, {
rawArch: arch,
viewArch: archXmlDoc,
viewId: viewDescription.id,
viewType: type,
viewSubType: jsClass,
noBreadcrumbs: props.noBreadcrumbs,
...extractLayoutComponents(descr),
});
const info = {
actionMenus,
mode: props.display.mode,
irFilters,
searchViewArch,
searchViewFields,
searchViewId,
};
// prepare the view props
const viewProps = {
info,
arch: archXmlDoc,
fields,
relatedModels,
resModel,
useSampleModel: false,
className,
};
if (viewDescription.custom_view_id) {
// for dashboard
viewProps.info.customViewId = viewDescription.custom_view_id;
}
if (props.globalState) {
viewProps.globalState = props.globalState;
}
if ("useSampleModel" in props) {
viewProps.useSampleModel = props.useSampleModel;
} else if (sample) {
viewProps.useSampleModel = evaluateBooleanExpr(sample);
}
for (const key in props) {
if (!STANDARD_PROPS.includes(key)) {
viewProps[key] = props[key];
}
}
const { noContentHelp } = props;
if (noContentHelp) {
viewProps.info.noContentHelp = noContentHelp;
}
const searchMenuTypes =
props.searchMenuTypes || descr.searchMenuTypes || this.constructor.searchMenuTypes;
viewProps.searchMenuTypes = searchMenuTypes;
const canOrderByCount = descr.canOrderByCount || this.constructor.canOrderByCount;
const finalProps = descr.props ? descr.props(viewProps, descr, this.env.config) : viewProps;
// prepare the WithSearch component props
this.Controller = descr.Controller;
this.componentProps = finalProps;
this.withSearchProps = {
...toRaw(props),
hideCustomGroupBy: props.hideCustomGroupBy || descr.hideCustomGroupBy,
searchMenuTypes,
canOrderByCount,
SearchModel: descr.SearchModel,
};
if (searchViewId !== undefined) {
this.withSearchProps.searchViewId = searchViewId;
}
if (searchViewArch) {
this.withSearchProps.searchViewArch = searchViewArch;
this.withSearchProps.searchViewFields = searchViewFields;
}
if (irFilters) {
this.withSearchProps.irFilters = irFilters;
}
if (descr.display) {
// FIXME: there's something inelegant here: display might come from
// the View's defaultProps, in which case, modifying it in place
// would have unwanted effects.
const viewDisplay = deepCopy(descr.display);
const display = { ...this.withSearchProps.display };
for (const key in viewDisplay) {
if (typeof display[key] === "object") {
Object.assign(display[key], viewDisplay[key]);
} else if (!(key in display) || display[key]) {
display[key] = viewDisplay[key];
}
}
this.withSearchProps.display = display;
}
for (const key in this.withSearchProps) {
if (!(key in WithSearch.props)) {
delete this.withSearchProps[key];
}
}
}
onWillUpdateProps(nextProps) {
const oldProps = pick(this.props, "arch", "type", "resModel");
const newProps = pick(nextProps, "arch", "type", "resModel");
if (JSON.stringify(oldProps) !== JSON.stringify(newProps)) {
return this.loadView(nextProps);
}
// we assume that nextProps can only vary in the search keys:
// comparison, context, domain, groupBy, orderBy
const { comparison, context, domain, groupBy, orderBy } = nextProps;
Object.assign(this.withSearchProps, { comparison, context, domain, groupBy, orderBy });
}
}