mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
[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:
@@ -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
|
||||||
|
|||||||
@@ -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,8 +550,18 @@ export class Component<T extends Env, Props extends {}> {
|
|||||||
*/
|
*/
|
||||||
__patch(vnode: VNode) {
|
__patch(vnode: VNode) {
|
||||||
const __owl__ = this.__owl__;
|
const __owl__ = this.__owl__;
|
||||||
const target = __owl__.vnode || document.createElement(vnode.sel!);
|
if (this.__target) {
|
||||||
__owl__.vnode = patch(target, vnode);
|
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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user