Compare commits

...

19 Commits

Author SHA1 Message Date
Romeo Fragomeli aec2373e6d [REL] v2.6.0
# v2.6.0

 - [IMP] translation contexts
2025-01-15 11:40:33 +01:00
Mathieu Duckerts-Antoine a9be149e1e [IMP] translation contexts
A new directive t-tanslation-context and a new family of directives (of the form
t-translation-context-...) are introduced to allow to translate terms in contexts.

Set t-translation-context="fr" on a node makes every call of the translation
function to be done with "fr" as seconde parameter when translating an
attribute/content within that node or its children (if no closer
t-translation-context directive is found).
A directive t-translation-context-attr="fr" can be used on a node to target its
attribute "attr". For example, if a div has an attribute title="a title",
use t-translation-context-title="pt" will make "a title" to be translated
in the context "pt". Note that this takes precedence over any other directive
t-translation-context found on a parent (or the div itself).
The translation function is in charge of the interpretation of the context: OWL
does not associate any meaning with a translation context.
2025-01-15 11:01:53 +01:00
Romeo Fragomeli b365ea5c9c [REL] v2.5.3
# v2.5.3

 - [IMP] tools: expose template compiler as npm command
 - [IMP] compiler: add prop suffix name in the error message
2025-01-10 11:11:03 +01:00
odoo b31fa81083 [IMP] tools: expose template compiler as npm command
Previously, we made it possible to compile templates ahead of time as
this was required by the owl devtools. Unfortunately, no effort was made
at the time to make this ahead-of-time compiler outside of the owl repo.

This commit refactors the AoT template compiler by splitting it in two
parts: a typescript module that gets bundled in the dist folder that
gives a programmatic way to call the standalone compiler from another
javascript module by importing it, and a command line utility that is
exposed as an executable files available to npm run-script of dependent
modules (configured in the bin field of owl's package.json).

This allows a simple workflow for modules wanting to use owl:

`npm install @odoo/owl`

add a script to your package.json that calls compile_owl_templates on
your source files, eg:

```jsonc
{
  // ...
  "scripts": {
    "build": "compile_owl_templates src -o dist/templates.js"
  }
}
```

`npm run build`
2025-01-09 16:17:36 +01:00
Romain Derie 9b656fd9e4 [IMP] compiler: add prop suffix name in the error message
Without this, you only get the following error:
```
UncaughtPromiseError > OwlError
Uncaught promise > Invalid prop suffix
OwlError: Invalid prop suffix
```

Which is not helping much to find the problematic code.
2025-01-07 13:34:03 -05:00
Julien Carion (juca) 17f4823b13 [FIX] devtools: sort object keys numerically as well
This commit makes the alphabetical sort of object keys account for
numerical order as well so that it will feel right when handling objects
with numerical keys.
2025-01-06 12:15:39 +01:00
Julien Carion (juca) d3bc101177 [FIX] devtools: fix observe variables
This commit solves issues with the observe variables functionality: it
first restricts refreshing the observed variables to when owl is loaded
on the page and then fixes how simplified paths are handled to find
components so that both type of path will work fine no matter from where
the function is called. This would cause issues when observing variables
from the observed state section.
2024-12-12 11:27:30 +01:00
Julien Carion (juca) 4287beae19 [REL] devtools: chrome v1.3.0, firefox v1.1.0 2024-12-05 15:43:39 +01:00
Julien Carion (juca) a073c68685 [IMP] devtools: update doc
This commit updates the owl devttols documentation for its new versions
(1.3.0 chrome and 1.1.0 firefox).
2024-12-05 15:43:39 +01:00
Julien Carion (juca) 7078d64049 [IMP] devtools: observe variables
This commit allows the user to observe variables so that their content
will be refreshed every 200ms and will be displayed as long as they
are found in the tree.
2024-12-05 15:43:39 +01:00
Julien Carion (juca) ccd31f12d9 [IMP] devtools: add lifecycle hooks management
This commit adds lifecycle hooks to the interface of the selected
component and allows to inject breakpoints into these hooks.
2024-12-05 15:43:39 +01:00
Julien Carion (juca) aff4019cde [REF] devtools: linting and context menu rework
This commit cleans up linting issues and proposes a new implementation
for the devtools custom context menus.
2024-12-05 15:43:39 +01:00
Julien Carion (juca) 5ecb4809ff [FIX] devtools: arrays are objects
This commit fixes some dead code related to the display of arrays inside
other objects. Arrays are objects indeed so the condition was impossible
to satisfy.
2024-12-05 15:43:39 +01:00
Julien Carion (juca) c2daecc07b [FIX] devtools: don't evaluate getters at object stringification
This commit prevents the serializer to evaluate object getters when
it is trying to display an object in a reduced form. This would cause
issues when a getter was returning an error instead of a value.
2024-12-05 15:43:39 +01:00
Julien Carion (juca) fcda17c8e9 [IMP] devtools: sort object keys in alphabetical order
Some big objects are quite hard to read because keys are sorted by
order of definition inside the object. It is better to sort them in
alphabetical order as is done in the base devtools.
2024-12-05 15:43:39 +01:00
Géry Debongnie aeed79c7e5 [REL] v2.5.2
# v2.5.2

 - [FIX] runtime: make error recovery more robust
2024-12-02 16:51:14 +01:00
Géry Debongnie 1c5b6f2573 [FIX] runtime: make error recovery more robust
The error recovery process is necessary, but is particularly subtle. An
error may occur basically any time some user code is called, which may
leave the internal owl state in an invalid situation.

In this commit, we try to improve Owl behaviour when a crash occurs in a
onMounted hook. Before this commit, it was possible for other component
mounted hooks to be called even though the component was not mounted, or
for willUnmount hooks to be called even though the component was
mounted, but its onMounted hooks had not been called.

All these situations need to be handled when we catch an error. In this
commit, we try to clean more internal state in the error handling code,
to make Owl more consistent.
2024-11-28 16:27:56 +01:00
Romeo Fragomeli cd9b72158b [REL] v2.5.1
# v2.5.1

 - [FIX] compiler: multiple modifiers on t-custom
2024-11-26 09:42:49 +01:00
Jorge Pinna Puissant 7fc552e2f8 [FIX] compiler: multiple modifiers on t-custom
Since [1] it's possible to have a custom directive, this directive only
supported one modifier. This commit, add the support to have multiple
modifiers on a t-custom directive.

[1] : https://github.com/odoo/owl/commit/7e687234bf40bdcf5e6598401d8604d66b14ad32
2024-11-25 16:14:56 +01:00
54 changed files with 2951 additions and 769 deletions
+13 -11
View File
@@ -56,17 +56,19 @@ extensions.
For reference, here is a list of all standard QWeb directives:
| Name | Description |
| ------------------------------ | --------------------------------------------------------------- |
| `t-esc` | [Outputting safely a value](#outputting-data) |
| `t-out` | [Outputting value, possibly without escaping](#outputting-data) |
| `t-set`, `t-value` | [Setting variables](#setting-variables) |
| `t-if`, `t-elif`, `t-else`, | [conditionally rendering](#conditionals) |
| `t-foreach`, `t-as` | [Loops](#loops) |
| `t-att`, `t-attf-*`, `t-att-*` | [Dynamic attributes](#dynamic-attributes) |
| `t-call` | [Rendering sub templates](#sub-templates) |
| `t-debug`, `t-log` | [Debugging](#debugging) |
| `t-translation` | [Disabling the translation of a node](translations.md) |
| Name | Description |
| ------------------------------ | ----------------------------------------------------------------------- |
| `t-esc` | [Outputting safely a value](#outputting-data) |
| `t-out` | [Outputting value, possibly without escaping](#outputting-data) |
| `t-set`, `t-value` | [Setting variables](#setting-variables) |
| `t-if`, `t-elif`, `t-else`, | [conditionally rendering](#conditionals) |
| `t-foreach`, `t-as` | [Loops](#loops) |
| `t-att`, `t-attf-*`, `t-att-*` | [Dynamic attributes](#dynamic-attributes) |
| `t-call` | [Rendering sub templates](#sub-templates) |
| `t-debug`, `t-log` | [Debugging](#debugging) |
| `t-translation` | [Disabling the translation of a node](translations.md) |
| `t-translation-context` | [Context of translations within a node](translations.md) |
| `t-translation-context-*` | [Context of translation for a specific node attribute](translations.md) |
The component system in Owl requires additional directives, to express various
needs. Here is a list of all Owl specific directives:
+37 -5
View File
@@ -1,17 +1,28 @@
# 🦉 Translations 🦉
If properly setup, Owl can translate all rendered templates. To do
so, it needs a translate function, which takes a string and returns a string.
so, it needs a translate function, which takes
- a string (the term to translate)
- a string (the translation context of the term)
and returns a string.
For example:
```js
const translations = {
hello: "bonjour",
yes: "oui",
no: "non",
fr: {
hello: "bonjour",
yes: "oui",
no: "non",
},
pt: {
hello: "bom dia",
yes: "sim",
no: "não",
},
};
const translateFn = (str) => translations[str] || str;
const translateFn = (str, ctx) => translations[ctx]?.[str] || str;
const app = new App(Root, { templates, tranaslateFn });
// ...
@@ -27,6 +38,11 @@ Once setup, all rendered templates will be translated using `translateFn`:
`placeholder`, `label` and `alt`,
- translating text nodes can be disabled with the special attribute `t-translation`,
if its value is `off`.
- the translate function receives as second parameter a context that can be used
to contextualized the translation. That context can be set globally on a node
and its children by using `t-translation-context`. If a specific node
attribute `x` needs another context, that context can be specified with a
special directive `t-translation-context-x`.
So, with the above `translateFn`, the following templates:
@@ -46,6 +62,22 @@ will be rendered as:
<input placeholder="bonjour" other="yes"/>
```
and the following template:
```xml
<div t-translation-context="fr" title="hello">hello</div>
<div>Are you sure?</div>
<input t-translation-context-placeholder="pt" placeholder="hello" other="yes"/>
```
will be rendered as:
```xml
<div title="bonjour">bonjour</div>
<div>Are you sure?</div>
<input placeholder="bom dia" other="yes"/>
```
Note that the translation is done during the compilation of the template, not
when it is rendered.
+16
View File
@@ -82,6 +82,22 @@ in the sources tab as well.
<img src="screenshots/function_menu.png"/>
Using the right-click context menu on a property also allows to observe variables. Observed variables will
be sent to a dedicated section of the details window and their value will be refreshed every 200ms. These
variables are only shown when they are found and their access path will be kept in memory inside the
browser so that it will always persist until the user decides to stop observing the variable. As in the
browser's devtools, observed objects are displayed in reduced form and cannot be interacted with. It is
still possible to send them to the console or remove them from the list using right-click.
<img src="screenshots/observe_variables.png"/>
The last section of the details window is filled with the component's lifecycle hooks. Using right click on
them allows to place breakpoints inside the hook (either on its instance or class, hooks like mounted and
willStart cannot have instance-based breakpoints because they will never trigger). Conditions in conditional
breakpoints will be evaluated in the context of the component's definition.
<img src="screenshots/hooks.png"/>
There are several icons available to perform several of the actions described before in the components
tree context menu and all these actions are also available by opening the menu by right-clicking on the
component's name. Using the left click on the component's name will focus it in the components tree.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 KiB

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 93 KiB

+127 -26
View File
@@ -1625,6 +1625,13 @@ function makeRootFiber(node) {
fibersInError.delete(current);
fibersInError.delete(root);
current.appliedToDom = false;
if (current instanceof RootFiber) {
// it is possible that this fiber is a fiber that crashed while being
// mounted, so the mounted list is possibly corrupted. We restore it to
// its normal initial state (which is empty list or a list with a mount
// fiber.
current.mounted = current instanceof MountFiber ? [current] : [];
}
}
return current;
}
@@ -1741,6 +1748,7 @@ class RootFiber extends Fiber {
const node = this.node;
this.locked = true;
let current = undefined;
let mountedFibers = this.mounted;
try {
// Step 1: calling all willPatch lifecycle hooks
for (current of this.willPatch) {
@@ -1760,7 +1768,6 @@ class RootFiber extends Fiber {
node._patch();
this.locked = false;
// Step 4: calling all mounted lifecycle hooks
let mountedFibers = this.mounted;
while ((current = mountedFibers.pop())) {
current = current;
if (current.appliedToDom) {
@@ -1781,6 +1788,15 @@ class RootFiber extends Fiber {
}
}
catch (e) {
// if mountedFibers is not empty, this means that a crash occured while
// calling the mounted hooks of some component. So, there may still be
// some component that have been mounted, but for which the mounted hooks
// have not been called. Here, we remove the willUnmount hooks for these
// specific component to prevent a worse situation (willUnmount being
// called even though mounted has not been called)
for (let fiber of mountedFibers) {
fiber.node.willUnmount = [];
}
this.locked = false;
node.app.handleError({ fiber: current || this, error: e });
}
@@ -3722,6 +3738,7 @@ function createContext(parentCtx, params) {
index: 0,
forceNewBlock: true,
translate: parentCtx.translate,
translationCtx: parentCtx.translationCtx,
tKeyExpr: null,
nameSpace: parentCtx.nameSpace,
tModelSelectedExpr: parentCtx.tModelSelectedExpr,
@@ -3824,6 +3841,7 @@ class CodeGenerator {
forceNewBlock: false,
isLast: true,
translate: true,
translationCtx: "",
tKeyExpr: null,
});
// define blocks and utility functions
@@ -3961,9 +3979,9 @@ class CodeGenerator {
})
.join("");
}
translate(str) {
translate(str, translationCtx) {
const match = translationRE.exec(str);
return match[1] + this.translateFn(match[2]) + match[3];
return match[1] + this.translateFn(match[2], translationCtx) + match[3];
}
/**
* @returns the newly created block name, if any
@@ -4004,7 +4022,9 @@ class CodeGenerator {
return this.compileTSlot(ast, ctx);
case 16 /* TTranslation */:
return this.compileTTranslation(ast, ctx);
case 17 /* TPortal */:
case 17 /* TTranslationContext */:
return this.compileTTranslationContext(ast, ctx);
case 18 /* TPortal */:
return this.compileTPortal(ast, ctx);
}
}
@@ -4042,7 +4062,7 @@ class CodeGenerator {
let { block, forceNewBlock } = ctx;
let value = ast.value;
if (value && ctx.translate !== false) {
value = this.translate(value);
value = this.translate(value, ctx.translationCtx);
}
if (!ctx.inPreTag) {
value = value.replace(whitespaceRE, " ");
@@ -4077,6 +4097,7 @@ class CodeGenerator {
return `[${modifiersCode}${this.captureExpression(handler)}, ctx]`;
}
compileTDomNode(ast, ctx) {
var _a;
let { block, forceNewBlock } = ctx;
const isNewBlock = !block || forceNewBlock || ast.dynamicTag !== null || ast.ns;
let codeIdx = this.target.code.length;
@@ -4132,7 +4153,8 @@ class CodeGenerator {
}
}
else if (this.translatableAttributes.includes(key)) {
attrs[key] = this.translateFn(ast.attrs[key]);
const attrTranslationCtx = ((_a = ast.attrsTranslationCtx) === null || _a === void 0 ? void 0 : _a[key]) || ctx.translationCtx;
attrs[key] = this.translateFn(ast.attrs[key], attrTranslationCtx);
}
else {
expr = `"${ast.attrs[key]}"`;
@@ -4576,7 +4598,7 @@ class CodeGenerator {
else {
let value;
if (ast.defaultValue) {
const defaultValue = toStringExpression(ctx.translate ? this.translate(ast.defaultValue) : ast.defaultValue);
const defaultValue = toStringExpression(ctx.translate ? this.translate(ast.defaultValue, ctx.translationCtx) : ast.defaultValue);
if (ast.value) {
value = `withDefault(${expr}, ${defaultValue})`;
}
@@ -4610,9 +4632,10 @@ class CodeGenerator {
* "some-prop" "state" "'some-prop': ctx['state']"
* "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])"
*/
formatProp(name, value) {
formatProp(name, value, attrsTranslationCtx, translationCtx) {
if (name.endsWith(".translate")) {
value = toStringExpression(this.translateFn(value));
const attrTranslationCtx = (attrsTranslationCtx === null || attrsTranslationCtx === void 0 ? void 0 : attrsTranslationCtx[name]) || translationCtx;
value = toStringExpression(this.translateFn(value, attrTranslationCtx));
}
else {
value = this.captureExpression(value);
@@ -4628,14 +4651,14 @@ class CodeGenerator {
case "translate":
break;
default:
throw new OwlError("Invalid prop suffix");
throw new OwlError(`Invalid prop suffix: ${suffix}`);
}
}
name = /^[a-z_]+$/i.test(name) ? name : `'${name}'`;
return `${name}: ${value || undefined}`;
}
formatPropObject(obj) {
return Object.entries(obj).map(([k, v]) => this.formatProp(k, v));
formatPropObject(obj, attrsTranslationCtx, translationCtx) {
return Object.entries(obj).map(([k, v]) => this.formatProp(k, v, attrsTranslationCtx, translationCtx));
}
getPropString(props, dynProps) {
let propString = `{${props.join(",")}}`;
@@ -4648,7 +4671,9 @@ class CodeGenerator {
let { block } = ctx;
// props
const hasSlotsProp = "slots" in (ast.props || {});
const props = ast.props ? this.formatPropObject(ast.props) : [];
const props = ast.props
? this.formatPropObject(ast.props, ast.propsTranslationCtx, ctx.translationCtx)
: [];
// slots
let slotDef = "";
if (ast.slots) {
@@ -4671,7 +4696,7 @@ class CodeGenerator {
params.push(`__scope: "${scope}"`);
}
if (ast.slots[slotName].attrs) {
params.push(...this.formatPropObject(ast.slots[slotName].attrs));
params.push(...this.formatPropObject(ast.slots[slotName].attrs, ast.slots[slotName].attrsTranslationCtx, ctx.translationCtx));
}
const slotInfo = `{${params.join(", ")}}`;
slotStr.push(`'${slotName}': ${slotInfo}`);
@@ -4784,7 +4809,9 @@ class CodeGenerator {
if (isMultiple) {
key = this.generateComponentKey(key);
}
const props = ast.attrs ? this.formatPropObject(ast.attrs) : [];
const props = ast.attrs
? this.formatPropObject(ast.attrs, ast.attrsTranslationCtx, ctx.translationCtx)
: [];
const scope = this.getPropString(props, dynProps);
if (ast.defaultContent) {
const name = this.compileInNewTarget("defaultContent", ast.defaultContent, ctx);
@@ -4817,6 +4844,12 @@ class CodeGenerator {
}
return null;
}
compileTTranslationContext(ast, ctx) {
if (ast.content) {
return this.compileAST(ast.content, Object.assign({}, ctx, { translationCtx: ast.translationCtx }));
}
return null;
}
compileTPortal(ast, ctx) {
if (!this.staticDefs.find((d) => d.id === "Portal")) {
this.staticDefs.push({ id: "Portal", expr: `app.Portal` });
@@ -4886,6 +4919,7 @@ function parseNode(node, ctx) {
parseTOutNode(node, ctx) ||
parseTKey(node, ctx) ||
parseTTranslation(node, ctx) ||
parseTTranslationContext(node, ctx) ||
parseTSlot(node, ctx) ||
parseComponent(node, ctx) ||
parseDOMNode(node, ctx) ||
@@ -4934,10 +4968,10 @@ function parseTCustom(node, ctx) {
throw new OwlError(`Custom directive "${directiveName}" is not defined`);
}
const value = node.getAttribute(attr);
const modifier = attr.split(".").length > 1 ? attr.split(".")[1] : undefined;
const modifiers = attr.split(".").slice(1);
node.removeAttribute(attr);
try {
customDirective(node, value, modifier);
customDirective(node, value, modifiers);
}
catch (error) {
throw new OwlError(`Custom directive "${directiveName}" throw the following error: ${error}`);
@@ -4994,6 +5028,7 @@ function parseDOMNode(node, ctx) {
node.removeAttribute("t-ref");
const nodeAttrsNames = node.getAttributeNames();
let attrs = null;
let attrsTranslationCtx = null;
let on = null;
let model = null;
for (let attr of nodeAttrsNames) {
@@ -5054,6 +5089,11 @@ function parseDOMNode(node, ctx) {
else if (attr === "xmlns") {
ns = value;
}
else if (attr.startsWith("t-translation-context-")) {
const attrName = attr.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
}
else if (attr !== "t-name") {
if (attr.startsWith("t-") && !attr.startsWith("t-att")) {
throw new OwlError(`Unknown QWeb directive: '${attr}'`);
@@ -5075,6 +5115,7 @@ function parseDOMNode(node, ctx) {
tag: tagName,
dynamicTag,
attrs,
attrsTranslationCtx,
on,
ref,
content: children,
@@ -5215,7 +5256,15 @@ function parseTCall(node, ctx) {
if (ast && ast.type === 11 /* TComponent */) {
return {
...ast,
slots: { default: { content: tcall, scope: null, on: null, attrs: null } },
slots: {
default: {
content: tcall,
scope: null,
on: null,
attrs: null,
attrsTranslationCtx: null,
},
},
};
}
}
@@ -5330,9 +5379,15 @@ function parseComponent(node, ctx) {
node.removeAttribute("t-slot-scope");
let on = null;
let props = null;
let propsTranslationCtx = null;
for (let name of node.getAttributeNames()) {
const value = node.getAttribute(name);
if (name.startsWith("t-")) {
if (name.startsWith("t-translation-context-")) {
const attrName = name.slice(22);
propsTranslationCtx = propsTranslationCtx || {};
propsTranslationCtx[attrName] = value;
}
else if (name.startsWith("t-")) {
if (name.startsWith("t-on-")) {
on = on || {};
on[name.slice(5)] = value;
@@ -5376,6 +5431,7 @@ function parseComponent(node, ctx) {
const slotAst = parseNode(slotNode, ctx);
let on = null;
let attrs = null;
let attrsTranslationCtx = null;
let scope = null;
for (let attributeName of slotNode.getAttributeNames()) {
const value = slotNode.getAttribute(attributeName);
@@ -5383,6 +5439,11 @@ function parseComponent(node, ctx) {
scope = value;
continue;
}
else if (attributeName.startsWith("t-translation-context-")) {
const attrName = attributeName.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
}
else if (attributeName.startsWith("t-on-")) {
on = on || {};
on[attributeName.slice(5)] = value;
@@ -5393,17 +5454,32 @@ function parseComponent(node, ctx) {
}
}
slots = slots || {};
slots[name] = { content: slotAst, on, attrs, scope };
slots[name] = { content: slotAst, on, attrs, attrsTranslationCtx, scope };
}
// default slot
const defaultContent = parseChildNodes(clone, ctx);
slots = slots || {};
// t-set-slot="default" has priority over content
if (defaultContent && !slots.default) {
slots.default = { content: defaultContent, on, attrs: null, scope: defaultSlotScope };
slots.default = {
content: defaultContent,
on,
attrs: null,
attrsTranslationCtx: null,
scope: defaultSlotScope,
};
}
}
return { type: 11 /* TComponent */, name, isDynamic, dynamicProps, props, slots, on };
return {
type: 11 /* TComponent */,
name,
isDynamic,
dynamicProps,
props,
propsTranslationCtx,
slots,
on,
};
}
// -----------------------------------------------------------------------------
// Slots
@@ -5415,6 +5491,7 @@ function parseTSlot(node, ctx) {
const name = node.getAttribute("t-slot");
node.removeAttribute("t-slot");
let attrs = null;
let attrsTranslationCtx = null;
let on = null;
for (let attributeName of node.getAttributeNames()) {
const value = node.getAttribute(attributeName);
@@ -5422,6 +5499,11 @@ function parseTSlot(node, ctx) {
on = on || {};
on[attributeName.slice(5)] = value;
}
else if (attributeName.startsWith("t-translation-context-")) {
const attrName = attributeName.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
}
else {
attrs = attrs || {};
attrs[attributeName] = value;
@@ -5431,10 +5513,14 @@ function parseTSlot(node, ctx) {
type: 14 /* TSlot */,
name,
attrs,
attrsTranslationCtx,
on,
defaultContent: parseChildNodes(node, ctx),
};
}
// -----------------------------------------------------------------------------
// Translation
// -----------------------------------------------------------------------------
function parseTTranslation(node, ctx) {
if (node.getAttribute("t-translation") !== "off") {
return null;
@@ -5446,6 +5532,21 @@ function parseTTranslation(node, ctx) {
};
}
// -----------------------------------------------------------------------------
// Translation Context
// -----------------------------------------------------------------------------
function parseTTranslationContext(node, ctx) {
const translationCtx = node.getAttribute("t-translation-context");
if (!translationCtx) {
return null;
}
node.removeAttribute("t-translation-context");
return {
type: 17 /* TTranslationContext */,
content: parseNode(node, ctx),
translationCtx,
};
}
// -----------------------------------------------------------------------------
// Portal
// -----------------------------------------------------------------------------
function parseTPortal(node, ctx) {
@@ -5462,7 +5563,7 @@ function parseTPortal(node, ctx) {
};
}
return {
type: 17 /* TPortal */,
type: 18 /* TPortal */,
target,
content,
};
@@ -5604,7 +5705,7 @@ function compile(template, options = {
}
// do not modify manually. This file is generated by the release script.
const version = "2.5.0";
const version = "2.6.0";
// -----------------------------------------------------------------------------
// Scheduler
@@ -6082,6 +6183,6 @@ TemplateSet.prototype._compileTemplate = function _compileTemplate(name, templat
export { App, Component, EventBus, OwlError, __info__, batched, blockDom, loadFile, markRaw, markup, mount, onError, onMounted, onPatched, onRendered, onWillDestroy, onWillPatch, onWillRender, onWillStart, onWillUnmount, onWillUpdateProps, reactive, status, toRaw, useChildSubEnv, useComponent, useEffect, useEnv, useExternalListener, useRef, useState, useSubEnv, validate, validateType, whenReady, xml };
__info__.date = '2024-11-25T09:30:45.930Z';
__info__.hash = '6b24864';
__info__.date = '2025-01-15T10:40:24.184Z';
__info__.hash = 'a9be149';
__info__.url = 'https://github.com/odoo/owl';
+827 -212
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@odoo/owl",
"version": "2.5.0",
"version": "2.6.0",
"description": "Odoo Web Library (OWL)",
"main": "dist/owl.cjs.js",
"module": "dist/owl.es.js",
@@ -32,7 +32,10 @@
"check-formatting": "prettier {src/*.ts,src/**/*.ts,tests/*.ts,tests/**/*.ts,doc/*.md,doc/**/*.md,tools/devtools/**/*.js} --check",
"lint": "eslint src/**/*.ts tests/**/*.ts",
"release": "node tools/release.js",
"compile_templates": "node tools/compile_xml.js"
"compile_templates": "node tools/compile_owl_templates.mjs"
},
"bin": {
"compile_owl_templates": "tools/compile_owl_templates.mjs"
},
"repository": {
"type": "git",
@@ -46,6 +49,7 @@
"homepage": "https://github.com/odoo/owl#readme",
"devDependencies": {
"@types/jest": "^27.0.1",
"@types/jsdom": "^21.1.7",
"@types/node": "^14.11.8",
"@typescript-eslint/eslint-plugin": "5.48.1",
"@typescript-eslint/parser": "5.48.1",
@@ -97,5 +101,8 @@
"prettier": {
"printWidth": 100,
"endOfLine": "auto"
},
"dependencies": {
"jsdom": "^25.0.1"
}
}
+33 -25
View File
@@ -1,6 +1,6 @@
import pkg from "./package.json";
import git from "git-rev-sync";
import typescript from 'rollup-plugin-typescript2';
import typescript from "rollup-plugin-typescript2";
import { terser } from "rollup-plugin-terser";
import dts from "rollup-plugin-dts";
@@ -12,7 +12,7 @@ const ES_FILENAME = "dist/owl.es.js";
if (pkg.module !== ES_FILENAME || pkg.main !== CJS_FILENAME) {
throw new Error("package.json has been modified. Build script should be updated accordingly");
}
}
const outro = `
__info__.date = '${new Date().toISOString()}';
@@ -21,39 +21,37 @@ __info__.url = 'https://github.com/odoo/owl';
`;
switch (process.argv[4]) {
case "compiler":
input = "src/compiler/index.ts",
output = [
getConfigForFormat('cjs', 'dist/compiler.js', ''),
]
case "compiler":
(input = "src/compiler/index.ts"),
(output = [getConfigForFormat("cjs", "dist/compiler.js", "")]);
break;
case "runtime":
input = "src/runtime/index.ts";
output = [
getConfigForFormat('esm', addSuffix(ES_FILENAME, 'runtime'), outro),
getConfigForFormat('cjs', addSuffix(CJS_FILENAME, 'runtime'), outro),
getConfigForFormat('iife', addSuffix(IIFE_FILENAME, 'runtime'), outro),
getConfigForFormat('iife', addSuffix(IIFE_FILENAME, 'runtime'), outro, true),
]
getConfigForFormat("esm", addSuffix(ES_FILENAME, "runtime"), outro),
getConfigForFormat("cjs", addSuffix(CJS_FILENAME, "runtime"), outro),
getConfigForFormat("iife", addSuffix(IIFE_FILENAME, "runtime"), outro),
getConfigForFormat("iife", addSuffix(IIFE_FILENAME, "runtime"), outro, true),
];
break;
default:
input = "src/index.ts",
output = [
getConfigForFormat('esm', ES_FILENAME, outro),
getConfigForFormat('cjs', CJS_FILENAME, outro),
getConfigForFormat('iife', IIFE_FILENAME, outro),
getConfigForFormat('iife', IIFE_FILENAME, outro, true),
]
}
(input = "src/index.ts"),
(output = [
getConfigForFormat("esm", ES_FILENAME, outro),
getConfigForFormat("cjs", CJS_FILENAME, outro),
getConfigForFormat("iife", IIFE_FILENAME, outro),
getConfigForFormat("iife", IIFE_FILENAME, outro, true),
]);
}
/**
* Generate from a string depicting a path a new path for the minified version.
* @param {string} pkgFileName file name
*/
function addSuffix(pkgFileName, suffix) {
const parts = pkgFileName.split('.');
const parts = pkgFileName.split(".");
parts.splice(parts.length - 1, 0, suffix);
return parts.join('.');
return parts.join(".");
}
/**
@@ -71,7 +69,7 @@ function getConfigForFormat(format, generatedFileName, outro, minified = false)
outro: outro,
freeze: false,
plugins: minified ? [terser()] : [],
indent: ' ', // indent with 4 spaces
indent: " ", // indent with 4 spaces
};
}
@@ -81,9 +79,19 @@ export default [
output,
plugins: [
typescript({
useTsconfigDeclarationDir: true
useTsconfigDeclarationDir: true,
}),
]
],
},
{
input: "src/compiler/standalone/index.ts",
output: [{ file: "dist/compile_templates.mjs", format: "es" }],
external: ["fs", "fs/promises", "path", "jsdom"],
plugins: [
typescript({
useTsconfigDeclarationDir: true,
}),
],
},
{
input: "dist/types/index.d.ts",
+1 -1
View File
@@ -1,4 +1,4 @@
export type customDirectives = Record<
string,
(node: Element, value: string, modifier?: string) => void
(node: Element, value: string, modifier: string[]) => void
>;
+53 -15
View File
@@ -24,6 +24,7 @@ import {
ASTTOut,
ASTTPortal,
ASTTranslation,
ASTTranslationContext,
ASTTSet,
ASTType,
Attrs,
@@ -35,7 +36,7 @@ type BlockType = "block" | "text" | "multi" | "list" | "html" | "comment";
const whitespaceRE = /\s+/g;
export interface Config {
translateFn?: (s: string) => string;
translateFn?: (s: string, translationCtx: string) => string;
translatableAttributes?: string[];
dev?: boolean;
}
@@ -171,6 +172,7 @@ interface Context {
forceNewBlock: boolean;
isLast?: boolean;
translate: boolean;
translationCtx: string;
tKeyExpr: string | null;
nameSpace?: string;
tModelSelectedExpr?: string;
@@ -185,6 +187,7 @@ function createContext(parentCtx: Context, params?: Partial<Context>): Context {
index: 0,
forceNewBlock: true,
translate: parentCtx.translate,
translationCtx: parentCtx.translationCtx,
tKeyExpr: null,
nameSpace: parentCtx.nameSpace,
tModelSelectedExpr: parentCtx.tModelSelectedExpr,
@@ -263,7 +266,7 @@ export class CodeGenerator {
target = new CodeTarget("template");
templateName?: string;
dev: boolean;
translateFn: (s: string) => string;
translateFn: (s: string, translationCtx: string) => string;
translatableAttributes: string[] = TRANSLATABLE_ATTRS;
ast: AST;
staticDefs: { id: string; expr: string }[] = [];
@@ -303,6 +306,7 @@ export class CodeGenerator {
forceNewBlock: false,
isLast: true,
translate: true,
translationCtx: "",
tKeyExpr: null,
});
// define blocks and utility functions
@@ -457,9 +461,9 @@ export class CodeGenerator {
.join("");
}
translate(str: string): string {
translate(str: string, translationCtx: string): string {
const match = translationRE.exec(str) as any;
return match[1] + this.translateFn(match[2]) + match[3];
return match[1] + this.translateFn(match[2], translationCtx) + match[3];
}
/**
@@ -501,6 +505,8 @@ export class CodeGenerator {
return this.compileTSlot(ast, ctx);
case ASTType.TTranslation:
return this.compileTTranslation(ast, ctx);
case ASTType.TTranslationContext:
return this.compileTTranslationContext(ast, ctx);
case ASTType.TPortal:
return this.compileTPortal(ast, ctx);
}
@@ -542,7 +548,7 @@ export class CodeGenerator {
let value = ast.value;
if (value && ctx.translate !== false) {
value = this.translate(value);
value = this.translate(value, ctx.translationCtx);
}
if (!ctx.inPreTag) {
value = value.replace(whitespaceRE, " ");
@@ -631,7 +637,8 @@ export class CodeGenerator {
}
}
} else if (this.translatableAttributes.includes(key)) {
attrs[key] = this.translateFn(ast.attrs[key]);
const attrTranslationCtx = ast.attrsTranslationCtx?.[key] || ctx.translationCtx;
attrs[key] = this.translateFn(ast.attrs[key], attrTranslationCtx);
} else {
expr = `"${ast.attrs[key]}"`;
attrName = key;
@@ -1104,7 +1111,7 @@ export class CodeGenerator {
let value: string;
if (ast.defaultValue) {
const defaultValue = toStringExpression(
ctx.translate ? this.translate(ast.defaultValue) : ast.defaultValue
ctx.translate ? this.translate(ast.defaultValue, ctx.translationCtx) : ast.defaultValue
);
if (ast.value) {
value = `withDefault(${expr}, ${defaultValue})`;
@@ -1139,9 +1146,15 @@ export class CodeGenerator {
* "some-prop" "state" "'some-prop': ctx['state']"
* "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])"
*/
formatProp(name: string, value: string): string {
formatProp(
name: string,
value: string,
attrsTranslationCtx: { [name: string]: string } | null,
translationCtx: string
): string {
if (name.endsWith(".translate")) {
value = toStringExpression(this.translateFn(value));
const attrTranslationCtx = attrsTranslationCtx?.[name] || translationCtx;
value = toStringExpression(this.translateFn(value, attrTranslationCtx));
} else {
value = this.captureExpression(value);
}
@@ -1156,15 +1169,21 @@ export class CodeGenerator {
case "translate":
break;
default:
throw new OwlError("Invalid prop suffix");
throw new OwlError(`Invalid prop suffix: ${suffix}`);
}
}
name = /^[a-z_]+$/i.test(name) ? name : `'${name}'`;
return `${name}: ${value || undefined}`;
}
formatPropObject(obj: { [prop: string]: any }): string[] {
return Object.entries(obj).map(([k, v]) => this.formatProp(k, v));
formatPropObject(
obj: { [prop: string]: any },
attrsTranslationCtx: { [name: string]: string } | null,
translationCtx: string
): string[] {
return Object.entries(obj).map(([k, v]) =>
this.formatProp(k, v, attrsTranslationCtx, translationCtx)
);
}
getPropString(props: string[], dynProps: string | null): string {
@@ -1181,7 +1200,9 @@ export class CodeGenerator {
let { block } = ctx;
// props
const hasSlotsProp = "slots" in (ast.props || {});
const props: string[] = ast.props ? this.formatPropObject(ast.props) : [];
const props: string[] = ast.props
? this.formatPropObject(ast.props, ast.propsTranslationCtx, ctx.translationCtx)
: [];
// slots
let slotDef: string = "";
@@ -1205,7 +1226,13 @@ export class CodeGenerator {
params.push(`__scope: "${scope}"`);
}
if (ast.slots[slotName].attrs) {
params.push(...this.formatPropObject(ast.slots[slotName].attrs!));
params.push(
...this.formatPropObject(
ast.slots[slotName].attrs!,
ast.slots[slotName].attrsTranslationCtx,
ctx.translationCtx
)
);
}
const slotInfo = `{${params.join(", ")}}`;
slotStr.push(`'${slotName}': ${slotInfo}`);
@@ -1332,7 +1359,9 @@ export class CodeGenerator {
key = this.generateComponentKey(key);
}
const props = ast.attrs ? this.formatPropObject(ast.attrs) : [];
const props = ast.attrs
? this.formatPropObject(ast.attrs, ast.attrsTranslationCtx, ctx.translationCtx)
: [];
const scope = this.getPropString(props, dynProps);
if (ast.defaultContent) {
const name = this.compileInNewTarget("defaultContent", ast.defaultContent, ctx);
@@ -1365,6 +1394,15 @@ export class CodeGenerator {
}
return null;
}
compileTTranslationContext(ast: ASTTranslationContext, ctx: Context): string | null {
if (ast.content) {
return this.compileAST(
ast.content,
Object.assign({}, ctx, { translationCtx: ast.translationCtx })
);
}
return null;
}
compileTPortal(ast: ASTTPortal, ctx: Context): string {
if (!this.staticDefs.find((d) => d.id === "Portal")) {
this.staticDefs.push({ id: "Portal", expr: `app.Portal` });
+86 -7
View File
@@ -27,6 +27,7 @@ export const enum ASTType {
TSlot,
TCallBlock,
TTranslation,
TTranslationContext,
TPortal,
}
@@ -56,6 +57,7 @@ export interface ASTDomNode {
tag: string;
content: AST[];
attrs: Attrs | null;
attrsTranslationCtx: Attrs | null;
ref: string | null;
on: EventHandlers | null;
model: TModelInfo | null;
@@ -127,6 +129,7 @@ interface SlotDefinition {
scope: string | null;
on: EventHandlers | null;
attrs: Attrs | null;
attrsTranslationCtx: Attrs | null;
}
export interface ASTComponent {
@@ -136,6 +139,7 @@ export interface ASTComponent {
dynamicProps: string | null;
on: EventHandlers | null;
props: { [name: string]: string } | null;
propsTranslationCtx: { [name: string]: string } | null;
slots: { [name: string]: SlotDefinition } | null;
}
@@ -143,6 +147,7 @@ export interface ASTSlot {
type: ASTType.TSlot;
name: string;
attrs: Attrs | null;
attrsTranslationCtx: Attrs | null;
on: EventHandlers | null;
defaultContent: AST | null;
}
@@ -168,6 +173,12 @@ export interface ASTTranslation {
content: AST | null;
}
export interface ASTTranslationContext {
type: ASTType.TTranslationContext;
content: AST | null;
translationCtx: string;
}
export interface ASTTPortal {
type: ASTType.TPortal;
target: string;
@@ -192,6 +203,7 @@ export type AST =
| ASTLog
| ASTDebug
| ASTTranslation
| ASTTranslationContext
| ASTTPortal;
// -----------------------------------------------------------------------------
@@ -245,6 +257,7 @@ function parseNode(node: Node, ctx: ParsingContext): AST | null {
parseTOutNode(node, ctx) ||
parseTKey(node, ctx) ||
parseTTranslation(node, ctx) ||
parseTTranslationContext(node, ctx) ||
parseTSlot(node, ctx) ||
parseComponent(node, ctx) ||
parseDOMNode(node, ctx) ||
@@ -299,10 +312,10 @@ function parseTCustom(node: Element, ctx: ParsingContext): AST | null {
throw new OwlError(`Custom directive "${directiveName}" is not defined`);
}
const value = node.getAttribute(attr)!;
const modifier = attr.split(".").length > 1 ? attr.split(".")[1] : undefined;
const modifiers = attr.split(".").slice(1);
node.removeAttribute(attr);
try {
customDirective(node, value, modifier);
customDirective(node, value, modifiers);
} catch (error) {
throw new OwlError(
`Custom directive "${directiveName}" throw the following error: ${error}`
@@ -368,6 +381,7 @@ function parseDOMNode(node: Element, ctx: ParsingContext): AST | null {
const nodeAttrsNames = node.getAttributeNames();
let attrs: ASTDomNode["attrs"] = null;
let attrsTranslationCtx: ASTDomNode["attrsTranslationCtx"] = null;
let on: EventHandlers | null = null;
let model: TModelInfo | null = null;
@@ -428,6 +442,10 @@ function parseDOMNode(node: Element, ctx: ParsingContext): AST | null {
throw new OwlError(`Invalid attribute: '${attr}'`);
} else if (attr === "xmlns") {
ns = value;
} else if (attr.startsWith("t-translation-context-")) {
const attrName = attr.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
} else if (attr !== "t-name") {
if (attr.startsWith("t-") && !attr.startsWith("t-att")) {
throw new OwlError(`Unknown QWeb directive: '${attr}'`);
@@ -450,6 +468,7 @@ function parseDOMNode(node: Element, ctx: ParsingContext): AST | null {
tag: tagName,
dynamicTag,
attrs,
attrsTranslationCtx,
on,
ref,
content: children,
@@ -609,7 +628,15 @@ function parseTCall(node: Element, ctx: ParsingContext): AST | null {
if (ast && ast.type === ASTType.TComponent) {
return {
...ast,
slots: { default: { content: tcall, scope: null, on: null, attrs: null } },
slots: {
default: {
content: tcall,
scope: null,
on: null,
attrs: null,
attrsTranslationCtx: null,
},
},
};
}
}
@@ -744,9 +771,14 @@ function parseComponent(node: Element, ctx: ParsingContext): AST | null {
let on: ASTComponent["on"] = null;
let props: ASTComponent["props"] = null;
let propsTranslationCtx: ASTComponent["propsTranslationCtx"] = null;
for (let name of node.getAttributeNames()) {
const value = node.getAttribute(name)!;
if (name.startsWith("t-")) {
if (name.startsWith("t-translation-context-")) {
const attrName = name.slice(22);
propsTranslationCtx = propsTranslationCtx || {};
propsTranslationCtx[attrName] = value;
} else if (name.startsWith("t-")) {
if (name.startsWith("t-on-")) {
on = on || {};
on[name.slice(5)] = value;
@@ -794,12 +826,17 @@ function parseComponent(node: Element, ctx: ParsingContext): AST | null {
const slotAst = parseNode(slotNode, ctx);
let on: SlotDefinition["on"] = null;
let attrs: Attrs | null = null;
let attrsTranslationCtx: Attrs | null = null;
let scope: string | null = null;
for (let attributeName of slotNode.getAttributeNames()) {
const value = slotNode.getAttribute(attributeName)!;
if (attributeName === "t-slot-scope") {
scope = value;
continue;
} else if (attributeName.startsWith("t-translation-context-")) {
const attrName = attributeName.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
} else if (attributeName.startsWith("t-on-")) {
on = on || {};
on[attributeName.slice(5)] = value;
@@ -809,7 +846,7 @@ function parseComponent(node: Element, ctx: ParsingContext): AST | null {
}
}
slots = slots || {};
slots[name] = { content: slotAst, on, attrs, scope };
slots[name] = { content: slotAst, on, attrs, attrsTranslationCtx, scope };
}
// default slot
@@ -817,10 +854,25 @@ function parseComponent(node: Element, ctx: ParsingContext): AST | null {
slots = slots || {};
// t-set-slot="default" has priority over content
if (defaultContent && !slots.default) {
slots.default = { content: defaultContent, on, attrs: null, scope: defaultSlotScope };
slots.default = {
content: defaultContent,
on,
attrs: null,
attrsTranslationCtx: null,
scope: defaultSlotScope,
};
}
}
return { type: ASTType.TComponent, name, isDynamic, dynamicProps, props, slots, on };
return {
type: ASTType.TComponent,
name,
isDynamic,
dynamicProps,
props,
propsTranslationCtx,
slots,
on,
};
}
// -----------------------------------------------------------------------------
@@ -834,12 +886,17 @@ function parseTSlot(node: Element, ctx: ParsingContext): AST | null {
const name = node.getAttribute("t-slot")!;
node.removeAttribute("t-slot");
let attrs: Attrs | null = null;
let attrsTranslationCtx: Attrs | null = null;
let on: ASTComponent["on"] = null;
for (let attributeName of node.getAttributeNames()) {
const value = node.getAttribute(attributeName)!;
if (attributeName.startsWith("t-on-")) {
on = on || {};
on[attributeName.slice(5)] = value;
} else if (attributeName.startsWith("t-translation-context-")) {
const attrName = attributeName.slice(22);
attrsTranslationCtx = attrsTranslationCtx || {};
attrsTranslationCtx[attrName] = value;
} else {
attrs = attrs || {};
attrs[attributeName] = value;
@@ -849,11 +906,16 @@ function parseTSlot(node: Element, ctx: ParsingContext): AST | null {
type: ASTType.TSlot,
name,
attrs,
attrsTranslationCtx,
on,
defaultContent: parseChildNodes(node, ctx),
};
}
// -----------------------------------------------------------------------------
// Translation
// -----------------------------------------------------------------------------
function parseTTranslation(node: Element, ctx: ParsingContext): AST | null {
if (node.getAttribute("t-translation") !== "off") {
return null;
@@ -865,6 +927,23 @@ function parseTTranslation(node: Element, ctx: ParsingContext): AST | null {
};
}
// -----------------------------------------------------------------------------
// Translation Context
// -----------------------------------------------------------------------------
function parseTTranslationContext(node: Element, ctx: ParsingContext): AST | null {
const translationCtx = node.getAttribute("t-translation-context");
if (!translationCtx) {
return null;
}
node.removeAttribute("t-translation-context");
return {
type: ASTType.TTranslationContext,
content: parseNode(node, ctx),
translationCtx,
};
}
// -----------------------------------------------------------------------------
// Portal
// -----------------------------------------------------------------------------
+89
View File
@@ -0,0 +1,89 @@
// -----------------------------------------------------------------------------
// This file exports a function that allows compiling templates ahead of time.
// It is used by the "compile_owl_template" command registered in the "bin"
// section of owl's package.json
// -----------------------------------------------------------------------------
import { readdir, readFile, stat } from "fs/promises";
import path from "path";
import "./setup_jsdom";
// Owl imports must be made after setting up jsdom in the global namespace
import { compile } from "..";
// -----------------------------------------------------------------------------
// helpers
// -----------------------------------------------------------------------------
async function getXmlFiles(paths: string[]): Promise<string[]> {
return (
await Promise.all(
paths.map(async (file) => {
const stats = await stat(path.join(file));
if (stats.isDirectory()) {
return await getXmlFiles(
(await readdir(file)).map((fileName) => path.join(file, fileName))
);
}
if (file.endsWith(".xml")) {
return file;
}
return [];
})
)
).flat();
}
// adapted from https://medium.com/@mhagemann/the-ultimate-way-to-slugify-a-url-string-in-javascript-b8e4a0d849e1
const a = "·-_,:;";
const p = new RegExp(a.split("").join("|"), "g");
function slugify(str: string) {
return str
.replace(/\//g, "") // remove /
.replace(/\./g, "_") // Replace . with _
.replace(p, (c) => "_") // Replace special characters
.replace(/&/g, "_and_") // Replace & with and
.replace(/[^\w\-]+/g, ""); // Remove all non-word characters
}
// -----------------------------------------------------------------------------
// main
// -----------------------------------------------------------------------------
export async function compileTemplates(paths: string[]) {
const files = await getXmlFiles(paths);
process.stdout.write(`Processing ${files.length} files`);
let xmlStrings = await Promise.all(files.map((file) => readFile(file, "utf8")));
const templates = [];
const errors = [];
for (let i = 0; i < files.length; i++) {
const fileName = files[i];
const fileContent = xmlStrings[i];
process.stdout.write(`.`);
const parser = new DOMParser();
const doc = parser.parseFromString(fileContent, "text/xml");
for (const template of doc.querySelectorAll("[t-name]")) {
const name = template.getAttribute("t-name");
if (template.hasAttribute("owl")) {
template.removeAttribute("owl");
}
const fnName = slugify(name!);
try {
const fn = compile(template).toString().replace("anonymous", fnName);
templates.push(`"${name}": ${fn},\n`);
} catch (e) {
errors.push({ name, fileName, e });
}
}
}
process.stdout.write(`\n`);
for (let { name, fileName, e } of errors) {
console.warn(`Error while compiling '${name}' (in file ${fileName})`);
console.error(e);
}
console.log(`${templates.length} templates compiled`);
return `export const templates = {\n ${templates.join("\n")} \n}`;
}
+13
View File
@@ -0,0 +1,13 @@
import jsdom from "jsdom";
// -----------------------------------------------------------------------------
// add global DOM stuff for compiler. Needs to be in a separate file so rollup
// doesn't hoist the owl imports above this block of code.
// -----------------------------------------------------------------------------
var document = new jsdom.JSDOM("", {});
var window = document.window;
global.document = window.document;
global.window = window as unknown as Window & typeof globalThis;
global.DOMParser = window.DOMParser;
global.Element = window.Element;
global.Node = window.Node;
+17 -1
View File
@@ -30,6 +30,13 @@ export function makeRootFiber(node: ComponentNode): Fiber {
fibersInError.delete(current);
fibersInError.delete(root);
current.appliedToDom = false;
if (current instanceof RootFiber) {
// it is possible that this fiber is a fiber that crashed while being
// mounted, so the mounted list is possibly corrupted. We restore it to
// its normal initial state (which is empty list or a list with a mount
// fiber.
current.mounted = current instanceof MountFiber ? [current] : [];
}
}
return current;
}
@@ -152,6 +159,7 @@ export class RootFiber extends Fiber {
const node = this.node;
this.locked = true;
let current: Fiber | undefined = undefined;
let mountedFibers = this.mounted;
try {
// Step 1: calling all willPatch lifecycle hooks
for (current of this.willPatch) {
@@ -173,7 +181,6 @@ export class RootFiber extends Fiber {
this.locked = false;
// Step 4: calling all mounted lifecycle hooks
let mountedFibers = this.mounted;
while ((current = mountedFibers.pop())) {
current = current;
if (current.appliedToDom) {
@@ -194,6 +201,15 @@ export class RootFiber extends Fiber {
}
}
} catch (e) {
// if mountedFibers is not empty, this means that a crash occured while
// calling the mounted hooks of some component. So, there may still be
// some component that have been mounted, but for which the mounted hooks
// have not been called. Here, we remove the willUnmount hooks for these
// specific component to prevent a worse situation (willUnmount being
// called even though mounted has not been called)
for (let fiber of mountedFibers) {
fiber.node.willUnmount = [];
}
this.locked = false;
node.app.handleError({ fiber: current || this, error: e });
}
+2 -2
View File
@@ -12,7 +12,7 @@ const bdom = { text, createBlock, list, multi, html, toggler, comment };
export interface TemplateSetConfig {
dev?: boolean;
translatableAttributes?: string[];
translateFn?: (s: string) => string;
translateFn?: (s: string, translationCtx: string) => string;
templates?: string | Document | Record<string, string>;
getTemplate?: (s: string) => Element | Function | string | void;
customDirectives?: customDirectives;
@@ -27,7 +27,7 @@ export class TemplateSet {
rawTemplates: typeof globalTemplates = Object.create(globalTemplates);
templates: { [name: string]: Template } = {};
getRawTemplate?: (s: string) => Element | Function | string | void;
translateFn?: (s: string) => string;
translateFn?: (s: string, translationCtx: string) => string;
translatableAttributes?: string[];
Portal = Portal;
customDirectives: customDirectives;
+1 -1
View File
@@ -1,2 +1,2 @@
// do not modify manually. This file is generated by the release script.
export const version = "2.5.0";
export const version = "2.6.0";
@@ -14,7 +14,7 @@ exports[`t-custom can use t-custom directive on a node 1`] = `
}"
`;
exports[`t-custom can use t-custom directive with modifier on a node 1`] = `
exports[`t-custom can use t-custom directive with modifiers on a node 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
@@ -1,5 +1,128 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`translation context body of t-sets are translated in context 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { isBoundary, withDefault, setContextValue } = helpers;
return function template(ctx, node, key = \\"\\") {
ctx = Object.create(ctx);
ctx[isBoundary] = 1
setContextValue(ctx, \\"label\\", \`traduit\`);
const b2 = text(ctx['label']);
return multi([b2]);
}
}"
`;
exports[`translation context default slot params and content translated in context 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { callSlot } = helpers;
let block1 = createBlock(\`<div><block-child-0/></div>\`);
function defaultContent1(ctx, node, key = \\"\\") {
return text(\` foo \`);
}
return function template(ctx, node, key = \\"\\") {
const b3 = callSlot(ctx, node, key, 'default', false, {param: \`param\`,title: \`título\`}, defaultContent1.bind(this));
return block1([], [b3]);
}
}"
`;
exports[`translation context props with modifier .translate are translated in context 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`ChildComponent\`, true, false, false, []);
return function template(ctx, node, key = \\"\\") {
return comp1({text: \`jeu\`}, key + \`__1\`, node, this, null);
}
}"
`;
exports[`translation context props with modifier .translate are translated in context 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<span><block-text-0/></span>\`);
return function template(ctx, node, key = \\"\\") {
let txt1 = ctx['props'].text;
return block1([txt1]);
}
}"
`;
exports[`translation context slot attrs and text contents are translated in context 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { capture, markRaw } = helpers;
const comp1 = app.createComponent(\`ChildComponent\`, true, true, false, []);
function slot1(ctx, node, key = \\"\\") {
return text(\`jeu\`);
}
return function template(ctx, node, key = \\"\\") {
const ctx1 = capture(ctx);
return comp1({slots: markRaw({'a': {__render: slot1.bind(this), __ctx: ctx1, title: \`título\`}})}, key + \`__1\`, node, this, null);
}
}"
`;
exports[`translation context slot attrs and text contents are translated in context 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let { callSlot } = helpers;
let block1 = createBlock(\`<div><block-child-0/></div>\`);
return function template(ctx, node, key = \\"\\") {
const b2 = callSlot(ctx, node, key, 'a', false, {});
return block1([], [b2]);
}
}"
`;
exports[`translation context translation of attributes in context 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div title=\\"titre\\" label=\\"game\\"/>\`);
return function template(ctx, node, key = \\"\\") {
return block1();
}
}"
`;
exports[`translation context translation of text in context 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block2 = createBlock(\`<div>word</div>\`);
let block3 = createBlock(\`<div>mot</div>\`);
return function template(ctx, node, key = \\"\\") {
const b2 = block2();
const b3 = block3();
return multi([b2, b3]);
}
}"
`;
exports[`translation support body of t-sets are translated 1`] = `
"function anonymous(app, bdom, helpers
) {
+210 -4
View File
@@ -43,6 +43,7 @@ describe("qweb parser", () => {
dynamicTag: null,
content: [],
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -70,6 +71,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -84,6 +86,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -98,6 +101,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -109,6 +113,7 @@ describe("qweb parser", () => {
tag: "span",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -128,6 +133,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -139,6 +145,7 @@ describe("qweb parser", () => {
tag: "span",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -156,6 +163,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -181,6 +189,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -201,6 +210,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -223,6 +233,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -246,6 +257,7 @@ describe("qweb parser", () => {
tag: "span",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -262,6 +274,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: { class: "abc" },
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -280,6 +293,7 @@ describe("qweb parser", () => {
height: "90px",
width: "100px",
},
attrsTranslationCtx: null,
content: [
{
attrs: {
@@ -290,6 +304,7 @@ describe("qweb parser", () => {
stroke: "green",
"stroke-width": "1",
},
attrsTranslationCtx: null,
content: [],
dynamicTag: null,
model: null,
@@ -312,6 +327,7 @@ describe("qweb parser", () => {
parse(`<g><circle cx="50" cy="50" r="4" stroke="green" stroke-width="1" fill="yellow"/></g>`)
).toEqual({
attrs: null,
attrsTranslationCtx: null,
content: [
{
attrs: {
@@ -322,6 +338,7 @@ describe("qweb parser", () => {
stroke: "green",
"stroke-width": "1",
},
attrsTranslationCtx: null,
content: [],
dynamicTag: null,
model: null,
@@ -348,6 +365,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
content: [
@@ -356,6 +374,7 @@ describe("qweb parser", () => {
tag: "pre",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
content: [],
@@ -391,6 +410,7 @@ describe("qweb parser", () => {
tag: "span",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -413,6 +433,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -455,6 +476,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -469,6 +491,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -489,6 +512,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -530,6 +554,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -607,6 +632,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -626,6 +652,7 @@ describe("qweb parser", () => {
tag: "h1",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -639,6 +666,7 @@ describe("qweb parser", () => {
tag: "h2",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -685,6 +713,7 @@ describe("qweb parser", () => {
{
type: ASTType.DomNode,
attrs: null,
attrsTranslationCtx: null,
on: null,
tag: "div",
dynamicTag: null,
@@ -705,6 +734,7 @@ describe("qweb parser", () => {
{
type: ASTType.DomNode,
attrs: null,
attrsTranslationCtx: null,
on: null,
tag: "div",
dynamicTag: null,
@@ -742,6 +772,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -811,6 +842,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -853,6 +885,7 @@ describe("qweb parser", () => {
tag: "span",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -887,6 +920,7 @@ describe("qweb parser", () => {
tag: "span",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -920,6 +954,7 @@ describe("qweb parser", () => {
"t-att-selected": "category.id==options.active_category_id",
"t-att-value": "category.id",
},
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -940,6 +975,7 @@ describe("qweb parser", () => {
).toEqual({
type: ASTType.DomNode,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -987,6 +1023,7 @@ describe("qweb parser", () => {
ref: null,
model: null,
attrs: null,
attrsTranslationCtx: null,
ns: null,
content: [{ type: ASTType.TEsc, expr: "item", defaultValue: "" }],
},
@@ -1010,6 +1047,7 @@ describe("qweb parser", () => {
name: "Comp",
dynamicProps: null,
props: null,
propsTranslationCtx: null,
slots: null,
on: null,
},
@@ -1099,6 +1137,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -1139,6 +1178,7 @@ describe("qweb parser", () => {
tag: "button",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: { click: "add" },
ref: null,
model: null,
@@ -1175,6 +1215,7 @@ describe("qweb parser", () => {
tag: "select",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
content: [
@@ -1183,6 +1224,7 @@ describe("qweb parser", () => {
tag: "option",
dynamicTag: null,
attrs: { value: "1" },
attrsTranslationCtx: null,
on: null,
ref: null,
content: [],
@@ -1212,6 +1254,7 @@ describe("qweb parser", () => {
tag: "select",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
content: [
@@ -1220,6 +1263,7 @@ describe("qweb parser", () => {
tag: "option",
dynamicTag: null,
attrs: { "t-att-value": "valueVar" },
attrsTranslationCtx: null,
on: null,
ref: null,
content: [],
@@ -1251,6 +1295,7 @@ describe("qweb parser", () => {
name: "MyComponent",
dynamicProps: null,
props: null,
propsTranslationCtx: null,
on: null,
slots: null,
isDynamic: false,
@@ -1263,6 +1308,7 @@ describe("qweb parser", () => {
name: "MyComponent",
dynamicProps: null,
props: { a: "1", b: "'b'" },
propsTranslationCtx: null,
isDynamic: false,
on: null,
slots: null,
@@ -1275,6 +1321,7 @@ describe("qweb parser", () => {
name: "MyComponent",
dynamicProps: "state",
props: { a: "1" },
propsTranslationCtx: null,
isDynamic: false,
on: null,
slots: null,
@@ -1287,6 +1334,7 @@ describe("qweb parser", () => {
name: "MyComponent",
dynamicProps: null,
props: null,
propsTranslationCtx: null,
isDynamic: false,
on: { click: "someMethod" },
slots: null,
@@ -1329,12 +1377,14 @@ describe("qweb parser", () => {
name: "MyComponent",
dynamicProps: null,
props: null,
propsTranslationCtx: null,
isDynamic: false,
on: null,
slots: {
default: {
content: { type: ASTType.Text, value: "foo" },
attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
},
@@ -1350,12 +1400,14 @@ describe("qweb parser", () => {
name: "MyComponent",
dynamicProps: null,
props: null,
propsTranslationCtx: null,
isDynamic: false,
on: null,
slots: {
default: {
content: { type: ASTType.Text, value: "foo" },
attrs: { param: "param" },
attrsTranslationCtx: null,
on: null,
scope: null,
},
@@ -1370,6 +1422,7 @@ describe("qweb parser", () => {
isDynamic: false,
dynamicProps: null,
props: null,
propsTranslationCtx: null,
on: null,
slots: {
default: {
@@ -1381,6 +1434,7 @@ describe("qweb parser", () => {
tag: "span",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
content: [],
ref: null,
model: null,
@@ -1392,6 +1446,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
content: [],
ref: null,
model: null,
@@ -1401,6 +1456,7 @@ describe("qweb parser", () => {
],
},
attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
},
@@ -1415,9 +1471,11 @@ describe("qweb parser", () => {
name: "MyComponent",
on: null,
props: null,
propsTranslationCtx: null,
slots: {
mySlot: {
attrs: null,
attrsTranslationCtx: null,
content: null,
on: null,
scope: null,
@@ -1434,9 +1492,16 @@ describe("qweb parser", () => {
isDynamic: false,
dynamicProps: null,
props: null,
propsTranslationCtx: null,
on: null,
slots: {
name: { content: { type: ASTType.Text, value: "foo" }, attrs: null, on: null, scope: null },
name: {
content: { type: ASTType.Text, value: "foo" },
attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
},
},
});
});
@@ -1448,11 +1513,13 @@ describe("qweb parser", () => {
isDynamic: false,
dynamicProps: null,
props: null,
propsTranslationCtx: null,
on: null,
slots: {
name: {
content: { type: ASTType.Text, value: "foo" },
attrs: { param: "param" },
attrsTranslationCtx: null,
on: null,
scope: null,
},
@@ -1469,12 +1536,14 @@ describe("qweb parser", () => {
isDynamic: false,
dynamicProps: null,
props: null,
propsTranslationCtx: null,
on: null,
slots: {
name: {
content: { type: ASTType.Text, value: "foo" },
on: { click: "doStuff" },
attrs: null,
attrsTranslationCtx: null,
scope: null,
},
},
@@ -1493,16 +1562,24 @@ describe("qweb parser", () => {
name: "MyComponent",
dynamicProps: null,
props: null,
propsTranslationCtx: null,
isDynamic: false,
on: null,
slots: {
default: {
content: { type: ASTType.Text, value: " " },
attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
},
name: {
content: { type: ASTType.Text, value: "foo" },
attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
},
name: { content: { type: ASTType.Text, value: "foo" }, attrs: null, on: null, scope: null },
},
});
});
@@ -1518,11 +1595,24 @@ describe("qweb parser", () => {
name: "MyComponent",
dynamicProps: null,
props: null,
propsTranslationCtx: null,
isDynamic: false,
on: null,
slots: {
a: { content: { type: ASTType.Text, value: "foo" }, attrs: null, on: null, scope: null },
b: { content: { type: ASTType.Text, value: "bar" }, attrs: null, on: null, scope: null },
a: {
content: { type: ASTType.Text, value: "foo" },
attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
},
b: {
content: { type: ASTType.Text, value: "bar" },
attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
},
},
});
});
@@ -1533,6 +1623,7 @@ describe("qweb parser", () => {
name: "myComponent",
dynamicProps: null,
props: null,
propsTranslationCtx: null,
isDynamic: true,
on: null,
slots: null,
@@ -1545,6 +1636,7 @@ describe("qweb parser", () => {
name: "mycomponent",
dynamicProps: null,
props: { a: "1", b: "'b'" },
propsTranslationCtx: null,
isDynamic: true,
on: null,
slots: null,
@@ -1557,6 +1649,7 @@ describe("qweb parser", () => {
name: "mycomponent",
dynamicProps: "state",
props: { a: "1" },
propsTranslationCtx: null,
isDynamic: true,
on: null,
slots: null,
@@ -1587,12 +1680,14 @@ describe("qweb parser", () => {
name: "MyComponent",
dynamicProps: null,
props: null,
propsTranslationCtx: null,
isDynamic: false,
on: null,
slots: {
default: {
content: { body: null, name: "subTemplate", type: ASTType.TCall, context: null },
attrs: null,
attrsTranslationCtx: null,
scope: null,
on: null,
},
@@ -1613,11 +1708,13 @@ describe("qweb parser", () => {
name: "MyComponent",
dynamicProps: null,
props: null,
propsTranslationCtx: null,
isDynamic: false,
on: null,
slots: {
default: {
attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
content: {
@@ -1626,11 +1723,13 @@ describe("qweb parser", () => {
name: "Child",
dynamicProps: null,
props: null,
propsTranslationCtx: null,
on: null,
slots: {
brol: {
content: { type: ASTType.Text, value: "coucou" },
attrs: null,
attrsTranslationCtx: null,
scope: null,
on: null,
},
@@ -1654,11 +1753,13 @@ describe("qweb parser", () => {
name: "MyComponent",
dynamicProps: null,
props: null,
propsTranslationCtx: null,
isDynamic: false,
on: null,
slots: {
default: {
attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
content: {
@@ -1667,11 +1768,13 @@ describe("qweb parser", () => {
name: "Child",
dynamicProps: null,
props: null,
propsTranslationCtx: null,
on: null,
slots: {
brol: {
content: { type: ASTType.Text, value: "coucou" },
attrs: null,
attrsTranslationCtx: null,
on: null,
scope: null,
},
@@ -1691,6 +1794,7 @@ describe("qweb parser", () => {
type: ASTType.TSlot,
name: "default",
attrs: null,
attrsTranslationCtx: null,
on: null,
defaultContent: null,
});
@@ -1701,6 +1805,7 @@ describe("qweb parser", () => {
type: ASTType.TSlot,
name: "header",
attrs: null,
attrsTranslationCtx: null,
on: null,
defaultContent: { type: ASTType.Text, value: "default content" },
});
@@ -1711,6 +1816,7 @@ describe("qweb parser", () => {
type: ASTType.TSlot,
name: "default",
attrs: null,
attrsTranslationCtx: null,
on: { "click.prevent": "doSomething" },
defaultContent: null,
});
@@ -1728,6 +1834,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -1746,6 +1853,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: null,
model: null,
@@ -1765,6 +1873,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: "name",
model: null,
@@ -1779,6 +1888,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: "name",
model: null,
@@ -1795,6 +1905,7 @@ describe("qweb parser", () => {
tag: "div",
dynamicTag: null,
attrs: null,
attrsTranslationCtx: null,
on: null,
ref: "name",
model: null,
@@ -1831,6 +1942,7 @@ describe("qweb parser", () => {
body: {
content: {
attrs: null,
attrsTranslationCtx: null,
content: [
{
type: ASTType.Text,
@@ -1859,6 +1971,92 @@ describe("qweb parser", () => {
});
});
// ---------------------------------------------------------------------------
// t-translation-context
// ---------------------------------------------------------------------------
test('t-translation-context="fr"', async () => {
expect(parse(`<t t-translation-context="fr">word</t>`)).toEqual({
type: ASTType.TTranslationContext,
content: {
type: ASTType.Text,
value: "word",
},
translationCtx: "fr",
});
expect(parse(`<div t-translation-context="fr">word</div>`)).toEqual({
content: {
attrs: null,
attrsTranslationCtx: null,
content: [
{
type: 0,
value: "word",
},
],
dynamicTag: null,
model: null,
ns: null,
on: null,
ref: null,
tag: "div",
type: ASTType.DomNode,
},
translationCtx: "fr",
type: ASTType.TTranslationContext,
});
});
// ---------------------------------------------------------------------------
// t-translation-context-attr
// ---------------------------------------------------------------------------
test('t-translation-context="fr" and t-translation-context-title="pt" for a div attr title', async () => {
expect(
parse(
`<div t-translation-context="fr" title="hello" t-translation-context-title="pt">word</div>`
)
).toEqual({
content: {
attrs: { title: "hello" },
attrsTranslationCtx: { title: "pt" },
content: [
{
type: 0,
value: "word",
},
],
dynamicTag: null,
model: null,
ns: null,
on: null,
ref: null,
tag: "div",
type: ASTType.DomNode,
},
translationCtx: "fr",
type: ASTType.TTranslationContext,
});
});
test('t-translation-context-title="fr" for component prop title', async () => {
expect(parse(`<Comp title="hello" t-translation-context-title="fr" />`)).toEqual({
dynamicProps: null,
isDynamic: false,
name: "Comp",
on: null,
props: {
title: "hello",
},
propsTranslationCtx: {
title: "fr",
},
slots: null,
type: ASTType.TComponent,
});
});
// ---------------------------------------------------------------------------
// t-model
// ---------------------------------------------------------------------------
@@ -1866,6 +2064,7 @@ describe("qweb parser", () => {
expect(parse(`<input t-model="state.stuff" />`)).toEqual({
type: ASTType.DomNode,
attrs: null,
attrsTranslationCtx: null,
content: [],
on: null,
ref: null,
@@ -1886,6 +2085,7 @@ describe("qweb parser", () => {
expect(parse(`<input t-model="state['stuff']" />`)).toEqual({
type: ASTType.DomNode,
attrs: null,
attrsTranslationCtx: null,
content: [],
on: null,
ref: null,
@@ -1906,6 +2106,7 @@ describe("qweb parser", () => {
expect(parse(`<input t-model.lazy.trim.number="state.stuff" />`)).toEqual({
type: ASTType.DomNode,
attrs: null,
attrsTranslationCtx: null,
content: [],
on: null,
ref: null,
@@ -1927,6 +2128,7 @@ describe("qweb parser", () => {
expect(parse(`<textarea t-model="state.stuff" />`)).toEqual({
type: ASTType.DomNode,
attrs: null,
attrsTranslationCtx: null,
content: [],
on: null,
ref: null,
@@ -1947,6 +2149,7 @@ describe("qweb parser", () => {
expect(parse(`<input type="checkbox" t-model="state.stuff" />`)).toEqual({
type: ASTType.DomNode,
attrs: { type: "checkbox" },
attrsTranslationCtx: null,
content: [],
on: null,
ref: null,
@@ -1967,6 +2170,7 @@ describe("qweb parser", () => {
expect(parse(`<input type="radio" t-model="state.stuff" />`)).toEqual({
type: ASTType.DomNode,
attrs: { type: "radio" },
attrsTranslationCtx: null,
content: [],
on: null,
ref: null,
@@ -1987,6 +2191,7 @@ describe("qweb parser", () => {
expect(parse(`<input type="radio" t-model.lazy.trim.number="state.stuff" />`)).toEqual({
type: ASTType.DomNode,
attrs: { type: "radio" },
attrsTranslationCtx: null,
content: [],
on: null,
ref: null,
@@ -2012,6 +2217,7 @@ describe("qweb parser", () => {
expect(parse(`<div t-tag="theTag" />`)).toEqual({
type: ASTType.DomNode,
attrs: null,
attrsTranslationCtx: null,
content: [],
on: null,
ref: null,
+7 -5
View File
@@ -31,25 +31,27 @@ describe("t-custom", () => {
expect(steps).toEqual(["clicked"]);
});
test("can use t-custom directive with modifier on a node", async () => {
test("can use t-custom directive with modifiers on a node", async () => {
const steps: string[] = [];
class SomeComponent extends Component {
static template = xml`<div t-custom-plop.mouse="click" class="my-div"/>`;
static template = xml`<div t-custom-plop.mouse.stop="click" class="my-div"/>`;
click() {
steps.push("clicked");
}
}
const app = new App(SomeComponent, {
customDirectives: {
plop: (node, value, modifier) => {
plop: (node, value, modifiers) => {
node.setAttribute("t-on-click", value);
steps.push(modifier || "");
for (let mod of modifiers) {
steps.push(mod);
}
},
},
});
await app.mount(fixture);
expect(fixture.innerHTML).toBe(`<div class="my-div"></div>`);
fixture.querySelector("div")!.click();
expect(steps).toEqual(["mouse", "clicked"]);
expect(steps).toEqual(["mouse", "stop", "clicked"]);
});
});
+125 -2
View File
@@ -86,7 +86,7 @@ describe("translation support", () => {
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("<div> mot </div>");
expect(translateFn).toHaveBeenCalledWith("word");
expect(translateFn).toHaveBeenCalledWith("word", "");
});
test("translation works, even if initial string has inner consecutive white space", async () => {
@@ -97,7 +97,7 @@ describe("translation support", () => {
const translateFn = jest.fn((expr: string) => (expr === "some word" ? "un mot" : expr));
await mount(SomeComponent, fixture, { translateFn });
expect(translateFn).toHaveBeenCalledWith("some word");
expect(translateFn).toHaveBeenCalledWith("some word", "");
expect(fixture.innerHTML).toBe("<div>un mot</div>");
});
@@ -171,3 +171,126 @@ describe("translation support", () => {
expect(fixture.innerHTML).toBe("translated");
});
});
describe("translation context", () => {
test("translation of text in context", async () => {
class SomeComponent extends Component {
static template = xml`
<div>word</div>
<div t-translation-context="fr">word</div>
`;
}
const translateFn = jest.fn((expr: string, translationCtx: string) =>
translationCtx === "fr" ? (expr === "word" ? "mot" : expr) : expr
);
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("<div>word</div><div>mot</div>");
expect(translateFn).toHaveBeenCalledWith("word", "");
expect(translateFn).toHaveBeenCalledWith("word", "fr");
});
test("translation of attributes in context", async () => {
class SomeComponent extends Component {
static template = xml`
<div t-translation-context="en" t-translation-context-title="fr" title="title" label="game"/>
`;
}
const translateFn = jest.fn((expr: string, translationCtx: string) =>
translationCtx === "fr" ? (expr === "title" ? "titre" : expr) : expr
);
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe(`<div title="titre" label="game"></div>`);
expect(translateFn).toHaveBeenCalledWith("title", "fr");
expect(translateFn).toHaveBeenCalledWith("game", "en");
});
test("body of t-sets are translated in context", async () => {
class SomeComponent extends Component {
static template = xml`
<t t-set="label" t-translation-context="fr">untranslated</t>
<t t-esc="label"/>`;
}
const translateFn = jest.fn((expr: string, translationCtx: string) =>
translationCtx === "fr" ? "traduit" : expr
);
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("traduit");
expect(translateFn).toHaveBeenCalledWith("untranslated", "fr");
});
test("props with modifier .translate are translated in context", async () => {
class ChildComponent extends Component {
static props = ["text"];
static template = xml`<span t-esc="props.text"/>`;
}
class SomeComponent extends Component {
static components = { ChildComponent };
static template = xml`
<ChildComponent text.translate="game" t-translation-context-text.translate="fr" />`;
}
const translateFn = jest.fn((expr: string, translationCtx: string) =>
translationCtx === "fr" ? "jeu" : expr
);
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("<span>jeu</span>");
expect(translateFn).toHaveBeenCalledWith("game", "fr");
});
test("slot attrs and text contents are translated in context", async () => {
class ChildComponent extends Component {
static template = xml`
<div t-translation-context="ja">
<t t-slot="a"/>
</div>`;
}
class SomeComponent extends Component {
static components = { ChildComponent };
static template = xml`
<ChildComponent t-translation-context="fr">
<t t-set-slot="a" title.translate="title" t-translation-context-title.translate="pt">game</t>
</ChildComponent>
`;
}
const translateFn = jest.fn((expr: string, translationCtx: string) =>
translationCtx === "fr" ? "jeu" : translationCtx === "pt" ? "título" : expr
);
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("<div>jeu</div>");
expect(translateFn).toHaveBeenCalledWith("game", "fr");
expect(translateFn).toHaveBeenCalledWith("title", "pt");
});
test("default slot params and content translated in context", async () => {
class SomeComponent extends Component {
static template = xml`
<div>
<t
t-slot="default"
t-translation-context="fr"
param.translate="param"
title.translate="title"
t-translation-context-title.translate="pt"
>
foo
</t>
</div>`;
}
const translateFn = jest.fn((expr: string, translationCtx: string) =>
translationCtx === "pt" ? "título" : expr
);
await mount(SomeComponent, fixture, { translateFn });
expect(fixture.innerHTML).toBe("<div> foo </div>");
expect(translateFn).toHaveBeenCalledWith("foo", "fr");
expect(translateFn).toHaveBeenCalledWith("param", "fr");
expect(translateFn).toHaveBeenCalledWith("title", "pt");
});
});
@@ -1191,6 +1191,135 @@ exports[`can catch errors error in mounted on a component with a sibling (proper
}"
`;
exports[`can catch errors error in onMounted, graceful recovery 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(null, false, false, false, []);
return function template(ctx, node, key = \\"\\") {
const Comp1 = ctx['component'];
return toggler(Comp1, comp1({}, (Comp1).name + key + \`__1\`, node, this, Comp1));
}
}"
`;
exports[`can catch errors error in onMounted, graceful recovery 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`Child\`, true, false, false, []);
const comp2 = app.createComponent(\`Boom\`, true, false, false, []);
return function template(ctx, node, key = \\"\\") {
const b2 = text(\`parent\`);
const b3 = comp1({}, key + \`__1\`, node, this, null);
const b4 = comp2({}, key + \`__2\`, node, this, null);
return multi([b2, b3, b4]);
}
}"
`;
exports[`can catch errors error in onMounted, graceful recovery 3`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`abc\`);
}
}"
`;
exports[`can catch errors error in onMounted, graceful recovery 4`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`boom\`);
}
}"
`;
exports[`can catch errors error in onMounted, graceful recovery 5`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`def\`);
}
}"
`;
exports[`can catch errors error in onMounted, graceful recovery, variation 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(null, false, false, false, []);
return function template(ctx, node, key = \\"\\") {
let b2, b3;
b2 = text(\`R\`);
if (ctx['state'].gogogo) {
const Comp1 = ctx['component'];
b3 = toggler(Comp1, comp1({}, (Comp1).name + key + \`__1\`, node, this, Comp1));
}
return multi([b2, b3]);
}
}"
`;
exports[`can catch errors error in onMounted, graceful recovery, variation 3`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`Child\`, true, false, false, []);
const comp2 = app.createComponent(\`Boom\`, true, false, false, []);
return function template(ctx, node, key = \\"\\") {
const b2 = text(\`parent\`);
const b3 = comp1({}, key + \`__1\`, node, this, null);
const b4 = comp2({}, key + \`__2\`, node, this, null);
return multi([b2, b3, b4]);
}
}"
`;
exports[`can catch errors error in onMounted, graceful recovery, variation 4`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`abc\`);
}
}"
`;
exports[`can catch errors error in onMounted, graceful recovery, variation 5`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`boom\`);
}
}"
`;
exports[`can catch errors error in onMounted, graceful recovery, variation 6`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`def\`);
}
}"
`;
exports[`can catch errors onError in class inheritance is called if rethrown 1`] = `
"function anonymous(app, bdom, helpers
) {
+194
View File
@@ -1678,4 +1678,198 @@ describe("can catch errors", () => {
`);
expect(fixture.innerHTML).toBe("2");
});
test("error in onMounted, graceful recovery", async () => {
class Child extends Component {
static template = xml`abc`;
setup() {
useLogLifecycle();
}
}
class OtherChild extends Component {
static template = xml`def`;
setup() {
useLogLifecycle();
}
}
class Boom extends Component {
static template = xml`boom`;
setup() {
useLogLifecycle();
onMounted(() => {
throw new Error("boom");
});
}
}
class Parent extends Component {
static template = xml`parent<Child/><Boom/>`;
static components = { Child, Boom };
setup() {
useLogLifecycle();
}
}
class Root extends Component {
static template = xml`<t t-component="component"/>`;
component: any = Parent;
setup() {
useLogLifecycle();
onError(() => {
logStep("error");
this.component = OtherChild;
this.render();
});
}
}
await mount(Root, fixture);
expect(fixture.innerHTML).toBe("def");
expect(steps.splice(0)).toMatchInlineSnapshot(`
Array [
"Root:setup",
"Root:willStart",
"Root:willRender",
"Parent:setup",
"Parent:willStart",
"Root:rendered",
"Parent:willRender",
"Child:setup",
"Child:willStart",
"Boom:setup",
"Boom:willStart",
"Parent:rendered",
"Child:willRender",
"Child:rendered",
"Boom:willRender",
"Boom:rendered",
"Boom:mounted",
"error",
"Root:willRender",
"OtherChild:setup",
"OtherChild:willStart",
"Root:rendered",
"OtherChild:willRender",
"OtherChild:rendered",
"OtherChild:mounted",
"Root:mounted",
]
`);
});
test("error in onMounted, graceful recovery, variation", async () => {
class Child extends Component {
static template = xml`abc`;
setup() {
useLogLifecycle();
}
}
class OtherChild extends Component {
static template = xml`def`;
setup() {
useLogLifecycle();
}
}
class Boom extends Component {
static template = xml`boom`;
setup() {
useLogLifecycle();
onMounted(() => {
throw new Error("boom");
});
}
}
class Parent extends Component {
static template = xml`parent<Child/><Boom/>`;
static components = { Child, Boom };
setup() {
useLogLifecycle();
}
}
class Root extends Component {
static template = xml`R<t t-if="state.gogogo" t-component="component"/>`;
component: any = Parent;
state = useState({ gogogo: false });
setup() {
useLogLifecycle();
onError(() => {
logStep("error");
this.component = OtherChild;
this.render();
});
}
}
const root = await mount(Root, fixture);
expect(fixture.innerHTML).toBe("R");
// standard mounting process
expect(steps.splice(0)).toMatchInlineSnapshot(`
Array [
"Root:setup",
"Root:willStart",
"Root:willRender",
"Root:rendered",
"Root:mounted",
]
`);
root.state.gogogo = true;
await nextTick();
expect(fixture.innerHTML).toBe("Rparentabcboom");
// rerender, root creates sub components, it crashes, tries to recover
expect(steps.splice(0)).toMatchInlineSnapshot(`
Array [
"Root:willRender",
"Parent:setup",
"Parent:willStart",
"Root:rendered",
"Parent:willRender",
"Child:setup",
"Child:willStart",
"Boom:setup",
"Boom:willStart",
"Parent:rendered",
"Child:willRender",
"Child:rendered",
"Boom:willRender",
"Boom:rendered",
"Root:willPatch",
"Boom:mounted",
"error",
"Root:willRender",
"OtherChild:setup",
"OtherChild:willStart",
"Root:rendered",
]
`);
await nextTick();
expect(fixture.innerHTML).toBe("Rdef");
expect(steps.splice(0)).toMatchInlineSnapshot(`
Array [
"OtherChild:willRender",
"OtherChild:rendered",
"Root:willPatch",
"Child:willDestroy",
"Boom:willUnmount",
"Boom:willDestroy",
"Parent:willDestroy",
"OtherChild:mounted",
"Root:patched",
]
`);
});
});
+1 -1
View File
@@ -339,7 +339,7 @@ test("throw if prop uses an unknown suffix", async () => {
await expect(async () => {
await mount(Parent, fixture);
}).rejects.toThrowError("Invalid prop suffix");
}).rejects.toThrowError("Invalid prop suffix: somesuffix");
});
test(".alike suffix in a simple case", async () => {
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
// this is the "compile_owl_templates" command that owl makes available when
// installed as a node_module.
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { dirname } from "path";
import { compileTemplates } from "../dist/compile_templates.mjs";
import { parseArgs } from "util";
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
output: {
type: "string",
short: "o",
default: "templates.js",
},
},
});
if (positionals.length) {
const result = await compileTemplates(positionals);
const outputPath = values.output;
const dir = dirname(outputPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(outputPath, result);
} else {
console.log("Please provide a path");
}
-113
View File
@@ -1,113 +0,0 @@
const fs = require("fs");
const path = require("path");
const jsdom = require("jsdom");
// -----------------------------------------------------------------------------
// add global DOM stuff for compiler
// -----------------------------------------------------------------------------
var document = new jsdom.JSDOM("", {});
var window = document.window;
global.document = window.document;
global.window = window;
global.DOMParser = window.DOMParser;
global.Element = window.Element;
global.Node = window.Node;
// this needs to be below the jsdom stuff
const { compile } = require("../dist/compiler.js");
// -----------------------------------------------------------------------------
// helpers
// -----------------------------------------------------------------------------
async function getXmlFiles(dir) {
let xmls = [];
const files = await fs.promises.readdir(dir);
const filesStats = await Promise.all(files.map((file) => fs.promises.stat(path.join(dir, file))));
for (let i in files) {
const name = path.join(dir, files[i]);
if (filesStats[i].isDirectory()) {
xmls = xmls.concat(await getXmlFiles(name));
} else {
if (name.endsWith(".xml")) {
xmls.push(name);
}
}
}
return xmls;
}
function writeToFile(filepath, data) {
if (!fs.existsSync(path.dirname(filepath))) {
fs.mkdirSync(path.dirname(filepath), { recursive: true });
}
fs.writeFile(filepath, data, (err) => {
if (err) {
process.stdout.write(`Error while writing file ${filepath}: ${err}`);
return;
}
});
}
// adapted from https://medium.com/@mhagemann/the-ultimate-way-to-slugify-a-url-string-in-javascript-b8e4a0d849e1
const a = "·-_,:;";
const p = new RegExp(a.split("").join("|"), "g");
function slugify(str) {
return str
.replace(/\//g, "") // remove /
.replace(/\./g, "_") // Replace . with _
.replace(p, (c) => '_') // Replace special characters
.replace(/&/g, "_and_") // Replace & with and
.replace(/[^\w\-]+/g, "") // Remove all non-word characters
}
// -----------------------------------------------------------------------------
// main
// -----------------------------------------------------------------------------
async function compileTemplates(files) {
process.stdout.write(`Processing ${files.length} files`);
let xmlStrings = await Promise.all(files.map((file) => fs.promises.readFile(file, "utf8")));
const templates = [];
const errors = [];
for (let i = 0; i < files.length; i++) {
const fileName = files[i];
const fileContent = xmlStrings[i];
process.stdout.write(`.`);
const parser = new DOMParser();
const doc = parser.parseFromString(fileContent, "text/xml");
for (const template of doc.querySelectorAll("[t-name]")) {
const name = template.getAttribute("t-name");
if (template.hasAttribute("owl")) {
template.removeAttribute("owl")
}
const fnName = slugify(name);
try {
const fn = compile(template).toString().replace('anonymous', fnName);
templates.push(`"${name}": ${fn},\n`);
} catch (e) {
errors.push({ name, fileName, e });
}
}
}
process.stdout.write(`\n`);
for (let { name, fileName, e } of errors) {
console.warn(`Error while compiling '${name}' (in file ${fileName})`);
console.error(e);
}
console.log(`${templates.length} templates compiled`);
return `export const templates = {\n ${templates.join("\n")} \n}`;
}
const templatesPath = process.argv[2];
if (templatesPath && templatesPath.length) {
getXmlFiles(templatesPath).then(async (files) => {
const result = await compileTemplates(files);
writeToFile("templates.js", result);
});
} else {
console.log("Please provide a path");
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Owl devtools",
"version": "1.2.2",
"version": "1.3.0",
"manifest_version": 3,
"description": "Chrome devtools extension for Odoo Owl framework",
"icons": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Owl devtools",
"version": "1.0.0",
"version": "1.1.0",
"description": "Firefox devtools extension for Odoo Owl framework",
"manifest_version": 2,
"browser_specific_settings": {
@@ -0,0 +1,36 @@
import { useStore } from "../store/store";
const { Component, useEffect, useRef } = owl;
export class ContextMenu extends Component {
static template = "devtools.ContextMenu";
static props = {
items: Array,
};
setup() {
this.store = useStore();
this.contextMenu = useRef("contextmenu");
useEffect(
(position) => {
const menu = this.contextMenu.el;
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
let { x, y } = position;
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth;
}
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight;
}
menu.style.left = x + "px";
// Need 25px offset because of the main navbar from the browser devtools
menu.style.top = y + "px";
},
() => [this.store.contextMenu?.position]
);
}
onClickItem(action) {
action();
this.store.contextMenu = null;
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="devtools.ContextMenu">
<div class="custom-menu" t-ref="contextmenu">
<ul class="my-1">
<li t-foreach="props.items" t-as="item" t-key="item_index" t-if="item.show" t-esc="item.title" t-on-click.stop="() => this.onClickItem(item.action)" class="custom-menu-item py-1 px-4"/>
</ul>
</div>
</t>
</templates>
@@ -1,4 +1,4 @@
const { Component, useRef, useEffect } = owl;
const { Component } = owl;
import { useStore } from "../../../store/store";
import { ObjectTreeElement } from "./object_tree_element/object_tree_element";
@@ -7,23 +7,64 @@ export class DetailsWindow extends Component {
static components = { ObjectTreeElement };
setup() {
this.store = useStore();
this.contextMenu = useRef("contextmenu");
this.contextMenuId = this.store.contextMenu.id++;
this.contextMenuEvent;
// Open the context menu when the ids match
useEffect(
(menuId) => {
if (menuId === this.contextMenuId) {
this.store.contextMenu.open(this.contextMenuEvent, this.contextMenu.el);
}
}
get contextMenuItems() {
return [
{
title: "Inspect source code",
show: true,
action: () => this.store.inspectComponent("source", this.store.activeComponent.path),
},
() => [this.store.contextMenu.activeMenu]
);
{
title: "Store as global variable",
show: this.store.activeComponent.path.length !== 1,
action: () =>
this.store.logObjectInConsole([
...this.store.activeComponent.path,
{ type: "item", value: "component" },
]),
},
{
title: "Inspect in Elements tab",
show: this.store.activeComponent.path.length !== 1,
action: () => this.store.inspectComponent("DOM", this.store.activeComponent.path),
},
{
title: "Force rerender",
show: this.store.activeComponent.path.length !== 1,
action: () => this.store.refreshComponent(this.store.activeComponent.path),
},
{
title: "Store observed states as global variable",
show: this.store.activeComponent.path.length !== 1,
action: () =>
this.store.logObjectInConsole([
...this.store.activeComponent.path,
{ type: "item", value: "subscriptions" },
]),
},
{
title: "Inspect compiled template",
show: this.store.activeComponent.path.length !== 1,
action: () =>
this.store.inspectComponent("compiled template", this.store.activeComponent.path),
},
{
title: "Log raw template",
show: this.store.activeComponent.path.length !== 1,
action: () => this.store.inspectComponent("raw template", this.store.activeComponent.path),
},
{
title: "Store as global variable",
show: this.store.activeComponent.path.length === 1,
action: () => this.store.logObjectInConsole([...this.store.activeComponent.path]),
},
];
}
openMenu(ev) {
this.contextMenuEvent = ev;
this.store.contextMenu.activeMenu = this.contextMenuId;
this.store.openContextMenu(ev, this.contextMenuItems);
}
toggleCategory(ev, category) {
@@ -20,6 +20,17 @@
</t>
</div>
<div class="details-container">
<div t-if="store.observedVariables.length and store.observedVariables.some((v) => v.visible)" id="observedVariables" class="details-panel ps-2 py-1">
<div class="d-flex mb-2">
<div class="w-100">
<b class="ps-2">observed variables</b>
</div>
<i title="Remove observed variables" class="fa fa-times utility-icon p-1" t-on-click.stop="() => this.store.clearObservedVariable()"></i>
</div>
<t t-foreach="store.observedVariables" t-as="observed" t-key="observed_index" t-if="observed.visible">
<ObjectTreeElement object="observed" index="observed_index"/>
</t>
</div>
<div t-if="store.activeComponent.env.children.length > 0" id="env" class="details-panel ps-2 py-1">
<div class="d-flex mb-2">
<div class="w-100" t-on-click.stop="(ev) => this.toggleCategory(ev, 'env')">
@@ -59,7 +70,7 @@
</t>
</div>
</div>
<div t-if="store.activeComponent.instance.children.length > 0" id="instance" class="details-panel ps-2 py-1">
<div t-if="store.activeComponent.instance.children.length > 0" id="instance" class="details-panel ps-2 py-1">
<div class="d-flex mb-2">
<div class="w-100 text-truncate" t-on-click.stop="(ev) => this.toggleCategory(ev, 'instance')">
<i class="fa mx-1 pointer-icon"
@@ -75,22 +86,19 @@
<ObjectTreeElement object="instance"/>
</t>
</div>
</div>
<div t-if="store.contextMenu.activeMenu === contextMenuId" class="custom-menu" t-ref="contextmenu">
<ul class="my-1">
<li t-on-click.stop="() => this.store.inspectComponent('source', store.activeComponent.path)" class="custom-menu-item py-1 px-4">Inspect source code</li>
<t t-if="store.activeComponent.path.length !== 1">
<li t-on-click.stop="() => this.store.logObjectInConsole([...store.activeComponent.path, { type: 'item', value: 'component'}])" class="custom-menu-item py-1 px-4">Store as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('DOM', store.activeComponent.path)" class="custom-menu-item py-1 px-4">Inspect in Elements tab</li>
<li t-on-click.stop="() => this.store.refreshComponent(store.activeComponent.path)" class="custom-menu-item py-1 px-4">Force rerender</li>
<li t-on-click.stop="() => this.store.logObjectInConsole([...store.activeComponent.path, { type: 'item', value: 'subscriptions'}])" class="custom-menu-item py-1 px-4">Store observed states as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('compiled template', store.activeComponent.path)" class="custom-menu-item py-1 px-4">Inspect compiled template</li>
<li t-on-click.stop="() => this.store.inspectComponent('raw template', store.activeComponent.path)" class="custom-menu-item py-1 px-4">Log raw template</li>
<div t-if="store.activeComponent.hooks?.children.length > 0" id="hooks" class="details-panel ps-2 py-1">
<div class="d-flex mb-2">
<div class="w-100" t-on-click.stop="(ev) => this.toggleCategory(ev, 'hooks')">
<i class="fa mx-1 pointer-icon"
t-att-class="{'fa-caret-right': !store.activeComponent.hooks.toggled, 'fa-caret-down': store.activeComponent.hooks.toggled}"
/><b>hooks</b>
</div>
<i title="Remove breakpoints" class="fa fa-times utility-icon p-1" t-on-click.stop="() => this.store.removeBreakpoints()"></i>
</div>
<t t-if="store.activeComponent.hooks.toggled" t-foreach="store.activeComponent.hooks.children" t-as="hook" t-key="hook_index">
<ObjectTreeElement object="hook"/>
</t>
<t t-else="">
<li t-on-click.stop="() => this.store.logObjectInConsole([...store.activeComponent.path])" class="custom-menu-item py-1 px-4">Store as global variable</li>
</t>
</ul>
</div>
</div>
</t>
</templates>
</templates>
@@ -13,19 +13,8 @@ export class ObjectTreeElement extends Component {
menuTop: 0,
menuLeft: 0,
});
this.contextMenu = useRef("contextmenu");
const inputRef = useRef("input");
this.store = useStore();
this.contextMenuId = this.store.contextMenu.id++;
this.contextMenuEvent,
useEffect(
(menuId) => {
if (menuId === this.contextMenuId) {
this.store.contextMenu.open(this.contextMenuEvent, this.contextMenu.el);
}
},
() => [this.store.contextMenu.activeMenu]
);
useEffect(
(editMode) => {
// Focus on the input when it is created
@@ -63,16 +52,76 @@ export class ObjectTreeElement extends Component {
return this.props.object.depth * 0.8 + 0.3;
}
get contextMenuItems() {
return [
{
title: "Store as global variable",
show: true,
action: () => this.store.logObjectInConsole(this.props.object.path),
},
{
title: "Inspect function source code",
show: this.props.object.contentType === "function",
action: () => this.store.inspectFunctionSource(this.props.object.path),
},
{
title: "Observe variable",
show: this.props.object.objectType !== "observed",
action: () => this.store.observeVariable(this.props.object.path),
},
{
title: "Unobserve variable",
show: this.props.object.objectType === "observed",
action: () => this.store.clearObservedVariable(this.props.index),
},
{
title: "Inject breakpoint on component",
show: this.props.object.contentType === "array" && this.props.object.objectType === "hook",
action: () =>
this.store.injectBreakpoint(this.props.object.name, this.store.activeComponent.path),
},
{
title: "Inject conditional breakpoint on component",
show: this.props.object.contentType === "array" && this.props.object.objectType === "hook",
action: () => {
const condition = window.prompt("Enter the condition");
if (condition) {
this.store.injectBreakpoint(
this.props.object.name,
this.store.activeComponent.path,
false,
condition
);
}
},
},
{
title: "Inject breakpoint on instance",
show:
this.props.object.contentType === "array" &&
this.props.object.objectType === "hook" &&
!["mounted", "willStart"].includes(this.props.object.name),
action: () =>
this.store.injectBreakpoint(
this.props.object.name,
this.store.activeComponent.path,
true
),
},
];
}
openMenu(ev) {
this.contextMenuEvent = ev;
this.store.contextMenu.activeMenu = this.contextMenuId;
this.store.openContextMenu(ev, this.contextMenuItems);
}
setupEditMode() {
if (!this.state.editMode) {
if (!this.props.object.hasChildren) {
this.state.editMode = true;
}
if (
!this.state.editMode &&
!this.props.object.hasChildren &&
!(this.props.object.objectType === "observed")
) {
this.state.editMode = true;
}
}
@@ -4,7 +4,7 @@
<div class="m-0 p-0 text-nowrap w-100 object-line"
t-att-class="props.class + (props.object.hasChildren ? ' bg-feedback' : '')"
t-on-click.stop="() => this.store.toggleObjectTreeElementsDisplay(this.props.object)"
t-on-contextmenu.prevent="openMenu"
t-on-contextmenu.prevent="openMenu"
>
<div t-attf-style="padding-left: {{objectPadding}}rem">
<i class="fa px-1 pointer-icon caret"
@@ -12,7 +12,7 @@
t-attf-style="visibility: {{props.object.hasChildren ? '' : 'hidden'}};"
/>
<t t-esc="props.object.name"/>
<t t-if="props.object.content.length > 0">: </t>
<t t-if="props.object.content.length > 0">: </t>
<t t-if="props.object.contentType == 'getter'">
<span class="getter-content object-content" t-att-class="objectLineClass" t-on-click.stop="() => this.store.loadGetterContent(this.props.object)">
<t t-esc="props.object.content"/>
@@ -20,25 +20,17 @@
</t>
<t t-else="">
<span class="object-content" t-att-class="objectLineClass" t-on-dblclick.stop="setupEditMode">
<t t-if="state.editMode">
<t t-if="state.editMode">
<input t-attf-id="objectEditionInput/{{pathAsString}}" t-ref="input" type="text" placeholder="" t-att-value="props.object.content" t-on-keydown.stop="editObject"/>
</t>
<t t-else="">
<t t-esc="props.object.content"/>
</t>
</span>
</span>
</t>
<span t-if="keyChanges" class="key-changes ms-1 badge p-1" title="Key additions/deletions are observed">+/-</span>
</div>
</div>
<div t-if="store.contextMenu.activeMenu === contextMenuId" class="custom-menu" t-ref="contextmenu">
<ul class="my-1">
<li t-on-click="() => this.store.logObjectInConsole(this.props.object.path)" class="custom-menu-item py-1 px-4 text-nowrap">Store as global variable</li>
<t t-if='props.object.contentType == "function"'>
<li t-on-click="() => this.store.inspectFunctionSource(this.props.object.path)" class="custom-menu-item py-1 px-4 text-nowrap">Inspect function source code</li>
</t>
</ul>
</div>
<t t-if="props.object.toggled" t-key="contextMenuId">
<t t-foreach="props.object.children" t-as="child" t-key="child_index">
<ObjectTreeElement object="child" class="this.classFor(child)"/>
@@ -16,10 +16,7 @@ export class TreeElement extends Component {
searched: false,
});
this.store = useStore();
this.contextMenu = useRef("contextmenu");
this.element = useRef("element");
this.contextMenuId = this.store.contextMenu.id++;
this.contextMenuEvent;
this.stringifiedPath = JSON.stringify(this.props.component.path);
// Scroll to the selected element when it changes
onMounted(() => {
@@ -38,15 +35,6 @@ export class TreeElement extends Component {
},
() => [this.props.component.selected]
);
// Open the context menu when the ids match
useEffect(
(menuId) => {
if (menuId === this.contextMenuId) {
this.store.contextMenu.open(this.contextMenuEvent, this.contextMenu.el);
}
},
() => [this.store.contextMenu.activeMenu]
);
// Effect to apply a short highlight effect to the component when it is rendered
useEffect(
() => {
@@ -86,9 +74,86 @@ export class TreeElement extends Component {
return minimizeKey(this.props.component.key);
}
get contextMenuItems() {
return [
{
title: "Expand children",
show: true,
action: () => this.store.toggleComponentAndChildren(this.props.component, true),
},
{
title: "Fold all children",
show: true,
action: () => this.store.toggleComponentAndChildren(this.props.component, false),
},
{
title: "Fold direct children",
show: true,
action: () => this.store.foldDirectChildren(this.props.component),
},
{
title: "Inspect source code",
show: true,
action: () => this.store.inspectComponent("source", this.props.component.path),
},
{
title: "Store as global variable",
show: this.props.component.path.length !== 1,
action: () =>
this.store.logObjectInConsole([
...this.props.component.path,
{ type: "item", value: "component" },
]),
},
{
title: "Inspect in Elements tab",
show: this.props.component.path.length !== 1,
action: () => this.store.inspectComponent("DOM", this.props.component.path),
},
{
title: "Force rerender",
show: this.props.component.path.length !== 1,
action: () => this.store.refreshComponent(this.props.component.path),
},
{
title: "Store observed states as global variable",
show: this.props.component.path.length !== 1,
action: () =>
this.store.logObjectInConsole([
...this.props.component.path,
{ type: "item", value: "subscriptions" },
]),
},
{
title: "Inspect compiled template",
show: this.props.component.path.length !== 1,
action: () => this.store.inspectComponent("compiled template", this.props.component.path),
},
{
title: "Log raw template",
show: this.props.component.path.length !== 1,
action: () => this.store.inspectComponent("raw template", this.props.component.path),
},
{
title: "Store as global variable",
show: this.props.component.path.length === 1,
action: () => this.store.logObjectInConsole([...this.props.component.path]),
},
{
title: "Don't fold component by default",
show: this.store.settings.componentsToggleBlacklist.has(this.props.component.name),
action: () => this.toggleComponentToBlacklist(),
},
{
title: "Fold component by default",
show: !this.store.settings.componentsToggleBlacklist.has(this.props.component.name),
action: () => this.toggleComponentToBlacklist(),
},
];
}
openMenu(ev) {
this.contextMenuEvent = ev;
this.store.contextMenu.activeMenu = this.contextMenuId;
this.store.openContextMenu(ev, this.contextMenuItems);
}
// Expand/fold the component node
@@ -2,15 +2,15 @@
<templates xml:space="preserve">
<t t-name="devtools.TreeElement" owl="1">
<div t-ref="element"
t-att-class="{'component-selected': props.component.selected,'component-highlighted': props.component.highlighted}"
class="tree-component m-0 p-0 w-100 text-nowrap user-select-none"
t-on-contextmenu.prevent="openMenu"
t-on-mouseover.stop="() => this.store.highlightComponent(props.component.path)"
t-att-class="{'component-selected': props.component.selected,'component-highlighted': props.component.highlighted}"
class="tree-component m-0 p-0 w-100 text-nowrap user-select-none"
t-on-contextmenu.prevent="openMenu"
t-on-mouseover.stop="() => this.store.highlightComponent(props.component.path)"
t-on-click.stop="toggleComponent"
>
<div class="component-wrapper" t-attf-style="padding-left: {{componentPadding}}rem">
<i class="fa px-1 pointer-icon caret"
t-att-class="{'fa-caret-right': !props.component.toggled, 'fa-caret-down': props.component.toggled}"
t-att-class="{'fa-caret-right': !props.component.toggled, 'fa-caret-down': props.component.toggled}"
t-on-click.stop="toggleDisplay"
t-attf-style="{{props.component.children.length > 0 ? '' : 'visibility: hidden;'}}"
/>
@@ -26,29 +26,6 @@
<span t-if="props.component.depth">&gt;</span>
<span class="version" t-else="">owl=<t t-esc="props.component.version"/></span>
</div>
<div t-if="store.contextMenu.activeMenu === contextMenuId" class="custom-menu" t-ref="contextmenu">
<ul class="my-1">
<li t-on-click.stop="() => this.store.toggleComponentAndChildren(props.component, true)" class="custom-menu-item py-1 px-4">Expand children</li>
<li t-on-click.stop="() => this.store.toggleComponentAndChildren(props.component, false)" class="custom-menu-item py-1 px-4">Fold all children</li>
<li t-on-click.stop="() => this.store.foldDirectChildren(props.component)" class="custom-menu-item py-1 px-4">Fold direct children</li>
<li t-on-click.stop="() => this.store.inspectComponent('source', props.component.path)" class="custom-menu-item py-1 px-4">Inspect source code</li>
<t t-if="props.component.path.length !== 1">
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.component.path, { type: 'item', value: 'component'}])" class="custom-menu-item py-1 px-4">Store as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('DOM', props.component.path)" class="custom-menu-item py-1 px-4">Inspect in Elements tab</li>
<li t-on-click.stop="() => this.store.refreshComponent(props.component.path)" class="custom-menu-item py-1 px-4">Force rerender</li>
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.component.path, { type: 'item', value: 'subscriptions'}])" class="custom-menu-item py-1 px-4">Store observed states as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('compiled template', props.component.path)" class="custom-menu-item py-1 px-4">Inspect compiled template</li>
<li t-on-click.stop="() => this.store.inspectComponent('raw template', props.component.path)" class="custom-menu-item py-1 px-4">Log raw template</li>
</t>
<t t-else="">
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.component.path])" class="custom-menu-item py-1 px-4">Store as global variable</li>
</t>
<li t-on-click.stop="() => this.toggleComponentToBlacklist()" class="custom-menu-item py-1 px-4">
<t t-if="store.settings.componentsToggleBlacklist.has(props.component.name)">Don't fold component by default</t>
<t t-else="">Fold component by default</t>
</li>
</ul>
</div>
</div>
<t t-if="props.component.toggled">
<t t-foreach="props.component.children" t-as="child" t-key="child.key">
@@ -1,13 +1,14 @@
const { Component } = owl;
import { ComponentsTab } from "./components_tab/components_tab";
import { Tab } from "./tab/tab";
import { ProfilerTab } from "./profiler_tab/profiler_tab";
import { ContextMenu } from "../context_menu/context_menu";
import { useStore } from "../store/store";
import { ComponentsTab } from "./components_tab/components_tab";
import { ProfilerTab } from "./profiler_tab/profiler_tab";
import { Tab } from "./tab/tab";
export class DevtoolsWindow extends Component {
static props = [];
static template = "devtools.DevtoolsWindow";
static components = { ComponentsTab, Tab, ProfilerTab };
static components = { ComponentsTab, Tab, ProfilerTab, ContextMenu };
setup() {
this.store = useStore();
}
@@ -28,6 +28,7 @@
Owl is not loaded on this page.
</div>
</t>
<ContextMenu t-if="store.contextMenu" items="store.contextMenu.items"/>
</div>
</t>
</templates>
@@ -1,24 +1,13 @@
import { minimizeKey } from "../../../../utils";
import { useStore } from "../../../store/store";
const { Component, useEffect, useRef } = owl;
const { Component } = owl;
export class Event extends Component {
static template = "devtools.Event";
setup() {
this.store = useStore();
this.componentContextMenu = useRef("componentContextmenu");
this.componentContextMenuId = this.store.contextMenu.id++;
this.contextMenuEvent,
useEffect(
(menuId) => {
if (menuId === this.componentContextMenuId) {
this.store.contextMenu.open(this.contextMenuEvent, this.componentContextMenu.el);
}
},
() => [this.store.contextMenu.activeMenu]
);
}
// Formatting for displaying the key of the component
@@ -54,13 +43,65 @@ export class Event extends Component {
}
}
openComponentMenu(ev) {
get contextMenuItems() {
return [
{
title: "Inspect source code",
show: true,
action: () => this.store.inspectComponent("source", this.props.event.path),
},
{
title: "Store as global variable",
show: this.props.event.path.length !== 1,
action: () =>
this.store.logObjectInConsole([
...this.props.event.path,
{ type: "item", value: "component" },
]),
},
{
title: "Inspect in Elements tab",
show: this.props.event.path.length !== 1,
action: () => this.store.inspectComponent("DOM", this.props.event.path),
},
{
title: "Force rerender",
show: this.props.event.path.length !== 1,
action: () => this.store.refreshComponent(this.props.event.path),
},
{
title: "Store observed states as global variable",
show: this.props.event.path.length !== 1,
action: () =>
this.store.logObjectInConsole([
...this.props.event.path,
{ type: "item", value: "subscriptions" },
]),
},
{
title: "Inspect compiled template",
show: this.props.event.path.length !== 1,
action: () => this.store.inspectComponent("compiled template", this.props.event.path),
},
{
title: "Log raw template",
show: this.props.event.path.length !== 1,
action: () => this.store.inspectComponent("raw template", this.props.event.path),
},
{
title: "Store as global variable",
show: this.props.event.path.length === 1,
action: () => this.store.logObjectInConsole([...this.props.event.path]),
},
];
}
openMenu(ev) {
if (this.props.event.type === "destroy") {
return;
} else {
ev.preventDefault();
this.contextMenuEvent = ev;
this.store.contextMenu.activeMenu = this.componentContextMenuId;
this.store.openContextMenu(ev, this.contextMenuItems);
}
}
}
@@ -8,11 +8,11 @@
t-att-class="{'fa-caret-right': !props.event.toggled, 'fa-caret-down': props.event.toggled}"
t-attf-style="visibility: {{props.event.origin ? '' : 'hidden'}};"
/>
<t t-esc="props.event.type"/>:
<t t-esc="props.event.type"/>:
&lt;<span style="cursor:pointer; color: var(--component-color);"
t-on-click.stop="() => this.store.selectComponent(props.event.path)"
t-on-mouseover.stop="() => this.store.highlightComponent(props.event.path)"
t-on-contextmenu="openComponentMenu"
t-on-click.stop="() => this.store.selectComponent(props.event.path)"
t-on-mouseover.stop="() => this.store.highlightComponent(props.event.path)"
t-on-contextmenu="openMenu"
t-esc="props.event.component"
/>
<t t-if="minimizedKey.length > 0">
@@ -29,10 +29,10 @@
<div class="my-0 pt-1 object-line">
<i class="fa fa-caret-right mx-1 pe-2" style="visibility: hidden;"></i>
<span>
origin:
&lt;<span style="cursor:pointer; color: var(--component-color);"
t-on-click.stop="() => this.store.selectComponent(props.event.origin.path)"
t-on-mouseover.stop="() => this.store.highlightComponent(props.event.origin.path)"
origin:
&lt;<span style="cursor:pointer; color: var(--component-color);"
t-on-click.stop="() => this.store.selectComponent(props.event.origin.path)"
t-on-mouseover.stop="() => this.store.highlightComponent(props.event.origin.path)"
t-esc="props.event.origin.component"
/>
<t t-if="originMinimizedKey.length > 0">
@@ -43,22 +43,6 @@
</span>
</div>
</t>
<div t-if="store.contextMenu.activeMenu === componentContextMenuId" class="custom-menu" t-ref="componentContextmenu">
<ul class="my-1">
<li t-on-click.stop="() => this.store.inspectComponent('source', props.event.path)" class="custom-menu-item py-1 px-4">Inspect source code</li>
<t t-if="props.event.path.length !== 1">
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.event.path, { type: 'item', value: 'component'}])" class="custom-menu-item py-1 px-4">Store as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('DOM', props.event.path)" class="custom-menu-item py-1 px-4">Inspect in Elements tab</li>
<li t-on-click.stop="() => this.store.refreshComponent(props.event.path)" class="custom-menu-item py-1 px-4">Force rerender</li>
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.event.path, { type: 'item', value: 'subscriptions'}])" class="custom-menu-item py-1 px-4">Store observed states as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('compiled template', props.event.path)" class="custom-menu-item py-1 px-4">Inspect compiled template</li>
<li t-on-click.stop="() => this.store.inspectComponent('raw template', props.event.path)" class="custom-menu-item py-1 px-4">Log raw template</li>
</t>
<t t-else="">
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.event.path])" class="custom-menu-item py-1 px-4">Store as global variable</li>
</t>
</ul>
</div>
</div>
</t>
</templates>
@@ -1,7 +1,7 @@
import { minimizeKey } from "../../../../utils";
import { useStore } from "../../../store/store";
const { Component, useRef, useEffect } = owl;
const { Component } = owl;
export class EventNode extends Component {
static template = "devtools.EventNode";
@@ -10,33 +10,89 @@ export class EventNode extends Component {
setup() {
this.store = useStore();
this.nodeContextMenu = useRef("nodeContextMenu");
this.nodeContextMenuId = this.store.contextMenu.id++;
this.componentContextMenu = useRef("componentContextmenu");
this.componentContextMenuId = this.store.contextMenu.id++;
this.contextMenuEvent,
useEffect(
(menuId) => {
if (menuId === this.nodeContextMenuId) {
this.store.contextMenu.open(this.contextMenuEvent, this.nodeContextMenu.el);
}
if (menuId === this.componentContextMenuId) {
this.store.contextMenu.open(this.contextMenuEvent, this.componentContextMenu.el);
}
},
() => [this.store.contextMenu.activeMenu]
);
}
get eventPadding() {
return this.props.event.depth * 0.8 + 0.3;
}
get nodeContextMenuItems() {
return [
{
title: "Expand children",
show: true,
action: () => this.store.toggleEventAndChildren(this.props.event, true),
},
{
title: "Fold all children",
show: true,
action: () => this.store.toggleEventAndChildren(this.props.event, false),
},
{
title: "Fold direct children",
show: true,
action: () => this.store.foldDirectChildren(this.props.event),
},
];
}
get componentContextMenuItems() {
return [
{
title: "Inspect source code",
show: true,
action: () => this.store.inspectComponent("source", this.props.event.path),
},
{
title: "Store as global variable",
show: this.props.event.path.length !== 1,
action: () =>
this.store.logObjectInConsole([
...this.props.event.path,
{ type: "item", value: "component" },
]),
},
{
title: "Inspect in Elements tab",
show: this.props.event.path.length !== 1,
action: () => this.store.inspectComponent("DOM", this.props.event.path),
},
{
title: "Force rerender",
show: this.props.event.path.length !== 1,
action: () => this.store.refreshComponent(this.props.event.path),
},
{
title: "Store observed states as global variable",
show: this.props.event.path.length !== 1,
action: () =>
this.store.logObjectInConsole([
...this.props.event.path,
{ type: "item", value: "subscriptions" },
]),
},
{
title: "Inspect compiled template",
show: this.props.event.path.length !== 1,
action: () => this.store.inspectComponent("compiled template", this.props.event.path),
},
{
title: "Log raw template",
show: this.props.event.path.length !== 1,
action: () => this.store.inspectComponent("raw template", this.props.event.path),
},
{
title: "Store as global variable",
show: this.props.event.path.length === 1,
action: () => this.store.logObjectInConsole([...this.props.event.path]),
},
];
}
openNodeMenu(ev) {
if (this.props.event.children.length) {
ev.preventDefault();
this.contextMenuEvent = ev;
this.store.contextMenu.activeMenu = this.nodeContextMenuId;
this.store.openContextMenu(ev, this.nodeContextMenuItems);
}
}
@@ -45,8 +101,7 @@ export class EventNode extends Component {
return;
} else {
ev.preventDefault();
this.contextMenuEvent = ev;
this.store.contextMenu.activeMenu = this.componentContextMenuId;
this.store.openContextMenu(ev, this.componentContextMenuItems);
}
}
@@ -2,8 +2,8 @@
<templates xml:space="preserve">
<t t-name="devtools.EventNode" owl="1">
<div class="my-0 p-0 object-line"
t-on-click.stop="toggleDisplay"
t-on-contextmenu="openNodeMenu"
t-on-click.stop="toggleDisplay"
t-on-contextmenu="openNodeMenu"
>
<div class="text-nowrap" t-attf-style="padding-left: {{eventPadding}}rem">
<i class="fa px-1 pointer-icon caret"
@@ -11,11 +11,11 @@
t-attf-style="visibility: {{props.event.children.length > 0 ? '' : 'hidden'}};"
/>
<span>
<t t-esc="props.event.type"/>:
&lt;<span style="cursor:pointer; color: var(--component-color);"
t-on-click.stop="() => this.store.selectComponent(props.event.path)"
t-on-mouseover.stop="() => this.store.highlightComponent(props.event.path)"
t-on-contextmenu.stop="openComponentMenu"
<t t-esc="props.event.type"/>:
&lt;<span style="cursor:pointer; color: var(--component-color);"
t-on-click.stop="() => this.store.selectComponent(props.event.path)"
t-on-mouseover.stop="() => this.store.highlightComponent(props.event.path)"
t-on-contextmenu.stop="openComponentMenu"
t-esc="props.event.component"/>
<t t-if="minimizedKey.length > 0">
<span t-if="minimizedKey.length > 0" style="color: var(--key-name);"> key</span>=<span style="color: var(--key-content);">
@@ -28,29 +28,6 @@
</span>
</div>
</div>
<div t-if="store.contextMenu.activeMenu === nodeContextMenuId" class="custom-menu" t-ref="nodeContextMenu">
<ul class="my-1">
<li t-on-click.stop="() => this.store.toggleEventAndChildren(props.event, true)" class="custom-menu-item py-1 px-4">Expand children</li>
<li t-on-click.stop="() => this.store.toggleEventAndChildren(props.event, false)" class="custom-menu-item py-1 px-4">Fold all children</li>
<li t-on-click.stop="() => this.store.foldDirectChildren(props.event)" class="custom-menu-item py-1 px-4">Fold direct children</li>
</ul>
</div>
<div t-if="store.contextMenu.activeMenu === componentContextMenuId" class="custom-menu" t-ref="componentContextmenu">
<ul class="my-1">
<li t-on-click.stop="() => this.store.inspectComponent('source', props.event.path)" class="custom-menu-item py-1 px-4">Inspect source code</li>
<t t-if="props.event.path.length !== 1">
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.event.path, { type: 'item', value: 'component'}])" class="custom-menu-item py-1 px-4">Store as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('DOM', props.event.path)" class="custom-menu-item py-1 px-4">Inspect in Elements tab</li>
<li t-on-click.stop="() => this.store.refreshComponent(props.event.path)" class="custom-menu-item py-1 px-4">Force rerender</li>
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.event.path, { type: 'item', value: 'subscriptions'}])" class="custom-menu-item py-1 px-4">Store observed states as global variable</li>
<li t-on-click.stop="() => this.store.inspectComponent('compiled template', props.event.path)" class="custom-menu-item py-1 px-4">Inspect compiled template</li>
<li t-on-click.stop="() => this.store.inspectComponent('raw template', props.event.path)" class="custom-menu-item py-1 px-4">Log raw template</li>
</t>
<t t-else="">
<li t-on-click.stop="() => this.store.logObjectInConsole([...props.event.path])" class="custom-menu-item py-1 px-4">Store as global variable</li>
</t>
</ul>
</div>
<t t-if="props.event.toggled">
<t t-foreach="props.event.children" t-as="child" t-key="child.id">
<EventNode event="child"/>
+72 -26
View File
@@ -11,30 +11,7 @@ export const store = reactive({
darkmode: false,
componentsToggleBlacklist: new Set(),
},
contextMenu: {
id: 0,
activeMenu: -1,
// Opens the context menu corresponding with the given menu html element
open(event, menu) {
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
let x = event.clientX;
let y = event.clientY;
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth;
}
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight;
}
menu.style.left = x + "px";
// Need 25px offset because of the main navbar from the browser devtools
menu.style.top = y - 25 + "px";
},
// Close the currently displayed context menu
close() {
this.activeMenu = -1;
},
},
contextMenu: null,
isFirefox: IS_FIREFOX,
frameUrls: ["top"],
activeFrame: "top",
@@ -56,9 +33,11 @@ export const store = reactive({
props: { toggled: true, children: [] },
env: { toggled: false, children: [] },
instance: { toggled: true, children: [] },
hooks: { toggled: true, children: [] },
version: "1.0",
},
selectedElement: null,
observedVariables: [],
componentSearch: {
search: "",
searchResults: [],
@@ -93,6 +72,16 @@ export const store = reactive({
evalFunctionInWindow("disableHTMLSelector", [], this.activeFrame);
},
openContextMenu(event, items) {
this.contextMenu = {
position: {
x: event.clientX,
y: event.clientY,
},
items,
};
},
// Load all data related to the components tree using the global hook loaded on the page
// Use fromOld to specify if we want to keep most of the toggled/selected data of the old tree
// when generating the new one
@@ -577,6 +566,42 @@ export const store = reactive({
}
},
async injectBreakpoint(hook, path, instanceOnly = false, condition = "1") {
path = [...path];
await evalFunctionInWindow(
"injectBreakpoint",
[hook, path, instanceOnly, condition],
this.activeFrame
);
await this.loadComponentsTree(true);
},
async removeBreakpoints() {
await evalFunctionInWindow("removeBreakpoints", [], this.activeFrame);
await this.loadComponentsTree(true);
},
async observeVariable(path) {
this.observedVariables.push({ path: [...path], visible: false });
this.observedVariables = await evalFunctionInWindow(
"getObservedVariables",
[store.observedVariables],
store.activeFrame
);
await browserInstance.storage.local.set({
observedVariables: toRaw(this.observedVariables).map((o) => o.path),
});
},
clearObservedVariable(index) {
if (index !== undefined) {
this.observedVariables.splice(index, 1);
} else {
this.observedVariables = [];
}
browserInstance.storage.local.set({ observedVariables: toRaw(this.observedVariables) });
},
// Trigger the highlight on the component in the page
highlightComponent(path) {
evalFunctionInWindow("highlightComponent", [path], this.activeFrame);
@@ -646,8 +671,9 @@ async function init() {
store.updateIFrameList();
// Global listeners to close the currently shown context menu when the user clicks or opens another
document.addEventListener("click", () => store.contextMenu.close(), { capture: true });
document.addEventListener("contextmenu", () => store.contextMenu.close(), { capture: true });
document.addEventListener("click", () => (store.contextMenu = null), { capture: true });
document.addEventListener("contextmenu", () => (store.contextMenu = null), { capture: true });
window.addEventListener("blur", () => (store.contextMenu = null), { capture: true });
// Make sure the events recorder is at its initial state in every frame
for (const frame of store.frameUrls) {
@@ -666,6 +692,16 @@ async function init() {
}
}
}, 500);
// Refresh observed variables values every 200 ms
setInterval(async () => {
if (store.owlStatus) {
store.observedVariables = await evalFunctionInWindow(
"getObservedVariables",
[[...store.observedVariables]],
store.activeFrame
);
}
}, 200);
}
let rootRendersTimeout = false;
@@ -760,6 +796,16 @@ async function loadSettings() {
storage.owlDevtoolsComponentsToggleBlacklist
);
}
// Observed variables
if (storage.observedVariables) {
store.observedVariables = [];
for (const path of storage.observedVariables) {
store.observedVariables.push({
path: [...path],
visible: false,
});
}
}
}
// Function to handle and store a batch of events coming from the page
@@ -51,6 +51,8 @@
this.requestedFrame = false;
this.enabledSelector = false;
this.eventsBatch = [];
this.breakpointsClassMap = new Map();
this.breakpointsHookMap = new Map();
// Object which defines how different types of data should be displayed when passed to the devtools
this.serializer = {
// Defines how leaf object nodes should be displayed in the extension when inside a bigger structure
@@ -58,15 +60,16 @@
// The asConstructorName parameter can be passed to change the display of objects and functions to
// be their constructor name (useful for prototype, map and set display)
serializeItem(value, asConstructorName = false) {
if (typeof value === "array") {
return "Array(" + value.length + ")";
} else if (typeof value === "object") {
if (typeof value === "object") {
if (value == null) {
return "null";
}
if (asConstructorName) {
return value.constructor.name;
}
if (value instanceof Array) {
return "Array(" + value.length + ")";
}
return "{...}";
} else if (typeof value === "undefined") {
return "undefined";
@@ -116,12 +119,17 @@
} else if (obj instanceof Number) {
result[0] = obj.toString();
} else {
for (const [key, value] of Object.entries(obj)) {
for (const key of Reflect.ownKeys(obj)) {
if (length > 25) {
result.push("...");
break;
}
const element = key + ": " + this.serializeItem(value);
let element;
if (Object.getOwnPropertyDescriptor(obj, key).hasOwnProperty("get")) {
element = key.toString() + ": (...)";
} else {
element = key.toString() + ": " + this.serializeItem(obj[key]);
}
length += element.length;
result.push(element);
}
@@ -135,7 +143,7 @@
result.push(element);
}
}
if (obj.constructor.name !== "Object") {
if (obj.constructor && obj.constructor.name !== "Object") {
return obj.constructor.name + " {" + result.join(", ") + "}";
}
return "{" + result.join(", ") + "}";
@@ -695,7 +703,7 @@
try {
obj = Object.getOwnPropertyDescriptor(obj, key.value).get.call(obj);
} catch (e) {
obj = e.toString();
obj = "Exception: " + e.toString();
}
break;
case "prototype getter":
@@ -713,13 +721,13 @@
try {
obj = Object.getOwnPropertyDescriptor(obj, key.value).get;
} catch (e) {
obj = e.toString();
obj = "Exception: " + e.toString();
}
} else {
try {
obj = obj[key.value];
} catch (e) {
obj = e.toString();
obj = "Exception: " + e.toString();
}
}
}
@@ -732,15 +740,20 @@
// Returns the asked property given its global path
getObjectProperty(path) {
// Just return the corresponding app if path is of length 1
if (path.length === 1) {
// Just return the corresponding app if path is of length 1 and not simplified
if (path.length === 1 && !isNaN(path[0])) {
return [...this.apps][path[0]];
}
// Path to the component node is only strings, becomes objects for properties
const index = path.findIndex((key) => typeof key !== "string");
const componentNode = this.getComponentNode(path.slice(0, index));
const obj = this.getObject(componentNode, path.slice(index));
return obj;
if (index === -1) {
return this.getComponentNode(path);
}
const componentNode = this.getComponentNode(index === 1 ? [path[0]] : path.slice(0, index));
if (componentNode) {
return this.getObject(componentNode, path.slice(index));
}
return "Exception: component not found";
}
// Returns a modified version of an object node that has compatible format with the devtools ObjectTreeElement component
@@ -788,6 +801,11 @@
child.name = "value";
obj = parentObj[1];
break;
case "getter":
child.name = key.value;
obj = parentObj[key.value];
break;
case "prototype getter":
case "set entry":
case "map entry":
case "item":
@@ -803,6 +821,8 @@
parentObj
).findIndex((sym) => sym === key.value);
child.path[child.path.length - 1].value = key.value.toString();
} else if (key.hasOwnProperty("symbolIndex")) {
obj = parentObj[Object.getOwnPropertySymbols(parentObj)[key.symbolIndex]];
} else {
obj = parentObj[key.value];
}
@@ -834,7 +854,7 @@
child.contentType = "set";
child.hasChildren = true;
break;
case obj.constructor.name === "Array":
case obj.constructor?.name === "Array":
child.contentType = "array";
child.hasChildren = obj.length > 0;
break;
@@ -842,11 +862,11 @@
child.contentType = "function";
child.hasChildren = true;
break;
case obj instanceof Object:
case typeof obj === "object":
child.contentType = "object";
child.hasChildren =
Object.keys(obj).length ||
Object.getOwnPropertySymbols(obj).length ||
Object.keys(obj).length > 0 ||
Object.getOwnPropertySymbols(obj).length > 0 ||
obj.constructor.name !== "Object";
break;
default:
@@ -890,12 +910,15 @@
path.shift();
}
// the value is either "props" or "env" here
if (objType !== "instance") {
if (objType !== "instance" && objType !== "hook") {
obj = oldTree[path[0].value].children;
path.shift();
// there is nothing otherwise but extension side it is in instance
} else {
} else if (objType === "instance") {
obj = oldTree.instance.children;
} else {
obj = oldTree.hooks.children;
path.shift();
}
// the first element here is directly in an array instead of a children array
obj = obj[path[0].childIndex];
@@ -1026,36 +1049,40 @@
break;
case "object":
case "function":
Reflect.ownKeys(obj).forEach((key) => {
if (
key !== "__proto__" &&
Object.getOwnPropertyDescriptor(obj, key).hasOwnProperty("get")
) {
let child = {
name: key,
depth: depth,
toggled: false,
objectType: objType,
path: [...path, { type: "getter", value: key, childIndex: children.length }],
contentType: "getter",
content: "(...)",
hasChildren: false,
children: [],
};
children.push(child);
}
const child = this.serializeObjectChild(
obj,
{ type: "item", value: key, childIndex: children.length },
depth,
objType,
path,
oldBranch?.children[index],
oldTree
);
if (child) children.push(child);
index++;
});
Reflect.ownKeys(obj)
.sort(compareKeys)
.forEach((key) => {
if (
key !== "__proto__" &&
Object.getOwnPropertyDescriptor(obj, key).hasOwnProperty("get")
) {
let child = {
name: key,
depth: depth,
toggled: false,
objectType: objType,
path: [...path, { type: "getter", value: key, childIndex: children.length }],
contentType: "getter",
content: "(...)",
hasChildren: false,
children: [],
};
children.push(child);
}
const child = this.serializeObjectChild(
obj,
{ type: "item", value: key, childIndex: children.length },
depth,
objType,
path,
oldBranch?.children[index],
oldTree
);
if (child) {
children.push(child);
}
index++;
});
}
let proto = Object.getPrototypeOf(obj);
while (proto) {
@@ -1099,25 +1126,44 @@
}
// Returns the Component node given its path and the root component node
getComponentNode(path) {
// The node is an app and not a component
if (path.length === 1) {
// All paths that consists in an array containing a single stringified number lead to an app
if (path.length === 1 && !isNaN(path[0])) {
return [...this.apps][path[0]];
}
// The second element in the path will always be the root of the app
let node = [...this.apps][path[0]]?.root;
if (!node) {
return null;
}
for (let i = 2; i < path.length; i++) {
// From this point onwards, it is an object path inside the component node
if (typeof path[i] !== "string") {
break;
}
if (node.children.hasOwnProperty(path[i])) {
node = node.children[path[i]];
} else {
let node;
// If the path is longer and its first item is indeed an app number, it is a regular path
if (!isNaN(path[0])) {
// The second element in the path will always be the root of the app
node = [...this.apps][path[0]]?.root;
if (!node) {
return null;
}
for (let i = 2; i < path.length; i++) {
// From this point onwards, it is an object path inside the component node
if (typeof path[i] !== "string") {
break;
}
if (node.children.hasOwnProperty(path[i])) {
node = node.children[path[i]];
} else {
return null;
}
}
// If the first path item is a more complex string, it is a simplified path where elements
// are a series of component names and indexes separated by slashes
} else {
const simplifiedPathArray = path[0].split("/");
node = [...this.apps][simplifiedPathArray[0]]?.root;
if (node.name !== simplifiedPathArray[1]) {
return null;
}
for (let i = 2; i < simplifiedPathArray.length; i += 2) {
const key = Reflect.ownKeys(node.children)[simplifiedPathArray[i]];
node = node.children[key];
if (node.name !== simplifiedPathArray[i + 1]) {
return null;
}
}
}
return node;
}
@@ -1155,42 +1201,46 @@
const propsPath = isApp
? [...path, { type: "item", value: "props" }]
: [...path, { type: "item", value: "component" }, { type: "item", value: "props" }];
Reflect.ownKeys(props).forEach((key) => {
let oldBranch = oldTree?.props.children[component.props.children.length];
const property = this.serializeObjectChild(
props,
{ type: "item", value: key, childIndex: component.props.children.length },
0,
"props",
propsPath,
oldBranch,
oldTree
);
if (property) {
component.props.children.push(property);
}
});
Reflect.ownKeys(props)
.sort(compareKeys)
.forEach((key) => {
let oldBranch = oldTree?.props.children[component.props.children.length];
const property = this.serializeObjectChild(
props,
{ type: "item", value: key, childIndex: component.props.children.length },
0,
"props",
propsPath,
oldBranch,
oldTree
);
if (property) {
component.props.children.push(property);
}
});
// Load env of the component
const env = isApp ? node.env : node.component.env;
component.env = { toggled: oldTree ? oldTree.env.toggled : false, children: [] };
const envPath = isApp
? [...path, { type: "item", value: "env" }]
: [...path, { type: "item", value: "component" }, { type: "item", value: "env" }];
Reflect.ownKeys(env).forEach((key) => {
let oldBranch = oldTree?.env.children[component.env.children.length];
const envElement = this.serializeObjectChild(
env,
{ type: "item", value: key, childIndex: component.env.children.length },
0,
"env",
envPath,
oldBranch,
oldTree
);
if (envElement) {
component.env.children.push(envElement);
}
});
Reflect.ownKeys(env)
.sort(compareKeys)
.forEach((key) => {
let oldBranch = oldTree?.env.children[component.env.children.length];
const envElement = this.serializeObjectChild(
env,
{ type: "item", value: key, childIndex: component.env.children.length },
0,
"env",
envPath,
oldBranch,
oldTree
);
if (envElement) {
component.env.children.push(envElement);
}
});
// Load env getters
let obj = Object.getPrototypeOf(env);
Reflect.ownKeys(obj).forEach((key) => {
@@ -1229,23 +1279,25 @@
const instance = isApp ? node : node.component;
component.instance = { toggled: oldTree ? oldTree.instance.toggled : true, children: [] };
const instancePath = isApp ? path : [...path, { type: "item", value: "component" }];
Reflect.ownKeys(instance).forEach((key) => {
if (!["env", "props"].includes(key)) {
let oldBranch = oldTree?.instance.children[component.instance.children.length];
const instanceElement = this.serializeObjectChild(
instance,
{ type: "item", value: key, childIndex: component.instance.children.length },
0,
"instance",
instancePath,
oldBranch,
oldTree
);
if (instanceElement) {
component.instance.children.push(instanceElement);
Reflect.ownKeys(instance)
.sort(compareKeys)
.forEach((key) => {
if (!["env", "props"].includes(key)) {
let oldBranch = oldTree?.instance.children[component.instance.children.length];
const instanceElement = this.serializeObjectChild(
instance,
{ type: "item", value: key, childIndex: component.instance.children.length },
0,
"instance",
instancePath,
oldBranch,
oldTree
);
if (instanceElement) {
component.instance.children.push(instanceElement);
}
}
}
});
});
// Load instance getters
obj = Object.getPrototypeOf(instance);
while (obj) {
@@ -1361,6 +1413,39 @@
component.subscriptions.children.push(subscription);
});
}
// Load hooks of the component
if (!isApp) {
component.hooks = { toggled: oldTree ? oldTree.hooks.toggled : true, children: [] };
const hooksList = [
"mounted",
"patched",
"willDestroy",
"willPatch",
"willStart",
"willUnmount",
"willUpdateProps",
];
const hooksPath = [...instancePath, { type: "item", value: "__owl__" }];
Reflect.ownKeys(instance.__owl__)
.sort(compareKeys)
.forEach((key) => {
if (hooksList.includes(key)) {
let oldBranch = oldTree?.hooks.children[component.hooks.children.length];
const property = this.serializeObjectChild(
instance.__owl__,
{ type: "item", value: key, childIndex: component.hooks.children.length },
0,
"hook",
hooksPath,
oldBranch,
oldTree
);
if (property) {
component.hooks.children.push(property);
}
}
});
}
return component;
}
// Replace the content of a parsed getter object with the result of the corresponding get method
@@ -1635,6 +1720,37 @@
}
return children;
}
getObservedVariables(current) {
const res = [...current];
for (let i = 0; i < current.length; i++) {
const path = current[i].path;
const parent = this.getObjectProperty(path.slice(0, path.length - 1));
if (parent && !(typeof parent === "string" && parent.startsWith("Exception: "))) {
const result = this.serializeObjectChild(
parent,
path.at(-1),
0,
"observed",
path.slice(0, path.length - 1),
{},
{}
);
result.hasChildren = false;
result.visible = true;
const index = path.findIndex((key) => typeof key !== "string");
if (index > 1) {
const componentNode = this.getComponentNode(path.slice(0, index));
result.path = [this.getComponentSimplifiedPath(componentNode)].concat(
path.slice(index)
);
}
res[i] = result;
} else {
res[i].visible = false;
}
}
return res;
}
// Returns the path of the given component node
getComponentPath(componentNode) {
let path = [];
@@ -1651,6 +1767,23 @@
path.unshift(index.toString());
return path;
}
// Returns the simplified path of the given component node (using component names and indexes)
getComponentSimplifiedPath(componentNode) {
let path = componentNode.name;
if (componentNode.parentKey) {
while (componentNode.parent) {
const previousKey = componentNode.parentKey;
componentNode = componentNode.parent;
path = `${componentNode.name}/${Reflect.ownKeys(componentNode.children).indexOf(
previousKey
)}/${path}`;
}
}
const appsArray = [...this.apps];
let index = appsArray.findIndex((app) => app === componentNode.app);
path = index.toString() + (path.length ? `/${path}` : "");
return path;
}
// Store the object into a temp window variable and log it to the console
sendObjectToConsole(path) {
const obj = this.getObjectProperty(path);
@@ -1698,6 +1831,48 @@
}
}
injectBreakpoint(hook, path, instanceOnly, condition) {
const componentNode = this.getObjectProperty(path);
const injectFunctionInHook = (comp, hook, fn) => {
comp[hook].push(fn);
};
const originalHook = [...componentNode.component.__owl__[hook]];
injectFunctionInHook(componentNode.component.__owl__, hook, () => {
debugger;
});
if (!this.breakpointsHookMap.get([componentNode.component.__owl__, hook])) {
this.breakpointsHookMap.set([componentNode.component.__owl__, hook], originalHook);
}
if (!instanceOnly) {
const componentClass = componentNode.component.constructor;
const originalSetup = componentClass.prototype.setup;
if (!this.breakpointsClassMap.get(componentClass)) {
this.breakpointsClassMap.set(componentClass, originalSetup);
}
componentClass.prototype.setup = function () {
const debuggerFunc = () => {
if (eval(condition)) {
this;
debugger;
}
};
injectFunctionInHook(this.__owl__, hook, debuggerFunc);
originalSetup.call(this, ...arguments);
};
}
}
removeBreakpoints() {
for (const [component, setup] of this.breakpointsClassMap) {
component.prototype.setup = setup;
}
this.breakpointsClassMap.clear();
for (const [ref, originalHook] of this.breakpointsHookMap) {
ref[0][ref[1]] = originalHook;
}
this.breakpointsHookMap.clear();
}
targetName(target, node) {
// check on component
const { component } = node;
@@ -1747,6 +1922,19 @@
}
}
function compareKeys(a, b) {
const isSymbolA = typeof a === "symbol";
const isSymbolB = typeof b === "symbol";
if (isSymbolA && !isSymbolB) {
return 1; // Place Symbols at the end
} else if (!isSymbolA && isSymbolB) {
return -1; // Place non-Symbols at the beginning
} else {
return String(a).localeCompare(String(b), undefined, { numeric: true }); // Sort other keys alphabetically
}
}
function checkOwlStatus() {
let owlStatus = 2;
if (!window.__OWL__DEVTOOLS_GLOBAL_HOOK__) {