Skip to content

Commit

Permalink
[IMP] parser: add support for custom directives
Browse files Browse the repository at this point in the history
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 : #1650
  • Loading branch information
jpp-odoo committed Nov 21, 2024
1 parent 26c7856 commit 4817304
Show file tree
Hide file tree
Showing 8 changed files with 169 additions and 6 deletions.
31 changes: 31 additions & 0 deletions doc/reference/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- [Sub Templates](#sub-templates)
- [Dynamic Sub Templates](#dynamic-sub-templates)
- [Debugging](#debugging)
- [Custom Directives](#custom-directives)
- [Fragments](#fragments)
- [Inline templates](#inline-templates)
- [Rendering svg](#rendering-svg)
Expand Down Expand Up @@ -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-model` | [Form input bindings](input_bindings.md) |
| `t-tag` | [Rendering nodes with dynamic tag name](#dynamic-tag-names) |
| `t-custom-*` | [Rendering nodes with custom directives](#custom-directives) |

## QWeb Template Reference

Expand Down Expand Up @@ -588,6 +590,35 @@ will stop execution if the browser dev tools are open.

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

Owl 2 supports templates with an arbitrary number of root elements, or even just
Expand Down
4 changes: 4 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type customDirectives = Record<
string,
(node: Element, value: string, modifier?: string) => void
>;
4 changes: 3 additions & 1 deletion src/compiler/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { customDirectives } from "../common/types";
import type { TemplateSet } from "../runtime/template_set";
import type { BDom } from "../runtime/blockdom";
import { CodeGenerator, Config } from "./code_generator";
Expand All @@ -10,13 +11,14 @@ export type TemplateFunction = (app: TemplateSet, bdom: any, helpers: any) => Te

interface CompileOptions extends Config {
name?: string;
customDirectives?: customDirectives;
}
export function compile(
template: string | Element,
options: CompileOptions = {}
): TemplateFunction {
// parsing
const ast = parse(template);
const ast = parse(template, options.customDirectives);

// some work
const hasSafeContext =
Expand Down
47 changes: 42 additions & 5 deletions src/compiler/parser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { OwlError } from "../common/owl_error";
import type { customDirectives } from "../common/types";
import { parseXML } from "../common/utils";

// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -198,37 +199,42 @@ export type AST =
// -----------------------------------------------------------------------------
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") {
const elem = parseXML(`<t>${xml}</t>`).firstChild as Element;
return _parse(elem);
return _parse(elem, ctx);
}
let ast = cache.get(xml);
if (!ast) {
// 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);
}
return ast;
}

function _parse(xml: Element): AST {
function _parse(xml: Element, ctx: ParsingContext): AST {
normalizeXML(xml);
const ctx = { inPreTag: false };
return parseNode(xml, ctx) || { type: ASTType.Text, value: "" };
}

interface ParsingContext {
tModelInfo?: TModelInfo | null;
nameSpace?: string;
inPreTag: boolean;
customDirectives?: customDirectives;
}

function parseNode(node: Node, ctx: ParsingContext): AST | null {
if (!(node instanceof Element)) {
return parseTextCommentNode(node, ctx);
}
return (
parseTCustom(node, ctx) ||
parseTDebugLog(node, ctx) ||
parseTForEach(node, ctx) ||
parseTIf(node, ctx) ||
Expand Down Expand Up @@ -277,6 +283,37 @@ function parseTextCommentNode(node: Node, ctx: ParsingContext): AST | 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
// -----------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ TemplateSet.prototype._compileTemplate = function _compileTemplate(
dev: this.dev,
translateFn: this.translateFn,
translatableAttributes: this.translatableAttributes,
customDirectives: this.customDirectives,
});
};
4 changes: 4 additions & 0 deletions src/runtime/template_set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Portal, portalTemplate } from "./portal";
import { helpers } from "./template_helpers";
import { OwlError } from "../common/owl_error";
import { parseXML } from "../common/utils";
import type { customDirectives } from "../common/types";

const bdom = { text, createBlock, list, multi, html, toggler, comment };

Expand All @@ -14,6 +15,7 @@ export interface TemplateSetConfig {
translateFn?: (s: string) => string;
templates?: string | Document | Record<string, string>;
getTemplate?: (s: string) => Element | Function | string | void;
customDirectives?: customDirectives;
}

export class TemplateSet {
Expand All @@ -27,6 +29,7 @@ export class TemplateSet {
translateFn?: (s: string) => string;
translatableAttributes?: string[];
Portal = Portal;
customDirectives: customDirectives;

constructor(config: TemplateSetConfig = {}) {
this.dev = config.dev || false;
Expand All @@ -42,6 +45,7 @@ export class TemplateSet {
}
}
this.getRawTemplate = config.getTemplate;
this.customDirectives = config.customDirectives || {};
}

addTemplate(name: string, template: string | Element) {
Expand Down
29 changes: 29 additions & 0 deletions tests/compiler/__snapshots__/t_custom.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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 changes: 55 additions & 0 deletions tests/compiler/t_custom.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});

0 comments on commit 4817304

Please sign in to comment.