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,
});
const dualSelectPairMaker = (item: ProviderBase): DualSelectPair => [
`${item.pk}`,
`${
item.assignedBackchannelApplicationName
? item.assignedBackchannelApplicationName
: item.assignedApplicationName
} (${item.name})`,
];
const dualSelectPairMaker = (item: ProviderBase): DualSelectPair => {
const label = item.assignedBackchannelApplicationName
? item.assignedBackchannelApplicationName
: item.assignedApplicationName;
return [
`${item.pk}`,
html`<div class="selection-main">${label}</div>
<div class="selection-desc">${item.name}</div>`,
label,
];
};
const provisionMaker = (results: ProviderData) => ({
pagination: results.pagination,

View File

@ -5,7 +5,7 @@ import {
} from "@goauthentik/elements/utils/eventEmitter";
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 { createRef, ref } from "lit/directives/ref.js";
import type { Ref } from "lit/directives/ref.js";
@ -32,9 +32,14 @@ import {
} from "./constants";
import type { BasePagination, DualSelectPair } from "./types";
type PairValue = string | TemplateResult;
type Pair = [string, PairValue];
const alphaSort = ([_k1, v1]: Pair, [_k2, v2]: Pair) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0);
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 mapDualPairs(pairs: DualSelectPair[]) {
return new Map(pairs.map(([k, v, _]) => [k, v]));
}
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.
*/
const keyfinder =
(key: string) =>
([k]: DualSelectPair) =>
k === key;
@customElement("ak-dual-select")
export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) {
static get styles() {
@ -159,22 +169,21 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
if (this.availablePane.value!.moveable.length === 0) {
return;
}
const options = new Map(this.options);
const selected = new Map(this.selected);
this.availablePane.value!.moveable.forEach((key) => {
const value = options.get(key);
if (value) {
selected.set(key, value);
}
});
this.selected = Array.from(selected.entries()).sort();
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) {
const requested = this.options.find(([k, _]) => k === key);
if (requested) {
this.selected = Array.from(new Map([...this.selected, requested]).entries()).sort();
const requested = this.options.find(keyfinder(key));
if (requested && !this.selected.find(keyfinder(requested[0]))) {
this.selected = [...this.selected, requested];
}
}
@ -182,8 +191,8 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
// updating the list of currently visible options;
addAllVisible() {
// Create a new array of all current options and selected, and de-dupe.
const selected = new Map([...this.options, ...this.selected]);
this.selected = Array.from(selected.entries()).sort();
const selected = mapDualPairs([...this.options, ...this.selected]);
this.selected = Array.from(selected.entries());
this.availablePane.value!.clearMove();
}
@ -192,18 +201,18 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
return;
}
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();
}
removeOne(key: string) {
this.selected = this.selected.filter(([k, _]) => k !== key);
this.selected = this.selected.filter(([k]) => k !== key);
}
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.selected = this.selected.filter(([k]) => !options.has(k));
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-main">
<span class="pf-c-dual-list-selector__item-text"
>${label}${this.selected.has(key)
? html`<i class="fa fa-check"></i>`
><span>${label}</span>${this.selected.has(key)
? html`<span
class="pf-c-dual-list-selector__item-text-selected-indicator"
><i class="fa fa-check"></i
></span>`
: nothing}</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-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;
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`
@ -160,11 +167,22 @@ export const selectedPaneStyles = 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 {
display: inline-block;
margin-left: 0.5rem;
padding-left: 1rem;
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);
}
`;

View File

@ -2,7 +2,9 @@ import { TemplateResult } from "lit";
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<
Pagination,