web: multi-select

Modified the display so that if it's a template we display it
correctly opposite the text, and provide classes that can be used
in the display to differentiate between the main label and the
descriptive label.

Added a sort key, so the select can sort the right-hand pane correctly.

Fixed the `this.selected` setters to use Arrays instead of maps.
Theoretically, this is terribly inefficient, as it makes it
theoretically O(n^2) rather than O(1), but in practice even if both
lists were 10,000 elements long a modern desktop could perform the
entire scan in 150ms or so.
This commit is contained in:
Ken Sternberg 2024-01-03 13:28:19 -08:00
parent 95ec1a6bb2
commit 0a0afbe08d
5 changed files with 69 additions and 34 deletions

View file

@ -47,14 +47,17 @@ const providerListArgs = (page: number) => ({
page, page,
}); });
const dualSelectPairMaker = (item: ProviderBase): DualSelectPair => [ const dualSelectPairMaker = (item: ProviderBase): DualSelectPair => {
`${item.pk}`, const label = item.assignedBackchannelApplicationName
`${
item.assignedBackchannelApplicationName
? item.assignedBackchannelApplicationName ? item.assignedBackchannelApplicationName
: item.assignedApplicationName : item.assignedApplicationName;
} (${item.name})`, return [
`${item.pk}`,
html`<div class="selection-main">${label}</div>
<div class="selection-desc">${item.name}</div>`,
label,
]; ];
};
const provisionMaker = (results: ProviderData) => ({ const provisionMaker = (results: ProviderData) => ({
pagination: results.pagination, pagination: results.pagination,

View file

@ -5,7 +5,7 @@ import {
} from "@goauthentik/elements/utils/eventEmitter"; } from "@goauthentik/elements/utils/eventEmitter";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { PropertyValues, TemplateResult, html, nothing } from "lit"; import { PropertyValues, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js"; import { createRef, ref } from "lit/directives/ref.js";
import type { Ref } from "lit/directives/ref.js"; import type { Ref } from "lit/directives/ref.js";
@ -32,9 +32,14 @@ import {
} from "./constants"; } from "./constants";
import type { BasePagination, DualSelectPair } from "./types"; import type { BasePagination, DualSelectPair } from "./types";
type PairValue = string | TemplateResult; function alphaSort([_k1, v1, s1]: DualSelectPair, [_k2, v2, s2]: DualSelectPair) {
type Pair = [string, PairValue]; const [l, r] = [s1 !== undefined ? s1 : v1, s2 !== undefined ? s2 : v2];
const alphaSort = ([_k1, v1]: Pair, [_k2, v2]: Pair) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0); return l < r ? -1 : l > r ? 1 : 0;
}
function mapDualPairs(pairs: DualSelectPair[]) {
return new Map(pairs.map(([k, v, _]) => [k, v]));
}
const styles = [PFBase, PFButton, globalVariables, mainStyles]; const styles = [PFBase, PFButton, globalVariables, mainStyles];
@ -48,6 +53,11 @@ const styles = [PFBase, PFButton, globalVariables, mainStyles];
* @fires ak-dual-select-change - A custom change event with the current `selected` list. * @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") @customElement("ak-dual-select")
export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) { export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) {
static get styles() { static get styles() {
@ -159,22 +169,21 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
if (this.availablePane.value!.moveable.length === 0) { if (this.availablePane.value!.moveable.length === 0) {
return; return;
} }
const options = new Map(this.options); this.selected = this.availablePane.value!.moveable.reduce(
const selected = new Map(this.selected); (acc, key) => {
this.availablePane.value!.moveable.forEach((key) => { const value = this.options.find(keyfinder(key));
const value = options.get(key); return value && !acc.find(keyfinder(value[0])) ? [...acc, value] : acc;
if (value) { },
selected.set(key, value); [...this.selected],
} );
}); // This is where the information gets... lossy. Dammit.
this.selected = Array.from(selected.entries()).sort();
this.availablePane.value!.clearMove(); this.availablePane.value!.clearMove();
} }
addOne(key: string) { addOne(key: string) {
const requested = this.options.find(([k, _]) => k === key); const requested = this.options.find(keyfinder(key));
if (requested) { if (requested && !this.selected.find(keyfinder(requested[0]))) {
this.selected = Array.from(new Map([...this.selected, requested]).entries()).sort(); this.selected = [...this.selected, requested];
} }
} }
@ -182,8 +191,8 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
// updating the list of currently visible options; // updating the list of currently visible options;
addAllVisible() { addAllVisible() {
// Create a new array of all current options and selected, and de-dupe. // Create a new array of all current options and selected, and de-dupe.
const selected = new Map([...this.options, ...this.selected]); const selected = mapDualPairs([...this.options, ...this.selected]);
this.selected = Array.from(selected.entries()).sort(); this.selected = Array.from(selected.entries());
this.availablePane.value!.clearMove(); this.availablePane.value!.clearMove();
} }
@ -192,18 +201,18 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
return; return;
} }
const deselected = new Set(this.selectedPane.value!.moveable); const deselected = new Set(this.selectedPane.value!.moveable);
this.selected = this.selected.filter(([key, _]) => !deselected.has(key)); this.selected = this.selected.filter(([key]) => !deselected.has(key));
this.selectedPane.value!.clearMove(); this.selectedPane.value!.clearMove();
} }
removeOne(key: string) { removeOne(key: string) {
this.selected = this.selected.filter(([k, _]) => k !== key); this.selected = this.selected.filter(([k]) => k !== key);
} }
removeAllVisible() { removeAllVisible() {
// Remove all the items from selected that are in the *currently visible* options list // 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.selected = this.selected.filter(([k]) => !options.has(k));
this.selectedPane.value!.clearMove(); this.selectedPane.value!.clearMove();
} }

View file

@ -138,8 +138,11 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
<span class="pf-c-dual-list-selector__item"> <span class="pf-c-dual-list-selector__item">
<span class="pf-c-dual-list-selector__item-main"> <span class="pf-c-dual-list-selector__item-main">
<span class="pf-c-dual-list-selector__item-text" <span class="pf-c-dual-list-selector__item-text"
>${label}${this.selected.has(key) ><span>${label}</span>${this.selected.has(key)
? html`<i class="fa fa-check"></i>` ? html`<span
class="pf-c-dual-list-selector__item-text-selected-indicator"
><i class="fa fa-check"></i
></span>`
: nothing}</span : nothing}</span
></span ></span
></span ></span

View file

@ -86,6 +86,8 @@ export const globalVariables = css`
--pf-c-dual-list-selector__list-item--m-disabled__item-toggle-icon--Color: var( --pf-c-dual-list-selector__list-item--m-disabled__item-toggle-icon--Color: var(
--pf-global--disabled-color--200 --pf-global--disabled-color--200
); );
--pf-c-dual-list-selector--selection-desc--FontSize: var(--pf-global--FontSize--xs);
--pf-c-dual-list-selector--selection-desc--Color: var(--pf-global--Color--dark-200);
} }
`; `;
@ -151,6 +153,11 @@ export const listStyles = css`
user-select: none; user-select: none;
flex-grow: 0; flex-grow: 0;
} }
.pf-c-dual-list-selector__item-text .selection-desc {
font-size: var(--pf-c-dual-list-selector--selection-desc--FontSize);
color: var(--pf-c-dual-list-selector--selection-desc--Color);
}
`; `;
export const selectedPaneStyles = css` export const selectedPaneStyles = css`
@ -160,11 +167,22 @@ export const selectedPaneStyles = css`
`; `;
export const availablePaneStyles = css` export const availablePaneStyles = css`
.pf-c-dual-list-selector__item-text {
display: grid;
grid-template-columns: 1fr auto;
}
.pf-c-dual-list-selector__item-text .pf-c-dual-list-selector__item-text-selected-indicator {
display: grid;
justify-content: center;
align-content: center;
}
.pf-c-dual-list-selector__item-text i { .pf-c-dual-list-selector__item-text i {
display: inline-block; display: inline-block;
margin-left: 0.5rem; padding-left: 1rem;
font-weight: 200; font-weight: 200;
color: var(--pf-global--palette--black-500); color: var(--pf-c-dual-list-selector--selection-desc--Color);
font-size: var(--pf-global--FontSize--xs); font-size: var(--pf-global--FontSize--xs);
} }
`; `;

View file

@ -2,7 +2,9 @@ import { TemplateResult } from "lit";
import { Pagination } from "@goauthentik/api"; import { Pagination } from "@goauthentik/api";
export type DualSelectPair = [string, string | TemplateResult]; // Key, Label (string or TemplateResult), (optional) string to sort by. If the sort string is
// missing, it will use the label, which doesn't always work for TemplateResults).
export type DualSelectPair = [string, string | TemplateResult, string?];
export type BasePagination = Pick< export type BasePagination = Pick<
Pagination, Pagination,