[ADD] hooks: add useExternalListener hook

It is very useful.

Also, this commit prettifies the code.

closes #608
This commit is contained in:
Géry Debongnie
2020-01-09 10:02:27 +01:00
parent 0c9c5b877b
commit 54f5819ef9
8 changed files with 147 additions and 59 deletions
+12
View File
@@ -17,6 +17,7 @@
- [`useContext`](#usecontext) - [`useContext`](#usecontext)
- [`useRef`](#useref) - [`useRef`](#useref)
- [`useSubEnv`](#usesubenv) - [`useSubEnv`](#usesubenv)
- [`useExternalListener`](#useexternallistener)
- [`useStore`](#usestore) - [`useStore`](#usestore)
- [`useDispatch`](#usedispatch) - [`useDispatch`](#usedispatch)
- [`useGetters`](#usegetters) - [`useGetters`](#usegetters)
@@ -354,6 +355,17 @@ will be added to the parent environment. Note that it will extend, not replace
the parent environment. And of course, the parent environment will not be the parent environment. And of course, the parent environment will not be
affected. affected.
### `useExternalListener`
The `useExternalListener` hook helps solve a very common problem: adding and removing
a listener on some target whenever a component is mounted/unmounted. For example,
a dropdown menu (or its parent) may need to listen to a `click` event on `window`
to be closed:
```js
useExternalListener(window, "click", this.closeMenu);
```
### `useStore` ### `useStore`
The `useStore` hook is the entry point for a component to connect to the store. The `useStore` hook is the entry point for a component to connect to the store.
+24
View File
@@ -131,3 +131,27 @@ export function useSubEnv(nextEnv) {
const component = Component.current!; const component = Component.current!;
component.env = Object.assign(Object.create(component.env), nextEnv); component.env = Object.assign(Object.create(component.env), nextEnv);
} }
// -----------------------------------------------------------------------------
// useExternalListener
// -----------------------------------------------------------------------------
/**
* When a component needs to listen to DOM Events on element(s) that are not
* part of his hierarchy, we can use the `useExternalListener` hook.
* It will correctly add and remove the event listener, whenever the
* component is mounted and unmounted.
*
* Example:
* a menu needs to listen to the click on window to be closed automatically
*
* Usage:
* in the constructor of the OWL component that needs to be notified,
* `useExternalListener(window, 'click', this._doSomething);`
* */
export function useExternalListener(target: HTMLElement, eventName: string, handler, eventParams?) {
const boundHandler = handler.bind(Component.current);
onMounted(() => target.addEventListener(eventName, boundHandler, eventParams));
onWillUnmount(() => target.removeEventListener(eventName, boundHandler, eventParams));
}
+15 -11
View File
@@ -1,4 +1,4 @@
import { compileExpr , compileExprToArray , QWebVar } from "./expression_parser"; import { compileExpr, compileExprToArray, QWebVar } from "./expression_parser";
export const INTERP_REGEXP = /\{\{.*?\}\}/g; export const INTERP_REGEXP = /\{\{.*?\}\}/g;
//------------------------------------------------------------------------------ //------------------------------------------------------------------------------
@@ -161,16 +161,18 @@ export class CompilationContext {
const argId = this.generateID(); const argId = this.generateID();
const tokens = compileExprToArray(expr, this.variables); const tokens = compileExprToArray(expr, this.variables);
const done = new Set(); const done = new Set();
return tokens.map((tok) => { return tokens
if (tok.varName) { .map(tok => {
if (!done.has(tok.varName)) { if (tok.varName) {
done.add(tok.varName); if (!done.has(tok.varName)) {
this.addLine(`const ${tok.varName}_${argId} = ${tok.value};`); done.add(tok.varName);
this.addLine(`const ${tok.varName}_${argId} = ${tok.value};`);
}
tok.value = `${tok.varName}_${argId}`;
} }
tok.value = `${tok.varName}_${argId}`; return tok.value;
} })
return tok.value; .join("");
}).join("");
} }
/** /**
@@ -194,7 +196,9 @@ export class CompilationContext {
const protectID = this.generateID(); const protectID = this.generateID();
this.rootContext.protectedScopeNumber++; this.rootContext.protectedScopeNumber++;
this.rootContext.shouldDefineScope = true; this.rootContext.shouldDefineScope = true;
const scopeExpr = codeBlock ? `Object.create(scope);` : `Object.assign(Object.create(context), scope);`; const scopeExpr = codeBlock
? `Object.create(scope);`
: `Object.assign(Object.create(context), scope);`;
this.addLine(`let _origScope${protectID} = scope;`); this.addLine(`let _origScope${protectID} = scope;`);
this.addLine(`scope = ${scopeExpr}`); this.addLine(`scope = ${scopeExpr}`);
return protectID; return protectID;
+3 -1
View File
@@ -283,5 +283,7 @@ export function compileExprToArray(expr: string, scope: { [key: string]: QWebVar
} }
export function compileExpr(expr: string, scope: { [key: string]: QWebVar }): string { export function compileExpr(expr: string, scope: { [key: string]: QWebVar }): string {
return compileExprToArray(expr, scope).map(t => t.value).join(""); return compileExprToArray(expr, scope)
.map(t => t.value)
.join("");
} }
+50 -39
View File
@@ -2683,7 +2683,7 @@ describe("other directives with t-component", () => {
<p>EndLoop: <t t-esc="iter"/></p> <p>EndLoop: <t t-esc="iter"/></p>
</div>`; </div>`;
state = useState({values: ['a', 'b']}); state = useState({ values: ["a", "b"] });
} }
const widget = new SomeWidget(); const widget = new SomeWidget();
await widget.mount(fixture); await widget.mount(fixture);
@@ -2710,23 +2710,23 @@ describe("other directives with t-component", () => {
<p><t t-esc="iter"/></p> <p><t t-esc="iter"/></p>
</div>`; </div>`;
state = {flag: 'if'}; state = { flag: "if" };
} }
const widget = new SomeWidget(); const widget = new SomeWidget();
await widget.mount(fixture); await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div><p>2</p></div>"); expect(fixture.innerHTML).toBe("<div><p>2</p></div>");
widget.state.flag = 'elif'; widget.state.flag = "elif";
await widget.render(); await widget.render();
expect(fixture.innerHTML).toBe("<div><p>3</p></div>"); expect(fixture.innerHTML).toBe("<div><p>3</p></div>");
widget.state.flag = 'false'; widget.state.flag = "false";
await widget.render(); await widget.render();
expect(fixture.innerHTML).toBe("<div><p>4</p></div>"); expect(fixture.innerHTML).toBe("<div><p>4</p></div>");
}); });
test("t-set in t-if", async () => { test("t-set in t-if", async () => {
// Weird that code block within 'if' leaks outside of it // Weird that code block within 'if' leaks outside of it
// Python does the same // Python does the same
class SomeWidget extends Component<any, any> { class SomeWidget extends Component<any, any> {
static template = xml` static template = xml`
<div> <div>
@@ -2743,16 +2743,16 @@ describe("other directives with t-component", () => {
<p><t t-esc="iter"/></p> <p><t t-esc="iter"/></p>
</div>`; </div>`;
state = {flag: 'if'}; state = { flag: "if" };
} }
const widget = new SomeWidget(); const widget = new SomeWidget();
await widget.mount(fixture); await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div><p>2</p></div>"); expect(fixture.innerHTML).toBe("<div><p>2</p></div>");
widget.state.flag = 'elif'; widget.state.flag = "elif";
await widget.render(); await widget.render();
expect(fixture.innerHTML).toBe("<div><p>3</p></div>"); expect(fixture.innerHTML).toBe("<div><p>3</p></div>");
widget.state.flag = 'false'; widget.state.flag = "false";
await widget.render(); await widget.render();
expect(fixture.innerHTML).toBe("<div><p>4</p></div>"); expect(fixture.innerHTML).toBe("<div><p>4</p></div>");
}); });
@@ -2777,7 +2777,10 @@ describe("other directives with t-component", () => {
}); });
test("t-set can't alter from within callee", async () => { test("t-set can't alter from within callee", async () => {
env.qweb.addTemplate("ChildWidget", `<div><t t-esc="iter"/><t t-set="iter" t-value="'called'"/><t t-esc="iter"/></div>`); env.qweb.addTemplate(
"ChildWidget",
`<div><t t-esc="iter"/><t t-set="iter" t-value="'called'"/><t t-esc="iter"/></div>`
);
class SomeWidget extends Component<any, any> { class SomeWidget extends Component<any, any> {
static template = xml` static template = xml`
<div> <div>
@@ -2795,7 +2798,10 @@ describe("other directives with t-component", () => {
}); });
test("t-set can't alter in t-call body", async () => { test("t-set can't alter in t-call body", async () => {
env.qweb.addTemplate("ChildWidget", `<div><t t-esc="iter"/><t t-set="iter" t-value="'called'"/><t t-esc="iter"/></div>`); env.qweb.addTemplate(
"ChildWidget",
`<div><t t-esc="iter"/><t t-set="iter" t-value="'called'"/><t t-esc="iter"/></div>`
);
class SomeWidget extends Component<any, any> { class SomeWidget extends Component<any, any> {
static template = xml` static template = xml`
<div> <div>
@@ -2841,7 +2847,7 @@ describe("other directives with t-component", () => {
let child; let child;
class ChildWidget extends Component<any, any> { class ChildWidget extends Component<any, any> {
static template = xml`<div><t t-esc="iter"/><t t-set="iter" t-value="'called'"/><t t-esc="iter"/></div>`; static template = xml`<div><t t-esc="iter"/><t t-set="iter" t-value="'called'"/><t t-esc="iter"/></div>`;
iter = 'child'; iter = "child";
constructor() { constructor() {
super(...arguments); super(...arguments);
child = this; child = this;
@@ -2861,7 +2867,7 @@ describe("other directives with t-component", () => {
await widget.mount(fixture); await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div><p>source</p><div>childcalled</div><p>source</p></div>"); expect(fixture.innerHTML).toBe("<div><p>source</p><div>childcalled</div><p>source</p></div>");
expect(child.iter).toBe('child'); expect(child.iter).toBe("child");
expect(QWeb.TEMPLATES[SomeWidget.template].fn.toString()).toMatchSnapshot(); expect(QWeb.TEMPLATES[SomeWidget.template].fn.toString()).toMatchSnapshot();
}); });
@@ -2877,7 +2883,7 @@ describe("other directives with t-component", () => {
<p>EndLoop: <t t-esc="iter"/></p> <p>EndLoop: <t t-esc="iter"/></p>
</div>`; </div>`;
state = useState({values: ['a', 'b']}); state = useState({ values: ["a", "b"] });
} }
const widget = new SomeWidget(); const widget = new SomeWidget();
await widget.mount(fixture); await widget.mount(fixture);
@@ -2896,7 +2902,7 @@ describe("other directives with t-component", () => {
<p>EndLoop: <t t-esc="iter"/></p> <p>EndLoop: <t t-esc="iter"/></p>
</div>`; </div>`;
state = useState({values: ['a', 'b']}); state = useState({ values: ["a", "b"] });
} }
const widget = new SomeWidget(); const widget = new SomeWidget();
await widget.mount(fixture); await widget.mount(fixture);
@@ -2913,18 +2919,20 @@ describe("other directives with t-component", () => {
</div> </div>
</div>`; </div>`;
state = useState({values: ['a', 'b']}); state = useState({ values: ["a", "b"] });
otherState = {vals: []}; otherState = { vals: [] };
} }
const widget = new SomeWidget(); const widget = new SomeWidget();
await widget.mount(fixture); await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>0: a<button>Expr</button></div><div>1: b<button>Expr</button></div></div>"); expect(fixture.innerHTML).toBe(
"<div><div>0: a<button>Expr</button></div><div>1: b<button>Expr</button></div></div>"
);
expect(widget.otherState.vals).toStrictEqual([]); expect(widget.otherState.vals).toStrictEqual([]);
const buttons = fixture.querySelectorAll('button'); const buttons = fixture.querySelectorAll("button");
buttons[0].click(); buttons[0].click();
buttons[1].click(); buttons[1].click();
expect(widget.otherState.vals).toStrictEqual(['a', 'b']); expect(widget.otherState.vals).toStrictEqual(["a", "b"]);
expect(QWeb.TEMPLATES[SomeWidget.template].fn.toString()).toMatchSnapshot(); expect(QWeb.TEMPLATES[SomeWidget.template].fn.toString()).toMatchSnapshot();
}); });
@@ -2940,18 +2948,20 @@ describe("other directives with t-component", () => {
</div> </div>
</div>`; </div>`;
state = useState({values: ['a', 'b']}); state = useState({ values: ["a", "b"] });
otherState = {vals: []}; otherState = { vals: [] };
} }
const widget = new SomeWidget(); const widget = new SomeWidget();
await widget.mount(fixture); await widget.mount(fixture);
expect(fixture.innerHTML).toBe("<div><div>0: a<button>Expr</button></div><div>1: b<button>Expr</button></div></div>"); expect(fixture.innerHTML).toBe(
"<div><div>0: a<button>Expr</button></div><div>1: b<button>Expr</button></div></div>"
);
expect(widget.otherState.vals).toStrictEqual([]); expect(widget.otherState.vals).toStrictEqual([]);
const buttons = fixture.querySelectorAll('button'); const buttons = fixture.querySelectorAll("button");
buttons[0].click(); buttons[0].click();
buttons[1].click(); buttons[1].click();
expect(widget.otherState.vals).toStrictEqual(['a_nova_0', 'b_nova_0_1']); expect(widget.otherState.vals).toStrictEqual(["a_nova_0", "b_nova_0_1"]);
expect(QWeb.TEMPLATES[SomeWidget.template].fn.toString()).toMatchSnapshot(); expect(QWeb.TEMPLATES[SomeWidget.template].fn.toString()).toMatchSnapshot();
}); });
@@ -2965,8 +2975,8 @@ describe("other directives with t-component", () => {
</div> </div>
</div>`; </div>`;
state = useState({values: ['a', 'b']}); state = useState({ values: ["a", "b"] });
otherState = {vals: new Array<string>()}; otherState = { vals: new Array<string>() };
addVal(val: string) { addVal(val: string) {
this.otherState.vals.push(val); this.otherState.vals.push(val);
@@ -2975,13 +2985,14 @@ describe("other directives with t-component", () => {
const widget = new SomeWidget(); const widget = new SomeWidget();
await widget.mount(fixture); await widget.mount(fixture);
expect(fixture.innerHTML).toBe(
expect(fixture.innerHTML).toBe("<div><div>0: a<button>meth call</button></div><div>1: b<button>meth call</button></div></div>"); "<div><div>0: a<button>meth call</button></div><div>1: b<button>meth call</button></div></div>"
);
expect(widget.otherState.vals).toStrictEqual([]); expect(widget.otherState.vals).toStrictEqual([]);
const buttons = fixture.querySelectorAll('button'); const buttons = fixture.querySelectorAll("button");
buttons[0].click(); buttons[0].click();
buttons[1].click(); buttons[1].click();
expect(widget.otherState.vals).toStrictEqual(['a', 'b']); expect(widget.otherState.vals).toStrictEqual(["a", "b"]);
expect(QWeb.TEMPLATES[SomeWidget.template].fn.toString()).toMatchSnapshot(); expect(QWeb.TEMPLATES[SomeWidget.template].fn.toString()).toMatchSnapshot();
}); });
@@ -2996,19 +3007,20 @@ describe("other directives with t-component", () => {
</div> </div>
</div>`; </div>`;
arr = ['a', 'b'] arr = ["a", "b"];
otherState = {vals: new Array<string>()}; otherState = { vals: new Array<string>() };
} }
const widget = new SomeWidget(); const widget = new SomeWidget();
await widget.mount(fixture); await widget.mount(fixture);
expect(fixture.innerHTML).toBe(
expect(fixture.innerHTML).toBe("<div><div><button>expr</button></div><div><button>expr</button></div></div>"); "<div><div><button>expr</button></div><div><button>expr</button></div></div>"
);
expect(widget.otherState.vals).toStrictEqual([]); expect(widget.otherState.vals).toStrictEqual([]);
const buttons = fixture.querySelectorAll('button'); const buttons = fixture.querySelectorAll("button");
buttons[0].click(); buttons[0].click();
buttons[1].click(); buttons[1].click();
expect(widget.otherState.vals).toStrictEqual(['0_0', '1_1']); expect(widget.otherState.vals).toStrictEqual(["0_0", "1_1"]);
expect(QWeb.TEMPLATES[SomeWidget.template].fn.toString()).toMatchSnapshot(); expect(QWeb.TEMPLATES[SomeWidget.template].fn.toString()).toMatchSnapshot();
}); });
}); });
@@ -6069,7 +6081,7 @@ describe("t-call", () => {
}); });
test("sub components in two t-calls", async () => { test("sub components in two t-calls", async () => {
class Child extends Component<any,any> { class Child extends Component<any, any> {
static template = xml`<span><t t-esc="props.val"/></span>`; static template = xml`<span><t t-esc="props.val"/></span>`;
} }
@@ -6083,7 +6095,7 @@ describe("t-call", () => {
<div t-else=""><t t-call="sub"/></div> <div t-else=""><t t-call="sub"/></div>
</div>`; </div>`;
static components = { Child }; static components = { Child };
state = useState({val: 1}); state = useState({ val: 1 });
} }
const parent = new Parent(); const parent = new Parent();
await parent.mount(fixture); await parent.mount(fixture);
@@ -6092,5 +6104,4 @@ describe("t-call", () => {
await nextTick(); await nextTick();
expect(fixture.innerHTML).toBe("<div><div><span>2</span></div></div>"); expect(fixture.innerHTML).toBe("<div><div><span>2</span></div></div>");
}); });
}); });
+3 -3
View File
@@ -137,8 +137,9 @@ describe("styles and component", () => {
color: red; color: red;
} }
}`); }`);
expect(sheet).toBe(`.parent-a .child-a, .parent-a .child-b, .parent-b .child-a, .parent-b .child-b { expect(sheet)
.toBe(`.parent-a .child-a, .parent-a .child-b, .parent-b .child-a, .parent-b .child-b {
color: red; color: red;
}`); }`);
}); });
@@ -166,5 +167,4 @@ describe("styles and component", () => {
color: red; color: red;
}`); }`);
}); });
}); });
+37 -1
View File
@@ -9,7 +9,8 @@ import {
onWillPatch, onWillPatch,
onWillStart, onWillStart,
onWillUpdateProps, onWillUpdateProps,
useSubEnv useSubEnv,
useExternalListener
} from "../src/hooks"; } from "../src/hooks";
import { xml } from "../src/tags"; import { xml } from "../src/tags";
@@ -592,4 +593,39 @@ describe("hooks", () => {
expect(fixture.innerHTML).toBe("<div><span>2</span></div>"); expect(fixture.innerHTML).toBe("<div><span>2</span></div>");
expect(steps).toEqual(["onWillStart", "onWillUpdateProps"]); expect(steps).toEqual(["onWillStart", "onWillUpdateProps"]);
}); });
test("useExternalListener", async () => {
let n = 0;
class MyComponent extends Component<any, any> {
static template = xml`<span><t t-esc="props.value"/></span>`;
constructor(parent, props) {
super(parent, props);
useExternalListener(window as any, "click", this.increment);
}
increment() {
n++;
}
}
class App extends Component<any, any> {
static template = xml`<div><MyComponent t-if="state.flag"/></div>`;
static components = { MyComponent };
state = useState({ flag: false });
}
const app = new App();
await app.mount(fixture);
expect(n).toBe(0);
window.dispatchEvent(new Event("click"));
expect(n).toBe(0);
app.state.flag = true;
await nextTick();
window.dispatchEvent(new Event("click"));
expect(n).toBe(1);
app.state.flag = false;
await nextTick();
window.dispatchEvent(new Event("click"));
expect(n).toBe(1);
});
}); });
+3 -4
View File
@@ -21,18 +21,17 @@ test("log a sub component with non stringifiable props", async () => {
const log = console.log; const log = console.log;
console.log = arg => steps.push(arg); console.log = arg => steps.push(arg);
class Child extends Component<any,any> { class Child extends Component<any, any> {
static template = xml`<span><t t-esc="props.obj.val"/></span>`; static template = xml`<span><t t-esc="props.obj.val"/></span>`;
} }
const circularObject: any = {val: 1}; const circularObject: any = { val: 1 };
circularObject.circularObject = circularObject; circularObject.circularObject = circularObject;
class Parent extends Component<any, any> { class Parent extends Component<any, any> {
static template = xml`<div><Child obj="obj"/></div>`; static template = xml`<div><Child obj="obj"/></div>`;
static components = { Child }; static components = { Child };
obj = circularObject; obj = circularObject;
} }
const parent = new Parent(null, {}); const parent = new Parent(null, {});