[IMP] runbot: add basic owl structure

Adds the minimal requirements for owl and xml file based templating for
the frontend.
This commit is contained in:
William Braeckman 2025-03-10 15:56:44 +01:00
parent 67407e3ec2
commit 0db0bfcf5b
7 changed files with 812 additions and 0 deletions

View File

@ -67,6 +67,16 @@
'runbot/static/src/diff_match_patch_module.js',
'runbot/static/src/fields/*',
],
'runbot.assets_frontend': [
('include', 'web.assets_frontend_minimal'), # Pray the gods this stays named correctly
'runbot/static/libs/owl.js',
'runbot/static/src/owl_module.js',
'runbot/static/src/vendored/**/*', # Vendored files coming from odoo modules
'runbot/static/src/frontend/root.js',
]
},
'post_load': 'runbot_post_load',
}

View File

@ -0,0 +1,64 @@
import { whenReady, Component, xml, App, onError } from '@runbot/owl';
import { getTemplate } from '@web/core/templates';
import { registry } from '@web/core/registry';
import { useRegistry } from '@web/core/registry_hook';
const mainComponents = registry.category('main.components');
class ErrorHandler extends Component {
static template = xml`<t t-slot="default" />`;
static props = ["onError", "slots"];
setup() {
onError((error) => {
this.props.onError(error);
});
}
}
class ComponentContainer extends Component {
static components = { ErrorHandler };
static props = {};
static template = xml`
<div class="o-main-components-container">
<t t-foreach="Components.entries" t-as="C" t-key="C[0]">
<ErrorHandler onError="error => this.handleComponentError(error, C)">
<t t-component="C[1].Component" t-props="C[1].props"/>
</ErrorHandler>
</t>
</div>
`;
setup() {
this.Components = useRegistry(mainComponents);
}
handleComponentError(error, C) {
console.error('Error while rendering', C[0], 'removing from app.');
// remove the faulty component and rerender without it
this.Components.entries.splice(this.Components.entries.indexOf(C), 1);
this.render();
/**
* we rethrow the error to notify the user something bad happened.
* We do it after a tick to make sure owl can properly finish its
* rendering
*/
Promise.resolve().then(() => {
throw error;
});
}
}
/**
* Bootstrap the frontend.
*/
(async function startApp() {
await whenReady();
const app = new App(ComponentContainer, {
getTemplate,
env: {},
});
await app.mount(document.body);
})();

View File

@ -0,0 +1,212 @@
/** @odoo-module alias=@web/core/registry default=false **/
import { EventBus, validate } from "@runbot/owl";
// -----------------------------------------------------------------------------
// Errors
// -----------------------------------------------------------------------------
export class KeyNotFoundError extends Error {}
export class DuplicatedKeyError extends Error {}
// -----------------------------------------------------------------------------
// Validation
// -----------------------------------------------------------------------------
const validateSchema = (name, key, value, schema) => {
if (!odoo.debug) {
return;
}
try {
validate(value, schema);
} catch (error) {
throw new Error(`Validation error for key "${key}" in registry "${name}": ${error}`);
}
};
// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------
/**
* @template S
* @template C
* @typedef {import("registries").RegistryData<S, C>} RegistryData
*/
/**
* @template T
* @typedef {T extends RegistryData<any, any> ? T : RegistryData<T, {}>} ToRegistryData
*/
/**
* @template T
* @typedef {ToRegistryData<T>["__itemShape"]} GetRegistryItemShape
*/
/**
* @template T
* @typedef {ToRegistryData<T>["__categories"]} GetRegistryCategories
*/
/**
* Registry
*
* The Registry class is basically just a mapping from a string key to an object.
* It is really not much more than an object. It is however useful for the
* following reasons:
*
* 1. it let us react and execute code when someone add something to the registry
* (for example, the FunctionRegistry subclass this for this purpose)
* 2. it throws an error when the get operation fails
* 3. it provides a chained API to add items to the registry.
*
* @template T
*/
export class Registry extends EventBus {
/**
* @param {string} [name]
*/
constructor(name) {
super();
/** @type {Record<string, [number, GetRegistryItemShape<T>]>}*/
this.content = {};
/** @type {{ [P in keyof GetRegistryCategories<T>]?: Registry<GetRegistryCategories<T>[P]> }} */
this.subRegistries = {};
/** @type {GetRegistryItemShape<T>[]}*/
this.elements = null;
/** @type {[string, GetRegistryItemShape<T>][]}*/
this.entries = null;
this.name = name;
this.validationSchema = null;
this.addEventListener("UPDATE", () => {
this.elements = null;
this.entries = null;
});
}
/**
* Add an entry (key, value) to the registry if key is not already used. If
* the parameter force is set to true, an entry with same key (if any) is replaced.
*
* Note that this also returns the registry, so another add method call can
* be chained
*
* @param {string} key
* @param {GetRegistryItemShape<T>} value
* @param {{force?: boolean, sequence?: number}} [options]
* @returns {Registry<T>}
*/
add(key, value, { force, sequence } = {}) {
if (this.validationSchema) {
validateSchema(this.name, key, value, this.validationSchema);
}
if (!force && key in this.content) {
throw new DuplicatedKeyError(
`Cannot add key "${key}" in the "${this.name}" registry: it already exists`
);
}
let previousSequence;
if (force) {
const elem = this.content[key];
previousSequence = elem && elem[0];
}
sequence = sequence === undefined ? previousSequence || 50 : sequence;
this.content[key] = [sequence, value];
const payload = { operation: "add", key, value };
this.trigger("UPDATE", payload);
return this;
}
/**
* Get an item from the registry
*
* @param {string} key
* @returns {GetRegistryItemShape<T>}
*/
get(key, defaultValue) {
if (arguments.length < 2 && !(key in this.content)) {
throw new KeyNotFoundError(`Cannot find key "${key}" in the "${this.name}" registry`);
}
const info = this.content[key];
return info ? info[1] : defaultValue;
}
/**
* Check the presence of a key in the registry
*
* @param {string} key
* @returns {boolean}
*/
contains(key) {
return key in this.content;
}
/**
* Get a list of all elements in the registry. Note that it is ordered
* according to the sequence numbers.
*
* @returns {GetRegistryItemShape<T>[]}
*/
getAll() {
if (!this.elements) {
const content = Object.values(this.content).sort((el1, el2) => el1[0] - el2[0]);
this.elements = content.map((elem) => elem[1]);
}
return this.elements.slice();
}
/**
* Return a list of all entries, ordered by sequence numbers.
*
* @returns {[string, GetRegistryItemShape<T>][]}
*/
getEntries() {
if (!this.entries) {
const entries = Object.entries(this.content).sort((el1, el2) => el1[1][0] - el2[1][0]);
this.entries = entries.map(([str, elem]) => [str, elem[1]]);
}
return this.entries.slice();
}
/**
* Remove an item from the registry
*
* @param {string} key
*/
remove(key) {
const value = this.content[key];
delete this.content[key];
const payload = { operation: "delete", key, value };
this.trigger("UPDATE", payload);
}
/**
* Open a sub registry (and create it if necessary)
*
* @template {keyof GetRegistryCategories<T> & string} K
* @param {K} subcategory
* @returns {Registry<GetRegistryCategories<T>[K]>}
*/
category(subcategory) {
if (!(subcategory in this.subRegistries)) {
this.subRegistries[subcategory] = new Registry(subcategory);
}
return this.subRegistries[subcategory];
}
addValidation(schema) {
if (this.validationSchema) {
throw new Error("Validation schema already set on this registry");
}
this.validationSchema = schema;
for (const [key, value] of this.getEntries()) {
validateSchema(this.name, key, value, schema);
}
}
}
/** @type {Registry<import("registries").GlobalRegistry>} */
export const registry = new Registry();

View File

@ -0,0 +1,28 @@
/** @odoo-module alias=@web/core/registry_hook default=false **/
import { useState, onWillStart, onWillDestroy } from "@runbot/owl";
export function useRegistry(registry) {
const state = useState({ entries: registry.getEntries() });
const listener = ({ detail }) => {
const index = state.entries.findIndex(([k]) => k === detail.key);
if (detail.operation === "add" && index === -1) {
// push the new entry at the right place
const newEntries = registry.getEntries();
const newEntry = newEntries.find(([k]) => k === detail.key);
const newIndex = newEntries.indexOf(newEntry);
if (newIndex === newEntries.length - 1) {
state.entries.push(newEntry);
} else {
state.entries.splice(newIndex, 0, newEntry);
}
} else if (detail.operation === "delete" && index >= 0) {
state.entries.splice(index, 1);
}
};
onWillStart(() => registry.addEventListener("UPDATE", listener));
onWillDestroy(() => registry.removeEventListener("UPDATE", listener));
return state;
}

View File

@ -0,0 +1,320 @@
/** @odoo-module alias=@web/core/template_inheritance default=false **/
const RSTRIP_REGEXP = /(?=\n[ \t]*$)/;
/**
* The child nodes of operation represent new content to create before target or
* or other elements to move before target from the target tree (tree from which target is part of).
* Some processing of text nodes has to be done in order to normalize the situation.
* Note: we assume that target has a parent element.
* @param {Element} target
* @param {Element} operation
*/
function addBefore(target, operation) {
const nodes = getNodes(target, operation);
if (nodes.length === 0) {
return;
}
const { previousSibling } = target;
target.before(...nodes);
if (previousSibling?.nodeType === Node.TEXT_NODE) {
const [text1, text2] = previousSibling.data.split(RSTRIP_REGEXP);
previousSibling.data = text1.trimEnd();
if (nodes[0].nodeType === Node.TEXT_NODE) {
mergeTextNodes(previousSibling, nodes[0]);
}
if (text2 && nodes.some((n) => n.nodeType !== Node.TEXT_NODE)) {
const textNode = document.createTextNode(text2);
target.before(textNode);
if (textNode.previousSibling.nodeType === Node.TEXT_NODE) {
mergeTextNodes(textNode.previousSibling, textNode);
}
}
}
}
/**
* element is part of a tree. Here we return the root element of that tree.
* Note: this root element is not necessarily the documentElement of the ownerDocument
* of element (hence the following code).
* @param {Element} element
* @returns {Element}
*/
function getRoot(element) {
while (element.parentElement) {
element = element.parentElement;
}
return element;
}
const HASCLASS_REGEXP = /hasclass\(([^)]*)\)/g;
const CLASS_CONTAINS_REGEX = /contains\(@class.*\)/g;
/**
* @param {Element} operation
* @returns {string}
*/
function getXpath(operation) {
const xpath = operation.getAttribute("expr");
if (odoo.debug) {
if (CLASS_CONTAINS_REGEX.test(xpath)) {
const parent = operation.closest("t[t-inherit]");
const templateName = parent.getAttribute("t-name") || parent.getAttribute("t-inherit");
console.warn(
`Error-prone use of @class in template "${templateName}" (or one of its inheritors).` +
" Use the hasclass(*classes) function to filter elements by their classes"
);
}
}
// hasclass does not exist in XPath 1.0 but is a custom function defined server side (see _hasclass) usable in lxml.
// Here we have to replace it by a complex condition (which is not nice).
// Note: we assume that classes do not contain the 2 chars , and )
return xpath.replaceAll(HASCLASS_REGEXP, (_, capturedGroup) => {
return capturedGroup
.split(",")
.map((c) => `contains(concat(' ', @class, ' '), ' ${c.trim().slice(1, -1)} ')`)
.join(" and ");
});
}
/**
* @param {Element} element
* @param {Element} operation
* @returns {Node|null}
*/
function getNode(element, operation) {
const root = getRoot(element);
const doc = new Document();
doc.appendChild(root); // => root is the documentElement of its ownerDocument (we do that in case root is a clone)
if (operation.tagName === "xpath") {
const xpath = getXpath(operation);
const result = doc.evaluate(xpath, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE);
return result.singleNodeValue;
}
for (const elem of root.querySelectorAll(operation.tagName)) {
if (
[...operation.attributes].every(
({ name, value }) => name === "position" || elem.getAttribute(name) === value
)
) {
return elem;
}
}
return null;
}
/**
* @param {Element} element
* @param {Element} operation
* @returns {Element}
*/
function getElement(element, operation) {
const node = getNode(element, operation);
if (!node) {
throw new Error(`Element '${operation.outerHTML}' cannot be located in element tree`);
}
if (!(node instanceof Element)) {
throw new Error(`Found node ${node} instead of an element`);
}
return node;
}
/**
* @param {Element} element
* @param {Element} operation
* @returns {Node[]}
*/
function getNodes(element, operation) {
const nodes = [];
for (const childNode of operation.childNodes) {
if (childNode.tagName === "xpath" && childNode.getAttribute?.("position") === "move") {
const node = getElement(element, childNode);
removeNode(node);
nodes.push(node);
} else {
nodes.push(childNode);
}
}
return nodes;
}
/**
* @param {Text} first
* @param {Text} second
* @param {boolean} [trimEnd=true]
*/
function mergeTextNodes(first, second, trimEnd = true) {
first.data = (trimEnd ? first.data.trimEnd() : first.data) + second.data;
second.remove();
}
function splitAndTrim(str, separator) {
return str.split(separator).map((s) => s.trim());
}
/**
* @param {Element} target
* @param {Element} operation
*/
function modifyAttributes(target, operation) {
for (const child of operation.children) {
if (child.tagName !== "attribute") {
continue;
}
const attributeName = child.getAttribute("name");
const firstNode = child.childNodes[0];
let value = firstNode?.nodeType === Node.TEXT_NODE ? firstNode.data : "";
const add = child.getAttribute("add") || "";
const remove = child.getAttribute("remove") || "";
if (add || remove) {
if (firstNode?.nodeType === Node.TEXT_NODE) {
throw new Error(`Useless element content ${firstNode.outerHTML}`);
}
const separator = child.getAttribute("separator") || ",";
const toRemove = new Set(splitAndTrim(remove, separator));
const values = splitAndTrim(target.getAttribute(attributeName) || "", separator).filter(
(s) => !toRemove.has(s)
);
values.push(...splitAndTrim(add, separator).filter((s) => s));
value = values.join(separator);
}
if (value) {
target.setAttribute(attributeName, value);
} else {
target.removeAttribute(attributeName);
}
}
}
/**
* Remove node and normalize surrounind text nodes (if any)
* Note: we assume that node has a parent element
* @param {Node} node
*/
function removeNode(node) {
const { nextSibling, previousSibling } = node;
node.remove();
if (nextSibling?.nodeType === Node.TEXT_NODE && previousSibling?.nodeType === Node.TEXT_NODE) {
mergeTextNodes(
previousSibling,
nextSibling,
previousSibling.parentElement.firstChild === previousSibling
);
}
}
/**
* @param {Element} root
* @param {Element} target
* @param {Element} operation
*/
function replace(root, target, operation) {
const mode = operation.getAttribute("mode") || "outer";
switch (mode) {
case "outer": {
const result = operation.ownerDocument.evaluate(
".//*[text()='$0']",
operation,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE
);
for (let i = 0; i < result.snapshotLength; i++) {
const loc = result.snapshotItem(i);
loc.firstChild.replaceWith(target.cloneNode(true));
}
if (target.parentElement) {
const nodes = getNodes(target, operation);
target.replaceWith(...nodes);
} else {
let operationContent = null;
let comment = null;
for (const child of operation.childNodes) {
if (child.nodeType === Node.ELEMENT_NODE) {
operationContent = child;
break;
}
if (child.nodeType === Node.COMMENT_NODE) {
comment = child;
}
}
root = operationContent.cloneNode(true);
if (target.hasAttribute("t-name")) {
root.setAttribute("t-name", target.getAttribute("t-name"));
}
if (comment) {
root.prepend(comment);
}
}
break;
}
case "inner":
while (target.firstChild) {
target.removeChild(target.lastChild);
}
target.append(...operation.childNodes);
break;
default:
throw new Error(`Invalid mode attribute: '${mode}'`);
}
return root;
}
/**
* @param {Element} root
* @param {Element} operations is a single element whose children represent operations to perform on root
* @param {string} [url=""]
* @returns {Element} root modified (in place) by the operations
*/
export function applyInheritance(root, operations, url = "") {
for (const operation of operations.children) {
const target = getElement(root, operation);
const position = operation.getAttribute("position") || "inside";
if (odoo.debug && url) {
const attributes = [...operation.attributes].map(
({ name, value }) =>
`${name}=${JSON.stringify(name === "position" ? position : value)}`
);
const comment = document.createComment(
` From file: ${url} ; ${attributes.join(" ; ")} `
);
if (position === "attributes") {
target.before(comment); // comment won't be visible if target is root
} else {
operation.prepend(comment);
}
}
switch (position) {
case "replace": {
root = replace(root, target, operation); // root can be replaced (see outer mode)
break;
}
case "attributes": {
modifyAttributes(target, operation);
break;
}
case "inside": {
const sentinel = document.createElement("sentinel");
target.append(sentinel);
addBefore(sentinel, operation);
removeNode(sentinel);
break;
}
case "after": {
const sentinel = document.createElement("sentinel");
target.after(sentinel);
addBefore(sentinel, operation);
removeNode(sentinel);
break;
}
case "before": {
addBefore(target, operation);
break;
}
default:
throw new Error(`Invalid position attribute: '${position}'`);
}
}
return root;
}

View File

@ -0,0 +1,176 @@
/** @odoo-module alias=@web/core/templates default=false */
import { applyInheritance } from "@web/core/template_inheritance";
const parser = new DOMParser();
/** @type {((document: Document) => void)[]} */
const templateProcessors = [];
/** @type {((url: string) => boolean)[]} */
let urlFilters = [];
function getParsedTemplate(templateString) {
const doc = parser.parseFromString(templateString, "text/xml");
for (const processor of templateProcessors) {
processor(doc);
}
return doc.firstChild;
}
function getClone(template) {
const c = template.cloneNode(true);
new Document().append(c); // => c is the documentElement of its ownerDocument
return c;
}
const registered = new Set();
function isRegistered(...args) {
const key = JSON.stringify([...args]);
if (registered.has(key)) {
return true;
}
registered.add(key);
return false;
}
let blockType = null;
let blockId = 0;
const templates = {};
const parsedTemplates = {};
const info = {};
export function registerTemplate(name, url, templateString) {
if (isRegistered(...arguments)) {
return;
}
if (blockType !== "templates") {
blockType = "templates";
blockId++;
}
if (name in templates && (info[name].url !== url || templates[name] !== templateString)) {
throw new Error(`Template ${name} already exists`);
}
templates[name] = templateString;
info[name] = { blockId, url };
}
const templateExtensions = {};
const parsedTemplateExtensions = {};
export function registerTemplateExtension(inheritFrom, url, templateString) {
if (isRegistered(...arguments)) {
return;
}
if (blockType !== "extensions") {
blockType = "extensions";
blockId++;
}
if (!templateExtensions[inheritFrom]) {
templateExtensions[inheritFrom] = [];
}
if (!templateExtensions[inheritFrom][blockId]) {
templateExtensions[inheritFrom][blockId] = [];
}
templateExtensions[inheritFrom][blockId].push({
templateString,
url,
});
}
/**
* @param {(document: Document) => void} processor
*/
export function registerTemplateProcessor(processor) {
templateProcessors.push(processor);
}
/**
* @param {typeof urlFilters} filters
*/
export function setUrlFilters(filters) {
urlFilters = filters;
}
function _getTemplate(name, blockId = null) {
if (!(name in parsedTemplates)) {
if (!(name in templates)) {
return null;
}
const templateString = templates[name];
parsedTemplates[name] = getParsedTemplate(templateString);
}
let processedTemplate = parsedTemplates[name];
const inheritFrom = processedTemplate.getAttribute("t-inherit");
if (inheritFrom) {
const parentTemplate = _getTemplate(inheritFrom, blockId || info[name].blockId);
if (!parentTemplate) {
throw new Error(
`Constructing template ${name}: template parent ${inheritFrom} not found`
);
}
const element = getClone(processedTemplate);
processedTemplate = applyInheritance(getClone(parentTemplate), element, info[name].url);
if (processedTemplate.tagName !== element.tagName) {
const temp = processedTemplate;
processedTemplate = new Document().createElement(element.tagName);
processedTemplate.append(...temp.childNodes);
}
for (const { name, value } of element.attributes) {
if (!["t-inherit", "t-inherit-mode"].includes(name)) {
processedTemplate.setAttribute(name, value);
}
}
}
for (const otherBlockId in templateExtensions[name] || {}) {
if (blockId && otherBlockId > blockId) {
break;
}
if (!(name in parsedTemplateExtensions)) {
parsedTemplateExtensions[name] = {};
}
if (!(otherBlockId in parsedTemplateExtensions[name])) {
parsedTemplateExtensions[name][otherBlockId] = [];
for (const { templateString, url } of templateExtensions[name][otherBlockId]) {
parsedTemplateExtensions[name][otherBlockId].push({
template: getParsedTemplate(templateString),
url,
});
}
}
for (const { template, url } of parsedTemplateExtensions[name][otherBlockId]) {
if (!urlFilters.every((filter) => filter(url))) {
continue;
}
processedTemplate = applyInheritance(
inheritFrom ? processedTemplate : getClone(processedTemplate),
getClone(template),
url
);
}
}
return processedTemplate;
}
/** @type {Record<string, Element>} */
let processedTemplates = {};
/**
* @param {string} name
*/
export function getTemplate(name) {
if (!processedTemplates[name]) {
processedTemplates[name] = _getTemplate(name);
}
return processedTemplates[name];
}
export function clearProcessedTemplates() {
processedTemplates = {};
}
export function checkPrimaryTemplateParents(namesToCheck) {
const missing = new Set(namesToCheck.filter((name) => !(name in templates)));
if (missing.size) {
console.error(`Missing (primary) parent templates: ${[...missing].join(", ")}`);
}
}

View File

@ -15,6 +15,8 @@
<script type="text/javascript" src="/runbot/static/libs/bootstrap/js/bootstrap.bundle.js"/>
<script type="text/javascript" src="/runbot/static/src/frontend/runbot.js"/>
<t t-call-assets="runbot.assets_frontend"/>
<t t-if="refresh">
<meta http-equiv="refresh" t-att-content="refresh"/>
</t>