odoo18/addons/web/static/src/views/view_compiler.js

477 lines
14 KiB
JavaScript

import {
append,
combineAttributes,
createElement,
createTextNode,
getTag,
} from "@web/core/utils/xml";
import { toStringExpression, BUTTON_CLICK_PARAMS } from "./utils";
/**
* @typedef Compiler
* @property {string} selector
* @property {(el: Element, params: Record<string, any>) => Element} fn
* @property {string} [class]
* @property {boolean} [doNotCopyAttributes]
*/
import { xml } from "@odoo/owl";
const BUTTON_STRING_PROPS = ["string", "size", "title", "icon", "id"];
const INTERP_REGEXP = /(\{\{|#\{)(.*?)(\}{1,2})/g;
/**
* @param {string} str
* @returns {string} the interpolated string to be injected into a component's node props.
*/
export function toInterpolatedStringExpression(str) {
const matches = str.matchAll(INTERP_REGEXP);
const parts = [];
let searchString = str;
for (const [match, head, expr] of matches) {
const index = searchString.indexOf(head);
const left = searchString.slice(0, index);
if (left) {
parts.push(toStringExpression(left));
}
parts.push(`(${expr})`);
searchString = searchString.slice(index + match.length);
}
parts.push(toStringExpression(searchString));
return parts.join("+");
}
/**
* @param {Element} el
* @param {string} attr
* @param {string} string
*/
export function appendAttr(el, attr, string) {
const attrKey = `t-att-${attr}`;
const attrVal = el.getAttribute(attrKey);
el.setAttribute(attrKey, appendToStringifiedObject(attrVal, string));
}
/**
* @param {string} originalTattr
* @param {string} string
* @returns {string}
*/
function appendToStringifiedObject(originalTattr, string) {
const re = /{(.*)}/;
const oldString = re.exec(originalTattr);
if (oldString) {
string = `${oldString[1]},${string}`;
}
return `{${string}}`;
}
/**
* @param {Element} target
* @param {...Element} sources
* @returns {Element}
*/
export function assignOwlDirectives(target, ...sources) {
for (const source of sources) {
for (const { name, value } of source.attributes) {
if (name.startsWith("t-attf-")) {
const propName = name.slice(7);
const interpolatedExpression = toInterpolatedStringExpression(value);
target.setAttribute(propName, interpolatedExpression);
} else if (name.startsWith("t-att-")) {
const propName = name.slice(6);
target.setAttribute(propName, value);
} else if (name.startsWith("t-")) {
target.setAttribute(name, value);
}
}
}
return target;
}
/**
* @param {Element} el
* @param {Element} compiled
*/
export function copyAttributes(el, compiled) {
const isComponent = isComponentNode(compiled);
const classes = el.className;
if (classes) {
if (isComponent) {
const cls = compiled.className;
compiled.setAttribute("class", cls ? `'${classes} ' + ${cls}` : `'${classes}'`);
} else {
compiled.classList.add(...classes.split(/\s+/).filter(Boolean));
}
}
let att = el.getAttribute("style");
if (att) {
if (isComponent) {
att = toStringExpression(att);
}
compiled.setAttribute("style", att);
}
}
/**
* Decodes a string within an attribute into an Object
* @param {string} str
* @return {Object}
*/
export function decodeObjectForTemplate(str) {
return JSON.parse(decodeURI(str));
}
/**
* Encodes an object into a string usable inside a pre-compiled template
* @param {Object}
* @return {string}
*/
export function encodeObjectForTemplate(obj) {
return `"${encodeURI(JSON.stringify(obj))}"`;
}
/**
* @param {Element} el
* @param {string} modifierName
* @returns {boolean | boolean[]}
*/
export function getModifier(el, modifierName) {
return el.getAttribute(modifierName);
}
/**
* @param {any} node
* @returns {string}
*/
function getTitleTag(node) {
return getTag(node)[0].toUpperCase() + getTag(node).slice(1);
}
/**
* @param {Node} node
* @returns {boolean}
*/
function isComment(node) {
return node.nodeType === 8;
}
/**
* @param {Element} el
* @returns {boolean}
*/
export function isComponentNode(el) {
return (
getTag(el) === getTitleTag(el) ||
(getTag(el, true) === "t" && "t-component" in el.attributes)
);
}
/**
* @param {Node} node
* @returns {boolean}
*/
export function isTextNode(node) {
return node.nodeType === 3;
}
/**
* @param {string} title
* @returns {Element}
*/
export function makeSeparator(title) {
const separator = createElement("div");
separator.className = "o_horizontal_separator mt-4 mb-3 text-uppercase fw-bolder small";
separator.textContent = title;
return separator;
}
export class ViewCompiler {
constructor(templates) {
/** @type {number} */
this.id = 1;
/** @type {Compiler[]} */
this.compilers = [
{
selector: "a[type]:not([data-bs-toggle]),a[data-type]:not([data-bs-toggle])",
fn: this.compileButton,
},
{
selector: "button:not([data-bs-toggle])",
fn: this.compileButton,
doNotCopyAttributes: true,
},
{ selector: "field", fn: this.compileField },
{ selector: "widget", fn: this.compileWidget },
];
this.templates = templates;
this.ctx = { readonly: "__comp__.props.readonly" };
this.owlDirectiveRegexesWhitelist = this.constructor.OWL_DIRECTIVE_WHITELIST.map(
(d) => new RegExp(d)
);
this.setup();
}
setup() {}
/**
* @param {any} invisible
* @param {Element} compiled
* @param {Record<string, any>} params
* @returns {Element}
*/
applyInvisible(invisible, compiled, params) {
if (!invisible || invisible === "False") {
return compiled;
}
if (invisible === "True" || invisible === "1") {
return;
}
const recordExpr = params.recordExpr || "__comp__.props.record";
let isVisileExpr = `!__comp__.evaluateBooleanExpr(${JSON.stringify(
invisible
)},${recordExpr}.evalContextWithVirtualIds)`;
if (compiled.hasAttribute("t-if")) {
const formerTif = compiled.getAttribute("t-if");
isVisileExpr = `( ${formerTif} ) and ${isVisileExpr}`;
}
compiled.setAttribute("t-if", isVisileExpr);
return compiled;
}
/**
* @param {string} key
* @param {Record<string, any>} params
* @returns {string}
*/
compile(key, params = {}) {
const root = this.templates[key].cloneNode(true);
const child = this.compileNode(root, params);
const newRoot = createElement("t", [child]);
newRoot.setAttribute("t-translation", "off");
return newRoot;
}
/**
* @param {Node} node
* @param {Record<string, any>} params
* @returns {Element | Text | void}
*/
compileNode(node, params = {}, evalInvisible = true) {
if (isComment(node)) {
return;
}
if (isTextNode(node)) {
return createTextNode(node.nodeValue);
}
this.validateNode(node);
let invisible;
if (evalInvisible) {
invisible = getModifier(node, "invisible");
if (!params.compileInvisibleNodes && (invisible === "True" || invisible === "1")) {
return;
}
}
const compiler = this.compilers.find((cp) => node.matches(cp.selector));
let compiledNode;
if (compiler) {
compiledNode = compiler.fn.call(this, node, params);
if (!compiler.doNotCopyAttributes && compiledNode) {
copyAttributes(node, compiledNode);
}
} else {
compiledNode = this.compileGenericNode(node, params);
}
if (evalInvisible && compiledNode) {
compiledNode = this.applyInvisible(invisible, compiledNode, params);
}
return compiledNode;
}
//-----------------------------------------------------------------------------
// Compilers
//-----------------------------------------------------------------------------
/**
* @param {Element} el
* @param {Record<string, any>} params
* @returns {Element}
*/
compileButton(el, params) {
let tag = getTag(el, true);
const type = el.getAttribute("type");
if (tag === "a" && type === "url") {
tag = "button";
}
const recordExpr = params.recordExpr || "__comp__.props.record";
const button = createElement("ViewButton", {
tag: toStringExpression(tag),
record: recordExpr,
});
assignOwlDirectives(button, el);
combineAttributes(
button,
"className",
[toStringExpression(el.className), button.className],
"+` `+"
);
el.removeAttribute("class");
button.removeAttribute("class");
const clickParams = {};
const attrs = {};
for (const { name, value } of el.attributes) {
if (BUTTON_CLICK_PARAMS.includes(name)) {
clickParams[name] = value;
} else if (BUTTON_STRING_PROPS.includes(name)) {
button.setAttribute(name, toStringExpression(value));
} else if (!name.startsWith("t-")) {
attrs[name] = value;
}
}
button.setAttribute("clickParams", JSON.stringify(clickParams));
button.setAttribute("attrs", JSON.stringify(attrs));
// Button's body
const buttonContent = [];
for (const child of el.childNodes) {
const compiled = this.compileNode(child, params);
if (compiled) {
buttonContent.push(compiled);
}
}
if (buttonContent.length) {
const contentSlot = createElement("t");
contentSlot.setAttribute("t-set-slot", "contents");
append(button, contentSlot);
for (const buttonChild of buttonContent) {
append(contentSlot, buttonChild);
}
}
return button;
}
/**
* @param {Element} el
* @returns {Element}
*/
compileField(el, params) {
const fieldName = el.getAttribute("name");
const fieldId = el.getAttribute("field_id");
const field = createElement("Field");
const recordExpr = params.recordExpr || "__comp__.props.record";
field.setAttribute("id", `'${fieldId}'`);
field.setAttribute("name", `'${fieldName}'`);
field.setAttribute("record", recordExpr);
field.setAttribute("fieldInfo", `__comp__.props.archInfo.fieldNodes['${fieldId}']`);
field.setAttribute(
"readonly",
`__comp__.props.archInfo.activeActions?.edit === false and !${recordExpr}.isNew`
);
if (el.hasAttribute("widget")) {
field.setAttribute("type", `'${el.getAttribute("widget")}'`);
}
return field;
}
/**
* @param {Element} el
* @param {Record<string, any>} params
* @returns {Element}
*/
compileGenericNode(el, params) {
const compiled = createElement(el.nodeName.toLowerCase());
const metaAttrs = ["column_invisible", "invisible", "readonly", "required"];
for (const attr of el.attributes) {
if (metaAttrs.includes(attr.name)) {
continue;
}
compiled.setAttribute(attr.name, attr.value);
}
for (const child of el.childNodes) {
append(compiled, this.compileNode(child, params));
}
if (el.hasAttribute("t-foreach") && !el.hasAttribute("t-key")) {
compiled.setAttribute("t-key", `${el.getAttribute("t-as")}_index`);
console.warn(`Missing attribute "t-key" in "t-foreach" statement.`);
}
return compiled;
}
/**
* @param {Element} el
* @returns {Element}
*/
compileWidget(el) {
const widgetId = el.getAttribute("widget_id");
const props = { record: "__comp__.props.record" };
if (el.hasAttribute("name")) {
props.name = `'${el.getAttribute("name")}'`;
}
if (el.hasAttribute("class")) {
props.className = `'${el.getAttribute("class")}'`;
}
props.widgetInfo = `__comp__.props.archInfo.widgetNodes['${widgetId}']`;
const widget = createElement("Widget", props);
return assignOwlDirectives(widget, el);
}
validateNode(node) {
// detect attributes not in whitelist, starting with t-
const attributes = Object.values(node.attributes).map((attr) => attr.name);
const regexes = this.owlDirectiveRegexesWhitelist;
for (const attr of attributes) {
if (attr.startsWith("t-") && !regexes.some((regex) => regex.test(attr))) {
console.warn(`Forbidden directive ${attr} used in arch`);
}
}
}
}
ViewCompiler.OWL_DIRECTIVE_WHITELIST = [];
let templateCache = Object.create(null);
/**
* @param {typeof ViewCompiler} ViewCompiler
* @param {string} key
* @param {Record<string, Element>} templates
* @param {Record<string, any>} [params]
* @returns {Record<string, string>}
*/
export function useViewCompiler(ViewCompiler, templates, params) {
const compiledTemplates = {};
let compiler;
for (const tname in templates) {
const key = `${ViewCompiler.name}/${templates[tname].outerHTML}`;
if (!templateCache[key]) {
compiler = compiler || new ViewCompiler(templates);
templateCache[key] = xml`${compiler.compile(tname, params).outerHTML}`;
}
compiledTemplates[tname] = templateCache[key];
}
return compiledTemplates;
}
/*
* clear the view compiler's cache.
* FIXME: that function only purges the compiler's cache and NOT the cache in owl's app.
* the owl.xml function creates an internal template each time, so the cache is here to prevent
* creating new owl templates every time. If we clear the cache, new templates WILL be created,
* even if the arch to compile is the same.
* This is how a memory leak occurs. :-)
*/
export function resetViewCompilerCache() {
templateCache = Object.create(null);
}