From 8e4929c3b0a5f7dd092249ef0436b3af4fe5ea2d Mon Sep 17 00:00:00 2001 From: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com> Date: Fri, 12 Apr 2024 14:26:55 -0700 Subject: [PATCH] web: manage stacked modals with a stack (#9193) * web: fix esbuild issue with style sheets Getting ESBuild, Lit, and Storybook to all agree on how to read and parse stylesheets is a serious pain. This fix better identifies the value types (instances) being passed from various sources in the repo to the three *different* kinds of style processors we're using (the native one, the polyfill one, and whatever the heck Storybook does internally). Falling back to using older CSS instantiating techniques one era at a time seems to do the trick. It's ugly, but in the face of the aggressive styling we use to avoid Flashes of Unstyled Content (FLoUC), it's the logic with which we're left. In standard mode, the following warning appears on the console when running a Flow: ``` Autofocus processing was blocked because a document already has a focused element. ``` In compatibility mode, the following **error** appears on the console when running a Flow: ``` crawler-inject.js:1106 Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'. at initDomMutationObservers (crawler-inject.js:1106:18) at crawler-inject.js:1114:24 at Array.forEach () at initDomMutationObservers (crawler-inject.js:1114:10) at crawler-inject.js:1549:1 initDomMutationObservers @ crawler-inject.js:1106 (anonymous) @ crawler-inject.js:1114 initDomMutationObservers @ crawler-inject.js:1114 (anonymous) @ crawler-inject.js:1549 ``` Despite this error, nothing seems to be broken and flows work as anticipated. * web: manage stacked modals with a stack "Events flow up. Instructions flow down." This commit creates a top-level listening controller associated with the main Interface that listens for ModalShow events and registers the modal with a stack. When it receives a corresponding KeyUp:Escape, it closes the topmost modal and removes all references to that modal from the stack. When it receives a ModalHide event, it removes all references to the target modal and removes all references to that modal from the stack. This commit includes a few new techniques. First, thanks to Justin Fagnani and the Shoelace team, this commit includes an alternative technique for declaring custom events by leveraging the GlobalEventHandlers type. This actually works better: the event is explicit, easy to understand, and the typescript language server actually gets them to correspond correctly; if you listen for a specific custom event, the handler had better be of the right type to receive that specific event! Second, this introduces the first custom decorator, @bound(), which eliminates the need to say `this.eventHandler = this.eventHandler.bind(this)` from event handling methods that will have to be passed outside the `this` context of an HTMLElement. After conducting several experiments to see if I understood the PropertyDescriptor protocol correctly, I conclud that this is a safe technique for wiring up `removeEventListener()` handlers. * Prettier had opinions. * web: manage stacked modals with a stack By reviewer request, the `.closeModal()` protocol has been updated so that if the method returns `false` (explicitly; `undefined` is not `false`!), the `.closeModal()` protocol is aborted, the modal remains at the top of the stack, and cleanup is not initiated. Modal forms can now have an "are you sure?" pass if the user triggers a close without saving the form. Figuring out how to close *two* modals if the user *is* sure, and making the Form modal return `true` when the user *is* sure, are left for a future exercise. :-) * web: fix stack handling bug for `Escape`, and make Lint happier about loops --- web/src/elements/Interface/Interface.ts | 5 + web/src/elements/buttons/ModalButton.ts | 27 ++-- .../ModalOrchestrationController.ts | 122 ++++++++++++++++++ web/src/elements/decorators/bound.ts | 31 +++++ web/src/elements/forms/ModalForm.ts | 4 +- web/src/elements/table/TableModal.ts | 17 +-- 6 files changed, 176 insertions(+), 30 deletions(-) create mode 100644 web/src/elements/controllers/ModalOrchestrationController.ts create mode 100644 web/src/elements/decorators/bound.ts diff --git a/web/src/elements/Interface/Interface.ts b/web/src/elements/Interface/Interface.ts index 8da9454603f6..8bedbe16e763 100644 --- a/web/src/elements/Interface/Interface.ts +++ b/web/src/elements/Interface/Interface.ts @@ -1,4 +1,5 @@ import { UIConfig, uiConfig } from "@goauthentik/common/ui/config"; +import { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js"; import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; import { state } from "lit/decorators.js"; @@ -22,6 +23,7 @@ export type AkInterface = HTMLElement & { const brandContext = Symbol("brandContext"); const configContext = Symbol("configContext"); +const modalController = Symbol("modalController"); export class Interface extends AKElement implements AkInterface { @state() @@ -31,6 +33,8 @@ export class Interface extends AKElement implements AkInterface { [configContext]!: ConfigContextController; + [modalController]!: ModalOrchestrationController; + @state() config?: Config; @@ -42,6 +46,7 @@ export class Interface extends AKElement implements AkInterface { document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)]; this[brandContext] = new BrandContextController(this); this[configContext] = new ConfigContextController(this); + this[modalController] = new ModalOrchestrationController(this); this.dataset.akInterfaceRoot = "true"; } diff --git a/web/src/elements/buttons/ModalButton.ts b/web/src/elements/buttons/ModalButton.ts index 5e43b5a4551e..1fb4a059af90 100644 --- a/web/src/elements/buttons/ModalButton.ts +++ b/web/src/elements/buttons/ModalButton.ts @@ -1,5 +1,9 @@ import { PFSize } from "@goauthentik/common/enums.js"; import { AKElement } from "@goauthentik/elements/Base"; +import { + ModalHideEvent, + ModalShowEvent, +} from "@goauthentik/elements/controllers/ModalOrchestrationController.js"; import { CSSResult, TemplateResult, css, html, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; @@ -65,22 +69,9 @@ export class ModalButton extends AKElement { ]; } - firstUpdated(): void { - if (this.handlerBound) return; - window.addEventListener("keyup", this.keyUpHandler); - this.handlerBound = true; - } - - keyUpHandler = (e: KeyboardEvent): void => { - if (e.code === "Escape") { - this.resetForms(); - this.open = false; - } - }; - - disconnectedCallback(): void { - super.disconnectedCallback(); - window.removeEventListener("keyup", this.keyUpHandler); + closeModal() { + this.resetForms(); + this.open = false; } resetForms(): void { @@ -93,6 +84,7 @@ export class ModalButton extends AKElement { onClick(): void { this.open = true; + this.dispatchEvent(new ModalShowEvent(this)); this.querySelectorAll("*").forEach((child) => { if ("requestUpdate" in child) { (child as AKElement).requestUpdate(); @@ -119,8 +111,7 @@ export class ModalButton extends AKElement { >