[IMP] component: add position option to mount method

A component can now be mounted in different positions: either 'before' (at
the start), 'after' (at the end), or 'attach' (take possession of a target
element)

closes #539
This commit is contained in:
Géry Debongnie
2019-12-04 17:10:52 +01:00
parent a0bfbf6dd0
commit 7ec05ff8cf
4 changed files with 123 additions and 19 deletions
+16 -5
View File
@@ -234,12 +234,23 @@ component.
We explain here all the public methods of the `Component` class. We explain here all the public methods of the `Component` class.
- **`mount(target)`** (async): this is the main way a - **`mount(target, options)`** (async): this is the main way a
component is added to the DOM: the root component is mounted to a target component is added to the DOM: the root component is mounted to a target
HTMLElement (or document fragment). Obviously, this is asynchronous, since each children need to be HTMLElement (or document fragment). Obviously, this is asynchronous, since each children need to be
created as well. Most applications will need to call `mount` exactly once, on created as well. Most applications will need to call `mount` exactly once, on
the root component. the root component.
The `options` argument is an optional object with a `position` key. The
`position` key can have three possible values: `first-child`, `last-child`, `self`.
- `first-child`: with this option, the component will be prepended inside the target,
- `last-child` (default value): with this option, the component will be
appended in the target element,
- `self`: the target will be used as the root element for the component. This
means that the target has to be an HTMLElement (and not a document fragment).
In this situation, it is possible that the component cannot be unmounted. For
example, if its target is `document.body`.
Note that if a component is mounted, unmounted and remounted, it will be Note that if a component is mounted, unmounted and remounted, it will be
automatically re-rendered to ensure that changes in its state (or something automatically re-rendered to ensure that changes in its state (or something
in the environment, or in the store, or ...) will be taken into account. in the environment, or in the store, or ...) will be taken into account.
@@ -258,25 +269,25 @@ We explain here all the public methods of the `Component` class.
// app is now visible // app is now visible
``` ```
- **`unmount()`**: in case a component needs to be detached/removed from the DOM, this * **`unmount()`**: in case a component needs to be detached/removed from the DOM, this
method can be used. Most applications should not call `unmount`, this is more method can be used. Most applications should not call `unmount`, this is more
useful to the underlying component system. useful to the underlying component system.
- **`render()`** (async): calling this method directly will cause a rerender. Note * **`render()`** (async): calling this method directly will cause a rerender. Note
that this should be very rare to have to do it manually, the Owl framework is that this should be very rare to have to do it manually, the Owl framework is
most of the time responsible for doing that at an appropriate moment. most of the time responsible for doing that at an appropriate moment.
Note that the render method is asynchronous, so one cannot observe the updated Note that the render method is asynchronous, so one cannot observe the updated
DOM in the same stack frame. DOM in the same stack frame.
- **`shouldUpdate(nextProps)`**: this method is called each time a component's props * **`shouldUpdate(nextProps)`**: this method is called each time a component's props
are updated. It returns a boolean, which indicates if the component should are updated. It returns a boolean, which indicates if the component should
ignore a props update. If it returns false, then `willUpdateProps` will not ignore a props update. If it returns false, then `willUpdateProps` will not
be called, and no rendering will occur. Its default implementation is to be called, and no rendering will occur. Its default implementation is to
always return true. This is an optimization, similar to React's `shouldComponentUpdate`. Most of the time, this should not be used, but it always return true. This is an optimization, similar to React's `shouldComponentUpdate`. Most of the time, this should not be used, but it
can be useful if we are handling large number of components. can be useful if we are handling large number of components.
- **`destroy()`**. As its name suggests, this method will remove the component, * **`destroy()`**. As its name suggests, this method will remove the component,
and perform all necessary cleanup, such as unmounting the component, its children, and perform all necessary cleanup, such as unmounting the component, its children,
removing the parent/children relationship. This method should almost never be removing the parent/children relationship. This method should almost never be
called directly (except maybe on the root component), but should be done by the called directly (except maybe on the root component), but should be done by the
+27 -2
View File
@@ -36,6 +36,10 @@ export interface Env {
[key: string]: any; [key: string]: any;
} }
interface MountOptions {
position?: "first-child" | "last-child" | "self";
}
/** /**
* This is mostly an internal detail of implementation. The Meta interface is * This is mostly an internal detail of implementation. The Meta interface is
* useful to typecheck and describe the internal keys used by Owl to manage the * useful to typecheck and describe the internal keys used by Owl to manage the
@@ -102,6 +106,7 @@ export class Component<T extends Env, Props extends {}> {
static env: any = {}; static env: any = {};
// expose scheduler s.t. it can be mocked for testing purposes // expose scheduler s.t. it can be mocked for testing purposes
static scheduler: Scheduler = scheduler; static scheduler: Scheduler = scheduler;
__target: HTMLElement | undefined;
/** /**
* The `el` is the root element of the component. Note that it could be null: * The `el` is the root element of the component. Note that it could be null:
@@ -291,7 +296,8 @@ export class Component<T extends Env, Props extends {}> {
* *
* Note that a component can be mounted an unmounted several times * Note that a component can be mounted an unmounted several times
*/ */
async mount(target: HTMLElement | DocumentFragment): Promise<void> { async mount(target: HTMLElement | DocumentFragment, options: MountOptions = {}): Promise<void> {
const position = options.position || "last-child";
const __owl__ = this.__owl__; const __owl__ = this.__owl__;
if (__owl__.isMounted) { if (__owl__.isMounted) {
return Promise.resolve(); return Promise.resolve();
@@ -301,7 +307,16 @@ export class Component<T extends Env, Props extends {}> {
message += `\nMaybe the DOM is not ready yet? (in that case, you can use owl.utils.whenReady)`; message += `\nMaybe the DOM is not ready yet? (in that case, you can use owl.utils.whenReady)`;
throw new Error(message); throw new Error(message);
} }
const fiber = new Fiber(null, this, false, target); let inserter =
position === "last-child"
? el => target.appendChild(el)
: position === "first-child"
? el => target.prepend(el)
: el => {};
if (position === "self") {
this.__target = target as HTMLElement;
}
const fiber = new Fiber(null, this, false, inserter);
fiber.shouldPatch = false; fiber.shouldPatch = false;
if (!__owl__.vnode) { if (!__owl__.vnode) {
this.__prepareAndRender(fiber, () => {}); this.__prepareAndRender(fiber, () => {});
@@ -535,9 +550,19 @@ export class Component<T extends Env, Props extends {}> {
*/ */
__patch(vnode: VNode) { __patch(vnode: VNode) {
const __owl__ = this.__owl__; const __owl__ = this.__owl__;
if (this.__target) {
if (this.__target.tagName.toLowerCase() !== vnode.sel) {
throw new Error(
`Cannot attach '${this.constructor.name}' to target node (not same tag name)`
);
}
__owl__.vnode = patch(this.__target, vnode);
delete this.__target;
} else {
const target = __owl__.vnode || document.createElement(vnode.sel!); const target = __owl__.vnode || document.createElement(vnode.sel!);
__owl__.vnode = patch(target, vnode); __owl__.vnode = patch(target, vnode);
} }
}
/** /**
* The __prepare method is only called by the t-component directive, when a * The __prepare method is only called by the t-component directive, when a
+10 -10
View File
@@ -46,7 +46,7 @@ export class Fiber {
// scheduler. // scheduler.
counter: number = 0; counter: number = 0;
target: HTMLElement | null; inserter: (el: HTMLElement) => void | null;
scope: any; scope: any;
vars: any; vars: any;
@@ -62,10 +62,10 @@ export class Fiber {
error?: Error; error?: Error;
constructor(parent: Fiber | null, component: Component<any, any>, force, target) { constructor(parent: Fiber | null, component: Component<any, any>, force, inserter) {
this.component = component; this.component = component;
this.force = force; this.force = force;
this.target = target; this.inserter = inserter;
const __owl__ = component.__owl__; const __owl__ = component.__owl__;
this.scope = __owl__.scope; this.scope = __owl__.scope;
@@ -181,7 +181,7 @@ export class Fiber {
complete() { complete() {
let component = this.component; let component = this.component;
this.isCompleted = true; this.isCompleted = true;
if (!this.target && !component.__owl__.isMounted) { if (!this.inserter && !component.__owl__.isMounted) {
return; return;
} }
@@ -211,7 +211,7 @@ export class Fiber {
const fiber = patchQueue[i]; const fiber = patchQueue[i];
component = fiber.component; component = fiber.component;
component.__patch(fiber.vnode!); component.__patch(fiber.vnode!);
if (!fiber.shouldPatch && (!fiber.target || i !== 0)) { if (!fiber.shouldPatch && (!fiber.inserter || i !== 0)) {
component.__owl__.pvnode!.elm = component.__owl__.vnode!.elm; component.__owl__.pvnode!.elm = component.__owl__.vnode!.elm;
} }
component.__owl__.currentFiber = null; component.__owl__.currentFiber = null;
@@ -219,9 +219,9 @@ export class Fiber {
// insert into the DOM (mount case) // insert into the DOM (mount case)
let inDOM = false; let inDOM = false;
if (this.target) { if (this.inserter) {
this.target.appendChild(this.component.el!); this.inserter(this.component.el!);
inDOM = document.body.contains(this.target); inDOM = document.body.contains(this.component.el);
this.component.env.qweb.trigger("dom-appended"); this.component.env.qweb.trigger("dom-appended");
} }
@@ -229,12 +229,12 @@ export class Fiber {
for (let i = patchLen - 1; i >= 0; i--) { for (let i = patchLen - 1; i >= 0; i--) {
const fiber = patchQueue[i]; const fiber = patchQueue[i];
component = fiber.component; component = fiber.component;
if (fiber.shouldPatch && !this.target) { if (fiber.shouldPatch && !this.inserter) {
component.patched(); component.patched();
if (component.__owl__.patchedCB) { if (component.__owl__.patchedCB) {
component.__owl__.patchedCB(); component.__owl__.patchedCB();
} }
} else if (this.target ? inDOM : true) { } else if (this.inserter ? inDOM : true) {
component.__callMounted(); component.__callMounted();
} }
} }
+68
View File
@@ -358,6 +358,74 @@ describe("basic widget properties", () => {
}); });
}); });
describe("mount targets", () => {
test("can attach a component to an existing node (if same tagname)", async () => {
class App extends Component<any, any> {
static template = xml`<div>app</div>`;
}
const div = document.createElement("div");
fixture.appendChild(div);
const app = new App();
await app.mount(div, { position: "self" });
expect(fixture.innerHTML).toBe("<div>app</div>");
});
test("cannot attach a component to an existing node (if not same tagname)", async () => {
class App extends Component<any, any> {
static template = xml`<span>app</span>`;
}
const div = document.createElement("div");
fixture.appendChild(div);
const app = new App();
let error;
try {
await app.mount(div, { position: "self" });
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Cannot attach 'App' to target node (not same tag name)");
});
test("can mount a component (with position='first-child')", async () => {
class App extends Component<any, any> {
static template = xml`<div>app</div>`;
}
const span = document.createElement("span");
fixture.appendChild(span);
const app = new App();
await app.mount(fixture, { position: "first-child" });
expect(fixture.innerHTML).toBe("<div>app</div><span></span>");
});
test("can mount a component (with position='last-child')", async () => {
class App extends Component<any, any> {
static template = xml`<div>app</div>`;
}
const span = document.createElement("span");
fixture.appendChild(span);
const app = new App();
await app.mount(fixture, { position: "last-child" });
expect(fixture.innerHTML).toBe("<span></span><div>app</div>");
});
test("default mount option is 'last-child'", async () => {
class App extends Component<any, any> {
static template = xml`<div>app</div>`;
}
const span = document.createElement("span");
fixture.appendChild(span);
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("<span></span><div>app</div>");
});
});
describe("lifecycle hooks", () => { describe("lifecycle hooks", () => {
test("willStart hook is called", async () => { test("willStart hook is called", async () => {
let willstart = false; let willstart = false;