[IMP] qweb: add t-slot directive

Closes #67
This commit is contained in:
Géry Debongnie
2019-06-11 15:02:10 +02:00
parent 45a2b0122d
commit 710f42d4e4
7 changed files with 388 additions and 5 deletions
+61
View File
@@ -15,6 +15,7 @@
- [`t-mounted` directive](#t-mounted-directive)
- [Props Validation](#props-validation)
- [Keeping References](#keeping-references)
- [Slots](#slots)
- [Asynchronous rendering](#asynchronous-rendering)
## Overview
@@ -748,6 +749,66 @@ The `t-ref` directive also accepts dynamic values with string interpolation
this.refs.widget_44;
```
### Slots
To make generic components, it is useful to be able for a parent widget to *inject*
some sub template, but still be the owner. For example, a generic dialog widget
will need to render some content, some footer, but with the parent as the
rendering context.
This is what *slots* are for.
```xml
<div t-name="Dialog" class="modal">
<div class="modal-title"><t t-esc="props.title"/></div>
<div class="modal-content">
<t t-slot="content"/>
</div>
<div class="modal-footer">
<t t-slot="footer"/>
</div>
</div>
```
Slots are defined by the caller, with the `t-set` directive:
```xml
<div t-name="SomeWidget">
<div>some widget</div>
<t t-widget="Dialog" title="Some Dialog">
<t t-set="content">
<div>hey</div>
</t>
<t t-set="footer">
<button t-on-click="doSomething">ok</button>
</t>
</t>
</div>
```
In this example, the widget `Dialog` will render the slots `content` and `footer`
with its parent as rendering context. This means that clicking on the button
will execute the `doSomething` method on the parent, not on the dialog.
Warning! Slots have a technical constraint: the result of the slot rendering
should have exactly one root node. So,
```xml
<t t-set="content">
<div>A</div>
<div>B</div>
</t>
```
is not allowed. A workaround could be to wrap the content in a div:
```xml
<div t-set="content">
<div>A</div>
<div>B</div>
</div>
```
### Asynchronous rendering
Working with asynchronous code always adds a lot of complexity to a system. Whenever
+3 -2
View File
@@ -73,6 +73,7 @@ needs. Here is a list of all Owl specific directives:
| `t-on-*` | [Event handling](component.md#event-handling) |
| `t-transition` | [Defining an animation](animations.md#css-transitions) |
| `t-mounted` | [Callback when a node or component is mounted](component.md#t-mounted-directive) |
| `t-slot` | [Rendering a slot](component.md#slots) |
## QWeb Engine
@@ -177,10 +178,10 @@ root nodes.
QWeb expressions are strings that will be processed at compile time. Each variable in
the javascript expression will be replaced by a lookup in the context (so, the
widget). For example, `a + b.c(d)` will be converted into:
widget). For example, `a + b.c(d)` will be converted into:
```js
context['a'] + context['b'].c(context['d'])
context["a"] + context["b"].c(context["d"]);
```
It is useful to explain the various rules that applies on these expressions:
+5
View File
@@ -140,6 +140,11 @@ export class QWeb {
// able to map a qweb instance to a template name.
id = nextID++;
// slots contains sub templates defined with t-set inside t-widget nodes, and
// are meant to be used by the t-slot directive.
slots = {};
nextSlotId = 1;
constructor(data?: string) {
if (data) {
this.addTemplates(data);
+35 -1
View File
@@ -11,6 +11,7 @@ import { QWeb, UTILS } from "./qweb_core";
* - t-transition
* - t-widget/t-keepalive
* - t-mounted
* - t-slot
*/
//------------------------------------------------------------------------------
@@ -351,7 +352,7 @@ QWeb.addDirective({
name: "widget",
extraNames: ["props", "keepalive"],
priority: 100,
atNodeEncounter({ ctx, value, node }): boolean {
atNodeEncounter({ ctx, value, node, qweb }): boolean {
ctx.addLine("//WIDGET");
ctx.rootContext.shouldDefineOwner = true;
ctx.rootContext.shouldDefineQWeb = true;
@@ -523,6 +524,21 @@ QWeb.addDirective({
ctx.addLine(
`context.__owl__.cmap[${templateID}] = w${widgetID}.__owl__.id;`
);
// SLOTS
const slotNodes = node.querySelectorAll("[t-set]");
if (slotNodes.length) {
const slotId = qweb.nextSlotId++;
for (let i = 0, length = slotNodes.length; i < length; i++) {
const slotNode = slotNodes[i];
const key = slotNode.getAttribute("t-set")!;
slotNode.removeAttribute("t-set");
const slotFn = qweb._compile(`slot_${key}_template`, slotNode);
qweb.slots[`${slotId}_${key}`] = slotFn.bind(qweb);
}
ctx.addLine(`w${widgetID}.__owl__.slotId = ${slotId};`);
}
ctx.addLine(`def${defID} = w${widgetID}._prepare();`);
// hack: specify empty remove hook to prevent the node from being removed from the DOM
// FIXME: click to re-add widget during remove transition -> leak
@@ -602,3 +618,21 @@ QWeb.addDirective({
);
}
});
//------------------------------------------------------------------------------
// t-slot
//------------------------------------------------------------------------------
QWeb.addDirective({
name: "slot",
priority: 80,
atNodeEncounter({ ctx, value }): boolean {
const slotKey = ctx.generateID();
ctx.addLine(
`const slot${slotKey} = this.slots[context.__owl__.slotId + '_' + '${value}'];`
);
ctx.addLine(
`c${ctx.parentNode}.push(slot${slotKey}(context.__owl__.parent, extra));`
);
return true;
}
});
@@ -583,3 +583,64 @@ exports[`random stuff/miscellaneous snapshotting compiled code 1`] = `
return vn1;
}"
`;
exports[`t-slot directive can define and call slots 1`] = `
"function anonymous(context,extra
) {
let utils = this.utils;
let QWeb = this.constructor;
let owner = context;
var h = this.utils.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
//WIDGET
let _2_index = c1.length;
c1.push(null);
let def3;
let w4 = 4 in context.__owl__.cmap ? context.__owl__.children[context.__owl__.cmap[4]] : false;
let props4 = {};
if (w4 && w4.__owl__.renderPromise && !w4.__owl__.vnode) {
if (utils.shallowEqual(props4, w4.__owl__.renderProps)) {
def3 = w4.__owl__.renderPromise;
} else {
w4.destroy();
w4 = false;
}
}
if (!w4) {
let widgetKey4 = \`Dialog\`;
let W4 = context.widgets && context.widgets[widgetKey4] || QWeb.widgets[widgetKey4];
if (!W4) {throw new Error('Cannot find the definition of widget \\"' + widgetKey4 + '\\"')}
w4 = new W4(owner, props4);
context.__owl__.cmap[4] = w4.__owl__.id;
w4.__owl__.slotsId = 1;
def3 = w4._prepare();
def3 = def3.then(vnode=>{let pvnode=h(vnode.sel, {key: 4, hook: {insert(vn) {let nvn=w4._mount(vnode, pvnode.elm);pvnode.elm=nvn.elm;},remove() {},destroy(vn) {w4.destroy();}}});c1[_2_index]=pvnode;w4.__owl__.pvnode = pvnode;});
} else {
def3 = def3 || w4._updateProps(props4, extra.forceUpdate, extra.patchQueue);
def3 = def3.then(()=>{if (w4.__owl__.isDestroyed) {return};let pvnode=w4.__owl__.pvnode;c1[_2_index]=pvnode;});
}
extra.promises.push(def3);
return vn1;
}"
`;
exports[`t-slot directive can define and call slots 2`] = `
"function anonymous(context,extra
) {
var h = this.utils.h;
let c1 = [], p1 = {key:1};
var vn1 = h('div', p1, c1);
let c2 = [], p2 = {key:2};
var vn2 = h('div', p2, c2);
c1.push(vn2);
const slot3 = this.slots[context.__owl__.slotsId + '_' + 'header'];
c2.push(slot3(context.__owl__.parent, extra));
let c4 = [], p4 = {key:4};
var vn4 = h('div', p4, c4);
c1.push(vn4);
const slot5 = this.slots[context.__owl__.slotsId + '_' + 'footer'];
c4.push(slot5(context.__owl__.parent, extra));
return vn1;
}"
`;
+101
View File
@@ -2613,3 +2613,104 @@ describe("can deduce template from name", () => {
expect(fixture.innerHTML).toBe("<span>Rochefort 10</span>");
});
});
describe("t-slot directive", () => {
test("can define and call slots", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="Parent">
<t t-widget="Dialog">
<t t-set="header"><span>header</span></t>
<t t-set="footer"><span>footer</span></t>
</t>
</div>
<div t-name="Dialog">
<div><t t-slot="header"/></div>
<div><t t-slot="footer"/></div>
</div>
</templates>
`);
class Dialog extends Widget {}
class Parent extends Widget {
widgets = { Dialog };
}
const parent = new Parent(env);
await parent.mount(fixture);
expect(fixture.innerHTML).toBe(
"<div><div><div><span>header</span></div><div><span>footer</span></div></div></div>"
);
expect(env.qweb.templates.Parent.fn.toString()).toMatchSnapshot();
expect(env.qweb.templates.Dialog.fn.toString()).toMatchSnapshot();
});
test("slots are rendered with proper context", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="Parent">
<span class="counter"><t t-esc="state.val"/></span>
<t t-widget="Dialog">
<t t-set="footer"><button t-on-click="doSomething">do something</button></t>
</t>
</div>
<span t-name="Dialog"><t t-slot="footer"/></span>
</templates>
`);
class Dialog extends Widget {}
class Parent extends Widget {
widgets = { Dialog };
state = { val: 0 };
doSomething() {
this.state.val++;
}
}
const parent = new Parent(env);
await parent.mount(fixture);
expect(fixture.innerHTML).toBe(
'<div><span class="counter">0</span><span><button>do something</button></span></div>'
);
fixture.querySelector("button")!.click();
await nextTick();
expect(fixture.innerHTML).toBe(
'<div><span class="counter">1</span><span><button>do something</button></span></div>'
);
});
test("refs are properly bound in slots", async () => {
env.qweb.addTemplates(`
<templates>
<div t-name="Parent">
<span class="counter"><t t-esc="state.val"/></span>
<t t-widget="Dialog">
<t t-set="footer"><button t-ref="myButton" t-on-click="doSomething">do something</button></t>
</t>
</div>
<span t-name="Dialog"><t t-slot="footer"/></span>
</templates>
`);
class Dialog extends Widget {}
class Parent extends Widget {
widgets = { Dialog };
state = { val: 0 };
doSomething() {
this.state.val++;
}
}
const parent = new Parent(env);
await parent.mount(fixture);
expect(fixture.innerHTML).toBe(
'<div><span class="counter">0</span><span><button>do something</button></span></div>'
);
(<any>parent.refs.myButton).click();
await nextTick();
expect(fixture.innerHTML).toBe(
'<div><span class="counter">1</span><span><button>do something</button></span></div>'
);
});
});
+122 -2
View File
@@ -233,7 +233,6 @@ const ANIMATION_CSS = `button {
}
`;
const LIFECYCLE_DEMO = `class HookWidget extends owl.Component {
constructor() {
super(...arguments);
@@ -1082,6 +1081,121 @@ const RESPONSIVE_CSS = `body {
}
`;
const SLOTS = `// This example will not work if your browser does not support ESNext class fields
// We show here how slots can be used to create generic components. In this
// example, the Card component is basically only a container, and is created
// by giving it slots, inside the t-widget directive.
class Card extends owl.Component {
state = { fullDisplay: true };
toggleDisplay() {
this.state.fullDisplay = !this.state.fullDisplay;
}
}
class Counter extends owl.Component {
state = {val: 1};
inc() {
this.state.val++;
}
}
// Main root widget
class App extends owl.Component {
widgets = {Card, Counter};
state = {a: 1, b: 3};
inc(key, delta) {
this.state[key] += delta;
}
}
// Application setup
const qweb = new owl.QWeb(TEMPLATES);
const app = new App({ qweb });
app.mount(document.body);`;
const SLOTS_XML = `<templates>
<div t-name="Card" class="card" t-att-class="state.fullDisplay ? 'full' : 'small'">
<div class="card-title">
<t t-esc="props.title"/><button t-on-click="toggleDisplay">Toggle</button>
</div>
<t t-if="state.fullDisplay">
<div class="card-content" >
<t t-slot="content"/>
</div>
<div class="card-footer">
<t t-slot="footer"/>
</div>
</t>
</div>
<div t-name="Counter">
<t t-esc="state.val"/><button t-on-click="inc">Inc</button>
</div>
<div t-name="App" class="main">
<t t-widget="Card" title="'Title card A'">
<t t-set="content">Content of card 1... [<t t-esc="state.a"/>]</t>
<t t-set="footer"><button t-on-click="inc('a', 1)">Increment A</button></t>
</t>
<t t-widget="Card" title="'Title card B'">
<div t-set="content">
<div>Card 2... [<t t-esc="state.b"/>]</div>
<t t-widget="Counter"/>
</div>
<t t-set="footer"><button t-on-click="inc('b', -1)">Decrement B</button></t>
</t>
</div>
</templates>`;
const SLOTS_CSS = `.main {
display: flex;
}
.card {
display: flex;
flex-direction: column;
background-color: #eeeeee;
width: 200px;
height: 100px;
margin: 10px;
border: 1px solid gray;
}
.card.full {
height: 100px;
}
.card.small {
height: 25px;
}
.card-title {
flex: 0 0 25px;
font-weight: bold;
background-color: darkcyan;
color: white;
padding: 2px;
}
.card-title button {
float: right;
}
.card-content {
flex: 1 1 auto;
padding: 5px;
border-top: 1px solid white;
}
.card-footer {
border-top: 1px solid white;
}`;
const EMPTY = `class App extends owl.Component {
}
@@ -1113,7 +1227,7 @@ export const SAMPLES = [
description: "Animations",
code: ANIMATION,
xml: ANIMATION_XML,
css: ANIMATION_CSS,
css: ANIMATION_CSS
},
{
description: "Lifecycle demo",
@@ -1132,6 +1246,12 @@ export const SAMPLES = [
css: RESPONSIVE_CSS,
xml: RESPONSIVE_XML
},
{
description: "Slots",
code: SLOTS,
xml: SLOTS_XML,
css: SLOTS_CSS
},
{
description: "Empty",
code: EMPTY