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`
-