442 lines
15 KiB
JavaScript
442 lines
15 KiB
JavaScript
import { Domain } from "@web/core/domain";
|
|
import { evaluateBooleanExpr, evaluateExpr } from "@web/core/py_js/py";
|
|
import { registry } from "@web/core/registry";
|
|
import { utils } from "@web/core/ui/ui_service";
|
|
import { exprToBoolean } from "@web/core/utils/strings";
|
|
import { getFieldContext } from "@web/model/relational_model/utils";
|
|
import { X2M_TYPES, getClassNameFromDecoration } from "@web/views/utils";
|
|
import { getTooltipInfo } from "./field_tooltip";
|
|
|
|
import { Component, xml } from "@odoo/owl";
|
|
|
|
const isSmall = utils.isSmall;
|
|
|
|
const viewRegistry = registry.category("views");
|
|
const fieldRegistry = registry.category("fields");
|
|
|
|
const supportedInfoValidation = {
|
|
type: Array,
|
|
element: Object,
|
|
shape: {
|
|
label: String,
|
|
name: String,
|
|
type: String,
|
|
availableTypes: { type: Array, element: String, optional: true },
|
|
default: { type: String, optional: true },
|
|
help: { type: String, optional: true },
|
|
choices: /* choices if type == selection */ {
|
|
type: Array,
|
|
element: Object,
|
|
shape: { label: String, value: String },
|
|
optional: true,
|
|
},
|
|
},
|
|
optional: true,
|
|
};
|
|
|
|
fieldRegistry.addValidation({
|
|
component: { validate: (c) => c.prototype instanceof Component },
|
|
displayName: { type: String, optional: true },
|
|
supportedAttributes: supportedInfoValidation,
|
|
supportedOptions: supportedInfoValidation,
|
|
supportedTypes: { type: Array, element: String, optional: true },
|
|
extractProps: { type: Function, optional: true },
|
|
isEmpty: { type: Function, optional: true },
|
|
isValid: { type: Function, optional: true }, // Override the validation for the validation visual feedbacks
|
|
additionalClasses: { type: Array, element: String, optional: true },
|
|
fieldDependencies: {
|
|
type: [Function, { type: Array, element: Object, shape: { name: String, type: String } }],
|
|
optional: true,
|
|
},
|
|
relatedFields: {
|
|
type: [
|
|
Function,
|
|
{
|
|
type: Array,
|
|
element: Object,
|
|
shape: {
|
|
name: String,
|
|
type: String,
|
|
readonly: { type: Boolean, optional: true },
|
|
selection: { type: Array, element: { type: Array, element: String } },
|
|
optional: true,
|
|
},
|
|
},
|
|
],
|
|
optional: true,
|
|
},
|
|
useSubView: { type: Boolean, optional: true },
|
|
label: { type: [String, { value: false }], optional: true },
|
|
listViewWidth: {
|
|
type: [
|
|
Number,
|
|
{
|
|
type: Array,
|
|
element: Number,
|
|
validate: (array) => array.length === 1 || array.length === 2,
|
|
},
|
|
Function,
|
|
],
|
|
optional: true,
|
|
},
|
|
});
|
|
|
|
class DefaultField extends Component {
|
|
static template = xml``;
|
|
static props = ["*"];
|
|
}
|
|
|
|
export function getFieldFromRegistry(fieldType, widget, viewType, jsClass) {
|
|
const prefixes = jsClass ? [jsClass, viewType, ""] : [viewType, ""];
|
|
const findInRegistry = (key) => {
|
|
for (const prefix of prefixes) {
|
|
const _key = prefix ? `${prefix}.${key}` : key;
|
|
if (fieldRegistry.contains(_key)) {
|
|
return fieldRegistry.get(_key);
|
|
}
|
|
}
|
|
};
|
|
if (widget) {
|
|
const field = findInRegistry(widget);
|
|
if (field) {
|
|
return field;
|
|
}
|
|
console.warn(`Missing widget: ${widget} for field of type ${fieldType}`);
|
|
}
|
|
return findInRegistry(fieldType) || { component: DefaultField };
|
|
}
|
|
|
|
export function fieldVisualFeedback(field, record, fieldName, fieldInfo) {
|
|
const readonly = evaluateBooleanExpr(fieldInfo.readonly, record.evalContextWithVirtualIds);
|
|
const required = evaluateBooleanExpr(fieldInfo.required, record.evalContextWithVirtualIds);
|
|
const inEdit = record.isInEdition;
|
|
|
|
let empty = !record.isNew;
|
|
if ("isEmpty" in field) {
|
|
empty = empty && field.isEmpty(record, fieldName);
|
|
} else {
|
|
empty = empty && !record.data[fieldName];
|
|
}
|
|
empty = inEdit ? empty && readonly : empty;
|
|
return {
|
|
readonly,
|
|
required,
|
|
invalid: field.isValid
|
|
? !field.isValid(record, fieldName, fieldInfo)
|
|
: record.isFieldInvalid(fieldName),
|
|
empty,
|
|
};
|
|
}
|
|
|
|
export function getPropertyFieldInfo(propertyField) {
|
|
const { name, relatedPropertyField, string, type } = propertyField;
|
|
|
|
const fieldInfo = {
|
|
name,
|
|
string,
|
|
type,
|
|
widget: type,
|
|
options: {},
|
|
column_invisible: "False",
|
|
invisible: "False",
|
|
readonly: "False",
|
|
required: "False",
|
|
attrs: {},
|
|
relatedPropertyField,
|
|
|
|
// ??? We don t use it ? But it s in the fieldInfo of the field
|
|
context: "{}",
|
|
help: undefined,
|
|
onChange: false,
|
|
forceSave: false,
|
|
decorations: {},
|
|
// ???
|
|
};
|
|
|
|
if (type === "many2one" || type === "many2many") {
|
|
const { domain, relation } = propertyField;
|
|
fieldInfo.relation = relation;
|
|
fieldInfo.domain = domain;
|
|
|
|
if (relation === "res.users" || relation === "res.partner") {
|
|
fieldInfo.widget =
|
|
propertyField.type === "many2one" ? "many2one_avatar" : "many2many_tags_avatar";
|
|
} else {
|
|
fieldInfo.widget = propertyField.type === "many2one" ? type : "many2many_tags";
|
|
}
|
|
} else if (type === "tags") {
|
|
fieldInfo.tags = propertyField.tags;
|
|
fieldInfo.widget = `property_tags`;
|
|
} else if (type === "selection") {
|
|
fieldInfo.selection = propertyField.selection;
|
|
}
|
|
|
|
fieldInfo.field = getFieldFromRegistry(propertyField.type, fieldInfo.widget);
|
|
let { relatedFields } = fieldInfo.field;
|
|
if (relatedFields) {
|
|
if (relatedFields instanceof Function) {
|
|
relatedFields = relatedFields({ options: {}, attrs: {} });
|
|
}
|
|
fieldInfo.relatedFields = Object.fromEntries(relatedFields.map((f) => [f.name, f]));
|
|
}
|
|
|
|
return fieldInfo;
|
|
}
|
|
export class Field extends Component {
|
|
static template = "web.Field";
|
|
static props = ["fieldInfo?", "*"];
|
|
static parseFieldNode = function (node, models, modelName, viewType, jsClass) {
|
|
const name = node.getAttribute("name");
|
|
const widget = node.getAttribute("widget");
|
|
const fields = models[modelName].fields;
|
|
if (!fields[name]) {
|
|
throw new Error(`"${modelName}"."${name}" field is undefined.`);
|
|
}
|
|
const field = getFieldFromRegistry(fields[name].type, widget, viewType, jsClass);
|
|
const fieldInfo = {
|
|
name,
|
|
type: fields[name].type,
|
|
viewType,
|
|
widget,
|
|
field,
|
|
context: "{}",
|
|
string: fields[name].string,
|
|
help: undefined,
|
|
onChange: false,
|
|
forceSave: false,
|
|
options: {},
|
|
decorations: {},
|
|
attrs: {},
|
|
domain: undefined,
|
|
};
|
|
|
|
for (const attr of ["invisible", "column_invisible", "readonly", "required"]) {
|
|
fieldInfo[attr] = node.getAttribute(attr);
|
|
if (fieldInfo[attr] === "True") {
|
|
if (attr === "column_invisible") {
|
|
fieldInfo.invisible = "True";
|
|
}
|
|
} else if (fieldInfo[attr] === null && fields[name][attr]) {
|
|
fieldInfo[attr] = "True";
|
|
}
|
|
}
|
|
|
|
for (const { name, value } of node.attributes) {
|
|
if (["name", "widget"].includes(name)) {
|
|
// avoid adding name and widget to attrs
|
|
continue;
|
|
}
|
|
if (["context", "string", "help", "domain"].includes(name)) {
|
|
fieldInfo[name] = value;
|
|
} else if (name === "on_change") {
|
|
fieldInfo.onChange = exprToBoolean(value);
|
|
} else if (name === "options") {
|
|
fieldInfo.options = evaluateExpr(value);
|
|
} else if (name === "force_save") {
|
|
fieldInfo.forceSave = exprToBoolean(value);
|
|
} else if (name.startsWith("decoration-")) {
|
|
// prepare field decorations
|
|
fieldInfo.decorations[name.replace("decoration-", "")] = value;
|
|
} else if (!name.startsWith("t-att")) {
|
|
// all other (non dynamic) attributes
|
|
fieldInfo.attrs[name] = value;
|
|
}
|
|
}
|
|
if (name === "id") {
|
|
fieldInfo.readonly = "True";
|
|
}
|
|
|
|
if (widget === "handle") {
|
|
fieldInfo.isHandle = true;
|
|
}
|
|
|
|
if (X2M_TYPES.includes(fields[name].type)) {
|
|
const views = {};
|
|
let relatedFields = fieldInfo.field.relatedFields;
|
|
if (relatedFields) {
|
|
if (relatedFields instanceof Function) {
|
|
relatedFields = relatedFields(fieldInfo);
|
|
}
|
|
for (const relatedField of relatedFields) {
|
|
if (!("readonly" in relatedField)) {
|
|
relatedField.readonly = true;
|
|
}
|
|
}
|
|
relatedFields = Object.fromEntries(relatedFields.map((f) => [f.name, f]));
|
|
views.default = { fieldNodes: relatedFields, fields: relatedFields };
|
|
if (!fieldInfo.field.useSubView) {
|
|
fieldInfo.viewMode = "default";
|
|
}
|
|
}
|
|
for (const child of node.children) {
|
|
const viewType = child.tagName;
|
|
const { ArchParser } = viewRegistry.get(viewType);
|
|
// We copy and hence isolate the subview from the main view's tree
|
|
// This way, the subview's tree is autonomous and CSS selectors will work normally
|
|
const childCopy = child.cloneNode(true);
|
|
const archInfo = new ArchParser().parse(childCopy, models, fields[name].relation);
|
|
views[viewType] = {
|
|
...archInfo,
|
|
limit: archInfo.limit || 40,
|
|
fields: models[fields[name].relation].fields,
|
|
};
|
|
}
|
|
|
|
let viewMode = node.getAttribute("mode");
|
|
if (viewMode) {
|
|
if (viewMode.split(",").length !== 1) {
|
|
viewMode = isSmall() ? "kanban" : "list";
|
|
}
|
|
} else {
|
|
if (views.list && !views.kanban) {
|
|
viewMode = "list";
|
|
} else if (!views.list && views.kanban) {
|
|
viewMode = "kanban";
|
|
} else if (views.list && views.kanban) {
|
|
viewMode = isSmall() ? "kanban" : "list";
|
|
}
|
|
}
|
|
if (viewMode) {
|
|
fieldInfo.viewMode = viewMode;
|
|
}
|
|
if (Object.keys(views).length) {
|
|
fieldInfo.relatedFields = models[fields[name].relation]?.fields;
|
|
fieldInfo.views = views;
|
|
}
|
|
}
|
|
if (fields[name].type === "many2one_reference") {
|
|
let relatedFields = fieldInfo.field.relatedFields;
|
|
if (relatedFields) {
|
|
relatedFields = Object.fromEntries(relatedFields.map((f) => [f.name, f]));
|
|
fieldInfo.viewMode = "default";
|
|
fieldInfo.views = {
|
|
default: { fieldNodes: relatedFields, fields: relatedFields },
|
|
};
|
|
}
|
|
}
|
|
|
|
return fieldInfo;
|
|
};
|
|
|
|
setup() {
|
|
if (this.props.fieldInfo) {
|
|
this.field = this.props.fieldInfo.field;
|
|
} else {
|
|
const fieldType = this.props.record.fields[this.props.name].type;
|
|
this.field = getFieldFromRegistry(fieldType, this.props.type);
|
|
}
|
|
}
|
|
|
|
get classNames() {
|
|
const { class: _class, fieldInfo, name, record } = this.props;
|
|
const { readonly, required, invalid, empty } = fieldVisualFeedback(
|
|
this.field,
|
|
record,
|
|
name,
|
|
fieldInfo || {}
|
|
);
|
|
const classNames = {
|
|
o_field_widget: true,
|
|
o_readonly_modifier: readonly,
|
|
o_required_modifier: required,
|
|
o_field_invalid: invalid,
|
|
o_field_empty: empty,
|
|
[`o_field_${this.type}`]: true,
|
|
[_class]: Boolean(_class),
|
|
};
|
|
if (this.field.additionalClasses) {
|
|
for (const cls of this.field.additionalClasses) {
|
|
classNames[cls] = true;
|
|
}
|
|
}
|
|
|
|
// generate field decorations classNames (only if field-specific decorations
|
|
// have been defined in an attribute, e.g. decoration-danger="other_field = 5")
|
|
// only handle the text-decoration.
|
|
if (fieldInfo && fieldInfo.decorations) {
|
|
const { decorations } = fieldInfo;
|
|
for (const decoName in decorations) {
|
|
const value = evaluateBooleanExpr(
|
|
decorations[decoName],
|
|
record.evalContextWithVirtualIds
|
|
);
|
|
classNames[getClassNameFromDecoration(decoName)] = value;
|
|
}
|
|
}
|
|
|
|
return classNames;
|
|
}
|
|
|
|
get type() {
|
|
return this.props.type || this.props.record.fields[this.props.name].type;
|
|
}
|
|
|
|
get fieldComponentProps() {
|
|
const record = this.props.record;
|
|
let readonly = this.props.readonly || false;
|
|
|
|
let propsFromNode = {};
|
|
if (this.props.fieldInfo) {
|
|
let fieldInfo = this.props.fieldInfo;
|
|
readonly =
|
|
readonly ||
|
|
evaluateBooleanExpr(fieldInfo.readonly, record.evalContextWithVirtualIds);
|
|
|
|
if (this.field.extractProps) {
|
|
if (this.props.attrs) {
|
|
fieldInfo = {
|
|
...fieldInfo,
|
|
attrs: { ...fieldInfo.attrs, ...this.props.attrs },
|
|
};
|
|
}
|
|
|
|
const dynamicInfo = {
|
|
get context() {
|
|
return getFieldContext(record, fieldInfo.name, fieldInfo.context);
|
|
},
|
|
domain() {
|
|
const evalContext = record.evalContext;
|
|
if (fieldInfo.domain) {
|
|
return new Domain(evaluateExpr(fieldInfo.domain, evalContext)).toList();
|
|
}
|
|
},
|
|
required: evaluateBooleanExpr(
|
|
fieldInfo.required,
|
|
record.evalContextWithVirtualIds
|
|
),
|
|
readonly: readonly,
|
|
};
|
|
propsFromNode = this.field.extractProps(fieldInfo, dynamicInfo);
|
|
}
|
|
}
|
|
|
|
const props = { ...this.props };
|
|
delete props.style;
|
|
delete props.class;
|
|
delete props.showTooltip;
|
|
delete props.fieldInfo;
|
|
delete props.attrs;
|
|
delete props.type;
|
|
delete props.readonly;
|
|
|
|
return {
|
|
readonly: readonly || !record.isInEdition || false,
|
|
...propsFromNode,
|
|
...props,
|
|
};
|
|
}
|
|
|
|
get tooltip() {
|
|
if (this.props.showTooltip) {
|
|
const tooltip = getTooltipInfo({
|
|
field: this.props.record.fields[this.props.name],
|
|
fieldInfo: this.props.fieldInfo || {},
|
|
});
|
|
if (Boolean(odoo.debug) || (tooltip && JSON.parse(tooltip).field.help)) {
|
|
return tooltip;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|