Skip to content

Commit

Permalink
web: manage stacked modals with a stack (#9193)
Browse files Browse the repository at this point in the history
* 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 (<anonymous>)
    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
  • Loading branch information
kensternberg-authentik authored Apr 12, 2024
1 parent 6df2875 commit 8e4929c
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 30 deletions.
5 changes: 5 additions & 0 deletions web/src/elements/Interface/Interface.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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()
Expand All @@ -31,6 +33,8 @@ export class Interface extends AKElement implements AkInterface {

[configContext]!: ConfigContextController;

[modalController]!: ModalOrchestrationController;

@state()
config?: Config;

Expand All @@ -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";
}

Expand Down
27 changes: 9 additions & 18 deletions web/src/elements/buttons/ModalButton.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
Expand All @@ -119,8 +111,7 @@ export class ModalButton extends AKElement {
>
<button
@click=${() => {
this.resetForms();
this.open = false;
this.dispatchEvent(new ModalHideEvent(this));
}}
class="pf-c-button pf-m-plain"
type="button"
Expand Down
122 changes: 122 additions & 0 deletions web/src/elements/controllers/ModalOrchestrationController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { bound } from "@goauthentik/elements/decorators/bound.js";

import { LitElement, ReactiveController, ReactiveControllerHost } from "lit";

type ReactiveElementHost = Partial<ReactiveControllerHost> & LitElement;

type ModalElement = LitElement & { closeModal(): void | boolean };

export class ModalShowEvent extends Event {
modal: ModalElement;
constructor(modal: ModalElement) {
super("ak-modal-show", { bubbles: true, composed: true });
this.modal = modal;
}
}

export class ModalHideEvent extends Event {
modal: ModalElement;
constructor(modal: ModalElement) {
super("ak-modal-hide", { bubbles: true, composed: true });
this.modal = modal;
}
}

declare global {
interface GlobalEventHandlersEventMap {
"ak-modal-show": ModalShowEvent;
"ak-modal-hide": ModalHideEvent;
}
}

const modalIsLive = (modal: ModalElement) => modal.isConnected && modal.checkVisibility();

/**
* class ModalOrchetrationController
*
* A top-level controller that listens for requests from modals to be added to
* the management list, such that the *topmost* modal will be closed (and all
* references to it eliminated) whenever the user presses the Escape key.
* Can also take ModalHideEvent requests and automatically close the modal
* sending the event.
*
* Both events that this responds to expect a reference to the modal to be part
* of the event payload.
*
* If the `.closeModal()` method on the target modal returns `false`
* *explicitly*, it will abort cleanup and the stack will keep the record that
* the modal is still open. This allows `.closeModal()` to return `undefined`
* and still behave correctly.
*/

export class ModalOrchestrationController implements ReactiveController {
host!: ReactiveElementHost;

knownModals: ModalElement[] = [];

constructor(host: ReactiveElementHost) {
this.host = host;
host.addController(this);
}

hostConnected() {
window.addEventListener("keyup", this.handleKeyup);
window.addEventListener("ak-modal-show", this.addModal);
window.addEventListener("ak-modal-hide", this.closeModal);
}

hostDisconnected() {
window.removeEventListener("keyup", this.handleKeyup);
window.removeEventListener("ak-modal-show", this.addModal);
window.removeEventListener("ak-modal-hide", this.closeModal);
}

@bound
addModal(e: ModalShowEvent) {
this.knownModals = [...this.knownModals, e.modal];
}

scheduleCleanup(modal: ModalElement) {
setTimeout(() => {
this.knownModals = this.knownModals.filter((m) => modalIsLive(m) && modal !== m);
}, 0);
}

@bound
closeModal(e: ModalHideEvent) {
const modal = e.modal;
if (!modalIsLive(modal)) {
return;
}
if (modal.closeModal() !== false) {
this.scheduleCleanup(modal);
}
}

removeTopmostModal() {
let checking = true;
while (checking) {
const modal = this.knownModals.pop();
if (!modal) {
break;
}
if (!modalIsLive(modal)) {
continue;
}

if (modal.closeModal() !== false) {
this.scheduleCleanup(modal);
}
checking = false;
break;
}
}

@bound
handleKeyup(e: KeyboardEvent) {
// The latter handles Firefox 37 and earlier.
if (e.key === "Escape" || e.key === "Esc") {
this.removeTopmostModal();
}
}
}
31 changes: 31 additions & 0 deletions web/src/elements/decorators/bound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Automatically binds a method to the `this` instance during instantiation.
// Uses the Typescript Experimental Decorator syntax, so we may be living with
// that for a long time.

// MDN is *not* very helpful. The type for a PropertyDescriptor is kept in
// typescript/lib/lib.es5.d.ts, but the description of what everything in
// a descriptor does isn't specified in MDN in its own page, only in
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty

// This decorator feels awkward. It gets a new instance of the method every time
// you reference the field. I wonder if there would be a way to create a lookup
// table; once you'd bound the method you could reuse that bound method for that
// instance, instead of throwing it away?

export function bound(
target: unknown,
key: string,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
if (typeof descriptor?.value !== "function") {
throw new Error("Only methods can be @bound.");
}
return {
configurable: true,
get() {
const method = descriptor.value.bind(this);
Object.defineProperty(this, key, { value: method, configurable: true, writable: true });
return method;
},
};
}
4 changes: 2 additions & 2 deletions web/src/elements/forms/ModalForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { EVENT_REFRESH } from "@goauthentik/common/constants";
import "@goauthentik/elements/LoadingOverlay";
import { ModalButton } from "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton";
import { ModalHideEvent } from "@goauthentik/elements/controllers/ModalOrchestrationController.js";
import { Form } from "@goauthentik/elements/forms/Form";

import { msg } from "@lit/localize";
Expand Down Expand Up @@ -92,8 +93,7 @@ export class ModalForm extends ModalButton {
: html``}
<ak-spinner-button
.callAction=${async () => {
this.resetForms();
this.open = false;
this.dispatchEvent(new ModalHideEvent(this));
}}
class="pf-m-secondary"
>
Expand Down
17 changes: 7 additions & 10 deletions web/src/elements/table/TableModal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { PFSize } from "@goauthentik/common/enums.js";
import { AKElement } from "@goauthentik/elements/Base";
import { MODAL_BUTTON_STYLES } from "@goauthentik/elements/buttons/ModalButton";
import { ModalShowEvent } from "@goauthentik/elements/controllers/ModalOrchestrationController.js";
import { Table } from "@goauthentik/elements/table/Table";

import { CSSResult } from "lit";
Expand Down Expand Up @@ -44,23 +45,18 @@ export abstract class TableModal<T> extends Table<T> {
);
}

constructor() {
super();
window.addEventListener("keyup", (e) => {
if (e.code === "Escape") {
this.resetForms();
this.open = false;
}
});
}

public async fetch(): Promise<void> {
if (!this.open) {
return;
}
return super.fetch();
}

closeModal() {
this.resetForms();
this.open = false;
}

resetForms(): void {
this.querySelectorAll<HTMLFormElement>("[slot=form]").forEach((form) => {
if ("resetForm" in form) {
Expand All @@ -71,6 +67,7 @@ export abstract class TableModal<T> extends Table<T> {

onClick(): void {
this.open = true;
this.dispatchEvent(new ModalShowEvent(this));
this.querySelectorAll("*").forEach((child) => {
if ("requestUpdate" in child) {
(child as AKElement).requestUpdate();
Expand Down

0 comments on commit 8e4929c

Please sign in to comment.