[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.
- **`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
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
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
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.
@@ -258,25 +269,25 @@ We explain here all the public methods of the `Component` class.
// 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
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
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
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
ignore a props update. If it returns false, then `willUpdateProps` will not
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
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,
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
+29 -4
View File
@@ -36,6 +36,10 @@ export interface Env {
[key: string]: any;
}
interface MountOptions {
position?: "first-child" | "last-child" | "self";
}
/**
* 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
@@ -102,6 +106,7 @@ export class Component<T extends Env, Props extends {}> {
static env: any = {};
// expose scheduler s.t. it can be mocked for testing purposes
static scheduler: Scheduler = scheduler;
__target: HTMLElement | undefined;
/**
* 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
*/
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__;
if (__owl__.isMounted) {
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)`;
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;
if (!__owl__.vnode) {
this.__prepareAndRender(fiber, () => {});
@@ -535,8 +550,18 @@ export class Component<T extends Env, Props extends {}> {
*/
__patch(vnode: VNode) {
const __owl__ = this.__owl__;
const target = __owl__.vnode || document.createElement(vnode.sel!);
__owl__.vnode = patch(target, vnode);
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!);
__owl__.vnode = patch(target, vnode);
}
}
/**
+10 -10
View File
@@ -46,7 +46,7 @@ export class Fiber {
// scheduler.
counter: number = 0;
target: HTMLElement | null;
inserter: (el: HTMLElement) => void | null;
scope: any;
vars: any;
@@ -62,10 +62,10 @@ export class Fiber {
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.force = force;
this.target = target;
this.inserter = inserter;
const __owl__ = component.__owl__;
this.scope = __owl__.scope;
@@ -181,7 +181,7 @@ export class Fiber {
complete() {
let component = this.component;
this.isCompleted = true;
if (!this.target && !component.__owl__.isMounted) {
if (!this.inserter && !component.__owl__.isMounted) {
return;
}
@@ -211,7 +211,7 @@ export class Fiber {
const fiber = patchQueue[i];
component = fiber.component;
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__.currentFiber = null;
@@ -219,9 +219,9 @@ export class Fiber {
// insert into the DOM (mount case)
let inDOM = false;
if (this.target) {
this.target.appendChild(this.component.el!);
inDOM = document.body.contains(this.target);
if (this.inserter) {
this.inserter(this.component.el!);
inDOM = document.body.contains(this.component.el);
this.component.env.qweb.trigger("dom-appended");
}
@@ -229,12 +229,12 @@ export class Fiber {
for (let i = patchLen - 1; i >= 0; i--) {
const fiber = patchQueue[i];
component = fiber.component;
if (fiber.shouldPatch && !this.target) {
if (fiber.shouldPatch && !this.inserter) {
component.patched();
if (component.__owl__.patchedCB) {
component.__owl__.patchedCB();
}
} else if (this.target ? inDOM : true) {
} else if (this.inserter ? inDOM : true) {
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", () => {
test("willStart hook is called", async () => {
let willstart = false;