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; } }