Files
authentik/web/src/elements/ak-dual-select/ak-dual-select.ts
2025-06-16 01:37:38 +02:00

376 lines
13 KiB
TypeScript

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";
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 { unsafeHTML } from "lit/directives/unsafe-html.js";
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.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 {
BasePagination,
DualSelectEventType,
DualSelectPair,
SearchbarEventDetail,
SearchbarEventSource,
} from "./types.js";
function localeComparator(a: DualSelectPair, b: DualSelectPair) {
const aSortBy = a[2] || a[0];
const bSortBy = b[2] || b[0];
return aSortBy.localeCompare(bSortBy);
}
function keyfinder(key: string) {
return ([k]: DualSelectPair) => k === key;
}
const DelegatedEvents = [
DualSelectEventType.AddSelected,
DualSelectEventType.RemoveSelected,
DualSelectEventType.AddAll,
DualSelectEventType.RemoveAll,
DualSelectEventType.DeleteAll,
DualSelectEventType.AddOne,
DualSelectEventType.RemoveOne,
] as const satisfies DualSelectEventType[];
/**
* @element ak-dual-select
*
* A master (but independent) component that shows two lists-- one of "available options" and one of
* "selected options". The Available Options panel supports pagination if it receives a valid and
* active pagination object (based on Django's pagination object) from the invoking component.
*
* @fires ak-dual-select-change - A custom change event with the current `selected` list.
*/
@customElement("ak-dual-select")
export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) {
static styles = [PFBase, PFButton, globalVariables, mainStyles];
//#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.
*/
@property({ type: Array })
selected: DualSelectPair[] = [];
@property({ type: Object })
pages?: BasePagination;
@property({ attribute: "available-label" })
availableLabel = msg("Available options");
@property({ attribute: "selected-label" })
selectedLabel = msg("Selected options");
//#endregion
//#region State
@state()
protected selectedFilter: string = "";
#selectedKeys: Set<string> = new Set();
//#endregion
//#region Refs
availablePane: Ref<AkDualSelectAvailablePane> = createRef();
selectedPane: Ref<AkDualSelectSelectedPane> = createRef();
//#endregion
//#region Lifecycle
constructor() {
super();
for (const eventName of DelegatedEvents) {
this.addCustomListener(eventName, this.#moveListener);
}
this.addCustomListener("ak-dual-select-move", () => {
this.requestUpdate();
});
this.addCustomListener("ak-search", this.#searchListener);
}
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("selected")) {
this.#selectedKeys = new Set(this.selected.map(([key]) => key));
}
// Pagination invalidates available moveables.
if (changedProperties.has("options") && this.availablePane.value) {
this.availablePane.value.clearMove();
}
}
//#endregion
//#region Event Listeners
#moveListener = (event: CustomEvent<string>) => {
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 });
event.stopPropagation();
};
protected 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();
}
protected addOne(key: string) {
const requested = this.options.find(keyfinder(key));
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;
protected addAllVisible() {
// Create a new array of all current options and selected, and de-dupe.
const selected = new Map<string, DualSelectPair>([
...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();
}
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();
}
protected removeOne(key: string) {
this.selected = this.selected.filter(([k]) => k !== key);
}
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));
this.selected = this.selected.filter(([k]) => !options.has(k));
this.selectedPane.value!.clearMove();
}
removeAll() {
this.selected = [];
this.selectedPane.value!.clearMove();
}
#searchListener = (event: CustomEvent<SearchbarEventDetail>) => {
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();
};
//#endregion
//#region Public Getters
get value() {
return this.selected;
}
get canAddAll() {
// False unless any visible option cannot be found in the selected list, so can still be
// added.
const allMoved =
this.options.length ===
this.options.filter(([key, _]) => this.#selectedKeys.has(key)).length;
return this.options.length > 0 && !allMoved;
}
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))
);
}
get needPagination() {
return (this.pages?.next ?? 0) > 0 || (this.pages?.previous ?? 0) > 0;
}
//#endregion
//#region Render
render() {
const selected =
this.selectedFilter === ""
? this.selected
: this.selected.filter(([_k, v, s]) => {
const value = s !== undefined ? s : v;
if (typeof value !== "string") {
throw new Error("Filter only works when there's a string comparator");
}
return value.toLowerCase().includes(this.selectedFilter.toLowerCase());
});
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.`) : "&nbsp;";
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`
<div class="ak-dual-list-selector">
<div class="ak-available-pane">
<div class="pf-c-dual-list-selector__header">
<div class="pf-c-dual-list-selector__title">
<div class="pf-c-dual-list-selector__title-text">
${this.availableLabel}
</div>
</div>
</div>
<ak-search-bar name="ak-dual-list-available-search"></ak-search-bar>
<div class="pf-c-dual-list-selector__status">
<span
class="pf-c-dual-list-selector__status-text"
id="basic-available-status-text"
>${unsafeHTML(availableStatus)}</span
>
</div>
<ak-dual-select-available-pane
${ref(this.availablePane)}
.options=${this.options}
.selected=${this.#selectedKeys}
></ak-dual-select-available-pane>
${this.needPagination
? html`<ak-pagination .pages=${this.pages}></ak-pagination>`
: nothing}
</div>
<ak-dual-select-controls
?add-active=${(this.availablePane.value?.moveable.length ?? 0) > 0}
?remove-active=${(this.selectedPane.value?.moveable.length ?? 0) > 0}
?add-all-active=${this.canAddAll}
?remove-all-active=${this.canRemoveAll}
?delete-all-active=${this.selected.length !== 0}
enable-select-all
enable-delete-all
></ak-dual-select-controls>
<div class="ak-selected-pane">
<div class="pf-c-dual-list-selector__header">
<div class="pf-c-dual-list-selector__title">
<div class="pf-c-dual-list-selector__title-text">
${this.selectedLabel}
</div>
</div>
</div>
<ak-search-bar name="ak-dual-list-selected-search"></ak-search-bar>
<div class="pf-c-dual-list-selector__status">
<span
class="pf-c-dual-list-selector__status-text"
id="basic-available-status-text"
>${unsafeHTML(selectedStatus)}</span
>
</div>
<ak-dual-select-selected-pane
${ref(this.selectedPane)}
.selected=${selected.toSorted(localeComparator)}
></ak-dual-select-selected-pane>
</div>
</div>
`;
}
//#endregion
}
declare global {
interface HTMLElementTagNameMap {
"ak-dual-select": AkDualSelect;
}
}