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.
|
||||
|
||||
- **`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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user