Skip to content

Commit 0cde4b8

Browse files
sdegueldreged-odoo
authored andcommitted
[IMP] compiler: add support for the .translate suffix
Previously, if you wanted to pass a prop and have it be translated, you had to either to the translation manually in JS, or use a workaround with t-set and a body so that Owl would translate it for you, and then pass the t-set variable as a prop. This is quite inconvenient and is a common use case. This commit introduces the `.translate` suffix to solve this issue. When a prop uses this suffix, it is treated as a string instead of a JS expression, avoiding the need for quotes as well as their escaping and allowing extraction tools such as babel to generate a clean string as the term's translation id. This is also more ergonomic. This suffix is available for both component props and slot props. This change will still require some work in Odoo to correctly extract the terms for props using this suffix.
1 parent 66a8013 commit 0cde4b8

File tree

7 files changed

+184
-4
lines changed

7 files changed

+184
-4
lines changed

doc/reference/props.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,28 @@ class SomeComponent extends Component {
140140
The `.bind` suffix also implies `.alike`, so these props will not cause additional
141141
renderings.
142142

143+
## Translatable props
144+
145+
When you need to pass a user-facing string to a subcomponent, you likely want it
146+
to be translated. Unfortunately, because props are arbitrary expressions, it wouldn't
147+
be practical for Owl to find out which parts of the expression are strings and translate
148+
them, and it also makes it difficult for tooling to extract these strings to generate
149+
terms to translate. While you can work around this issue by doing the translation in
150+
JavaScript, or by using `t-set` with a body (the body of `t-set` is translated),
151+
and passing the variable as a prop, this is a sufficiently common use case that Owl
152+
provides a suffix for this purpose: `.translate`.
153+
154+
```xml
155+
<t t-name="ParentComponent">
156+
<Child someProp.translate="some message"/>
157+
</t>
158+
```
159+
160+
Note that the content of this attribute is _NOT_ treated as a JavaScript expression:
161+
it is treated as a string, as if it was an attribute on an HTML element, and translated
162+
before being passed to the component. If you need to interpolate some data into the
163+
string, you will still have to do this in JavaScript.
164+
143165
## Dynamic Props
144166

145167
The `t-props` directive can be used to specify totally dynamic props:

doc/reference/slots.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,16 +201,17 @@ use this `Notebook` component:
201201

202202
```xml
203203
<Notebook>
204-
<t t-set-slot="page1" title="'Page 1'">
204+
<t t-set-slot="page1" title.translate="Page 1">
205205
<div>this is in the page 1</div>
206206
</t>
207-
<t t-set-slot="page2" title="'Page 2'" hidden="somevalue">
207+
<t t-set-slot="page2" title.translate="Page 2" hidden="somevalue">
208208
<div>this is in the page 2</div>
209209
</t>
210210
</Notebook>
211211
```
212212

213-
Slot params works like normal props, so one can use the `.bind` suffix to
213+
Slot params works like normal props, so one can use suffixes like `.translate`
214+
when a prop is a user facing string and should be translated, or `.bind` to
214215
bind a function if needed.
215216

216217
## Slot scopes

src/compiler/code_generator.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1136,7 +1136,11 @@ export class CodeGenerator {
11361136
* "onClick.bind" "onClick" "onClick: bind(ctx, ctx['onClick'])"
11371137
*/
11381138
formatProp(name: string, value: string): string {
1139-
value = this.captureExpression(value);
1139+
if (name.endsWith(".translate")) {
1140+
value = toStringExpression(this.translateFn(value));
1141+
} else {
1142+
value = this.captureExpression(value);
1143+
}
11401144
if (name.includes(".")) {
11411145
let [_name, suffix] = name.split(".");
11421146
name = _name;
@@ -1145,6 +1149,7 @@ export class CodeGenerator {
11451149
value = `(${value}).bind(this)`;
11461150
break;
11471151
case "alike":
1152+
case "translate":
11481153
break;
11491154
default:
11501155
throw new OwlError("Invalid prop suffix");

tests/components/__snapshots__/props.test.ts.snap

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,29 @@ exports[`.alike suffix in a simple case 2`] = `
6666
}"
6767
`;
6868
69+
exports[`.translate props are translated 1`] = `
70+
"function anonymous(app, bdom, helpers
71+
) {
72+
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
73+
const comp1 = app.createComponent(\`Child\`, true, false, false, []);
74+
75+
return function template(ctx, node, key = \\"\\") {
76+
return comp1({message: \`translated message\`}, key + \`__1\`, node, this, null);
77+
}
78+
}"
79+
`;
80+
81+
exports[`.translate props are translated 2`] = `
82+
"function anonymous(app, bdom, helpers
83+
) {
84+
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
85+
86+
return function template(ctx, node, key = \\"\\") {
87+
return text(ctx['props'].message);
88+
}
89+
}"
90+
`;
91+
6992
exports[`basics accept ES6-like syntax for props (with getters) 1`] = `
7093
"function anonymous(app, bdom, helpers
7194
) {
@@ -412,6 +435,29 @@ exports[`can bind function prop with bind suffix 2`] = `
412435
}"
413436
`;
414437
438+
exports[`can use .translate suffix 1`] = `
439+
"function anonymous(app, bdom, helpers
440+
) {
441+
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
442+
const comp1 = app.createComponent(\`Child\`, true, false, false, []);
443+
444+
return function template(ctx, node, key = \\"\\") {
445+
return comp1({message: \`some message\`}, key + \`__1\`, node, this, null);
446+
}
447+
}"
448+
`;
449+
450+
exports[`can use .translate suffix 2`] = `
451+
"function anonymous(app, bdom, helpers
452+
) {
453+
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
454+
455+
return function template(ctx, node, key = \\"\\") {
456+
return text(ctx['props'].message);
457+
}
458+
}"
459+
`;
460+
415461
exports[`do not crash when binding anonymous function prop with bind suffix 1`] = `
416462
"function anonymous(app, bdom, helpers
417463
) {

tests/components/__snapshots__/slots.test.ts.snap

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

3+
exports[`slots .translate slot props are translated 1`] = `
4+
"function anonymous(app, bdom, helpers
5+
) {
6+
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
7+
let { capture, markRaw } = helpers;
8+
const comp1 = app.createComponent(\`Child\`, true, true, false, []);
9+
10+
return function template(ctx, node, key = \\"\\") {
11+
const ctx1 = capture(ctx);
12+
return comp1({slots: markRaw({'default': {message: \`translated message\`}})}, key + \`__1\`, node, this, null);
13+
}
14+
}"
15+
`;
16+
17+
exports[`slots .translate slot props are translated 2`] = `
18+
"function anonymous(app, bdom, helpers
19+
) {
20+
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
21+
22+
return function template(ctx, node, key = \\"\\") {
23+
return text(ctx['props'].slots.default.message);
24+
}
25+
}"
26+
`;
27+
328
exports[`slots can define a default content 1`] = `
429
"function anonymous(app, bdom, helpers
530
) {
@@ -201,6 +226,31 @@ exports[`slots can render only empty slot 1`] = `
201226
}"
202227
`;
203228

229+
exports[`slots can use .translate suffix on slot props 1`] = `
230+
"function anonymous(app, bdom, helpers
231+
) {
232+
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
233+
let { capture, markRaw } = helpers;
234+
const comp1 = app.createComponent(\`Child\`, true, true, false, []);
235+
236+
return function template(ctx, node, key = \\"\\") {
237+
const ctx1 = capture(ctx);
238+
return comp1({slots: markRaw({'default': {message: \`some message\`}})}, key + \`__1\`, node, this, null);
239+
}
240+
}"
241+
`;
242+
243+
exports[`slots can use .translate suffix on slot props 2`] = `
244+
"function anonymous(app, bdom, helpers
245+
) {
246+
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
247+
248+
return function template(ctx, node, key = \\"\\") {
249+
return text(ctx['props'].slots.default.message);
250+
}
251+
}"
252+
`;
253+
204254
exports[`slots can use component in default-content of t-slot 1`] = `
205255
"function anonymous(app, bdom, helpers
206256
) {

tests/components/props.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,34 @@ test("bound functions are considered 'alike'", async () => {
299299
expect(fixture.innerHTML).toBe("3child");
300300
});
301301

302+
test("can use .translate suffix", async () => {
303+
class Child extends Component {
304+
static template = xml`<t t-esc="props.message"/>`;
305+
}
306+
307+
class Parent extends Component {
308+
static template = xml`<Child message.translate="some message"/>`;
309+
static components = { Child };
310+
}
311+
312+
await mount(Parent, fixture);
313+
expect(fixture.innerHTML).toBe("some message");
314+
});
315+
316+
test(".translate props are translated", async () => {
317+
class Child extends Component {
318+
static template = xml`<t t-esc="props.message"/>`;
319+
}
320+
321+
class Parent extends Component {
322+
static template = xml`<Child message.translate="some message"/>`;
323+
static components = { Child };
324+
}
325+
326+
await mount(Parent, fixture, { translateFn: () => "translated message" });
327+
expect(fixture.innerHTML).toBe("translated message");
328+
});
329+
302330
test("throw if prop uses an unknown suffix", async () => {
303331
class Child extends Component {
304332
static template = xml`<t t-esc="props.val"/>`;

tests/components/slots.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,34 @@ describe("slots", () => {
179179
expect(fixture.innerHTML).toBe("<span>default empty</span>");
180180
});
181181

182+
test("can use .translate suffix on slot props", async () => {
183+
class Child extends Component {
184+
static template = xml`<t t-esc="props.slots.default.message"/>`;
185+
}
186+
187+
class Parent extends Component {
188+
static template = xml`<Child><t t-set-slot="default" message.translate="some message"/></Child>`;
189+
static components = { Child };
190+
}
191+
192+
await mount(Parent, fixture);
193+
expect(fixture.innerHTML).toBe("some message");
194+
});
195+
196+
test(".translate slot props are translated", async () => {
197+
class Child extends Component {
198+
static template = xml`<t t-esc="props.slots.default.message"/>`;
199+
}
200+
201+
class Parent extends Component {
202+
static template = xml`<Child><t t-set-slot="default" message.translate="some message"/></Child>`;
203+
static components = { Child };
204+
}
205+
206+
await mount(Parent, fixture, { translateFn: () => "translated message" });
207+
expect(fixture.innerHTML).toBe("translated message");
208+
});
209+
182210
test("default slot with slot scope: shorthand syntax", async () => {
183211
let child: any;
184212
class Child extends Component {

0 commit comments

Comments
 (0)