[IMP] parser: add support for custom directives

This commit adds the support for custom directives. To use the
custom directive, an Object of functions needs to be configured on
the owl APP:
```js
 new App(..., {
    customDirectives: {
     test_directive: function (el, value) {
            el.setAttribute("t-on-click", value);
            return el;
       }
   }
  });
```
The functions will be called when a custom directive with the name of the
function is found. The original element will be replaced with the one
returned by the function.
This :
```xml
<div t-custom-test_directive="click" />
```
will be replace by :
```xml
<div t-on-click="value"/>
```

issue : https://github.com/odoo/owl/issues/1650
This commit is contained in:
Jorge Pinna Puissant
2024-11-07 12:26:45 +01:00
committed by Géry Debongnie
parent 968e96ad08
commit 7e687234bf
9 changed files with 171 additions and 6 deletions
+2
View File
@@ -66,6 +66,8 @@ The `config` object is an object with some of the following keys:
needs a template. If undefined is returned, owl looks into the app templates. needs a template. If undefined is returned, owl looks into the app templates.
- **`warnIfNoStaticProps (boolean, default=false)`**: if true, Owl will log a warning - **`warnIfNoStaticProps (boolean, default=false)`**: if true, Owl will log a warning
whenever it encounters a component that does not provide a [static props description](props.md#props-validation). whenever it encounters a component that does not provide a [static props description](props.md#props-validation).
- **`customDirectives (object)`**: if given, the corresponding function on the object will be called
on the template custom directives: `t-custom-*` (see [Custom Directives](templates.md#custom-directives)).
## `mount` helper ## `mount` helper
+31
View File
@@ -18,6 +18,7 @@
- [Sub Templates](#sub-templates) - [Sub Templates](#sub-templates)
- [Dynamic Sub Templates](#dynamic-sub-templates) - [Dynamic Sub Templates](#dynamic-sub-templates)
- [Debugging](#debugging) - [Debugging](#debugging)
- [Custom Directives](#custom-directives)
- [Fragments](#fragments) - [Fragments](#fragments)
- [Inline templates](#inline-templates) - [Inline templates](#inline-templates)
- [Rendering svg](#rendering-svg) - [Rendering svg](#rendering-svg)
@@ -80,6 +81,7 @@ needs. Here is a list of all Owl specific directives:
| `t-slot`, `t-set-slot`, `t-slot-scope` | [Rendering a slot](slots.md) | | `t-slot`, `t-set-slot`, `t-slot-scope` | [Rendering a slot](slots.md) |
| `t-model` | [Form input bindings](input_bindings.md) | | `t-model` | [Form input bindings](input_bindings.md) |
| `t-tag` | [Rendering nodes with dynamic tag name](#dynamic-tag-names) | | `t-tag` | [Rendering nodes with dynamic tag name](#dynamic-tag-names) |
| `t-custom-*` | [Rendering nodes with custom directives](#custom-directives) |
## QWeb Template Reference ## QWeb Template Reference
@@ -588,6 +590,35 @@ will stop execution if the browser dev tools are open.
will print 42 to the console. will print 42 to the console.
### Custom Directives
Owl 2 supports the declaration of custom directives. To use them, an Object of functions needs to be configured on the owl APP:
```js
new App(..., {
customDirectives: {
test_directive: function (el, value) {
el.setAttribute("t-on-click", value);
}
}
});
```
The functions will be called when a custom directive with the name of the
function is found. The original element will be replaced with the one
modified by the function.
This :
```xml
<div t-custom-test_directive="click" />
```
will be replaced by :
```xml
<div t-on-click="value"/>
```
## Fragments ## Fragments
Owl 2 supports templates with an arbitrary number of root elements, or even just Owl 2 supports templates with an arbitrary number of root elements, or even just
+4
View File
@@ -0,0 +1,4 @@
export type customDirectives = Record<
string,
(node: Element, value: string, modifier?: string) => void
>;
+3 -1
View File
@@ -1,3 +1,4 @@
import type { customDirectives } from "../common/types";
import type { TemplateSet } from "../runtime/template_set"; import type { TemplateSet } from "../runtime/template_set";
import type { BDom } from "../runtime/blockdom"; import type { BDom } from "../runtime/blockdom";
import { CodeGenerator, Config } from "./code_generator"; import { CodeGenerator, Config } from "./code_generator";
@@ -10,13 +11,14 @@ export type TemplateFunction = (app: TemplateSet, bdom: any, helpers: any) => Te
interface CompileOptions extends Config { interface CompileOptions extends Config {
name?: string; name?: string;
customDirectives?: customDirectives;
} }
export function compile( export function compile(
template: string | Element, template: string | Element,
options: CompileOptions = {} options: CompileOptions = {}
): TemplateFunction { ): TemplateFunction {
// parsing // parsing
const ast = parse(template); const ast = parse(template, options.customDirectives);
// some work // some work
const hasSafeContext = const hasSafeContext =
+42 -5
View File
@@ -1,4 +1,5 @@
import { OwlError } from "../common/owl_error"; import { OwlError } from "../common/owl_error";
import type { customDirectives } from "../common/types";
import { parseXML } from "../common/utils"; import { parseXML } from "../common/utils";
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -198,23 +199,26 @@ export type AST =
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
const cache: WeakMap<Element, AST> = new WeakMap(); const cache: WeakMap<Element, AST> = new WeakMap();
export function parse(xml: string | Element): AST { export function parse(xml: string | Element, customDir?: customDirectives): AST {
const ctx = {
inPreTag: false,
customDirectives: customDir,
};
if (typeof xml === "string") { if (typeof xml === "string") {
const elem = parseXML(`<t>${xml}</t>`).firstChild as Element; const elem = parseXML(`<t>${xml}</t>`).firstChild as Element;
return _parse(elem); return _parse(elem, ctx);
} }
let ast = cache.get(xml); let ast = cache.get(xml);
if (!ast) { if (!ast) {
// we clone here the xml to prevent modifying it in place // we clone here the xml to prevent modifying it in place
ast = _parse(xml.cloneNode(true) as Element); ast = _parse(xml.cloneNode(true) as Element, ctx);
cache.set(xml, ast); cache.set(xml, ast);
} }
return ast; return ast;
} }
function _parse(xml: Element): AST { function _parse(xml: Element, ctx: ParsingContext): AST {
normalizeXML(xml); normalizeXML(xml);
const ctx = { inPreTag: false };
return parseNode(xml, ctx) || { type: ASTType.Text, value: "" }; return parseNode(xml, ctx) || { type: ASTType.Text, value: "" };
} }
@@ -222,6 +226,7 @@ interface ParsingContext {
tModelInfo?: TModelInfo | null; tModelInfo?: TModelInfo | null;
nameSpace?: string; nameSpace?: string;
inPreTag: boolean; inPreTag: boolean;
customDirectives?: customDirectives;
} }
function parseNode(node: Node, ctx: ParsingContext): AST | null { function parseNode(node: Node, ctx: ParsingContext): AST | null {
@@ -229,6 +234,7 @@ function parseNode(node: Node, ctx: ParsingContext): AST | null {
return parseTextCommentNode(node, ctx); return parseTextCommentNode(node, ctx);
} }
return ( return (
parseTCustom(node, ctx) ||
parseTDebugLog(node, ctx) || parseTDebugLog(node, ctx) ||
parseTForEach(node, ctx) || parseTForEach(node, ctx) ||
parseTIf(node, ctx) || parseTIf(node, ctx) ||
@@ -277,6 +283,37 @@ function parseTextCommentNode(node: Node, ctx: ParsingContext): AST | null {
return null; return null;
} }
function parseTCustom(node: Element, ctx: ParsingContext): AST | null {
if (!ctx.customDirectives) {
return null;
}
const nodeAttrsNames = node.getAttributeNames();
for (let attr of nodeAttrsNames) {
if (attr === "t-custom" || attr === "t-custom-") {
throw new OwlError("Missing custom directive name with t-custom directive");
}
if (attr.startsWith("t-custom-")) {
const directiveName = attr.split(".")[0].slice(9);
const customDirective = ctx.customDirectives[directiveName];
if (!customDirective) {
throw new OwlError(`Custom directive "${directiveName}" is not defined`);
}
const value = node.getAttribute(attr)!;
const modifier = attr.split(".").length > 1 ? attr.split(".")[1] : undefined;
node.removeAttribute(attr);
try {
customDirective(node, value, modifier);
} catch (error) {
throw new OwlError(
`Custom directive "${directiveName}" throw the following error: ${error}`
);
}
return parseNode(node, ctx);
}
}
return null;
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// debugging // debugging
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
+1
View File
@@ -12,5 +12,6 @@ TemplateSet.prototype._compileTemplate = function _compileTemplate(
dev: this.dev, dev: this.dev,
translateFn: this.translateFn, translateFn: this.translateFn,
translatableAttributes: this.translatableAttributes, translatableAttributes: this.translatableAttributes,
customDirectives: this.customDirectives,
}); });
}; };
+4
View File
@@ -5,6 +5,7 @@ import { Portal, portalTemplate } from "./portal";
import { helpers } from "./template_helpers"; import { helpers } from "./template_helpers";
import { OwlError } from "../common/owl_error"; import { OwlError } from "../common/owl_error";
import { parseXML } from "../common/utils"; import { parseXML } from "../common/utils";
import type { customDirectives } from "../common/types";
const bdom = { text, createBlock, list, multi, html, toggler, comment }; const bdom = { text, createBlock, list, multi, html, toggler, comment };
@@ -14,6 +15,7 @@ export interface TemplateSetConfig {
translateFn?: (s: string) => string; translateFn?: (s: string) => string;
templates?: string | Document | Record<string, string>; templates?: string | Document | Record<string, string>;
getTemplate?: (s: string) => Element | Function | string | void; getTemplate?: (s: string) => Element | Function | string | void;
customDirectives?: customDirectives;
} }
export class TemplateSet { export class TemplateSet {
@@ -27,6 +29,7 @@ export class TemplateSet {
translateFn?: (s: string) => string; translateFn?: (s: string) => string;
translatableAttributes?: string[]; translatableAttributes?: string[];
Portal = Portal; Portal = Portal;
customDirectives: customDirectives;
constructor(config: TemplateSetConfig = {}) { constructor(config: TemplateSetConfig = {}) {
this.dev = config.dev || false; this.dev = config.dev || false;
@@ -42,6 +45,7 @@ export class TemplateSet {
} }
} }
this.getRawTemplate = config.getTemplate; this.getRawTemplate = config.getTemplate;
this.customDirectives = config.customDirectives || {};
} }
addTemplate(name: string, template: string | Element) { addTemplate(name: string, template: string | Element) {
@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`t-custom can use t-custom directive on a node 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\" block-handler-0=\\"click\\"/>\`);
return function template(ctx, node, key = \\"\\") {
let hdlr1 = [ctx['click'], ctx];
return block1([hdlr1]);
}
}"
`;
exports[`t-custom can use t-custom directive with modifier on a node 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
let block1 = createBlock(\`<div class=\\"my-div\\" block-handler-0=\\"click\\"/>\`);
return function template(ctx, node, key = \\"\\") {
let hdlr1 = [ctx['click'], ctx];
return block1([hdlr1]);
}
}"
`;
+55
View File
@@ -0,0 +1,55 @@
import { App, Component, xml } from "../../src";
import { makeTestFixture, snapshotEverything } from "../helpers";
let fixture: HTMLElement;
snapshotEverything();
beforeEach(() => {
fixture = makeTestFixture();
});
describe("t-custom", () => {
test("can use t-custom directive on a node", async () => {
const steps: string[] = [];
class SomeComponent extends Component {
static template = xml`<div t-custom-plop="click" class="my-div"/>`;
click() {
steps.push("clicked");
}
}
const app = new App(SomeComponent, {
customDirectives: {
plop: (node, value) => {
node.setAttribute("t-on-click", value);
},
},
});
await app.mount(fixture);
expect(fixture.innerHTML).toBe(`<div class="my-div"></div>`);
fixture.querySelector("div")!.click();
expect(steps).toEqual(["clicked"]);
});
test("can use t-custom directive with modifier on a node", async () => {
const steps: string[] = [];
class SomeComponent extends Component {
static template = xml`<div t-custom-plop.mouse="click" class="my-div"/>`;
click() {
steps.push("clicked");
}
}
const app = new App(SomeComponent, {
customDirectives: {
plop: (node, value, modifier) => {
node.setAttribute("t-on-click", value);
steps.push(modifier || "");
},
},
});
await app.mount(fixture);
expect(fixture.innerHTML).toBe(`<div class="my-div"></div>`);
fixture.querySelector("div")!.click();
expect(steps).toEqual(["mouse", "clicked"]);
});
});