459 lines
15 KiB
JavaScript
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(/&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 });
|
|
}
|
|
}
|