diff --git a/doc/reference/props.md b/doc/reference/props.md index b9f864e36..508afad30 100644 --- a/doc/reference/props.md +++ b/doc/reference/props.md @@ -140,6 +140,28 @@ class SomeComponent extends Component { The `.bind` suffix also implies `.alike`, so these props will not cause additional renderings. +## Translatable props + +When you need to pass a user-facing string to a subcomponent, you likely want it +to be translated. Unfortunately, because props are arbitrary expressions, it wouldn't +be practical for Owl to find out which parts of the expression are strings and translate +them, and it also makes it difficult for tooling to extract these strings to generate +terms to translate. While you can work around this issue by doing the translation in +JavaScript, or by using `t-set` with a body (the body of `t-set` is translated), +and passing the variable as a prop, this is a sufficiently common use case that Owl +provides a suffix for this purpose: `.translate`. + +```xml + + + +``` + +Note that the content of this attribute is _NOT_ treated as a JavaScript expression: +it is treated as a string, as if it was an attribute on an HTML element, and translated +before being passed to the component. If you need to interpolate some data into the +string, you will still have to do this in JavaScript. + ## Dynamic Props The `t-props` directive can be used to specify totally dynamic props: diff --git a/doc/reference/slots.md b/doc/reference/slots.md index d565a7314..3fb00e3fb 100644 --- a/doc/reference/slots.md +++ b/doc/reference/slots.md @@ -201,16 +201,17 @@ use this `Notebook` component: ```xml - +
this is in the page 1
-
``` -Slot params works like normal props, so one can use the `.bind` suffix to +Slot params works like normal props, so one can use suffixes like `.translate` +when a prop is a user facing string and should be translated, or `.bind` to bind a function if needed. ## Slot scopes diff --git a/src/compiler/code_generator.ts b/src/compiler/code_generator.ts index ed1d97bf8..63e40f02a 100644 --- a/src/compiler/code_generator.ts +++ b/src/compiler/code_generator.ts @@ -1136,7 +1136,11 @@ export class CodeGenerator { * "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])" */ formatProp(name: string, value: string): string { - value = this.captureExpression(value); + if (name.endsWith(".translate")) { + value = toStringExpression(this.translateFn(value)); + } else { + value = this.captureExpression(value); + } if (name.includes(".")) { let [_name, suffix] = name.split("."); name = _name; @@ -1145,6 +1149,7 @@ export class CodeGenerator { value = `(${value}).bind(this)`; break; case "alike": + case "translate": break; default: throw new OwlError("Invalid prop suffix"); diff --git a/tests/components/__snapshots__/props.test.ts.snap b/tests/components/__snapshots__/props.test.ts.snap index 7a0f8b1f3..6504ed856 100644 --- a/tests/components/__snapshots__/props.test.ts.snap +++ b/tests/components/__snapshots__/props.test.ts.snap @@ -66,6 +66,29 @@ exports[`.alike suffix in a simple case 2`] = ` }" `; +exports[`.translate props are translated 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + const comp1 = app.createComponent(\`Child\`, true, false, false, []); + + return function template(ctx, node, key = \\"\\") { + return comp1({message: \`translated message\`}, key + \`__1\`, node, this, null); + } +}" +`; + +exports[`.translate props are translated 2`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + return text(ctx['props'].message); + } +}" +`; + exports[`basics accept ES6-like syntax for props (with getters) 1`] = ` "function anonymous(app, bdom, helpers ) { @@ -412,6 +435,29 @@ exports[`can bind function prop with bind suffix 2`] = ` }" `; +exports[`can use .translate suffix 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + const comp1 = app.createComponent(\`Child\`, true, false, false, []); + + return function template(ctx, node, key = \\"\\") { + return comp1({message: \`some message\`}, key + \`__1\`, node, this, null); + } +}" +`; + +exports[`can use .translate suffix 2`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + return text(ctx['props'].message); + } +}" +`; + exports[`do not crash when binding anonymous function prop with bind suffix 1`] = ` "function anonymous(app, bdom, helpers ) { diff --git a/tests/components/__snapshots__/slots.test.ts.snap b/tests/components/__snapshots__/slots.test.ts.snap index 068d76fd4..ced3282b3 100644 --- a/tests/components/__snapshots__/slots.test.ts.snap +++ b/tests/components/__snapshots__/slots.test.ts.snap @@ -1,5 +1,30 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`slots .translate slot props are translated 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + let { capture, markRaw } = helpers; + const comp1 = app.createComponent(\`Child\`, true, true, false, []); + + return function template(ctx, node, key = \\"\\") { + const ctx1 = capture(ctx); + return comp1({slots: markRaw({'default': {message: \`translated message\`}})}, key + \`__1\`, node, this, null); + } +}" +`; + +exports[`slots .translate slot props are translated 2`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + return text(ctx['props'].slots.default.message); + } +}" +`; + exports[`slots can define a default content 1`] = ` "function anonymous(app, bdom, helpers ) { @@ -201,6 +226,31 @@ exports[`slots can render only empty slot 1`] = ` }" `; +exports[`slots can use .translate suffix on slot props 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + let { capture, markRaw } = helpers; + const comp1 = app.createComponent(\`Child\`, true, true, false, []); + + return function template(ctx, node, key = \\"\\") { + const ctx1 = capture(ctx); + return comp1({slots: markRaw({'default': {message: \`some message\`}})}, key + \`__1\`, node, this, null); + } +}" +`; + +exports[`slots can use .translate suffix on slot props 2`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + return function template(ctx, node, key = \\"\\") { + return text(ctx['props'].slots.default.message); + } +}" +`; + exports[`slots can use component in default-content of t-slot 1`] = ` "function anonymous(app, bdom, helpers ) { diff --git a/tests/components/props.test.ts b/tests/components/props.test.ts index e1a11a9ab..540304120 100644 --- a/tests/components/props.test.ts +++ b/tests/components/props.test.ts @@ -299,6 +299,34 @@ test("bound functions are considered 'alike'", async () => { expect(fixture.innerHTML).toBe("3child"); }); +test("can use .translate suffix", async () => { + class Child extends Component { + static template = xml``; + } + + class Parent extends Component { + static template = xml``; + static components = { Child }; + } + + await mount(Parent, fixture); + expect(fixture.innerHTML).toBe("some message"); +}); + +test(".translate props are translated", async () => { + class Child extends Component { + static template = xml``; + } + + class Parent extends Component { + static template = xml``; + static components = { Child }; + } + + await mount(Parent, fixture, { translateFn: () => "translated message" }); + expect(fixture.innerHTML).toBe("translated message"); +}); + test("throw if prop uses an unknown suffix", async () => { class Child extends Component { static template = xml``; diff --git a/tests/components/slots.test.ts b/tests/components/slots.test.ts index 167358579..a0a75b465 100644 --- a/tests/components/slots.test.ts +++ b/tests/components/slots.test.ts @@ -179,6 +179,34 @@ describe("slots", () => { expect(fixture.innerHTML).toBe("default empty"); }); + test("can use .translate suffix on slot props", async () => { + class Child extends Component { + static template = xml``; + } + + class Parent extends Component { + static template = xml``; + static components = { Child }; + } + + await mount(Parent, fixture); + expect(fixture.innerHTML).toBe("some message"); + }); + + test(".translate slot props are translated", async () => { + class Child extends Component { + static template = xml``; + } + + class Parent extends Component { + static template = xml``; + static components = { Child }; + } + + await mount(Parent, fixture, { translateFn: () => "translated message" }); + expect(fixture.innerHTML).toBe("translated message"); + }); + test("default slot with slot scope: shorthand syntax", async () => { let child: any; class Child extends Component {