 71be3acd1a
			
		
	
	71be3acd1a
	
	
	
		
			
			fix: typing error when variables are not converted to string Co-authored-by: leandro.saraiva <leandro.saraiva@adonite.com>
		
			
				
	
	
		
			376 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			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 = String(a[2] || a[0]);
 | |
|     const bSortBy = String(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.`) : " ";
 | |
| 
 | |
|         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;
 | |
|     }
 | |
| }
 |