This commit introduces the new calendar view written in owl. closes odoo/odoo#101185 X-original-commit: e88988f58582d5b49a32d7102a74a176f40c4c69 Related: odoo/enterprise#31808 Signed-off-by: Aaron Bohy (aab) <aab@odoo.com> Signed-off-by: Bruno Boi (boi) <boi@odoo.com> Co-authored-by: Bruno Boi <boi@odoo.com> Co-authored-by: Jorge Pinna Puissant <jpp@odoo.com>
409 lines
14 KiB
JavaScript
409 lines
14 KiB
JavaScript
/** @odoo-module **/
|
|
|
|
import { evaluateExpr } 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 { ControlPanel } from "@web/search/control_panel/control_panel";
|
|
import { extractLayoutComponents } from "@web/search/layout";
|
|
import { SearchPanel } from "@web/search/search_panel/search_panel";
|
|
import { WithSearch } from "@web/search/with_search/with_search";
|
|
import { OnboardingBanner } from "@web/views/onboarding_banner";
|
|
import { useActionLinks } from "@web/views/view_hook";
|
|
|
|
const { Component, markRaw, onWillUpdateProps, onWillStart, toRaw, useSubEnv, reactive } = owl;
|
|
const viewRegistry = registry.category("views");
|
|
|
|
/** @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} ControlPanel
|
|
* @property {Component} SearchPanel
|
|
* @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,
|
|
actionFlags: {},
|
|
breadcrumbs: reactive([
|
|
{
|
|
get name() {
|
|
return displayName;
|
|
},
|
|
},
|
|
]),
|
|
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: [],
|
|
ControlPanel: ControlPanel,
|
|
SearchPanel: SearchPanel,
|
|
Banner: OnboardingBanner,
|
|
};
|
|
return config;
|
|
}
|
|
|
|
/** @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 {string[]} [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 {}
|
|
export class LegacyFormViewInDialogError extends Error {}
|
|
|
|
const STANDARD_PROPS = [
|
|
"resModel",
|
|
"type",
|
|
|
|
"arch",
|
|
"fields",
|
|
"relatedModels",
|
|
"viewId",
|
|
"actionMenus",
|
|
"loadActionMenus",
|
|
|
|
"searchViewArch",
|
|
"searchViewFields",
|
|
"searchViewId",
|
|
"irFilters",
|
|
"loadIrFilters",
|
|
|
|
"comparison",
|
|
"context",
|
|
"domain",
|
|
"groupBy",
|
|
"orderBy",
|
|
|
|
"useSampleModel",
|
|
"noContentHelp",
|
|
"className",
|
|
|
|
"display",
|
|
"globalState",
|
|
|
|
"activateFavorite",
|
|
"dynamicFilters",
|
|
"hideCustomGroupBy",
|
|
"searchMenuTypes",
|
|
|
|
// LEGACY: remove this later (clean when mappings old state <-> new state are established)
|
|
"searchPanel",
|
|
"searchModel",
|
|
];
|
|
|
|
export class View extends Component {
|
|
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,
|
|
},
|
|
});
|
|
|
|
this.handleActionLinks = useActionLinks({ resModel });
|
|
|
|
onWillStart(() => this.loadView(this.props));
|
|
onWillUpdateProps((nextProps) => this.onWillUpdateProps(nextProps));
|
|
}
|
|
|
|
async loadView(props) {
|
|
// determine view type
|
|
let descr = viewRegistry.get(props.type);
|
|
const type = descr.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, 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 parser = new DOMParser();
|
|
const xml = parser.parseFromString(arch, "text/xml");
|
|
const rootNode = xml.documentElement;
|
|
|
|
let subType = rootNode.getAttribute("js_class");
|
|
const bannerRoute = rootNode.getAttribute("banner_route");
|
|
const sample = rootNode.getAttribute("sample");
|
|
|
|
// determine ViewClass to instantiate (if not already done)
|
|
if (subType) {
|
|
if (viewRegistry.contains(subType)) {
|
|
descr = viewRegistry.get(subType);
|
|
} else {
|
|
subType = null;
|
|
}
|
|
}
|
|
|
|
if (descr.type === "form" && descr.isLegacy && this.env.inDialog) {
|
|
throw new LegacyFormViewInDialogError();
|
|
}
|
|
|
|
Object.assign(this.env.config, {
|
|
viewArch: rootNode,
|
|
viewId: viewDescription.id,
|
|
viewType: type,
|
|
viewSubType: subType,
|
|
bannerRoute,
|
|
noBreadcrumbs: props.noBreadcrumbs,
|
|
...extractLayoutComponents(descr),
|
|
});
|
|
const info = {
|
|
actionMenus,
|
|
mode: props.display.mode,
|
|
irFilters,
|
|
searchViewArch,
|
|
searchViewFields,
|
|
searchViewId,
|
|
};
|
|
|
|
// prepare the view props
|
|
const viewProps = {
|
|
info,
|
|
arch,
|
|
fields,
|
|
relatedModels,
|
|
resModel,
|
|
useSampleModel: false,
|
|
className: `${props.className} o_view_controller o_${this.env.config.viewType}_view`,
|
|
};
|
|
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 = Boolean(evaluateExpr(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 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,
|
|
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 });
|
|
}
|
|
}
|
|
|
|
View._download = async function () {};
|
|
|
|
View.template = "web.View";
|
|
View.components = { WithSearch };
|
|
View.defaultProps = {
|
|
display: {},
|
|
context: {},
|
|
loadActionMenus: false,
|
|
loadIrFilters: false,
|
|
className: "",
|
|
};
|
|
|
|
View.searchMenuTypes = ["filter", "groupBy", "favorite"];
|