From 0a5a1e61dd058956da136d7cfcabe44ed4eb2946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Mon, 8 May 2023 11:33:50 +0200 Subject: [PATCH] [IMP] portal: add support for .closest modifier It is sometimes useful in practice to be able to configure the portal so that it looks for a target as close as possible as the portal location. For example, in odoo, a portal may be set up in a form view that should target the current form view. But if the form view itself is in a dialog, it may fail, because it may find a valid target in the form view that is located underneath. With this commit, we add a .closest modifier to the `t-portal` directive. --- doc/reference/portal.md | 8 +++++ src/compiler/code_generator.ts | 4 ++- src/compiler/parser.ts | 15 ++++++++-- src/runtime/portal.ts | 31 +++++++++++++++----- tests/compiler/parser.test.ts | 11 +++++++ tests/misc/__snapshots__/portal.test.ts.snap | 23 +++++++++++++++ tests/misc/portal.test.ts | 18 ++++++++++++ 7 files changed, 99 insertions(+), 11 deletions(-) diff --git a/doc/reference/portal.md b/doc/reference/portal.md index de7e639e7..4baf0ea09 100644 --- a/doc/reference/portal.md +++ b/doc/reference/portal.md @@ -15,3 +15,11 @@ class SomeComponent extends Component { The `t-portal` directive takes a valid css selector as argument. The content of the portalled template will be mounted at the corresponding location. Note that Owl need to insert an empty text node at the location of the portalled content. + +The `t-portal` directive supports a `.closest` modifier. It is useful to select +the closest target from the portal location: Owl will look for a target in the +current parent element, then in its parent, and so on until it finds it. + +```xml +
some content
+``` diff --git a/src/compiler/code_generator.ts b/src/compiler/code_generator.ts index d56bbb2b2..c0414597f 100644 --- a/src/compiler/code_generator.ts +++ b/src/compiler/code_generator.ts @@ -1371,7 +1371,9 @@ export class CodeGenerator { }); const target = compileExpr(ast.target); - const blockString = `${id}({target: ${target},slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, key + \`${key}\`, node, ctx, Portal)`; + const blockString = `${id}({target: ${target},${ + ast.isClosest ? "isClosest: true," : "" + }slots: {'default': {__render: ${name}.bind(this), __ctx: ${ctxStr}}}}, key + \`${key}\`, node, ctx, Portal)`; if (block) { this.insertAnchor(block); } diff --git a/src/compiler/parser.ts b/src/compiler/parser.ts index 42a89f131..e3f7ca7c3 100644 --- a/src/compiler/parser.ts +++ b/src/compiler/parser.ts @@ -169,6 +169,7 @@ export interface ASTTranslation { export interface ASTTPortal { type: ASTType.TPortal; target: string; + isClosest: boolean; content: AST; } @@ -833,11 +834,18 @@ function parseTTranslation(node: Element, ctx: ParsingContext): AST | null { // ----------------------------------------------------------------------------- function parseTPortal(node: Element, ctx: ParsingContext): AST | null { - if (!node.hasAttribute("t-portal")) { + let target, isClosest; + if (node.hasAttribute("t-portal")) { + target = node.getAttribute("t-portal")!; + node.removeAttribute("t-portal"); + isClosest = false; + } else if (node.hasAttribute("t-portal.closest")) { + target = node.getAttribute("t-portal.closest")!; + node.removeAttribute("t-portal.closest"); + isClosest = true; + } else { return null; } - const target = node.getAttribute("t-portal")!; - node.removeAttribute("t-portal"); const content = parseNode(node, ctx); if (!content) { return { @@ -848,6 +856,7 @@ function parseTPortal(node: Element, ctx: ParsingContext): AST | null { return { type: ASTType.TPortal, target, + isClosest, content, }; } diff --git a/src/runtime/portal.ts b/src/runtime/portal.ts index 37ba8a325..484308061 100644 --- a/src/runtime/portal.ts +++ b/src/runtime/portal.ts @@ -5,20 +5,34 @@ import { OwlError } from "./error_handling"; const VText: any = text("").constructor; +function getTarget( + currentParentEl: HTMLElement | Document, + selector: string, + isClosest: boolean +): HTMLElement | null { + if (!isClosest || currentParentEl === document) { + return document.querySelector(selector); + } + const attempt = currentParentEl.querySelector(selector) as HTMLElement | null; + return attempt || getTarget(currentParentEl.parentElement!, selector, true); +} + class VPortal extends VText implements Partial> { content: BDom | null; selector: string; + isClosest: boolean; target: HTMLElement | null = null; - constructor(selector: string, content: BDom) { + constructor(selector: string, isClosest: boolean, content: BDom) { super(""); this.selector = selector; + this.isClosest = isClosest; this.content = content; } mount(parent: HTMLElement, anchor: ChildNode) { super.mount(parent, anchor); - this.target = document.querySelector(this.selector) as any; + this.target = getTarget(parent, this.selector, this.isClosest); if (this.target) { this.content!.mount(this.target!, null); } else { @@ -54,16 +68,19 @@ class VPortal extends VText implements Partial> { export function portalTemplate(app: any, bdom: any, helpers: any) { let { callSlot } = helpers; return function template(ctx: any, node: any, key = ""): any { - return new VPortal(ctx.props.target, callSlot(ctx, node, key, "default", false, null)); + return new VPortal( + ctx.props.target, + ctx.props.isClosest, + callSlot(ctx, node, key, "default", false, null) + ); }; } export class Portal extends Component { static template = "__portal__"; static props = { - target: { - type: String, - }, + target: String, + isClosest: { type: Boolean, optional: true }, slots: true, }; @@ -73,7 +90,7 @@ export class Portal extends Component { onMounted(() => { const portal: VPortal = node.bdom; if (!portal.target) { - const target: HTMLElement = document.querySelector(this.props.target); + const target = getTarget(portal.parentEl, this.props.target, this.props.isClosest); if (target) { portal.content!.moveBeforeDOMNode(target.firstChild, target); } else { diff --git a/tests/compiler/parser.test.ts b/tests/compiler/parser.test.ts index cdbbcacef..e11fe3e82 100644 --- a/tests/compiler/parser.test.ts +++ b/tests/compiler/parser.test.ts @@ -1998,6 +1998,7 @@ describe("qweb parser", () => { test("t-portal", async () => { expect(parse(`Content`)).toEqual({ type: ASTType.TPortal, + isClosest: false, target: "target", content: { type: ASTType.Text, value: "Content" }, }); @@ -2008,6 +2009,7 @@ describe("qweb parser", () => { condition: "condition", content: { content: { type: ASTType.Text, value: "Content" }, + isClosest: false, target: "target", type: ASTType.TPortal, }, @@ -2016,4 +2018,13 @@ describe("qweb parser", () => { type: ASTType.TIf, }); }); + + test("t-portal with .closest", async () => { + expect(parse(`Content`)).toEqual({ + type: ASTType.TPortal, + isClosest: true, + target: "target", + content: { type: ASTType.Text, value: "Content" }, + }); + }); }); diff --git a/tests/misc/__snapshots__/portal.test.ts.snap b/tests/misc/__snapshots__/portal.test.ts.snap index 5840ced65..8b28653d9 100644 --- a/tests/misc/__snapshots__/portal.test.ts.snap +++ b/tests/misc/__snapshots__/portal.test.ts.snap @@ -999,3 +999,26 @@ exports[`Portal: UI/UX focus is kept across re-renders 2`] = ` } }" `; + +exports[`portal .closest suffix basic use of .suffix 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + const Portal = app.Portal; + const comp1 = app.createComponent(null, false, true, false, false); + + let block2 = createBlock(\`

far target

\`); + let block3 = createBlock(\`

close target

\`); + + function slot1(ctx, node, key = \\"\\") { + return text(\`portal content\`); + } + + return function template(ctx, node, key = \\"\\") { + const b2 = block2(); + const b5 = comp1({target: '.target',isClosest: true,slots: {'default': {__render: slot1.bind(this), __ctx: ctx}}}, key + \`__1\`, node, ctx, Portal); + const b3 = block3([], [b5]); + return multi([b2, b3]); + } +}" +`; diff --git a/tests/misc/portal.test.ts b/tests/misc/portal.test.ts index b5c230b48..626bb2304 100644 --- a/tests/misc/portal.test.ts +++ b/tests/misc/portal.test.ts @@ -1028,3 +1028,21 @@ describe("Portal: Props validation", () => { expect(error!.message).toBe(`invalid portal target`); }); }); + +describe("portal .closest suffix", () => { + test("basic use of .suffix", async () => { + class Parent extends Component { + static template = xml` +

far target

+
+

close target

+ portal content +
`; + } + + await mount(Parent, fixture); + expect(fixture.innerHTML).toBe( + '

far target

close targetportal content

' + ); + }); +});