From e40c5ac61792e25ce19e3d29f946aeb28661b499 Mon Sep 17 00:00:00 2001 From: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Date: Thu, 15 May 2025 14:47:47 +0200 Subject: [PATCH] web/admin: Dual select state management, custom event dispatching. (#14490) * web/admin: Fix issues surrounding dual select state management. * web: Fix nested path. * web: Use PatternFly variable. --- .../admin/AdminInterface/index.entrypoint.ts | 2 +- web/src/admin/outposts/OutpostForm.ts | 6 +- .../admin/policies/geoip/GeoIPPolicyForm.ts | 11 +- ...k-dual-select-dynamic-selected-provider.ts | 21 +- .../ak-dual-select/ak-dual-select-provider.ts | 183 ++++++------ .../elements/ak-dual-select/ak-dual-select.ts | 272 +++++++++--------- .../ak-dual-select-available-pane.ts | 135 +++++---- .../components/ak-dual-select-controls.ts | 111 ++++--- .../ak-dual-select-selected-pane.ts | 89 +++--- .../components/ak-pagination.ts | 134 ++++----- .../components/ak-search-bar.ts | 42 ++- web/src/elements/ak-dual-select/constants.ts | 7 - web/src/elements/ak-dual-select/index.ts | 6 +- .../stories/ak-dual-select-master.stories.ts | 7 +- .../stories/ak-dual-select-search.stories.ts | 13 +- .../stories/ak-pagination.stories.ts | 3 +- .../stories/sb-host-provider.ts | 4 +- web/src/elements/ak-dual-select/types.ts | 56 +++- web/src/elements/types.ts | 3 +- web/src/elements/utils/debounce.ts | 13 - web/src/elements/utils/eventEmitter.ts | 134 +++++---- 21 files changed, 671 insertions(+), 581 deletions(-) delete mode 100644 web/src/elements/ak-dual-select/constants.ts delete mode 100644 web/src/elements/utils/debounce.ts diff --git a/web/src/admin/AdminInterface/index.entrypoint.ts b/web/src/admin/AdminInterface/index.entrypoint.ts index 1fc674eec5..9dfc19513a 100644 --- a/web/src/admin/AdminInterface/index.entrypoint.ts +++ b/web/src/admin/AdminInterface/index.entrypoint.ts @@ -10,6 +10,7 @@ import { me } from "@goauthentik/common/users"; import { WebsocketClient } from "@goauthentik/common/ws"; import { AuthenticatedInterface } from "@goauthentik/elements/Interface"; import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js"; +import { SidebarToggleEventDetail } from "@goauthentik/elements/PageHeader"; import "@goauthentik/elements/ak-locale-context"; import "@goauthentik/elements/banner/EnterpriseStatusBanner"; import "@goauthentik/elements/banner/EnterpriseStatusBanner"; @@ -36,7 +37,6 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { LicenseSummaryStatusEnum, SessionUser, UiThemeEnum } from "@goauthentik/api"; -import { SidebarToggleEventDetail } from "../../elements/PageHeader.js"; import { AdminSidebarEnterpriseEntries, AdminSidebarEntries, diff --git a/web/src/admin/outposts/OutpostForm.ts b/web/src/admin/outposts/OutpostForm.ts index 3c276caaf7..fb1ac2fdf4 100644 --- a/web/src/admin/outposts/OutpostForm.ts +++ b/web/src/admin/outposts/OutpostForm.ts @@ -45,9 +45,9 @@ const providerListArgs = (page: number, search = "") => ({ }); const dualSelectPairMaker = (item: ProviderBase): DualSelectPair => { - const label = item.assignedBackchannelApplicationName - ? item.assignedBackchannelApplicationName - : item.assignedApplicationName; + const label = + item.assignedBackchannelApplicationName || item.assignedApplicationName || item.name; + return [ `${item.pk}`, html`
${label}
diff --git a/web/src/admin/policies/geoip/GeoIPPolicyForm.ts b/web/src/admin/policies/geoip/GeoIPPolicyForm.ts index f54bf54ab7..fa55a5ec32 100644 --- a/web/src/admin/policies/geoip/GeoIPPolicyForm.ts +++ b/web/src/admin/policies/geoip/GeoIPPolicyForm.ts @@ -15,7 +15,7 @@ import { DetailedCountry, GeoIPPolicy, PoliciesApi } from "@goauthentik/api"; import { countryCache } from "./CountryCache"; function countryToPair(country: DetailedCountry): DualSelectPair { - return [country.code, country.name]; + return [country.code, country.name, country.name]; } @customElement("ak-policy-geoip-form") @@ -210,17 +210,16 @@ export class GeoIPPolicyForm extends BasePolicyForm { .getCountries() .then((results) => { if (!search) return results; + return results.filter((result) => result.name .toLowerCase() .includes(search.toLowerCase()), ); }) - .then((results) => { - return { - options: results.map(countryToPair), - }; - }); + .then((results) => ({ + options: results.map(countryToPair), + })); }} .selected=${(this.instance?.countriesObj ?? []).map(countryToPair)} available-label="${msg("Available Countries")}" diff --git a/web/src/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.ts b/web/src/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.ts index 2a4d248728..5f98f8cf80 100644 --- a/web/src/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.ts +++ b/web/src/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.ts @@ -12,7 +12,6 @@ import type { DualSelectPair } from "./types.js"; * A top-level component for multi-select elements have dynamically generated "selected" * lists. */ - @customElement("ak-dual-select-dynamic-selected") export class AkDualSelectDynamic extends AkDualSelectProvider { /** @@ -23,20 +22,24 @@ export class AkDualSelectDynamic extends AkDualSelectProvider { * @attr */ @property({ attribute: false }) - selector: (_: DualSelectPair[]) => Promise = async (_) => Promise.resolve([]); + selector: (_: DualSelectPair[]) => Promise = () => Promise.resolve([]); - private firstUpdateHasRun = false; + #didFirstUpdate = false; willUpdate(changed: PropertyValues) { super.willUpdate(changed); + // On the first update *only*, even before rendering, when the options are handed up, update // the selected list with the contents derived from the selector. - if (!this.firstUpdateHasRun && this.options.length > 0) { - this.firstUpdateHasRun = true; - this.selector(this.options).then((selected) => { - this.selected = selected; - }); - } + + if (this.#didFirstUpdate) return; + if (this.options.length === 0) return; + + this.#didFirstUpdate = true; + + this.selector(this.options).then((selected) => { + this.selected = selected; + }); } render() { 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..d69661a3df 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,18 +1,16 @@ import { AkControlElement } from "@goauthentik/elements/AkControlElement.js"; -import { debounce } from "@goauthentik/elements/utils/debounce"; -import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; +import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter.js"; import { msg } from "@lit/localize"; import { PropertyValues, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { createRef, ref } from "lit/directives/ref.js"; -import type { Ref } from "lit/directives/ref.js"; import type { Pagination } from "@goauthentik/api"; -import "./ak-dual-select"; -import { AkDualSelect } from "./ak-dual-select"; -import type { DataProvider, DualSelectPair } from "./types"; +import "./ak-dual-select.js"; +import { AkDualSelect } from "./ak-dual-select.js"; +import { type DataProvider, DualSelectEventType, type DualSelectPair } from "./types.js"; /** * @element ak-dual-select-provider @@ -22,18 +20,19 @@ import type { DataProvider, DualSelectPair } from "./types"; * between authentik and the generic ak-dual-select component; aside from knowing that * the Pagination object "looks like Django," the interior components don't know anything * about authentik at all and could be dropped into Gravity unchanged.) - * */ - @customElement("ak-dual-select-provider") export class AkDualSelectProvider extends CustomListenerElement(AkControlElement) { - /** A function that takes a page and returns the DualSelectPair[] collection with which to update - * the "Available" pane. + //#region Properties + + /** + * A function that takes a page and returns the {@linkcode DualSelectPair DualSelectPair[]} + * collection with which to update the "Available" pane. * * @attr */ @property({ type: Object }) - provider!: DataProvider; + public provider!: DataProvider; /** * The list of selected items. This is the *complete* list, not paginated, as presented by a @@ -42,7 +41,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement * @attr */ @property({ type: Array }) - selected: DualSelectPair[] = []; + public selected: DualSelectPair[] = []; /** * The label for the left ("available") pane @@ -50,7 +49,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement * @attr */ @property({ attribute: "available-label" }) - availableLabel = msg("Available options"); + public availableLabel = msg("Available options"); /** * The label for the right ("selected") pane @@ -58,7 +57,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement * @attr */ @property({ attribute: "selected-label" }) - selectedLabel = msg("Selected options"); + public selectedLabel = msg("Selected options"); /** * The debounce for the search as the user is typing in a request @@ -66,103 +65,125 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement * @attr */ @property({ attribute: "search-delay", type: Number }) - searchDelay = 250; + public searchDelay = 250; + + public get value() { + return this.dualSelector.value!.selected.map(([k, _]) => k); + } + + public json() { + return this.value; + } + + //#endregion + + //#region State @state() - options: DualSelectPair[] = []; + protected options: DualSelectPair[] = []; - protected dualSelector: Ref = createRef(); + #loading = false; - protected isLoading = false; + #didFirstUpdate = false; + #selected: DualSelectPair[] = []; - private doneFirstUpdate = false; - private internalSelected: DualSelectPair[] = []; + #previousSearchValue = ""; protected pagination?: Pagination; - constructor() { - super(); - setTimeout(() => this.fetch(1), 0); - 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); + //#endregion + + //#region Refs + + protected dualSelector = createRef(); + + //#endregion + + //#region Lifecycle + + public connectedCallback(): void { + super.connectedCallback(); + this.addCustomListener(DualSelectEventType.NavigateTo, this.#navigationListener); + this.addCustomListener(DualSelectEventType.Change, this.#changeListener); + this.addCustomListener(DualSelectEventType.Search, this.#searchListener); + + this.#fetch(1); } willUpdate(changedProperties: PropertyValues) { - if (changedProperties.has("selected") && !this.doneFirstUpdate) { - this.doneFirstUpdate = true; - this.internalSelected = this.selected; - } - - if (changedProperties.has("searchDelay")) { - this.doSearch = debounce( - AkDualSelectProvider.prototype.doSearch.bind(this), - this.searchDelay, - ); + if (changedProperties.has("selected") && !this.#didFirstUpdate) { + this.#didFirstUpdate = true; + this.#selected = this.selected; } if (changedProperties.has("provider")) { this.pagination = undefined; - this.fetch(); + this.#previousSearchValue = ""; + this.#fetch(); } } - async fetch(page?: number, search = "") { - if (this.isLoading) { - return; - } - this.isLoading = true; - const goto = page ?? this.pagination?.current ?? 1; - const data = await this.provider(goto, search); - this.pagination = data.pagination; - this.options = data.options; - this.isLoading = false; - } + //#endregion - onNav(event: Event) { - if (!(event instanceof CustomEvent)) { - throw new Error(`Expecting a CustomEvent for navigation, received ${event} instead`); - } - this.fetch(event.detail); - } + //#region Private Methods - 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; - } + #fetch = async (page?: number, search = this.#previousSearchValue): Promise => { + if (this.#loading) return; - onSearch(event: Event) { - if (!(event instanceof CustomEvent)) { - throw new Error(`Expecting a CustomEvent for change, received ${event} instead`); - } - this.doSearch(event.detail); - } + this.#previousSearchValue = search; + this.#loading = true; - doSearch(search: string) { - this.pagination = undefined; - this.fetch(undefined, search); - } + page ??= this.pagination?.current ?? 1; - get value() { - return this.dualSelector.value!.selected.map(([k, _]) => k); - } + return this.provider(page, search) + .then((data) => { + this.pagination = data.pagination; + this.options = data.options; + }) + .catch((error) => { + console.error(error); + }) + .finally(() => { + this.#loading = false; + }); + }; - json() { - return this.value; - } + //#endregion + + //#region Event Listeners + + #navigationListener = (event: CustomEvent) => { + this.#fetch(event.detail, this.#previousSearchValue); + }; + + #changeListener = (event: CustomEvent<{ value: DualSelectPair[] }>) => { + this.#selected = event.detail.value; + this.selected = this.#selected; + }; + + #searchListener = (event: CustomEvent) => { + this.#doSearch(event.detail); + }; + + #searchTimeoutID?: ReturnType; + + #doSearch = (search: string) => { + clearTimeout(this.#searchTimeoutID); + + setTimeout(() => { + this.pagination = undefined; + this.#fetch(undefined, search); + }, this.searchDelay); + }; + + //#endregion render() { return html``; 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..84559f1240 100644 --- a/web/src/elements/ak-dual-select/ak-dual-select.ts +++ b/web/src/elements/ak-dual-select/ak-dual-select.ts @@ -3,6 +3,7 @@ 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"; @@ -15,34 +16,41 @@ import { globalVariables, mainStyles } from "./components/styles.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import "./components/ak-dual-select-available-pane"; -import { AkDualSelectAvailablePane } from "./components/ak-dual-select-available-pane"; -import "./components/ak-dual-select-controls"; -import "./components/ak-dual-select-selected-pane"; -import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane"; -import "./components/ak-pagination"; -import "./components/ak-search-bar"; +import "./components/ak-dual-select-available-pane.js"; +import { AkDualSelectAvailablePane } from "./components/ak-dual-select-available-pane.js"; +import "./components/ak-dual-select-controls.js"; +import "./components/ak-dual-select-selected-pane.js"; +import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane.js"; +import "./components/ak-pagination.js"; +import "./components/ak-search-bar.js"; 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"; + BasePagination, + DualSelectEventType, + DualSelectPair, + SearchbarEventDetail, + SearchbarEventSource, +} from "./types.js"; -function alphaSort([_k1, v1, s1]: DualSelectPair, [_k2, v2, s2]: DualSelectPair) { - const [l, r] = [s1 !== undefined ? s1 : v1, s2 !== undefined ? s2 : v2]; - return l < r ? -1 : l > r ? 1 : 0; +function localeComparator(a: DualSelectPair, b: DualSelectPair) { + const aSortBy = a[2]; + const bSortBy = b[2]; + + return aSortBy.localeCompare(bSortBy); } -function mapDualPairs(pairs: DualSelectPair[]) { - return new Map(pairs.map(([k, v, _]) => [k, v])); +function keyfinder(key: string) { + return ([k]: DualSelectPair) => k === key; } -const styles = [PFBase, PFButton, globalVariables, mainStyles]; +const DelegatedEvents = [ + DualSelectEventType.AddSelected, + DualSelectEventType.RemoveSelected, + DualSelectEventType.AddAll, + DualSelectEventType.RemoveAll, + DualSelectEventType.DeleteAll, + DualSelectEventType.AddOne, + DualSelectEventType.RemoveOne, +] as const satisfies DualSelectEventType[]; /** * @element ak-dual-select @@ -53,24 +61,25 @@ const styles = [PFBase, PFButton, globalVariables, mainStyles]; * * @fires ak-dual-select-change - A custom change event with the current `selected` list. */ - -const keyfinder = - (key: string) => - ([k]: DualSelectPair) => - k === key; - @customElement("ak-dual-select") export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) { - static get styles() { - return styles; - } + static styles = [PFBase, PFButton, globalVariables, mainStyles]; - /* The list of options to *currently* show. Note that this is not *all* the options, only the - * currently shown list of options from a pagination collection. */ + //#region Properties + + /** + * The list of options to *currently* show. + * + * Note that this is not *all* the options, + * only the currently shown list of options from a pagination collection. + */ @property({ type: Array }) options: DualSelectPair[] = []; - /* The list of options selected. This is the *entire* list and will not be paginated. */ + /** + * The list of options selected. + * This is the *entire* list and will not be paginated. + */ @property({ type: Array }) selected: DualSelectPair[] = []; @@ -83,138 +92,133 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE @property({ attribute: "selected-label" }) selectedLabel = msg("Selected options"); + //#endregion + + //#region State + @state() - selectedFilter: string = ""; + protected selectedFilter: string = ""; + + #selectedKeys: Set = new Set(); + + //#endregion + + //#region Refs availablePane: Ref = createRef(); selectedPane: Ref = createRef(); - selectedKeys: Set = new Set(); + //#endregion + + //#region Lifecycle constructor() { 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)); - }); + + for (const eventName of DelegatedEvents) { + this.addCustomListener(eventName, this.#moveListener); + } + this.addCustomListener("ak-dual-select-move", () => { this.requestUpdate(); }); - this.addCustomListener("ak-search", this.handleSearch); + + this.addCustomListener("ak-search", this.#searchListener); } willUpdate(changedProperties: PropertyValues) { if (changedProperties.has("selected")) { - this.selectedKeys = new Set(this.selected.map(([key, _]) => key)); + this.#selectedKeys = new Set(this.selected.map(([key]) => key)); } + // Pagination invalidates available moveables. if (changedProperties.has("options") && this.availablePane.value) { this.availablePane.value.clearMove(); } } - handleMove(eventName: string, event: Event) { - if (!(event instanceof CustomEvent)) { - throw new Error(`Expected move event here, got ${eventName}`); - } + //#endregion - 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; - } + //#region Event Listeners + + #moveListener = (event: CustomEvent) => { + match(event.type) + .with(DualSelectEventType.AddSelected, () => this.addSelected()) + .with(DualSelectEventType.RemoveSelected, () => this.removeSelected()) + .with(DualSelectEventType.AddAll, () => this.addAllVisible()) + .with(DualSelectEventType.RemoveAll, () => this.removeAllVisible()) + .with(DualSelectEventType.DeleteAll, () => this.removeAll()) + .with(DualSelectEventType.AddOne, () => this.addOne(event.detail)) + .with(DualSelectEventType.RemoveOne, () => this.removeOne(event.detail)) + .otherwise(() => { + throw new Error(`Expected move event here, got ${event.type}`); + }); + + this.dispatchCustomEvent(DualSelectEventType.Change, { value: this.value }); - default: - throw new Error( - `AkDualSelect.handleMove received unknown event type: ${eventName}`, - ); - } - this.dispatchCustomEvent("ak-dual-select-change", { value: this.value }); event.stopPropagation(); - } + }; + + protected addSelected() { + if (this.availablePane.value!.moveable.length === 0) return; - addSelected() { - if (this.availablePane.value!.moveable.length === 0) { - return; - } this.selected = this.availablePane.value!.moveable.reduce( (acc, key) => { const value = this.options.find(keyfinder(key)); + return value && !acc.find(keyfinder(value[0])) ? [...acc, value] : acc; }, [...this.selected], ); + // This is where the information gets... lossy. Dammit. this.availablePane.value!.clearMove(); } - addOne(key: string) { + protected addOne(key: string) { const requested = this.options.find(keyfinder(key)); - if (requested && !this.selected.find(keyfinder(requested[0]))) { - this.selected = [...this.selected, requested]; - } + + if (!requested) return; + if (this.selected.find(keyfinder(requested[0]))) return; + + this.selected = [...this.selected, requested]; } // These are the *currently visible* options; the parent node is responsible for paginating and // updating the list of currently visible options; - addAllVisible() { + protected addAllVisible() { // Create a new array of all current options and selected, and de-dupe. - const selected = mapDualPairs([...this.options, ...this.selected]); - this.selected = Array.from(selected.entries()); + const selected = new Map([ + ...this.options.map((pair) => [pair[0], pair] as const), + ...this.selected.map((pair) => [pair[0], pair] as const), + ]); + + this.selected = Array.from(selected.values()); + this.availablePane.value!.clearMove(); } - removeSelected() { - if (this.selectedPane.value!.moveable.length === 0) { - return; - } + protected removeSelected() { + if (this.selectedPane.value!.moveable.length === 0) return; + const deselected = new Set(this.selectedPane.value!.moveable); + this.selected = this.selected.filter(([key]) => !deselected.has(key)); + this.selectedPane.value!.clearMove(); } - removeOne(key: string) { + protected removeOne(key: string) { this.selected = this.selected.filter(([k]) => k !== key); } - removeAllVisible() { + protected removeAllVisible() { // Remove all the items from selected that are in the *currently visible* options list - const options = new Set(this.options.map(([k, _]) => k)); + const options = new Set(this.options.map(([k]) => k)); + this.selected = this.selected.filter(([k]) => !options.has(k)); + this.selectedPane.value!.clearMove(); } @@ -223,24 +227,25 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE this.selectedPane.value!.clearMove(); } - handleSearch(event: SearchbarEvent) { - switch (event.detail.source) { - case "ak-dual-list-available-search": - return this.handleAvailableSearch(event.detail.value); - case "ak-dual-list-selected-search": - return this.handleSelectedSearch(event.detail.value); - } + #searchListener = (event: CustomEvent) => { + const { source, value } = event.detail; + + match(source) + .with(SearchbarEventSource.Available, () => { + this.dispatchCustomEvent(DualSelectEventType.Search, value); + }) + .with(SearchbarEventSource.Selected, () => { + this.selectedFilter = value; + this.selectedPane.value!.clearMove(); + }) + .exhaustive(); + event.stopPropagation(); - } + }; - handleAvailableSearch(value: string) { - this.dispatchCustomEvent("ak-dual-select-search", value); - } + //#endregion - handleSelectedSearch(value: string) { - this.selectedFilter = value; - this.selectedPane.value!.clearMove(); - } + //#region Public Getters get value() { return this.selected; @@ -251,7 +256,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE // added. const allMoved = this.options.length === - this.options.filter(([key, _]) => this.selectedKeys.has(key)).length; + this.options.filter(([key, _]) => this.#selectedKeys.has(key)).length; return this.options.length > 0 && !allMoved; } @@ -259,7 +264,8 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE get canRemoveAll() { // False if no visible option can be found in the selected list return ( - this.options.length > 0 && !!this.options.find(([key, _]) => this.selectedKeys.has(key)) + this.options.length > 0 && + !!this.options.find(([key, _]) => this.#selectedKeys.has(key)) ); } @@ -267,6 +273,10 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE return (this.pages?.next ?? 0) > 0 || (this.pages?.previous ?? 0) > 0; } + //#endregion + + //#region Render + render() { const selected = this.selectedFilter === "" @@ -282,11 +292,15 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE const availableCount = this.availablePane.value?.toMove.size ?? 0; const selectedCount = this.selectedPane.value?.toMove.size ?? 0; const selectedTotal = selected.length; + const availableStatus = availableCount > 0 ? msg(str`${availableCount} item(s) marked to add.`) : " "; + const selectedTotalStatus = msg(str`${selectedTotal} item(s) selected.`); + const selectedCountStatus = selectedCount > 0 ? " " + msg(str`${selectedCount} item(s) marked to remove.`) : ""; + const selectedStatus = `${selectedTotalStatus} ${selectedCountStatus}`; return html` @@ -310,7 +324,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE ${this.needPagination ? html`` @@ -344,12 +358,14 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE `; } + + //#endregion } declare global { 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..3167b837dd 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,24 @@ import { AKElement } from "@goauthentik/elements/Base"; import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; -import { html, nothing } from "lit"; +import { PropertyValues, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; import { map } from "lit/directives/map.js"; +import { createRef, ref } from "lit/directives/ref.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 { EVENT_ADD_ONE } from "../constants"; -import type { DualSelectPair } from "../types"; - -const styles = [PFBase, PFButton, PFDualListSelector, listStyles, availablePaneStyles]; +import { DualSelectEventType, DualSelectPair } from "../types.js"; const hostAttributes = [ ["aria-labelledby", "dual-list-selector-available-pane-status"], ["aria-multiselectable", "true"], ["role", "listbox"], -]; +] as const satisfies Array<[string, string]>; /** * @element ak-dual-select-available-panel @@ -37,81 +35,109 @@ const hostAttributes = [ * * It is not expected that the `ak-dual-select-available-move-changed` event will be used; instead, * the attribute will be read by the parent when a control is clicked. - * */ @customElement("ak-dual-select-available-pane") -export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) { - static get styles() { - return styles; - } +export class AkDualSelectAvailablePane extends CustomEmitterElement( + AKElement, +) { + static styles = [PFBase, PFButton, PFDualListSelector, listStyles, availablePaneStyles]; + + //#region Properties /* The array of key/value pairs this pane is currently showing */ @property({ type: Array }) readonly options: DualSelectPair[] = []; - /* A set (set being easy for lookups) of keys with all the pairs selected, so that the ones - * currently being shown that have already been selected can be marked and their clicks ignored. - * + /** + * A set (set being easy for lookups) of keys with all the pairs selected, + * so that the ones currently being shown that have already been selected + * can be marked and their clicks ignored. */ @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. + //#endregion + + //#region State + + /** + * 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); - } + //#endregion + + //#region Refs + + protected listRef = createRef(); + + //#region Lifecycle connectedCallback() { super.connectedCallback(); - hostAttributes.forEach(([attr, value]) => { + + for (const [attr, value] of hostAttributes) { if (!this.hasAttribute(attr)) { this.setAttribute(attr, value); } - }); + } } - clearMove() { + protected updated(changed: PropertyValues) { + if (changed.has("options")) { + this.listRef.value?.scrollTo(0, 0); + } + } + + //#region Public API + + public clearMove() { this.toMove = new Set(); } - 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"); - // Necessary because updating a map won't trigger a state change - this.requestUpdate(); - } - - onMove(key: string) { - this.toMove.delete(key); - this.dispatchCustomEvent(EVENT_ADD_ONE, key); - this.requestUpdate(); - } - get moveable() { return Array.from(this.toMove.values()); } + //#endregion + + //#region Event Listeners + + #clickListener(key: string): void { + if (this.selected.has(key)) return; + + if (this.toMove.has(key)) { + this.toMove.delete(key); + } else { + this.toMove.add(key); + } + + this.dispatchCustomEvent( + DualSelectEventType.MoveChanged, + Array.from(this.toMove.values()).sort(), + ); + + this.dispatchCustomEvent(DualSelectEventType.Move); + + // Necessary because updating a map won't trigger a state change + this.requestUpdate(); + } + + #moveListener(key: string): void { + this.toMove.delete(key); + + this.dispatchCustomEvent(DualSelectEventType.AddOne, key); + this.requestUpdate(); + } + + //#region Render + // 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 @@ -119,17 +145,18 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) { render() { return html` -
+
    ${map(this.options, ([key, label]) => { const selected = classMap({ "pf-m-selected": this.toMove.has(key), }); + return html`
  • this.onClick(key)} - @dblclick=${() => this.onMove(key)} + @click=${() => this.#clickListener(key)} + @dblclick=${() => this.#moveListener(key)} role="option" data-ak-key=${key} tabindex="-1" @@ -154,6 +181,8 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
`; } + + //#endregion } export default AkDualSelectAvailablePane; 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..66eab4f2a8 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 @@ -8,34 +8,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"; - -const styles = [ - PFBase, - PFButton, - css` - :host { - align-self: center; - padding-right: var(--pf-c-dual-list-selector__controls--PaddingRight); - padding-left: var(--pf-c-dual-list-selector__controls--PaddingLeft); - } - .pf-c-dual-list-selector { - max-width: 4rem; - } - .ak-dual-list-selector__controls { - display: grid; - justify-content: center; - align-content: center; - height: 100%; - } - `, -]; +import { DualSelectEventType } from "../types.js"; /** * @element ak-dual-select-controls @@ -43,64 +16,84 @@ const styles = [ * The "control box" for a dual-list multi-select. It's controlled by the parent orchestrator as to * whether or not any of its controls are enabled. It sends a variety of messages to the parent * orchestrator which will then reconcile the "available" and "selected" panes at need. - * */ - @customElement("ak-dual-select-controls") -export class AkDualSelectControls extends CustomEmitterElement(AKElement) { - static get styles() { - return styles; - } +export class AkDualSelectControls extends CustomEmitterElement(AKElement) { + static styles = [ + PFBase, + PFButton, + css` + :host { + align-self: center; + padding-right: var(--pf-c-dual-list-selector__controls--PaddingRight); + padding-left: var(--pf-c-dual-list-selector__controls--PaddingLeft); + } + .pf-c-dual-list-selector { + max-width: calc(var(--pf-global--spacer-md, 1rem) * 4); + } + .ak-dual-list-selector__controls { + display: grid; + justify-content: center; + align-content: center; + height: 100%; + } + `, + ]; - /* Set to true if any *visible* elements can be added to the selected list + /** + * Set to true if any *visible* elements can be added to the selected list. */ @property({ attribute: "add-active", type: Boolean }) addActive = false; - /* Set to true if any elements can be removed from the selected list (essentially, + /** + * Set to true if any elements can be removed from the selected list (essentially, * if the selected list is not empty) */ @property({ attribute: "remove-active", type: Boolean }) removeActive = false; - /* Set to true if *all* the currently visible elements can be moved + /** + * Set to true if *all* the currently visible elements can be moved * into the selected list (essentially, if any visible elements are - * not currently selected) + * not currently selected). */ @property({ attribute: "add-all-active", type: Boolean }) addAllActive = false; - /* Set to true if *any* of the elements currently visible in the available + /** + * Set to true if *any* of the elements currently visible in the available * pane are available to be moved to the selected list, enabling that - * all of those specific elements be moved out of the selected list + * all of those specific elements be moved out of the selected list. */ @property({ attribute: "remove-all-active", type: Boolean }) removeAllActive = false; - /* if deleteAll is enabled, set to true to show that there are elements in the + /** + * if deleteAll is enabled, set to true to show that there are elements in the * selected list that can be deleted. */ @property({ attribute: "delete-all-active", type: Boolean }) enableDeleteAll = false; - /* Set to true if you want the `...AllActive` buttons made available. */ + /** + * Set to true if you want the `...AllActive` buttons made available. + */ @property({ attribute: "enable-select-all", type: Boolean }) selectAll = false; - /* Set to true if you want the `ClearAllSelected` button made available */ + /** + * Set to true if you want the `ClearAllSelected` button made available + */ @property({ attribute: "enable-delete-all", type: Boolean }) deleteAll = false; - constructor() { - super(); - this.onClick = this.onClick.bind(this); - } - - onClick(eventName: string) { - this.dispatchCustomEvent(eventName); - } - - renderButton(label: string, event: string, active: boolean, direction: string) { + renderButton( + label: string, + eventType: DualSelectEventType, + active: boolean, + direction: string, + ) { return html`
-
-
- -
- -
- ` - : nothing; + const { pages } = this; + + if (!pages) return nothing; + + return html`
+
+
+
+ + ${msg(str`${pages.startIndex} - ${pages.endIndex} of ${pages.count}`)} + +
+
+ +
+
`; } } diff --git a/web/src/elements/ak-dual-select/components/ak-search-bar.ts b/web/src/elements/ak-dual-select/components/ak-search-bar.ts index e20d8bffeb..d29520ab3e 100644 --- a/web/src/elements/ak-dual-select/components/ak-search-bar.ts +++ b/web/src/elements/ak-dual-select/components/ak-search-bar.ts @@ -4,47 +4,45 @@ import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { html } from "lit"; import { customElement, property } from "lit/decorators.js"; import { createRef, ref } from "lit/directives/ref.js"; -import type { Ref } from "lit/directives/ref.js"; -import { globalVariables, searchStyles } from "./search.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import type { SearchbarEvent } from "../types"; - -const styles = [PFBase, globalVariables, searchStyles]; +import type { SearchbarEventDetail, SearchbarEventSource } from "../types.ts"; +import { globalVariables, searchStyles } from "./search.css.js"; @customElement("ak-search-bar") export class AkSearchbar extends CustomEmitterElement(AKElement) { - static get styles() { - return styles; - } + static styles = [PFBase, globalVariables, searchStyles]; @property({ type: String, reflect: true }) - value = ""; + public value = ""; /** * If you're using more than one search, this token can help listeners distinguishing between * those searches. Lit's own helpers sometimes erase the source and current targets. */ @property({ type: String }) - name = ""; + public name?: SearchbarEventSource; - input: Ref = createRef(); + protected inputRef = createRef(); - constructor() { - super(); - this.onChange = this.onChange.bind(this); - } + #changeListener = () => { + const inputElement = this.inputRef.value; - onChange(_event: Event) { - if (this.input.value) { - this.value = this.input.value.value; + if (inputElement) { + this.value = inputElement.value; } - this.dispatchCustomEvent("ak-search", { + + if (!this.name) { + console.warn("ak-search-bar: no name provided, event will not be dispatched"); + return; + } + + this.dispatchCustomEvent("ak-search", { source: this.name, value: this.value, }); - } + }; render() { return html` @@ -56,8 +54,8 @@ export class AkSearchbar extends CustomEmitterElement(AKElement) { > diff --git a/web/src/elements/ak-dual-select/constants.ts b/web/src/elements/ak-dual-select/constants.ts deleted file mode 100644 index 8e7db5369d..0000000000 --- a/web/src/elements/ak-dual-select/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const EVENT_ADD_SELECTED = "ak-dual-select-add"; -export const EVENT_REMOVE_SELECTED = "ak-dual-select-remove"; -export const EVENT_ADD_ALL = "ak-dual-select-add-all"; -export const EVENT_REMOVE_ALL = "ak-dual-select-remove-all"; -export const EVENT_DELETE_ALL = "ak-dual-select-remove-everything"; -export const EVENT_ADD_ONE = "ak-dual-select-add-one"; -export const EVENT_REMOVE_ONE = "ak-dual-select-remove-one"; diff --git a/web/src/elements/ak-dual-select/index.ts b/web/src/elements/ak-dual-select/index.ts index a5b14dabc3..3ed6f37f37 100644 --- a/web/src/elements/ak-dual-select/index.ts +++ b/web/src/elements/ak-dual-select/index.ts @@ -1,7 +1,7 @@ -import { AkDualSelect } from "./ak-dual-select"; -import "./ak-dual-select"; -import { AkDualSelectProvider } from "./ak-dual-select-provider"; import "./ak-dual-select-provider"; +import { AkDualSelectProvider } from "./ak-dual-select-provider.js"; +import "./ak-dual-select.js"; +import { AkDualSelect } from "./ak-dual-select.js"; export { AkDualSelect, AkDualSelectProvider }; export default AkDualSelect; diff --git a/web/src/elements/ak-dual-select/stories/ak-dual-select-master.stories.ts b/web/src/elements/ak-dual-select/stories/ak-dual-select-master.stories.ts index 620bd4f202..c2ea031e6e 100644 --- a/web/src/elements/ak-dual-select/stories/ak-dual-select-master.stories.ts +++ b/web/src/elements/ak-dual-select/stories/ak-dual-select-master.stories.ts @@ -9,7 +9,7 @@ import { Pagination } from "@goauthentik/api"; import "../ak-dual-select"; import { AkDualSelect } from "../ak-dual-select"; -import type { DualSelectPair } from "../types"; +import { DualSelectEventType, type DualSelectPair } from "../types"; const goodForYouRaw = ` Apple, Arrowroot, Artichoke, Arugula, Asparagus, Avocado, Bamboo, Banana, Basil, Beet Root, @@ -24,7 +24,8 @@ Rosemary, Rutabaga, Shallot, Soybeans, Spinach, Squash, Strawberries, Sweet pota Thyme, Tomatillo, Tomato, Turnip, Waterchestnut, Watercress, Watermelon, Yams `; -const keyToPair = (key: string): DualSelectPair => [slug(key), key]; +const keyToPair = (key: string): DualSelectPair => [slug(key), key, key]; + const goodForYou: DualSelectPair[] = goodForYouRaw .split("\n") .join(" ") @@ -83,7 +84,7 @@ export class AkSbFruity extends LitElement { totalPages: Math.ceil(this.options.length / this.pageLength), }; this.onNavigation = this.onNavigation.bind(this); - this.addEventListener("ak-pagination-nav-to", this.onNavigation); + this.addEventListener(DualSelectEventType.NavigateTo, this.onNavigation); } onNavigation(evt: Event) { diff --git a/web/src/elements/ak-dual-select/stories/ak-dual-select-search.stories.ts b/web/src/elements/ak-dual-select/stories/ak-dual-select-search.stories.ts index dff6ba024a..967450c70c 100644 --- a/web/src/elements/ak-dual-select/stories/ak-dual-select-search.stories.ts +++ b/web/src/elements/ak-dual-select/stories/ak-dual-select-search.stories.ts @@ -1,5 +1,4 @@ import "@goauthentik/elements/messages/MessageContainer"; -import { debounce } from "@goauthentik/elements/utils/debounce"; import { Meta, StoryObj } from "@storybook/web-components"; import { TemplateResult, html } from "lit"; @@ -45,20 +44,24 @@ const displayMessage = (result: any) => { target!.replaceChildren(doc.firstChild!); }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const displayMessage2 = (result: any) => { +const displayMessage2 = (result: string) => { console.debug("Huh."); const doc = new DOMParser().parseFromString(`

Behavior: ${result}

`, "text/xml"); const target = document.querySelector("#action-button-message-pad-2"); target!.replaceChildren(doc.firstChild!); }; -const displayMessage2b = debounce(displayMessage2, 250); +let displayMessage2bTimeoutID: ReturnType; window.addEventListener("input", (event: Event) => { const message = (event.target as HTMLInputElement | undefined)?.value ?? "-- undefined --"; displayMessage(message); - displayMessage2b(message); + + clearTimeout(displayMessage2bTimeoutID); + + displayMessage2bTimeoutID = setTimeout(() => { + displayMessage2(message); + }, 250); }); 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..9c81b1327a 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 { DualSelectEventType } from "../types"; const metadata: Meta = { title: "Elements / Dual Select / Pagination Control", @@ -54,7 +55,7 @@ const handleMoveChanged = (result: any) => { ); }; -window.addEventListener("ak-pagination-nav-to", handleMoveChanged); +window.addEventListener(DualSelectEventType.NavigateTo, handleMoveChanged); type Story = StoryObj; diff --git a/web/src/elements/ak-dual-select/stories/sb-host-provider.ts b/web/src/elements/ak-dual-select/stories/sb-host-provider.ts index df945da20f..08fe29e439 100644 --- a/web/src/elements/ak-dual-select/stories/sb-host-provider.ts +++ b/web/src/elements/ak-dual-select/stories/sb-host-provider.ts @@ -12,9 +12,7 @@ import { globalVariables } from "../components/styles.css"; @customElement("sb-dual-select-host-provider") export class SbHostProvider extends LitElement { - static get styles() { - return globalVariables; - } + static styles = globalVariables; render() { return html``; diff --git a/web/src/elements/ak-dual-select/types.ts b/web/src/elements/ak-dual-select/types.ts index 57634dfac1..e59cbc62b0 100644 --- a/web/src/elements/ak-dual-select/types.ts +++ b/web/src/elements/ak-dual-select/types.ts @@ -2,19 +2,44 @@ import { TemplateResult } from "lit"; import { Pagination } from "@goauthentik/api"; -// -// - key: string -// - label (string or TemplateResult), -// - sortBy (optional) string to sort by. If the sort string is -// - localMapping: The object the key represents; used by some specific apps. API layers may use -// this as a way to find the preset object. -// -// Note that this is a *tuple*, not a record or map! +export const DualSelectEventType = { + AddSelected: "ak-dual-select-add", + RemoveSelected: "ak-dual-select-remove", + Search: "ak-dual-select-search", + AddAll: "ak-dual-select-add-all", + RemoveAll: "ak-dual-select-remove-all", + DeleteAll: "ak-dual-select-remove-everything", + AddOne: "ak-dual-select-add-one", + RemoveOne: "ak-dual-select-remove-one", + Move: "ak-dual-select-move", + MoveChanged: "ak-dual-select-available-move-changed", + Change: "ak-dual-select-change", + NavigateTo: "ak-pagination-nav-to", +} as const satisfies Record; -export type DualSelectPair = [ +export type DualSelectEventType = (typeof DualSelectEventType)[keyof typeof DualSelectEventType]; + +/** + * A tuple representing a single object in the dual select list. + */ +export type DualSelectPair = [ + /** + * The key used to identify the object in the API. + */ key: string, + /** + * A human-readable label for the object. + */ label: string | TemplateResult, - sortBy?: string, + /** + * A string to sort by. If not provided, the key will be used. + */ + sortBy: string, + /** + * A local mapping of the key to the object. This is used by some specific apps. + * + * API layers may use this as a way to find the preset object. + */ localMapping?: T, ]; @@ -30,9 +55,14 @@ export type DataProvision = { export type DataProvider = (page: number, search?: string) => Promise; +export const SearchbarEventSource = { + Available: "ak-dual-list-available-search", + Selected: "ak-dual-list-selected-search", +} as const satisfies Record; + +export type SearchbarEventSource = (typeof SearchbarEventSource)[keyof typeof SearchbarEventSource]; + export interface SearchbarEventDetail { - source: string; + source: SearchbarEventSource; value: string; } - -export type SearchbarEvent = CustomEvent; diff --git a/web/src/elements/types.ts b/web/src/elements/types.ts index 30ed33b2e6..5ef3b98cb4 100644 --- a/web/src/elements/types.ts +++ b/web/src/elements/types.ts @@ -12,7 +12,8 @@ export type ReactiveElementHost = Partial & HTMLE export type AbstractLitElementConstructor = abstract new (...args: never[]) => LitElement; -export type LitElementConstructor = new (...args: never[]) => LitElement; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type LitElementConstructor = new (...args: any[]) => LitElement; /** * A constructor that has been extended with a mixin. diff --git a/web/src/elements/utils/debounce.ts b/web/src/elements/utils/debounce.ts deleted file mode 100644 index ab9ac90a81..0000000000 --- a/web/src/elements/utils/debounce.ts +++ /dev/null @@ -1,13 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Callback = (...args: any[]) => any; -export function debounce(callback: F, wait: number) { - let timeout: ReturnType; - return (...args: Parameters) => { - // @ts-ignore - const context: T = this satisfies object; - if (timeout !== undefined) { - clearTimeout(timeout); - } - timeout = setTimeout(() => callback.apply(context, args), wait); - }; -} diff --git a/web/src/elements/utils/eventEmitter.ts b/web/src/elements/utils/eventEmitter.ts index a3748c6e22..b3d5578b82 100644 --- a/web/src/elements/utils/eventEmitter.ts +++ b/web/src/elements/utils/eventEmitter.ts @@ -1,18 +1,28 @@ -import { createMixin } from "@goauthentik/elements/types"; +import { + ConstructorWithMixin, + LitElementConstructor, + createMixin, +} from "@goauthentik/elements/types"; import { CustomEventDetail, isCustomEvent } from "@goauthentik/elements/utils/customEvents"; -export interface EmmiterElementHandler { - dispatchCustomEvent( - eventName: string, - detail?: T extends CustomEvent ? U : T, +export interface CustomEventEmitterMixin { + dispatchCustomEvent( + eventType: EventType, + detail?: D, eventInit?: EventInit, ): void; } -export const CustomEmitterElement = createMixin(({ SuperClass }) => { - return class EmmiterElementHandler extends SuperClass { +export function CustomEmitterElement< + EventType extends string = string, + T extends LitElementConstructor = LitElementConstructor, +>(SuperClass: T) { + abstract class CustomEventEmmiter + extends SuperClass + implements CustomEventEmitterMixin + { public dispatchCustomEvent( - eventName: string, + eventType: string, detail: D = {} as D, eventInit: EventInit = {}, ) { @@ -26,7 +36,7 @@ export const CustomEmitterElement = createMixin(({ SuperC } this.dispatchEvent( - new CustomEvent(eventName, { + new CustomEvent(eventType, { composed: true, bubbles: true, ...eventInit, @@ -34,34 +44,20 @@ export const CustomEmitterElement = createMixin(({ SuperC }), ); } - }; -}); + } -/** - * Mixin that enables Lit Elements to handle custom events in a more straightforward manner. - * - */ - -// This is a neat trick: this static "class" is just a namespace for these unique symbols. Because -// of all the constraints on them, they're legal field names in Typescript objects! Which means that -// we can use them as identifiers for internal references in a Typescript class with absolutely no -// risk that a future user who wants a name like 'addHandler' or 'removeHandler' will override any -// of those, either in this mixin or in any class that this is mixed into, past or present along the -// chain of inheritance. - -class HK { - public static readonly listenHandlers: unique symbol = Symbol(); - public static readonly addHandler: unique symbol = Symbol(); - public static readonly removeHandler: unique symbol = Symbol(); - public static readonly getHandler: unique symbol = Symbol(); + return CustomEventEmmiter as unknown as ConstructorWithMixin< + T, + CustomEventEmitterMixin + >; } -type EventHandler = (ev: CustomEvent) => void; -type EventMap = WeakMap; +type CustomEventListener = (ev: CustomEvent) => void; +type EventMap = WeakMap, CustomEventListener>; -export interface CustomEventTarget { - addCustomListener(eventName: string, handler: EventHandler): void; - removeCustomListener(eventName: string, handler: EventHandler): void; +export interface CustomEventTarget { + addCustomListener(eventType: EventType, handler: CustomEventListener): void; + removeCustomListener(eventType: EventType, handler: CustomEventListener): void; } /** @@ -72,11 +68,15 @@ export interface CustomEventTarget { */ export const CustomListenerElement = createMixin(({ SuperClass }) => { return class ListenerElementHandler extends SuperClass implements CustomEventTarget { - private [HK.listenHandlers] = new Map(); + #listenHandlers = new Map(); - private [HK.getHandler](eventName: string, handler: EventHandler) { - const internalMap = this[HK.listenHandlers].get(eventName); - return internalMap ? internalMap.get(handler) : undefined; + #getListener( + eventType: string, + handler: CustomEventListener, + ): CustomEventListener | undefined { + const internalMap = this.#listenHandlers.get(eventType) as EventMap | undefined; + + return internalMap?.get(handler); } // For every event NAME, we create a WeakMap that pairs the event handler given to us by the @@ -85,50 +85,58 @@ export const CustomListenerElement = createMixin(({ SuperClas // meanwhile, this allows us to remove it from the event listeners if it's still around // using the original handler's identity as the key. // - private [HK.addHandler]( - eventName: string, - handler: EventHandler, - internalHandler: EventHandler, + #addListener( + eventType: string, + handler: CustomEventListener, + internalHandler: CustomEventListener, ) { - if (!this[HK.listenHandlers].has(eventName)) { - this[HK.listenHandlers].set(eventName, new WeakMap()); + let internalMap = this.#listenHandlers.get(eventType) as EventMap | undefined; + + if (!internalMap) { + internalMap = new WeakMap(); + + this.#listenHandlers.set(eventType, internalMap as EventMap); } - const internalMap = this[HK.listenHandlers].get(eventName); + + internalMap.set(handler, internalHandler); + } + + #removeListener(eventType: string, listener: CustomEventListener) { + const internalMap = this.#listenHandlers.get(eventType) as EventMap | undefined; + if (internalMap) { - internalMap.set(handler, internalHandler); + internalMap.delete(listener); } } - private [HK.removeHandler](eventName: string, handler: EventHandler) { - const internalMap = this[HK.listenHandlers].get(eventName); - if (internalMap) { - internalMap.delete(handler); - } - } - - addCustomListener(eventName: string, handler: EventHandler) { + addCustomListener(eventType: string, listener: CustomEventListener) { const internalHandler = (event: Event) => { - if (!isCustomEvent(event)) { + if (!isCustomEvent(event)) { console.error( - `Received a standard event for custom event ${eventName}; event will not be handled.`, + `Received a standard event for custom event ${eventType}; event will not be handled.`, ); - return; + + return null; } - handler(event); + + return listener(event); }; - this[HK.addHandler](eventName, handler, internalHandler); - this.addEventListener(eventName, internalHandler); + + this.#addListener(eventType, listener, internalHandler); + this.addEventListener(eventType, internalHandler); } - removeCustomListener(eventName: string, handler: EventHandler) { - const realHandler = this[HK.getHandler](eventName, handler); + removeCustomListener(eventType: string, listener: CustomEventListener) { + const realHandler = this.#getListener(eventType, listener); + if (realHandler) { this.removeEventListener( - eventName, + eventType, realHandler as EventListenerOrEventListenerObject, ); } - this[HK.removeHandler](eventName, handler); + + this.#removeListener(eventType, listener); } }; });