Before this commit, in a calendar view with a properties field, a crash will be display when you click on an event. closes odoo/odoo#134476 Related: odoo/enterprise#46990 Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
670 lines
22 KiB
JavaScript
670 lines
22 KiB
JavaScript
/* @odoo-module */
|
|
|
|
import { markup, onWillDestroy, onWillStart, onWillUpdateProps, useComponent } from "@odoo/owl";
|
|
import { evalPartialContext, makeContext } from "@web/core/context";
|
|
import {
|
|
deserializeDate,
|
|
deserializeDateTime,
|
|
serializeDate,
|
|
serializeDateTime,
|
|
} from "@web/core/l10n/dates";
|
|
import { x2ManyCommands } from "@web/core/orm_service";
|
|
import { Deferred } from "@web/core/utils/concurrency";
|
|
import { omit } from "@web/core/utils/objects";
|
|
import { effect } from "@web/core/utils/reactive";
|
|
import { batched } from "@web/core/utils/timing";
|
|
import { orderByToString } from "@web/search/utils/order_by";
|
|
|
|
/**
|
|
* @param {boolean || string} value boolean or string encoding a python expression
|
|
* @returns {string} string encoding a python expression
|
|
*/
|
|
function convertBoolToPyExpr(value) {
|
|
if (value === true || value === false) {
|
|
return value ? "True" : "False";
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function makeActiveField({
|
|
context,
|
|
invisible,
|
|
readonly,
|
|
required,
|
|
onChange,
|
|
forceSave,
|
|
isHandle,
|
|
} = {}) {
|
|
return {
|
|
context: context || "{}",
|
|
invisible: convertBoolToPyExpr(invisible || false),
|
|
readonly: convertBoolToPyExpr(readonly || false),
|
|
required: convertBoolToPyExpr(required || false),
|
|
onChange: onChange || false,
|
|
forceSave: forceSave || false,
|
|
isHandle: isHandle || false,
|
|
};
|
|
}
|
|
|
|
const AGGREGATABLE_FIELD_TYPES = ["float", "integer", "monetary"]; // types that can be aggregated in grouped views
|
|
|
|
export class FetchRecordError extends Error {
|
|
constructor(resIds) {
|
|
super(`Can't fetch record(s) ${resIds}. They might have been deleted.`);
|
|
}
|
|
}
|
|
|
|
export function addFieldDependencies(activeFields, fields, fieldDependencies = []) {
|
|
for (const field of fieldDependencies) {
|
|
if (!("readonly" in field)) {
|
|
field.readonly = true;
|
|
}
|
|
if (field.name in activeFields) {
|
|
patchActiveFields(activeFields[field.name], makeActiveField(field));
|
|
} else {
|
|
activeFields[field.name] = makeActiveField(field);
|
|
}
|
|
if (!fields[field.name]) {
|
|
fields[field.name] = omit(field, [
|
|
"context",
|
|
"invisible",
|
|
"required",
|
|
"readonly",
|
|
"onChange",
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function applyProperties(records, activeFields, fields) {
|
|
for (const record of records) {
|
|
for (const fieldName in record) {
|
|
const field = fields[fieldName];
|
|
if (fieldName !== "id" && field.type === "properties" && record[fieldName]) {
|
|
const parent = record[field.definition_record];
|
|
const relatedPropertyField = {
|
|
fieldName,
|
|
};
|
|
if (parent) {
|
|
relatedPropertyField.id = parent.id;
|
|
relatedPropertyField.displayName = parent.display_name;
|
|
}
|
|
for (const property of record[fieldName]) {
|
|
const propertyFieldName = `${fieldName}.${property.name}`;
|
|
if (!fields[propertyFieldName]) {
|
|
fields[propertyFieldName] = {
|
|
...property,
|
|
name: propertyFieldName,
|
|
relatedPropertyField,
|
|
propertyName: property.name,
|
|
relation: property.comodel,
|
|
};
|
|
}
|
|
if (!activeFields[propertyFieldName]) {
|
|
activeFields[propertyFieldName] = createPropertyActiveField(property);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function completeActiveField(activeField, extra) {
|
|
if (extra.related) {
|
|
for (const fieldName in extra.related.activeFields) {
|
|
if (fieldName in activeField.related.activeFields) {
|
|
completeActiveField(
|
|
activeField.related.activeFields[fieldName],
|
|
extra.related.activeFields[fieldName]
|
|
);
|
|
} else {
|
|
activeField.related.activeFields[fieldName] = {
|
|
...extra.related.activeFields[fieldName],
|
|
};
|
|
}
|
|
}
|
|
Object.assign(activeField.related.fields, extra.related.fields);
|
|
}
|
|
}
|
|
|
|
export function completeActiveFields(activeFields, extraActiveFields) {
|
|
for (const fieldName in extraActiveFields) {
|
|
const extraActiveField = {
|
|
...extraActiveFields[fieldName],
|
|
invisible: "True",
|
|
};
|
|
if (fieldName in activeFields) {
|
|
completeActiveField(activeFields[fieldName], extraActiveField);
|
|
} else {
|
|
activeFields[fieldName] = extraActiveField;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createPropertyActiveField(property) {
|
|
const { type } = property;
|
|
|
|
const activeField = makeActiveField();
|
|
if (type === "one2many" || type === "many2many") {
|
|
activeField.related = {
|
|
fields: {
|
|
id: { name: "id", type: "integer" },
|
|
display_name: { name: "display_name", type: "char" },
|
|
},
|
|
activeFields: {
|
|
id: makeActiveField({ readonly: true }),
|
|
display_name: makeActiveField(),
|
|
},
|
|
};
|
|
}
|
|
return activeField;
|
|
}
|
|
|
|
export function combineModifiers(mod1, mod2, operator) {
|
|
if (operator === "AND") {
|
|
if (!mod1 || mod1 === "False" || !mod2 || mod2 === "False") {
|
|
return "False";
|
|
}
|
|
if (mod1 === "True") {
|
|
return mod2;
|
|
}
|
|
if (mod2 === "True") {
|
|
return mod1;
|
|
}
|
|
return "(" + mod1 + ") and (" + mod2 + ")";
|
|
} else if (operator === "OR") {
|
|
if (mod1 === "True" || mod2 === "True") {
|
|
return "True";
|
|
}
|
|
if (!mod1 || mod1 === "False") {
|
|
return mod2;
|
|
}
|
|
if (!mod2 || mod2 === "False") {
|
|
return mod1;
|
|
}
|
|
return "(" + mod1 + ") or (" + mod2 + ")";
|
|
}
|
|
throw new Error(
|
|
`Operator provided to "combineModifiers" must be "AND" or "OR", received ${operator}`
|
|
);
|
|
}
|
|
|
|
export function patchActiveFields(activeField, patch) {
|
|
activeField.invisible = combineModifiers(activeField.invisible, patch.invisible, "AND");
|
|
activeField.readonly = combineModifiers(activeField.readonly, patch.readonly, "AND");
|
|
activeField.required = combineModifiers(activeField.required, patch.required, "OR");
|
|
activeField.onChange = activeField.onChange || patch.onChange;
|
|
activeField.forceSave = activeField.forceSave || patch.forceSave;
|
|
activeField.isHandle = activeField.isHandle || patch.isHandle;
|
|
// x2manys
|
|
if (patch.related) {
|
|
const related = activeField.related;
|
|
for (const fieldName in patch.related.activeFields) {
|
|
if (fieldName in related.activeFields) {
|
|
patchActiveFields(
|
|
related.activeFields[fieldName],
|
|
patch.related.activeFields[fieldName]
|
|
);
|
|
} else {
|
|
related.activeFields[fieldName] = { ...patch.related.activeFields[fieldName] };
|
|
}
|
|
}
|
|
Object.assign(related.fields, patch.related.fields);
|
|
}
|
|
if ("limit" in patch) {
|
|
activeField.limit = patch.limit;
|
|
}
|
|
if ("defaultOrderBy" in patch) {
|
|
activeField.defaultOrderBy = patch.defaultOrderBy;
|
|
}
|
|
}
|
|
|
|
export function extractFieldsFromArchInfo({ fieldNodes, widgetNodes }, fields) {
|
|
const activeFields = {};
|
|
for (const fieldNode of Object.values(fieldNodes)) {
|
|
const fieldName = fieldNode.name;
|
|
const activeField = makeActiveField({
|
|
context: fieldNode.context,
|
|
invisible: combineModifiers(fieldNode.invisible, fieldNode.column_invisible, "OR"),
|
|
readonly: fieldNode.readonly,
|
|
required: fieldNode.required,
|
|
onChange: fieldNode.onChange,
|
|
forceSave: fieldNode.forceSave,
|
|
isHandle: fieldNode.isHandle,
|
|
});
|
|
if (["one2many", "many2many"].includes(fields[fieldName].type)) {
|
|
activeField.related = {
|
|
activeFields: {},
|
|
fields: {},
|
|
};
|
|
if (fieldNode.views) {
|
|
const viewDescr = fieldNode.views[fieldNode.viewMode];
|
|
if (viewDescr) {
|
|
activeField.related = extractFieldsFromArchInfo(viewDescr, viewDescr.fields);
|
|
activeField.limit = viewDescr.limit;
|
|
activeField.defaultOrderBy = viewDescr.defaultOrder;
|
|
if (fieldNode.views.form) {
|
|
// we already know the form view (it is inline), so add its fields (in invisible)
|
|
// s.t. they will be sent in the spec for onchange, and create commands returned
|
|
// by the onchange could return values for those fields (that would be displayed
|
|
// later if the user opens the form view)
|
|
const formArchInfo = extractFieldsFromArchInfo(
|
|
fieldNode.views.form,
|
|
fieldNode.views.form.fields
|
|
);
|
|
completeActiveFields(
|
|
activeField.related.activeFields,
|
|
formArchInfo.activeFields
|
|
);
|
|
Object.assign(activeField.related.fields, formArchInfo.fields);
|
|
}
|
|
|
|
if (fieldNode.viewMode !== "default" && fieldNode.views.default) {
|
|
const defaultArchInfo = extractFieldsFromArchInfo(
|
|
fieldNode.views.default,
|
|
fieldNode.views.default.fields
|
|
);
|
|
activeField.related.activeFields = {
|
|
...defaultArchInfo.activeFields,
|
|
...activeField.related.activeFields,
|
|
};
|
|
activeField.related.fields = Object.assign(
|
|
{},
|
|
defaultArchInfo.fields,
|
|
activeField.related.fields
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (fieldNode.field?.useSubView) {
|
|
activeField.required = "False";
|
|
}
|
|
}
|
|
|
|
if (fieldName in activeFields) {
|
|
patchActiveFields(activeFields[fieldName], activeField);
|
|
} else {
|
|
activeFields[fieldName] = activeField;
|
|
}
|
|
|
|
if (fieldNode.field) {
|
|
let fieldDependencies = fieldNode.field.fieldDependencies;
|
|
if (typeof fieldDependencies === "function") {
|
|
fieldDependencies = fieldDependencies(fieldNode);
|
|
}
|
|
addFieldDependencies(activeFields, fields, fieldDependencies);
|
|
}
|
|
}
|
|
|
|
for (const widgetInfo of Object.values(widgetNodes || {})) {
|
|
let fieldDependencies = widgetInfo.widget.fieldDependencies;
|
|
if (typeof fieldDependencies === "function") {
|
|
fieldDependencies = fieldDependencies(widgetInfo);
|
|
}
|
|
addFieldDependencies(activeFields, fields, fieldDependencies);
|
|
}
|
|
return { activeFields, fields };
|
|
}
|
|
|
|
export function getFieldContext(
|
|
record,
|
|
fieldName,
|
|
rawContext = record.activeFields[fieldName].context
|
|
) {
|
|
const context = {};
|
|
for (const key in record.context) {
|
|
if (
|
|
!key.startsWith("default_") &&
|
|
!key.startsWith("search_default_") &&
|
|
!key.endsWith("_view_ref")
|
|
) {
|
|
context[key] = record.context[key];
|
|
}
|
|
}
|
|
|
|
return {
|
|
...context,
|
|
...record.fields[fieldName].context,
|
|
...makeContext([rawContext], record.evalContext),
|
|
};
|
|
}
|
|
|
|
export function getBasicEvalContext(config) {
|
|
return {
|
|
...config.context,
|
|
active_id: config.resId || false,
|
|
active_ids: config.resId ? [config.resId] : [],
|
|
active_model: config.resModel,
|
|
current_company_id: config.currentCompanyId,
|
|
};
|
|
}
|
|
|
|
export function getFieldsSpec(
|
|
activeFields,
|
|
fields,
|
|
evalContext,
|
|
{ parentActiveFields, withInvisible } = {}
|
|
) {
|
|
const fieldsSpec = {};
|
|
const properties = [];
|
|
for (const fieldName in activeFields) {
|
|
if (fields[fieldName].relatedPropertyField) {
|
|
continue;
|
|
}
|
|
const { related, limit, defaultOrderBy, invisible } = activeFields[fieldName];
|
|
fieldsSpec[fieldName] = {};
|
|
// X2M
|
|
if (related && ((invisible !== "True" && invisible !== "1") || withInvisible)) {
|
|
fieldsSpec[fieldName].fields = getFieldsSpec(
|
|
related.activeFields,
|
|
related.fields,
|
|
evalContext,
|
|
{ parentActiveFields: activeFields, withInvisible }
|
|
);
|
|
fieldsSpec[fieldName].limit = limit;
|
|
if (defaultOrderBy) {
|
|
fieldsSpec[fieldName].order = orderByToString(defaultOrderBy);
|
|
}
|
|
}
|
|
// Properties
|
|
if (fields[fieldName].type === "properties") {
|
|
properties.push(fieldName);
|
|
}
|
|
// M2O
|
|
if (fields[fieldName].type === "many2one") {
|
|
fieldsSpec[fieldName].fields = {};
|
|
if (invisible !== "True" && invisible !== "1") {
|
|
fieldsSpec[fieldName].fields.display_name = {};
|
|
}
|
|
}
|
|
if (["many2one", "one2many", "many2many"].includes(fields[fieldName].type)) {
|
|
let context = activeFields[fieldName].context;
|
|
if (!context || context === "{}") {
|
|
context = fields[fieldName].context || {};
|
|
} else {
|
|
context = evalPartialContext(context, evalContext);
|
|
}
|
|
if (Object.keys(context).length > 0) {
|
|
fieldsSpec[fieldName].context = context;
|
|
}
|
|
}
|
|
// Reference
|
|
if (fields[fieldName].type === "reference") {
|
|
fieldsSpec[fieldName].fields = { display_name: {} };
|
|
}
|
|
}
|
|
|
|
for (const fieldName of properties) {
|
|
const fieldSpec = fieldsSpec[fields[fieldName].definition_record];
|
|
if (fieldSpec) {
|
|
if (!fieldSpec.fields) {
|
|
fieldSpec.fields = {};
|
|
}
|
|
fieldSpec.fields.display_name = {};
|
|
}
|
|
}
|
|
return fieldsSpec;
|
|
}
|
|
|
|
let nextId = 0;
|
|
/**
|
|
* @param {string} [prefix]
|
|
* @returns {string}
|
|
*/
|
|
export function getId(prefix = "") {
|
|
return `${prefix}_${++nextId}`;
|
|
}
|
|
|
|
/**
|
|
* @protected
|
|
* @param {Field | false} field
|
|
* @param {any} value
|
|
* @returns {any}
|
|
*/
|
|
export function parseServerValue(field, value) {
|
|
switch (field.type) {
|
|
case "char":
|
|
case "text": {
|
|
return value || "";
|
|
}
|
|
case "html": {
|
|
return markup(value || "");
|
|
}
|
|
case "date": {
|
|
return value ? deserializeDate(value) : false;
|
|
}
|
|
case "datetime": {
|
|
return value ? deserializeDateTime(value) : false;
|
|
}
|
|
case "selection": {
|
|
if (value === false) {
|
|
// process selection: convert false to 0, if 0 is a valid key
|
|
const hasKey0 = field.selection.find((option) => option[0] === 0);
|
|
return hasKey0 ? 0 : value;
|
|
}
|
|
return value;
|
|
}
|
|
case "reference": {
|
|
if (value === false) {
|
|
return false;
|
|
}
|
|
return {
|
|
resId: value.id.id,
|
|
resModel: value.id.model,
|
|
displayName: value.display_name,
|
|
};
|
|
}
|
|
case "many2one": {
|
|
if (Array.isArray(value)) {
|
|
// Used for web_read_group, where the value is an array of [id, display_name]
|
|
return value;
|
|
}
|
|
return value ? [value.id, value.display_name] : false;
|
|
}
|
|
case "properties": {
|
|
return value
|
|
? value.map((property) => ({
|
|
...property,
|
|
value: parseServerValue(property, property.value ?? false),
|
|
}))
|
|
: [];
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Extract useful information from a group data returned by a call to webReadGroup.
|
|
*
|
|
* @param {Object} groupData
|
|
* @param {string[]} groupBy
|
|
* @param {Object} fields
|
|
* @returns {Object}
|
|
*/
|
|
export function extractInfoFromGroupData(groupData, groupBy, fields) {
|
|
const info = {};
|
|
const groupByField = fields[groupBy[0].split(":")[0]];
|
|
// sometimes the key FIELD_ID_count doesn't exist and we have to get the count from `__count` instead
|
|
// see read_group in models.py
|
|
info.count = groupData.__count || groupData[`${groupByField.name}_count`];
|
|
info.length = info.count; // TODO: remove
|
|
info.range = groupData.__range ? groupData.__range[groupBy[0]] : null;
|
|
info.domain = groupData.__domain;
|
|
info.rawValue = groupData[groupBy[0]];
|
|
info.value = getValueFromGroupData(groupByField, info.rawValue, info.range);
|
|
info.displayName = getDisplayNameFromGroupData(groupByField, info.rawValue);
|
|
info.serverValue = getServerValueFromGroupData(groupByField, info.value);
|
|
info.aggregates = getAggregatesFromGroupData(groupData, fields);
|
|
return info;
|
|
}
|
|
|
|
/**
|
|
* @param {Object} groupData
|
|
* @returns {Object}
|
|
*/
|
|
function getAggregatesFromGroupData(groupData, fields) {
|
|
const aggregates = {};
|
|
for (const [key, value] of Object.entries(groupData)) {
|
|
if (key in fields && AGGREGATABLE_FIELD_TYPES.includes(fields[key].type)) {
|
|
aggregates[key] = value;
|
|
}
|
|
}
|
|
return aggregates;
|
|
}
|
|
|
|
/**
|
|
* @param {import("./datapoint").Field} field
|
|
* @param {any} rawValue
|
|
* @returns {string | false}
|
|
*/
|
|
function getDisplayNameFromGroupData(field, rawValue) {
|
|
if (field.type === "selection") {
|
|
return Object.fromEntries(field.selection)[rawValue];
|
|
}
|
|
if (["many2one", "many2many"].includes(field.type)) {
|
|
return rawValue ? rawValue[1] : false;
|
|
}
|
|
return rawValue;
|
|
}
|
|
|
|
/**
|
|
* @param {import("./datapoint").Field} field
|
|
* @param {any} value
|
|
* @returns {any}
|
|
*/
|
|
function getServerValueFromGroupData(field, value) {
|
|
switch (field.type) {
|
|
case "many2many": {
|
|
return value ? [value] : false;
|
|
}
|
|
case "datetime": {
|
|
return value ? serializeDateTime(value) : false;
|
|
}
|
|
case "date": {
|
|
return value ? serializeDate(value) : false;
|
|
}
|
|
default: {
|
|
return value || false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import("./datapoint").Field} field
|
|
* @param {any} rawValue
|
|
* @param {object} [range]
|
|
* @returns {any}
|
|
*/
|
|
function getValueFromGroupData(field, rawValue, range) {
|
|
if (["date", "datetime"].includes(field.type)) {
|
|
if (!range) {
|
|
return false;
|
|
}
|
|
const dateValue = parseServerValue(field, range.to);
|
|
return dateValue.minus({
|
|
[field.type === "date" ? "day" : "second"]: 1,
|
|
});
|
|
}
|
|
const value = parseServerValue(field, rawValue);
|
|
if (["many2one", "many2many"].includes(field.type)) {
|
|
return value ? value[0] : false;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Onchanges sometimes return update commands for records we don't know (e.g. if
|
|
* they are on a page we haven't loaded yet). We may actually never load them.
|
|
* When this happens, we must still be able to send back those commands to the
|
|
* server when saving. However, we can't send the commands exactly as we received
|
|
* them, since the values they contain have been "unity read". The purpose of this
|
|
* function is to transform field values from the unity format to the format
|
|
* expected by the server for a write.
|
|
* For instance, for a many2one: { id: 3, display_name: "Marc" } => 3.
|
|
*/
|
|
export function fromUnityToServerValues(values, fields, activeFields) {
|
|
const { CREATE, UPDATE } = x2ManyCommands;
|
|
const serverValues = {};
|
|
for (const fieldName in values) {
|
|
let value = values[fieldName];
|
|
switch (fields[fieldName].type) {
|
|
case "one2many":
|
|
case "many2many":
|
|
value = value.map((c) => {
|
|
if (c[0] === CREATE || c[0] === UPDATE) {
|
|
const _fields = activeFields[fieldName].related.fields;
|
|
const _activeFields = activeFields[fieldName].related.activeFields;
|
|
return [c[0], c[1], fromUnityToServerValues(c[2], _fields, _activeFields)];
|
|
}
|
|
return [c[0], c[1]];
|
|
});
|
|
break;
|
|
case "many2one":
|
|
value = value ? value.id : false;
|
|
break;
|
|
// case "reference":
|
|
// // TODO
|
|
// break;
|
|
}
|
|
serverValues[fieldName] = value;
|
|
}
|
|
return serverValues;
|
|
}
|
|
|
|
/**
|
|
* @param {any} field
|
|
* @returns {boolean}
|
|
*/
|
|
export function isRelational(field) {
|
|
return field && ["one2many", "many2many", "many2one"].includes(field.type);
|
|
}
|
|
|
|
/**
|
|
* This hook should only be used in a component field because it
|
|
* depends on the record props.
|
|
* The callback will be executed once during setup and each time
|
|
* a record value read in the callback changes.
|
|
* @param {(record) => void} callback
|
|
*/
|
|
export function useRecordObserver(callback) {
|
|
const component = useComponent();
|
|
let alive = true;
|
|
const fct = (props) => {
|
|
const def = new Deferred();
|
|
let firstCall = true;
|
|
effect(
|
|
async (record) => {
|
|
if (firstCall) {
|
|
firstCall = false;
|
|
await callback(record);
|
|
def.resolve();
|
|
} else {
|
|
return batched(
|
|
async (record) => {
|
|
if (!alive) {
|
|
// effect doesn't clean up when the component is unmounted.
|
|
// We must do it manually.
|
|
return;
|
|
}
|
|
await callback(record);
|
|
def.resolve();
|
|
},
|
|
() => new Promise((resolve) => window.requestAnimationFrame(resolve))
|
|
)(record);
|
|
}
|
|
},
|
|
[props.record]
|
|
);
|
|
return def;
|
|
};
|
|
onWillDestroy(() => {
|
|
alive = false;
|
|
});
|
|
onWillStart(() => fct(component.props));
|
|
onWillUpdateProps((props) => {
|
|
if (props.record.id !== component.props.record.id) {
|
|
return fct(props);
|
|
}
|
|
});
|
|
}
|