diff --git a/src/compiler/inline_expressions.ts b/src/common/inline_expressions.ts similarity index 99% rename from src/compiler/inline_expressions.ts rename to src/common/inline_expressions.ts index e67b769e..004c3cfe 100644 --- a/src/compiler/inline_expressions.ts +++ b/src/common/inline_expressions.ts @@ -1,4 +1,4 @@ -import { OwlError } from "../common/owl_error"; +import { OwlError } from "./owl_error"; /** * Owl QWeb Expression Parser diff --git a/src/compiler/code_generator.ts b/src/compiler/code_generator.ts index 63e40f02..a9d1eb50 100644 --- a/src/compiler/code_generator.ts +++ b/src/compiler/code_generator.ts @@ -4,7 +4,7 @@ import { interpolate, INTERP_REGEXP, replaceDynamicParts, -} from "./inline_expressions"; +} from "../common/inline_expressions"; import { AST, ASTComment, diff --git a/src/runtime/app.ts b/src/runtime/app.ts index ea6e9fd4..19a282ce 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -1,14 +1,14 @@ +import { OwlError } from "../common/owl_error"; import { version } from "../version"; import { Component, ComponentConstructor, Props } from "./component"; import { ComponentNode, saveCurrent } from "./component_node"; -import { nodeErrorHandlers, handleError } from "./error_handling"; -import { OwlError } from "../common/owl_error"; -import { Fiber, RootFiber, MountOptions } from "./fibers"; +import { handleError, nodeErrorHandlers } from "./error_handling"; +import { Fiber, MountOptions, RootFiber } from "./fibers"; +import { reactive, toRaw } from "./reactivity"; import { Scheduler } from "./scheduler"; import { validateProps } from "./template_helpers"; import { TemplateSet, TemplateSetConfig } from "./template_set"; import { validateTarget } from "./utils"; -import { toRaw, reactive } from "./reactivity"; // reimplement dev mode stuff see last change in 0f7a8289a6fb8387c3c1af41c6664b2a8448758f @@ -77,6 +77,7 @@ export class App< subRoots: Set> = new Set(); root: Root | null = null; warnIfNoStaticProps: boolean; + _lastRootEl: HTMLElement | ShadowRoot | null = null; // temporary ref to propagate to roots constructor(Root: ComponentConstructor, config: AppConfig = {}) { super(config); @@ -114,6 +115,8 @@ export class App< const root: Root = { node: null, mount: (target: HTMLElement | ShadowRoot, options?: MountOptions) => { + App.validateTarget(target); + // hack to make sure the sub root get the sub env if necessary. for owl 3, // would be nice to rethink the initialization process to make sure that // we can create a ComponentNode and give it explicitely the env, instead @@ -122,13 +125,24 @@ export class App< this.env = config.env as any; } const restore = saveCurrent(); + if (options?.position === "attach") { + if (Root.template) { + throw new Error("Cannot attach a component with a template"); + } + this._lastRootEl = target; + } else { + if (!Root.template) { + // no template => trigger an error + this.getTemplate(""); + } + } const node = this.makeNode(Root, props); root.node = node; + this._lastRootEl = null; restore(); if (config.env) { this.env = env; } - App.validateTarget(target); if (this.dev) { validateProps(Root, props, { __owl__: { app: this } }); } diff --git a/src/runtime/component.ts b/src/runtime/component.ts index 6e4bbd92..faf90bd7 100644 --- a/src/runtime/component.ts +++ b/src/runtime/component.ts @@ -9,6 +9,7 @@ export type Props = { [key: string]: any }; interface StaticComponentProperties { template: string; + dynamicContent?: { [spec: string]: string }; defaultProps?: any; props?: Schema; components?: { [componentName: string]: ComponentConstructor }; diff --git a/src/runtime/component_node.ts b/src/runtime/component_node.ts index 31d4d933..c81a37ac 100644 --- a/src/runtime/component_node.ts +++ b/src/runtime/component_node.ts @@ -6,7 +6,9 @@ import { OwlError } from "../common/owl_error"; import { Fiber, makeChildFiber, makeRootFiber, MountFiber, MountOptions } from "./fibers"; import { clearReactivesForCallback, getSubscriptions, reactive, targets } from "./reactivity"; import { STATUS } from "./status"; -import { batched, Callback } from "./utils"; +import { batched, Callback, Markup } from "./utils"; +import { xml } from "./template_set"; +import { compileExpr } from "../common/inline_expressions"; let currentNode: ComponentNode | null = null; @@ -124,11 +126,102 @@ export class ComponentNode

implements VNode (this as any).component[value](ev); + handlers.push({ + selector: parts[0], + event, + fn, + }); + } + if (parts[1] === "t-out") { + const fn = new Function("ctx", `return ${compileExpr(value)};`); + tOuts.push({ selector: parts[0], fn }); + } + } + const handleAttrs = () => { + for (let attr of attrs) { + const val = attr.fn.call(this.component, ctx); + // todo: cache the queryselector result? + const target = attr.selector === "root" ? el : (el.querySelector(attr.selector) as any); + if (target) { + target.setAttribute(attr.attr, val); + } + } + }; + const handleEvents = () => { + for (let handler of handlers) { + // const val = attr.fn.call(this.component, ctx); + // todo: cache the queryselector result? + const target = + handler.selector === "root" ? el : (el.querySelector(handler.selector) as any); + if (target) { + target.addEventListener(handler.event, handler.fn); + } + } + }; + const handleTOuts = () => { + for (let tOut of tOuts) { + const val = tOut.fn.call(this.component, ctx); + // todo: cache the queryselector result? + const target = tOut.selector === "root" ? el : (el.querySelector(tOut.selector) as any); + if (target) { + if (val instanceof Markup) { + target.innerHTML = val as any; + } else { + target.textContent = val; + } + } + } + }; + if (attrs.length) { + this.mounted.push(handleAttrs); + this.patched.push(handleAttrs); + } + if (handlers.length) { + this.mounted.push(handleEvents); + } + if (tOuts.length) { + this.mounted.push(handleTOuts); + this.patched.push(handleTOuts); + } + } + mountComponent(target: any, options?: MountOptions) { const fiber = new MountFiber(this, target, options); this.app.scheduler.addFiber(fiber); diff --git a/src/runtime/fibers.ts b/src/runtime/fibers.ts index 13b2124d..72e5d52c 100644 --- a/src/runtime/fibers.ts +++ b/src/runtime/fibers.ts @@ -207,7 +207,7 @@ export class RootFiber extends Fiber { } } -type Position = "first-child" | "last-child"; +type Position = "first-child" | "last-child" | "attach"; export interface MountOptions { position?: Position; diff --git a/src/runtime/hooks.ts b/src/runtime/hooks.ts index 2741b06a..c85fe697 100644 --- a/src/runtime/hooks.ts +++ b/src/runtime/hooks.ts @@ -136,3 +136,20 @@ export function useExternalListener( onMounted(() => target.addEventListener(eventName, boundHandler, eventParams)); onWillUnmount(() => target.removeEventListener(eventName, boundHandler, eventParams)); } + +// ----------------------------------------------------------------------------- +// useAttachedEl +// ----------------------------------------------------------------------------- + +/** + * The purpose of this hook is to allow attached components to get a reference to + * the element they have been attached on. + */ +export function useAttachedEl(): HTMLElement { + const node = getCurrent(); + const el = node.app._lastRootEl as HTMLElement; + if (!el) { + throw new Error("useAttachedEl can only be called with component that are attached"); + } + return el; +} diff --git a/tests/compiler/inline_expressions.test.ts b/tests/compiler/inline_expressions.test.ts index ab00a2b0..807a90f3 100644 --- a/tests/compiler/inline_expressions.test.ts +++ b/tests/compiler/inline_expressions.test.ts @@ -1,4 +1,4 @@ -import { compileExpr, tokenize } from "../../src/compiler/inline_expressions"; +import { compileExpr, tokenize } from "../../src/common/inline_expressions"; describe("tokenizer", () => { test("simple tokens", () => { diff --git a/tests/components/__snapshots__/lifecycle.test.ts.snap b/tests/components/__snapshots__/lifecycle.test.ts.snap index 516c6a22..a5b31073 100644 --- a/tests/components/__snapshots__/lifecycle.test.ts.snap +++ b/tests/components/__snapshots__/lifecycle.test.ts.snap @@ -332,6 +332,31 @@ exports[`lifecycle hooks lifecycle semantics, part 3 1`] = ` }" `; +exports[`lifecycle hooks lifecycle semantics, part 3 3`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + const comp1 = app.createComponent(\`GrandChild\`, true, false, false, []); + + return function template(ctx, node, key = \\"\\") { + return comp1({}, key + \`__1\`, node, this, null); + } +}" +`; + +exports[`lifecycle hooks lifecycle semantics, part 3 4`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`

\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + exports[`lifecycle hooks lifecycle semantics, part 4 1`] = ` "function anonymous(app, bdom, helpers ) { diff --git a/tests/components/attached_components.test.ts b/tests/components/attached_components.test.ts new file mode 100644 index 00000000..4725a891 --- /dev/null +++ b/tests/components/attached_components.test.ts @@ -0,0 +1,258 @@ +import { Component, markup, mount, useState, xml } from "../../src"; +import { useAttachedEl } from "../../src/runtime/hooks"; +import { makeTestFixture, nextTick, steps, useLogLifecycle } from "../helpers"; + +let fixture: HTMLElement; + +beforeEach(() => { + fixture = makeTestFixture(); +}); + +describe("basics", () => { + test("can attach an empty component", async () => { + fixture.innerHTML = "
hello
"; + + class Test extends Component { + setup() { + useLogLifecycle(); + } + } + + await mount(Test, fixture, { position: "attach" }); + expect(steps.splice(0)).toMatchInlineSnapshot(` + Array [ + "Test:setup", + "Test:willStart", + "Test:willRender", + "Test:rendered", + "Test:mounted", + ] + `); + expect(fixture.innerHTML).toBe("
hello
"); + }); + + test("attaching a component with a template throws", async () => { + fixture.innerHTML = "
hello
"; + + class Test extends Component { + static template = xml`hello`; + } + + let error: Error | null = null; + try { + await mount(Test, fixture, { position: "attach" }); + } catch (e: any) { + error = e; + } + expect(error!.message).toBe("Cannot attach a component with a template"); + + expect(fixture.innerHTML).toBe("
hello
"); + }); + + test("can attach a component with simple dynamic content", async () => { + fixture.innerHTML = "

hello

"; + + class Test extends Component { + static dynamicContent = { + "p:t-att-a": "value", + }; + value: string = ""; + setup() { + this.value = "b"; + } + } + + await mount(Test, fixture, { position: "attach" }); + expect(fixture.innerHTML).toBe('

hello

'); + }); + + test("useAttachedEl returns the attached element", async () => { + fixture.innerHTML = "

hello

"; + let el: any = null; + + class Test extends Component { + setup() { + el = useAttachedEl(); + } + } + + await mount(Test, fixture, { position: "attach" }); + expect(el).toBe(fixture); + }); + + test("useAttachedEl throws if component is not attached", async () => { + class Test extends Component { + static template = xml`hello`; + setup() { + useAttachedEl(); + } + } + + let error: any = null; + try { + await mount(Test, fixture); + } catch (_e: any) { + error = _e; + } + + expect(error.message).toBe("useAttachedEl can only be called with component that are attached"); + }); + + test("multiple dynamic attribute", async () => { + fixture.innerHTML = "

hello

"; + + class Test extends Component { + static dynamicContent = { + "p:t-att-a": "value", + "p:t-att-b": "value + 'coucou'", + }; + value: string = ""; + setup() { + this.value = "b"; + } + } + + await mount(Test, fixture, { position: "attach" }); + expect(fixture.innerHTML).toBe('

hello

'); + }); + + test("attrs can target root", async () => { + fixture.innerHTML = "

hello

"; + + class Test extends Component { + static dynamicContent = { + "root:t-att-a": "value", + }; + value: string = ""; + setup() { + this.value = "b"; + } + } + + await mount(Test, fixture, { position: "attach" }); + expect(fixture.outerHTML).toBe('

hello

'); + }); + + test("dynamic attribute is updated on rerender", async () => { + fixture.innerHTML = "

hello

"; + + class Test extends Component { + static dynamicContent = { + "p:t-att-a": "state.value", + }; + state: any; + setup() { + this.state = useState({ value: 1 }); + } + } + + const test = await mount(Test, fixture, { position: "attach" }); + expect(fixture.innerHTML).toBe('

hello

'); + test.state.value = 2; + await nextTick(); + expect(fixture.innerHTML).toBe('

hello

'); + }); + + test("t-on-click, basic", async () => { + fixture.innerHTML = "

hello

"; + + let ev: Event | null = null; + + class Test extends Component { + static dynamicContent = { + "p:t-att-a": "state.value", + "p:t-on-click": "onClick", + }; + state: any; + setup() { + this.state = useState({ value: 1 }); + } + onClick(_ev: any) { + ev = _ev; + this.state.value = 2; + } + } + + await mount(Test, fixture, { position: "attach" }); + expect(fixture.innerHTML).toBe('

hello

'); + fixture.querySelector("p")!.click(); + expect(ev).toBeInstanceOf(Event); + await nextTick(); + expect(fixture.innerHTML).toBe('

hello

'); + }); + + test("t-on-click, target root element", async () => { + fixture.innerHTML = "hello"; + let click = false; + class Test extends Component { + static dynamicContent = { + "root:t-on-click": "onClick", + }; + onClick() { + click = true; + } + } + + await mount(Test, fixture, { position: "attach" }); + fixture.click(); + expect(click).toBe(true); + }); + + test("t-out, basic", async () => { + fixture.innerHTML = "

hello

"; + + class Test extends Component { + static dynamicContent = { + "p:t-out": "state.value", + }; + state: any; + setup() { + this.state = useState({ value: 1 }); + } + } + + await mount(Test, fixture, { position: "attach" }); + expect(fixture.innerHTML).toBe("

1

"); + }); + + test("t-out, on root", async () => { + fixture.innerHTML = "hello"; + + class Test extends Component { + static dynamicContent = { + "root:t-out": "state.value", + }; + state: any; + setup() { + this.state = useState({ value: 1 }); + } + } + + expect(fixture.outerHTML).toBe("
hello
"); + await mount(Test, fixture, { position: "attach" }); + expect(fixture.outerHTML).toBe("
1
"); + }); + + test("t-out, with markup", async () => { + fixture.innerHTML = `

hello

hello

`; + + class Test extends Component { + static dynamicContent = { + "p.p1:t-out": "value1", + "p.p2:t-out": "value2", + }; + + value1: any; + value2: any; + setup() { + this.value1 = "
value1
"; + this.value2 = markup("
value2
"); + } + } + + await mount(Test, fixture, { position: "attach" }); + expect(fixture.innerHTML).toBe( + `

<div>value1</div>

value2

` + ); + }); +});