diff --git a/web/src/elements/ak-dual-select/ak-dual-select-provider.ts b/web/src/elements/ak-dual-select/ak-dual-select-provider.ts index 1a431cca7c..0e024991ee 100644 --- a/web/src/elements/ak-dual-select/ak-dual-select-provider.ts +++ b/web/src/elements/ak-dual-select/ak-dual-select-provider.ts @@ -1,6 +1,5 @@ import { AkControlElement } from "@goauthentik/elements/AkControlElement.js"; import { debounce } from "@goauthentik/elements/utils/debounce"; -import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; import { msg } from "@lit/localize"; import { PropertyValues, html } from "lit"; @@ -12,6 +11,11 @@ import type { Pagination } from "@goauthentik/api"; import "./ak-dual-select"; import { AkDualSelect } from "./ak-dual-select"; +import { + DualSelectChangeEvent, + DualSelectPaginatorNavEvent, + DualSelectSearchEvent, +} from "./events"; import type { DataProvider, DualSelectPair } from "./types"; /** @@ -26,7 +30,7 @@ import type { DataProvider, DualSelectPair } from "./types"; */ @customElement("ak-dual-select-provider") -export class AkDualSelectProvider extends CustomListenerElement(AkControlElement) { +export class AkDualSelectProvider extends AkControlElement { /** A function that takes a page and returns the DualSelectPair[] collection with which to update * the "Available" pane. * @@ -86,9 +90,9 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement this.onNav = this.onNav.bind(this); this.onChange = this.onChange.bind(this); this.onSearch = this.onSearch.bind(this); - this.addCustomListener("ak-pagination-nav-to", this.onNav); - this.addCustomListener("ak-dual-select-change", this.onChange); - this.addCustomListener("ak-dual-select-search", this.onSearch); + this.addEventListener(DualSelectPaginatorNavEvent.eventName, this.onNav); + this.addEventListener(DualSelectSearchEvent.eventName, this.onSearch); + this.addEventListener(DualSelectChangeEvent.eventName, this.onChange); } willUpdate(changedProperties: PropertyValues) { @@ -122,26 +126,16 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement this.isLoading = false; } - onNav(event: Event) { - if (!(event instanceof CustomEvent)) { - throw new Error(`Expecting a CustomEvent for navigation, received ${event} instead`); - } - this.fetch(event.detail); + onNav(event: DualSelectPaginatorNavEvent) { + this.fetch(event.page); } - onChange(event: Event) { - if (!(event instanceof CustomEvent)) { - throw new Error(`Expecting a CustomEvent for change, received ${event} instead`); - } - this.internalSelected = event.detail.value; - this.selected = this.internalSelected; + onChange(event: DualSelectChangeEvent) { + this.selected = this.internalSelected = event.selected; } - onSearch(event: Event) { - if (!(event instanceof CustomEvent)) { - throw new Error(`Expecting a CustomEvent for change, received ${event} instead`); - } - this.doSearch(event.detail); + onSearch(event: DualSelectSearchEvent) { + this.doSearch(event.search); } doSearch(search: string) { diff --git a/web/src/elements/ak-dual-select/ak-dual-select.ts b/web/src/elements/ak-dual-select/ak-dual-select.ts index 3316129622..129607465d 100644 --- a/web/src/elements/ak-dual-select/ak-dual-select.ts +++ b/web/src/elements/ak-dual-select/ak-dual-select.ts @@ -1,8 +1,5 @@ import { AKElement } from "@goauthentik/elements/Base"; -import { - CustomEmitterElement, - CustomListenerElement, -} from "@goauthentik/elements/utils/eventEmitter"; +import { match } from "ts-pattern"; import { msg, str } from "@lit/localize"; import { PropertyValues, html, nothing } from "lit"; @@ -23,15 +20,13 @@ import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-p import "./components/ak-pagination"; import "./components/ak-search-bar"; import { - EVENT_ADD_ALL, - EVENT_ADD_ONE, - EVENT_ADD_SELECTED, - EVENT_DELETE_ALL, - EVENT_REMOVE_ALL, - EVENT_REMOVE_ONE, - EVENT_REMOVE_SELECTED, -} from "./constants"; -import type { BasePagination, DualSelectPair, SearchbarEvent } from "./types"; + DualSelectChangeEvent, + DualSelectMoveRequestEvent, + DualSelectPanelSearchEvent, + DualSelectSearchEvent, + DualSelectUpdateEvent, +} from "./events"; +import type { BasePagination, DualSelectPair } from "./types"; function alphaSort([_k1, v1, s1]: DualSelectPair, [_k2, v2, s2]: DualSelectPair) { const [l, r] = [s1 !== undefined ? s1 : v1, s2 !== undefined ? s2 : v2]; @@ -60,7 +55,7 @@ const keyfinder = k === key; @customElement("ak-dual-select") -export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) { +export class AkDualSelect extends AKElement { static get styles() { return styles; } @@ -96,21 +91,9 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE super(); this.handleMove = this.handleMove.bind(this); this.handleSearch = this.handleSearch.bind(this); - [ - EVENT_ADD_ALL, - EVENT_ADD_SELECTED, - EVENT_DELETE_ALL, - EVENT_REMOVE_ALL, - EVENT_REMOVE_SELECTED, - EVENT_ADD_ONE, - EVENT_REMOVE_ONE, - ].forEach((eventName: string) => { - this.addCustomListener(eventName, (event: Event) => this.handleMove(eventName, event)); - }); - this.addCustomListener("ak-dual-select-move", () => { - this.requestUpdate(); - }); - this.addCustomListener("ak-search", this.handleSearch); + this.addEventListener(DualSelectMoveRequestEvent.eventName, this.handleMove); + this.addEventListener(DualSelectUpdateEvent.eventName, () => this.requestUpdate()); + this.addEventListener(DualSelectPanelSearchEvent.eventName, this.handleSearch); } willUpdate(changedProperties: PropertyValues) { @@ -123,47 +106,17 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE } } - handleMove(eventName: string, event: Event) { - if (!(event instanceof CustomEvent)) { - throw new Error(`Expected move event here, got ${eventName}`); - } - - switch (eventName) { - case EVENT_ADD_SELECTED: { - this.addSelected(); - break; - } - case EVENT_REMOVE_SELECTED: { - this.removeSelected(); - break; - } - case EVENT_ADD_ALL: { - this.addAllVisible(); - break; - } - case EVENT_REMOVE_ALL: { - this.removeAllVisible(); - break; - } - case EVENT_DELETE_ALL: { - this.removeAll(); - break; - } - case EVENT_ADD_ONE: { - this.addOne(event.detail); - break; - } - case EVENT_REMOVE_ONE: { - this.removeOne(event.detail); - break; - } - - default: - throw new Error( - `AkDualSelect.handleMove received unknown event type: ${eventName}`, - ); - } - this.dispatchCustomEvent("ak-dual-select-change", { value: this.value }); + handleMove(event: DualSelectMoveRequestEvent) { + match(event.move) + .with("add-all", () => this.addAllVisible()) + .with("add-one", () => this.addOne(event.key)) + .with("add-selected", () => this.addSelected()) + .with("delete-all", () => this.removeAll()) + .with("remove-all", () => this.removeAllVisible()) + .with("remove-one", () => this.removeOne(event.key)) + .with("remove-selected", () => this.removeSelected()) + .exhaustive(); + this.dispatchEvent(new DualSelectChangeEvent(this.value)); event.stopPropagation(); } @@ -182,7 +135,10 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE this.availablePane.value!.clearMove(); } - addOne(key: string) { + addOne(key?: string) { + if (!key) { + return; + } const requested = this.options.find(keyfinder(key)); if (requested && !this.selected.find(keyfinder(requested[0]))) { this.selected = [...this.selected, requested]; @@ -207,7 +163,10 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE this.selectedPane.value!.clearMove(); } - removeOne(key: string) { + removeOne(key?: string) { + if (!key) { + return; + } this.selected = this.selected.filter(([k]) => k !== key); } @@ -223,18 +182,18 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE this.selectedPane.value!.clearMove(); } - handleSearch(event: SearchbarEvent) { - switch (event.detail.source) { + handleSearch(event: DualSelectPanelSearchEvent) { + switch (event.source) { case "ak-dual-list-available-search": - return this.handleAvailableSearch(event.detail.value); + return this.handleAvailableSearch(event.filterOn); case "ak-dual-list-selected-search": - return this.handleSelectedSearch(event.detail.value); + return this.handleSelectedSearch(event.filterOn); } event.stopPropagation(); } handleAvailableSearch(value: string) { - this.dispatchCustomEvent("ak-dual-select-search", value); + this.dispatchEvent(new DualSelectSearchEvent(value)); } handleSelectedSearch(value: string) { diff --git a/web/src/elements/ak-dual-select/components/ak-dual-select-available-pane.ts b/web/src/elements/ak-dual-select/components/ak-dual-select-available-pane.ts index 1d4d235c6d..66ce3607c0 100644 --- a/web/src/elements/ak-dual-select/components/ak-dual-select-available-pane.ts +++ b/web/src/elements/ak-dual-select/components/ak-dual-select-available-pane.ts @@ -1,26 +1,19 @@ -import { AKElement } from "@goauthentik/elements/Base"; -import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; +import { bound } from "@goauthentik/elements/decorators/bound"; import { html, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { customElement, property } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; import { map } from "lit/directives/map.js"; -import { availablePaneStyles, listStyles } from "./styles.css"; -import PFButton from "@patternfly/patternfly/components/Button/button.css"; -import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css"; -import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import { availablePaneStyles } from "./styles.css"; -import { EVENT_ADD_ONE } from "../constants"; +import { + DualSelectMoveAvailableEvent, + DualSelectMoveRequestEvent, + DualSelectUpdateEvent, +} from "../events"; import type { DualSelectPair } from "../types"; - -const styles = [PFBase, PFButton, PFDualListSelector, listStyles, availablePaneStyles]; - -const hostAttributes = [ - ["aria-labelledby", "dual-list-selector-available-pane-status"], - ["aria-multiselectable", "true"], - ["role", "listbox"], -]; +import { AkDualSelectAbstractPane } from "./ak-dual-select-pane"; /** * @element ak-dual-select-available-panel @@ -40,9 +33,9 @@ const hostAttributes = [ * */ @customElement("ak-dual-select-available-pane") -export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) { +export class AkDualSelectAvailablePane extends AkDualSelectAbstractPane { static get styles() { - return styles; + return [...AkDualSelectAbstractPane.styles, availablePaneStyles]; } /* The array of key/value pairs this pane is currently showing */ @@ -56,68 +49,31 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) { @property({ type: Object }) readonly selected: Set = new Set(); - /* This is the only mutator for this object. It collects the list of objects the user has - * clicked on *in this pane*. It is explicitly marked as "public" to emphasize that the parent - * orchestrator for the dual-select widget can and will access it to get the list of keys to be - * moved (removed) if the user so requests. - * - */ - @state() - public toMove: Set = new Set(); - - constructor() { - super(); - this.onClick = this.onClick.bind(this); - this.onMove = this.onMove.bind(this); - } - - connectedCallback() { - super.connectedCallback(); - hostAttributes.forEach(([attr, value]) => { - if (!this.hasAttribute(attr)) { - this.setAttribute(attr, value); - } - }); - } - - clearMove() { - this.toMove = new Set(); - } - + @bound onClick(key: string) { if (this.selected.has(key)) { return; } - if (this.toMove.has(key)) { - this.toMove.delete(key); - } else { - this.toMove.add(key); - } - this.dispatchCustomEvent( - "ak-dual-select-available-move-changed", - Array.from(this.toMove.values()).sort(), - ); - this.dispatchCustomEvent("ak-dual-select-move"); + this.move(key); + this.dispatchEvent(new DualSelectMoveAvailableEvent(this.moveable.sort())); + this.dispatchEvent(new DualSelectUpdateEvent()); // Necessary because updating a map won't trigger a state change this.requestUpdate(); } + @bound onMove(key: string) { this.toMove.delete(key); - this.dispatchCustomEvent(EVENT_ADD_ONE, key); + this.dispatchEvent(new DualSelectMoveRequestEvent("add-one", key)); this.requestUpdate(); } - get moveable() { - return Array.from(this.toMove.values()); - } - // DO NOT use `Array.map()` instead of Lit's `map()` function. Lit's `map()` is object-aware and // will not re-arrange or reconstruct the list automatically if the actual sources do not // change; this allows the available pane to illustrate selected items with the checkmark // without causing the list to scroll back up to the top. - render() { + override render() { return html`
    diff --git a/web/src/elements/ak-dual-select/components/ak-dual-select-controls.ts b/web/src/elements/ak-dual-select/components/ak-dual-select-controls.ts index b00dce7df2..e8bc6d1eb6 100644 --- a/web/src/elements/ak-dual-select/components/ak-dual-select-controls.ts +++ b/web/src/elements/ak-dual-select/components/ak-dual-select-controls.ts @@ -1,5 +1,4 @@ import { AKElement } from "@goauthentik/elements/Base"; -import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { msg } from "@lit/localize"; import { css, html, nothing } from "lit"; @@ -8,13 +7,7 @@ import { customElement, property } from "lit/decorators.js"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { - EVENT_ADD_ALL, - EVENT_ADD_SELECTED, - EVENT_DELETE_ALL, - EVENT_REMOVE_ALL, - EVENT_REMOVE_SELECTED, -} from "../constants"; +import { DualSelectMoveRequestEvent, type MoveEventType } from "../events"; const styles = [ PFBase, @@ -47,7 +40,7 @@ const styles = [ */ @customElement("ak-dual-select-controls") -export class AkDualSelectControls extends CustomEmitterElement(AKElement) { +export class AkDualSelectControls extends AKElement { static get styles() { return styles; } @@ -96,11 +89,11 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) { this.onClick = this.onClick.bind(this); } - onClick(eventName: string) { - this.dispatchCustomEvent(eventName); + onClick(eventName: MoveEventType) { + this.dispatchEvent(new DualSelectMoveRequestEvent(eventName)); } - renderButton(label: string, event: string, active: boolean, direction: string) { + renderButton(label: string, event: MoveEventType, active: boolean, direction: string) { return html`
    `; // eslint-disable-next-line @typescript-eslint/no-explicit-any -const handleMoveChanged = (result: any) => { +const handleMoveChanged = (result: DualSelectMoveSelectedEvent) => { const target = document.querySelector("#action-button-message-pad"); target!.innerHTML = ""; - result.detail.forEach((key: string) => { + result.keys.forEach((key: string) => { target!.append(new DOMParser().parseFromString(`
  • ${key}
  • `, "text/xml").firstChild!); }); }; -window.addEventListener("ak-dual-select-selected-move-changed", handleMoveChanged); +window.addEventListener(DualSelectMoveSelectedEvent.eventName, handleMoveChanged); type Story = StoryObj; diff --git a/web/src/elements/ak-dual-select/stories/ak-pagination.stories.ts b/web/src/elements/ak-dual-select/stories/ak-pagination.stories.ts index 0ecbe68af9..0340aeaaa4 100644 --- a/web/src/elements/ak-dual-select/stories/ak-pagination.stories.ts +++ b/web/src/elements/ak-dual-select/stories/ak-pagination.stories.ts @@ -5,6 +5,7 @@ import { TemplateResult, html } from "lit"; import "../components/ak-pagination"; import { AkPagination } from "../components/ak-pagination"; +import { DualSelectPaginatorNavEvent } from "../events"; const metadata: Meta = { title: "Elements / Dual Select / Pagination Control", @@ -43,18 +44,18 @@ const container = (testItem: TemplateResult) =>
`; // eslint-disable-next-line @typescript-eslint/no-explicit-any -const handleMoveChanged = (result: any) => { +const handleMoveChanged = (result: DualSelectPaginatorNavEvent) => { console.debug(result); const target = document.querySelector("#action-button-message-pad"); target!.append( new DOMParser().parseFromString( - `
  • Request to move to page ${result.detail}
  • `, + `
  • Request to move to page ${result.page}
  • `, "text/xml", ).firstChild!, ); }; -window.addEventListener("ak-pagination-nav-to", handleMoveChanged); +window.addEventListener(DualSelectPaginatorNavEvent.eventName, handleMoveChanged); type Story = StoryObj; diff --git a/web/src/elements/ak-dual-select/types.ts b/web/src/elements/ak-dual-select/types.ts index e10d16296d..f903d17824 100644 --- a/web/src/elements/ak-dual-select/types.ts +++ b/web/src/elements/ak-dual-select/types.ts @@ -29,10 +29,3 @@ export type DataProvision = { }; export type DataProvider = (page: number, search?: string) => Promise; - -export interface SearchbarEvent extends CustomEvent { - detail: { - source: string; - value: string; - }; -}