web: restructure ak-dual-select to avoid using CustomEvent
# What - Replace all `CustomEvent<T>` handlers with a child class Event. - Use the standard `dispatchEvent()` and `addEventListener()` clauses. - Move common methods of the two panes into an abstract parent class. # Why CustomEvent is a bit of a side-show in JavaScript; it's perfectly acceptable to inherit from Event, and there's much better support for type management while using it, plus deconstructing the received event object is cleaner with child Events than unpacking the `details` object. This codebase is now more open to change, and is closer to our still-evolving style. Doing it this way mean that, once you have an event defined, you don't have to remember if you're sending a custom event or a normal event, and you don't need all that infrastructure for making your Lit objects sensitive to custom event handling. There's enough mixin clutter already. And just like I hate clutter, I hate duplication. The two panes had a lot in common with ARIA handling and storing, clearing, and assigning selected items to the "pending move" table. Moving all that into a parent class exposed the differences: one is a source and the other a sink; one reflects changes made, the other possible changes to be made. # Testing The Storybook tests have all been updated to match the codebase, and there are standalone tests for the various components (pagination, pane control, search) that can be exercised before deployment.
This commit is contained in:
@ -1,6 +1,5 @@
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
||||
import { debounce } from "@goauthentik/elements/utils/debounce";
|
||||
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { PropertyValues, html } from "lit";
|
||||
@ -12,6 +11,11 @@ import type { Pagination } from "@goauthentik/api";
|
||||
|
||||
import "./ak-dual-select";
|
||||
import { AkDualSelect } from "./ak-dual-select";
|
||||
import {
|
||||
DualSelectChangeEvent,
|
||||
DualSelectPaginatorNavEvent,
|
||||
DualSelectSearchEvent,
|
||||
} from "./events";
|
||||
import type { DataProvider, DualSelectPair } from "./types";
|
||||
|
||||
/**
|
||||
@ -26,7 +30,7 @@ import type { DataProvider, DualSelectPair } from "./types";
|
||||
*/
|
||||
|
||||
@customElement("ak-dual-select-provider")
|
||||
export class AkDualSelectProvider extends CustomListenerElement(AkControlElement) {
|
||||
export class AkDualSelectProvider extends AkControlElement {
|
||||
/** A function that takes a page and returns the DualSelectPair[] collection with which to update
|
||||
* the "Available" pane.
|
||||
*
|
||||
@ -86,9 +90,9 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
|
||||
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);
|
||||
this.addEventListener(DualSelectPaginatorNavEvent.eventName, this.onNav);
|
||||
this.addEventListener(DualSelectSearchEvent.eventName, this.onSearch);
|
||||
this.addEventListener(DualSelectChangeEvent.eventName, this.onChange);
|
||||
}
|
||||
|
||||
willUpdate(changedProperties: PropertyValues<this>) {
|
||||
@ -122,26 +126,16 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
onNav(event: Event) {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
throw new Error(`Expecting a CustomEvent for navigation, received ${event} instead`);
|
||||
}
|
||||
this.fetch(event.detail);
|
||||
onNav(event: DualSelectPaginatorNavEvent) {
|
||||
this.fetch(event.page);
|
||||
}
|
||||
|
||||
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;
|
||||
onChange(event: DualSelectChangeEvent) {
|
||||
this.selected = this.internalSelected = event.selected;
|
||||
}
|
||||
|
||||
onSearch(event: Event) {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
|
||||
}
|
||||
this.doSearch(event.detail);
|
||||
onSearch(event: DualSelectSearchEvent) {
|
||||
this.doSearch(event.search);
|
||||
}
|
||||
|
||||
doSearch(search: string) {
|
||||
|
@ -1,8 +1,5 @@
|
||||
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";
|
||||
@ -23,15 +20,13 @@ import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-p
|
||||
import "./components/ak-pagination";
|
||||
import "./components/ak-search-bar";
|
||||
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";
|
||||
DualSelectChangeEvent,
|
||||
DualSelectMoveRequestEvent,
|
||||
DualSelectPanelSearchEvent,
|
||||
DualSelectSearchEvent,
|
||||
DualSelectUpdateEvent,
|
||||
} from "./events";
|
||||
import type { BasePagination, DualSelectPair } from "./types";
|
||||
|
||||
function alphaSort([_k1, v1, s1]: DualSelectPair, [_k2, v2, s2]: DualSelectPair) {
|
||||
const [l, r] = [s1 !== undefined ? s1 : v1, s2 !== undefined ? s2 : v2];
|
||||
@ -60,7 +55,7 @@ const keyfinder =
|
||||
k === key;
|
||||
|
||||
@customElement("ak-dual-select")
|
||||
export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) {
|
||||
export class AkDualSelect extends AKElement {
|
||||
static get styles() {
|
||||
return styles;
|
||||
}
|
||||
@ -96,21 +91,9 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
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));
|
||||
});
|
||||
this.addCustomListener("ak-dual-select-move", () => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
this.addCustomListener("ak-search", this.handleSearch);
|
||||
this.addEventListener(DualSelectMoveRequestEvent.eventName, this.handleMove);
|
||||
this.addEventListener(DualSelectUpdateEvent.eventName, () => this.requestUpdate());
|
||||
this.addEventListener(DualSelectPanelSearchEvent.eventName, this.handleSearch);
|
||||
}
|
||||
|
||||
willUpdate(changedProperties: PropertyValues<this>) {
|
||||
@ -123,47 +106,17 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
}
|
||||
}
|
||||
|
||||
handleMove(eventName: string, event: Event) {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
throw new Error(`Expected move event here, got ${eventName}`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`AkDualSelect.handleMove received unknown event type: ${eventName}`,
|
||||
);
|
||||
}
|
||||
this.dispatchCustomEvent("ak-dual-select-change", { value: this.value });
|
||||
handleMove(event: DualSelectMoveRequestEvent) {
|
||||
match(event.move)
|
||||
.with("add-all", () => this.addAllVisible())
|
||||
.with("add-one", () => this.addOne(event.key))
|
||||
.with("add-selected", () => this.addSelected())
|
||||
.with("delete-all", () => this.removeAll())
|
||||
.with("remove-all", () => this.removeAllVisible())
|
||||
.with("remove-one", () => this.removeOne(event.key))
|
||||
.with("remove-selected", () => this.removeSelected())
|
||||
.exhaustive();
|
||||
this.dispatchEvent(new DualSelectChangeEvent(this.value));
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
@ -182,7 +135,10 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
this.availablePane.value!.clearMove();
|
||||
}
|
||||
|
||||
addOne(key: string) {
|
||||
addOne(key?: string) {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const requested = this.options.find(keyfinder(key));
|
||||
if (requested && !this.selected.find(keyfinder(requested[0]))) {
|
||||
this.selected = [...this.selected, requested];
|
||||
@ -207,7 +163,10 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
this.selectedPane.value!.clearMove();
|
||||
}
|
||||
|
||||
removeOne(key: string) {
|
||||
removeOne(key?: string) {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
this.selected = this.selected.filter(([k]) => k !== key);
|
||||
}
|
||||
|
||||
@ -223,18 +182,18 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
this.selectedPane.value!.clearMove();
|
||||
}
|
||||
|
||||
handleSearch(event: SearchbarEvent) {
|
||||
switch (event.detail.source) {
|
||||
handleSearch(event: DualSelectPanelSearchEvent) {
|
||||
switch (event.source) {
|
||||
case "ak-dual-list-available-search":
|
||||
return this.handleAvailableSearch(event.detail.value);
|
||||
return this.handleAvailableSearch(event.filterOn);
|
||||
case "ak-dual-list-selected-search":
|
||||
return this.handleSelectedSearch(event.detail.value);
|
||||
return this.handleSelectedSearch(event.filterOn);
|
||||
}
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
handleAvailableSearch(value: string) {
|
||||
this.dispatchCustomEvent("ak-dual-select-search", value);
|
||||
this.dispatchEvent(new DualSelectSearchEvent(value));
|
||||
}
|
||||
|
||||
handleSelectedSearch(value: string) {
|
||||
|
@ -1,26 +1,19 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
import { bound } from "@goauthentik/elements/decorators/bound";
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { map } from "lit/directives/map.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 { availablePaneStyles } from "./styles.css";
|
||||
|
||||
import { EVENT_ADD_ONE } from "../constants";
|
||||
import {
|
||||
DualSelectMoveAvailableEvent,
|
||||
DualSelectMoveRequestEvent,
|
||||
DualSelectUpdateEvent,
|
||||
} from "../events";
|
||||
import type { DualSelectPair } from "../types";
|
||||
|
||||
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, availablePaneStyles];
|
||||
|
||||
const hostAttributes = [
|
||||
["aria-labelledby", "dual-list-selector-available-pane-status"],
|
||||
["aria-multiselectable", "true"],
|
||||
["role", "listbox"],
|
||||
];
|
||||
import { AkDualSelectAbstractPane } from "./ak-dual-select-pane";
|
||||
|
||||
/**
|
||||
* @element ak-dual-select-available-panel
|
||||
@ -40,9 +33,9 @@ const hostAttributes = [
|
||||
*
|
||||
*/
|
||||
@customElement("ak-dual-select-available-pane")
|
||||
export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
|
||||
export class AkDualSelectAvailablePane extends AkDualSelectAbstractPane {
|
||||
static get styles() {
|
||||
return styles;
|
||||
return [...AkDualSelectAbstractPane.styles, availablePaneStyles];
|
||||
}
|
||||
|
||||
/* The array of key/value pairs this pane is currently showing */
|
||||
@ -56,68 +49,31 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
|
||||
@property({ type: Object })
|
||||
readonly selected: Set<string> = 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.
|
||||
*
|
||||
*/
|
||||
@state()
|
||||
public toMove: Set<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onMove = this.onMove.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
hostAttributes.forEach(([attr, value]) => {
|
||||
if (!this.hasAttribute(attr)) {
|
||||
this.setAttribute(attr, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearMove() {
|
||||
this.toMove = new Set();
|
||||
}
|
||||
|
||||
@bound
|
||||
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");
|
||||
this.move(key);
|
||||
this.dispatchEvent(new DualSelectMoveAvailableEvent(this.moveable.sort()));
|
||||
this.dispatchEvent(new DualSelectUpdateEvent());
|
||||
// Necessary because updating a map won't trigger a state change
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@bound
|
||||
onMove(key: string) {
|
||||
this.toMove.delete(key);
|
||||
this.dispatchCustomEvent(EVENT_ADD_ONE, key);
|
||||
this.dispatchEvent(new DualSelectMoveRequestEvent("add-one", key));
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
get moveable() {
|
||||
return Array.from(this.toMove.values());
|
||||
}
|
||||
|
||||
// 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
|
||||
// without causing the list to scroll back up to the top.
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
return html`
|
||||
<div class="pf-c-dual-list-selector__menu">
|
||||
<ul class="pf-c-dual-list-selector__list">
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html, nothing } from "lit";
|
||||
@ -8,13 +7,7 @@ import { customElement, property } from "lit/decorators.js";
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import {
|
||||
EVENT_ADD_ALL,
|
||||
EVENT_ADD_SELECTED,
|
||||
EVENT_DELETE_ALL,
|
||||
EVENT_REMOVE_ALL,
|
||||
EVENT_REMOVE_SELECTED,
|
||||
} from "../constants";
|
||||
import { DualSelectMoveRequestEvent, type MoveEventType } from "../events";
|
||||
|
||||
const styles = [
|
||||
PFBase,
|
||||
@ -47,7 +40,7 @@ const styles = [
|
||||
*/
|
||||
|
||||
@customElement("ak-dual-select-controls")
|
||||
export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
|
||||
export class AkDualSelectControls extends AKElement {
|
||||
static get styles() {
|
||||
return styles;
|
||||
}
|
||||
@ -96,11 +89,11 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
|
||||
this.onClick = this.onClick.bind(this);
|
||||
}
|
||||
|
||||
onClick(eventName: string) {
|
||||
this.dispatchCustomEvent(eventName);
|
||||
onClick(eventName: MoveEventType) {
|
||||
this.dispatchEvent(new DualSelectMoveRequestEvent(eventName));
|
||||
}
|
||||
|
||||
renderButton(label: string, event: string, active: boolean, direction: string) {
|
||||
renderButton(label: string, event: MoveEventType, active: boolean, direction: string) {
|
||||
return html`
|
||||
<div class="pf-c-dual-list-selector__controls-item">
|
||||
<button
|
||||
@ -121,23 +114,18 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
|
||||
render() {
|
||||
return html`
|
||||
<div class="ak-dual-list-selector__controls">
|
||||
${this.renderButton(
|
||||
msg("Add"),
|
||||
EVENT_ADD_SELECTED,
|
||||
this.addActive,
|
||||
"fa-angle-right",
|
||||
)}
|
||||
${this.renderButton(msg("Add"), "add-selected", this.addActive, "fa-angle-right")}
|
||||
${this.selectAll
|
||||
? html`
|
||||
${this.renderButton(
|
||||
msg("Add All Available"),
|
||||
EVENT_ADD_ALL,
|
||||
"add-all",
|
||||
this.addAllActive,
|
||||
"fa-angle-double-right",
|
||||
)}
|
||||
${this.renderButton(
|
||||
msg("Remove All Available"),
|
||||
EVENT_REMOVE_ALL,
|
||||
"remove-all",
|
||||
this.removeAllActive,
|
||||
"fa-angle-double-left",
|
||||
)}
|
||||
@ -145,14 +133,14 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
|
||||
: nothing}
|
||||
${this.renderButton(
|
||||
msg("Remove"),
|
||||
EVENT_REMOVE_SELECTED,
|
||||
"remove-selected",
|
||||
this.removeActive,
|
||||
"fa-angle-left",
|
||||
)}
|
||||
${this.deleteAll
|
||||
? html`${this.renderButton(
|
||||
msg("Remove All"),
|
||||
EVENT_DELETE_ALL,
|
||||
"delete-all",
|
||||
this.enableDeleteAll,
|
||||
"fa-times",
|
||||
)}`
|
||||
|
@ -0,0 +1,75 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
|
||||
import { TemplateResult } from "lit";
|
||||
import { state } from "lit/decorators.js";
|
||||
|
||||
import { 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";
|
||||
|
||||
const styles = [PFBase, PFButton, PFDualListSelector, listStyles];
|
||||
|
||||
const hostAttributes = [
|
||||
["aria-labelledby", "dual-list-selector-selected-pane-status"],
|
||||
["aria-multiselectable", "true"],
|
||||
["role", "listbox"],
|
||||
];
|
||||
|
||||
/**
|
||||
* @element ak-dual-select-panel
|
||||
*
|
||||
* The "selected options" or "right" pane in a dual-list multi-select. It receives from its parent
|
||||
* a list of the selected options, and maintains an internal list of objects selected to move.
|
||||
*
|
||||
* @fires ak-dual-select-selected-move-changed - When the list of "to move" entries changed.
|
||||
* Includes the current `toMove` content.
|
||||
*
|
||||
* @fires ak-dual-select-remove-one - Double-click with the element clicked on.
|
||||
*
|
||||
* It is not expected that the `ak-dual-select-selected-move-changed` will be used; instead, the
|
||||
* attribute will be read by the parent when a control is clicked.
|
||||
*
|
||||
*/
|
||||
export abstract class AkDualSelectAbstractPane extends AKElement {
|
||||
static get styles() {
|
||||
return styles;
|
||||
}
|
||||
|
||||
/*
|
||||
* 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<string> = new Set();
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
hostAttributes.forEach(([attr, value]) => {
|
||||
if (!this.hasAttribute(attr)) {
|
||||
this.setAttribute(attr, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearMove() {
|
||||
this.toMove = new Set();
|
||||
}
|
||||
|
||||
move(key: string) {
|
||||
if (this.toMove.has(key)) {
|
||||
this.toMove.delete(key);
|
||||
} else {
|
||||
this.toMove.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
get moveable() {
|
||||
return Array.from(this.toMove.values());
|
||||
}
|
||||
|
||||
abstract render(): TemplateResult;
|
||||
}
|
@ -1,26 +1,19 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
import { bound } from "@goauthentik/elements/decorators/bound";
|
||||
|
||||
import { html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { map } from "lit/directives/map.js";
|
||||
|
||||
import { listStyles, selectedPaneStyles } 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 { selectedPaneStyles } from "./styles.css";
|
||||
|
||||
import { EVENT_REMOVE_ONE } from "../constants";
|
||||
import {
|
||||
DualSelectMoveRequestEvent,
|
||||
DualSelectMoveSelectedEvent,
|
||||
DualSelectUpdateEvent,
|
||||
} from "../events";
|
||||
import type { DualSelectPair } from "../types";
|
||||
|
||||
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, selectedPaneStyles];
|
||||
|
||||
const hostAttributes = [
|
||||
["aria-labelledby", "dual-list-selector-selected-pane-status"],
|
||||
["aria-multiselectable", "true"],
|
||||
["role", "listbox"],
|
||||
];
|
||||
import { AkDualSelectAbstractPane } from "./ak-dual-select-pane";
|
||||
|
||||
/**
|
||||
* @element ak-dual-select-available-panel
|
||||
@ -38,70 +31,32 @@ const hostAttributes = [
|
||||
*
|
||||
*/
|
||||
@customElement("ak-dual-select-selected-pane")
|
||||
export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
|
||||
export class AkDualSelectSelectedPane extends AkDualSelectAbstractPane {
|
||||
static get styles() {
|
||||
return styles;
|
||||
return [...AkDualSelectAbstractPane.styles, selectedPaneStyles];
|
||||
}
|
||||
|
||||
/* The array of key/value pairs that are in the selected list. ALL of them. */
|
||||
@property({ type: Array })
|
||||
readonly selected: DualSelectPair[] = [];
|
||||
|
||||
/*
|
||||
* 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<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onMove = this.onMove.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
hostAttributes.forEach(([attr, value]) => {
|
||||
if (!this.hasAttribute(attr)) {
|
||||
this.setAttribute(attr, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearMove() {
|
||||
this.toMove = new Set();
|
||||
}
|
||||
|
||||
@bound
|
||||
onClick(key: string) {
|
||||
if (this.toMove.has(key)) {
|
||||
this.toMove.delete(key);
|
||||
} else {
|
||||
this.toMove.add(key);
|
||||
}
|
||||
this.dispatchCustomEvent(
|
||||
"ak-dual-select-selected-move-changed",
|
||||
Array.from(this.toMove.values()).sort(),
|
||||
);
|
||||
this.dispatchCustomEvent("ak-dual-select-move");
|
||||
this.move(key);
|
||||
this.dispatchEvent(new DualSelectMoveSelectedEvent(this.moveable.sort()));
|
||||
this.dispatchEvent(new DualSelectUpdateEvent());
|
||||
// Necessary because updating a map won't trigger a state change
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@bound
|
||||
onMove(key: string) {
|
||||
this.toMove.delete(key);
|
||||
this.dispatchCustomEvent(EVENT_REMOVE_ONE, key);
|
||||
this.dispatchEvent(new DualSelectMoveRequestEvent("remove-one", key));
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
get moveable() {
|
||||
return Array.from(this.toMove.values());
|
||||
}
|
||||
|
||||
render() {
|
||||
override render() {
|
||||
return html`
|
||||
<div class="pf-c-dual-list-selector__menu">
|
||||
<ul class="pf-c-dual-list-selector__list">
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { css, html, nothing } from "lit";
|
||||
@ -9,6 +8,7 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFPagination from "@patternfly/patternfly/components/Pagination/pagination.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { DualSelectPaginatorNavEvent } from "../events";
|
||||
import type { BasePagination } from "../types";
|
||||
|
||||
const styles = [
|
||||
@ -27,7 +27,7 @@ const styles = [
|
||||
];
|
||||
|
||||
@customElement("ak-pagination")
|
||||
export class AkPagination extends CustomEmitterElement(AKElement) {
|
||||
export class AkPagination extends AKElement {
|
||||
static get styles() {
|
||||
return styles;
|
||||
}
|
||||
@ -41,7 +41,7 @@ export class AkPagination extends CustomEmitterElement(AKElement) {
|
||||
}
|
||||
|
||||
onClick(nav: number | undefined) {
|
||||
this.dispatchCustomEvent("ak-pagination-nav-to", nav ?? 0);
|
||||
this.dispatchEvent(new DualSelectPaginatorNavEvent(nav ?? 0));
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
|
||||
import { html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
@ -9,12 +8,12 @@ import type { Ref } from "lit/directives/ref.js";
|
||||
import { globalVariables, searchStyles } from "./search.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import type { SearchbarEvent } from "../types";
|
||||
import { DualSelectPanelSearchEvent } from "../events";
|
||||
|
||||
const styles = [PFBase, globalVariables, searchStyles];
|
||||
|
||||
@customElement("ak-search-bar")
|
||||
export class AkSearchbar extends CustomEmitterElement(AKElement) {
|
||||
export class AkSearchbar extends AKElement {
|
||||
static get styles() {
|
||||
return styles;
|
||||
}
|
||||
@ -40,10 +39,7 @@ export class AkSearchbar extends CustomEmitterElement(AKElement) {
|
||||
if (this.input.value) {
|
||||
this.value = this.input.value.value;
|
||||
}
|
||||
this.dispatchCustomEvent<SearchbarEvent>("ak-search", {
|
||||
source: this.name,
|
||||
value: this.value,
|
||||
});
|
||||
this.dispatchEvent(new DualSelectPanelSearchEvent(this.name, this.value));
|
||||
}
|
||||
|
||||
render() {
|
||||
|
112
web/src/elements/ak-dual-select/events.ts
Normal file
112
web/src/elements/ak-dual-select/events.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { DualSelectPair } from "./types";
|
||||
|
||||
// Handled by the Server layer provider
|
||||
|
||||
// Request to provide a different page of the paginated results in the "available" panel.
|
||||
export class DualSelectPaginatorNavEvent extends Event {
|
||||
static readonly eventName = "ak-dual-select-paginator-nav";
|
||||
constructor(public page: number = 0) {
|
||||
super(DualSelectPaginatorNavEvent.eventName, { bubbles: true, composed: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Request to provide a filtered collection for the "available" panel via a search string
|
||||
export class DualSelectSearchEvent extends Event {
|
||||
static readonly eventName = "ak-dual-select-search";
|
||||
constructor(public search: string) {
|
||||
super(DualSelectSearchEvent.eventName, { bubbles: true, composed: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Request to update the "selected" list in the provider
|
||||
export class DualSelectChangeEvent extends Event {
|
||||
static readonly eventName = "ak-dual-select-change";
|
||||
constructor(public selected: DualSelectPair[]) {
|
||||
super(DualSelectChangeEvent.eventName, { bubbles: true, composed: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Paginator and specific item events
|
||||
|
||||
export const moveEvents = [
|
||||
"add-all",
|
||||
"add-one",
|
||||
"add-selected",
|
||||
"delete-all",
|
||||
"remove-all",
|
||||
"remove-one",
|
||||
"remove-selected",
|
||||
] as const;
|
||||
|
||||
export type MoveEventType = (typeof moveEvents)[number];
|
||||
|
||||
// Request to add or remove all, some, or just one item from the "selected" panel
|
||||
export class DualSelectMoveRequestEvent extends Event {
|
||||
static readonly eventName = "ak-dual-select-request-move";
|
||||
constructor(
|
||||
public move: MoveEventType,
|
||||
public key?: string,
|
||||
) {
|
||||
super(DualSelectMoveRequestEvent.eventName, { bubbles: true, composed: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Update events
|
||||
|
||||
// Request to update the viewset
|
||||
export class DualSelectUpdateEvent extends Event {
|
||||
static readonly eventName = "ak-dual-select-update";
|
||||
constructor() {
|
||||
super(DualSelectUpdateEvent.eventName, { bubbles: true, composed: true });
|
||||
}
|
||||
}
|
||||
|
||||
interface DualSelectMoveChangedEvent {
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
// Request to update the list of "marked for move" items in the "available" panel
|
||||
export class DualSelectMoveAvailableEvent extends Event implements DualSelectMoveChangedEvent {
|
||||
static readonly eventName = "ak-dual-select-move-available";
|
||||
constructor(public keys: string[]) {
|
||||
super(DualSelectMoveAvailableEvent.eventName, { bubbles: true, composed: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Request to update the list of "marked for move" items in the "selected" panel
|
||||
export class DualSelectMoveSelectedEvent extends Event implements DualSelectMoveChangedEvent {
|
||||
static readonly eventName = "ak-dual-select-move-selected";
|
||||
constructor(public keys: string[]) {
|
||||
super(DualSelectMoveSelectedEvent.eventName, { bubbles: true, composed: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Request to update either panel with a Filter
|
||||
export class DualSelectPanelSearchEvent extends Event {
|
||||
static readonly eventName = "ak-dual-select-panel-search";
|
||||
constructor(
|
||||
public source: string,
|
||||
public filterOn: string,
|
||||
) {
|
||||
super(DualSelectPanelSearchEvent.eventName, { bubbles: true, composed: true });
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
[DualSelectUpdateEvent.eventName]: DualSelectUpdateEvent;
|
||||
[DualSelectMoveAvailableEvent.eventName]: DualSelectMoveAvailableEvent;
|
||||
[DualSelectMoveSelectedEvent.eventName]: DualSelectMoveSelectedEvent;
|
||||
[DualSelectMoveRequestEvent.eventName]: DualSelectMoveRequestEvent;
|
||||
[DualSelectPaginatorNavEvent.eventName]: DualSelectPaginatorNavEvent;
|
||||
[DualSelectSearchEvent.eventName]: DualSelectSearchEvent;
|
||||
[DualSelectChangeEvent.eventName]: DualSelectChangeEvent;
|
||||
[DualSelectPanelSearchEvent.eventName]: DualSelectPanelSearchEvent;
|
||||
}
|
||||
|
||||
interface WindowEventMap {
|
||||
[DualSelectMoveRequestEvent.eventName]: DualSelectMoveRequestEvent;
|
||||
[DualSelectPaginatorNavEvent.eventName]: DualSelectPaginatorNavEvent;
|
||||
[DualSelectMoveSelectedEvent.eventName]: DualSelectMoveSelectedEvent;
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ import { TemplateResult, html } from "lit";
|
||||
|
||||
import "../components/ak-dual-select-available-pane";
|
||||
import { AkDualSelectAvailablePane } from "../components/ak-dual-select-available-pane";
|
||||
import { DualSelectMoveSelectedEvent } from "../events";
|
||||
import "./sb-host-provider";
|
||||
|
||||
const metadata: Meta<AkDualSelectAvailablePane> = {
|
||||
@ -53,15 +54,15 @@ const container = (testItem: TemplateResult) =>
|
||||
</div>`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleMoveChanged = (result: any) => {
|
||||
const handleMoveChanged = (result: DualSelectMoveSelectedEvent) => {
|
||||
const target = document.querySelector("#action-button-message-pad");
|
||||
target!.innerHTML = "";
|
||||
result.detail.forEach((key: string) => {
|
||||
result.keys.forEach((key: string) => {
|
||||
target!.append(new DOMParser().parseFromString(`<li>${key}</li>`, "text/xml").firstChild!);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("ak-dual-select-available-move-changed", handleMoveChanged);
|
||||
window.addEventListener(DualSelectMoveSelectedEvent.eventName, handleMoveChanged);
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
|
@ -5,6 +5,7 @@ import { TemplateResult, html } from "lit";
|
||||
|
||||
import "../components/ak-dual-select-controls";
|
||||
import { AkDualSelectControls } from "../components/ak-dual-select-controls";
|
||||
import { DualSelectMoveRequestEvent } from "../events";
|
||||
|
||||
const metadata: Meta<AkDualSelectControls> = {
|
||||
title: "Elements / Dual Select / Control Panel",
|
||||
@ -59,10 +60,9 @@ const displayMessage = (result: any) => {
|
||||
target!.appendChild(doc.firstChild!);
|
||||
};
|
||||
|
||||
window.addEventListener("ak-dual-select-add", () => displayMessage("add"));
|
||||
window.addEventListener("ak-dual-select-remove", () => displayMessage("remove"));
|
||||
window.addEventListener("ak-dual-select-add-all", () => displayMessage("add all"));
|
||||
window.addEventListener("ak-dual-select-remove-all", () => displayMessage("remove all"));
|
||||
window.addEventListener(DualSelectMoveRequestEvent.eventName, (ev: DualSelectMoveRequestEvent) =>
|
||||
displayMessage(ev.move.toString()),
|
||||
);
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
|
@ -9,6 +9,7 @@ import { Pagination } from "@goauthentik/api";
|
||||
|
||||
import "../ak-dual-select";
|
||||
import { AkDualSelect } from "../ak-dual-select";
|
||||
import { DualSelectPaginatorNavEvent } from "../events";
|
||||
import type { DualSelectPair } from "../types";
|
||||
|
||||
const goodForYouRaw = `
|
||||
@ -83,11 +84,11 @@ export class AkSbFruity extends LitElement {
|
||||
totalPages: Math.ceil(this.options.length / this.pageLength),
|
||||
};
|
||||
this.onNavigation = this.onNavigation.bind(this);
|
||||
this.addEventListener("ak-pagination-nav-to", this.onNavigation);
|
||||
this.addEventListener(DualSelectPaginatorNavEvent.eventName, this.onNavigation);
|
||||
}
|
||||
|
||||
onNavigation(evt: Event) {
|
||||
const current: number = (evt as CustomEvent).detail;
|
||||
onNavigation(evt: DualSelectPaginatorNavEvent) {
|
||||
const current = evt.page;
|
||||
const index = current - 1;
|
||||
if (index * this.pageLength > this.options.length) {
|
||||
console.warn(
|
||||
|
@ -6,6 +6,7 @@ import { TemplateResult, html } from "lit";
|
||||
|
||||
import "../components/ak-dual-select-selected-pane";
|
||||
import { AkDualSelectSelectedPane } from "../components/ak-dual-select-selected-pane";
|
||||
import { DualSelectMoveSelectedEvent } from "../events";
|
||||
import "./sb-host-provider";
|
||||
|
||||
const metadata: Meta<AkDualSelectSelectedPane> = {
|
||||
@ -50,15 +51,15 @@ const container = (testItem: TemplateResult) =>
|
||||
</div>`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleMoveChanged = (result: any) => {
|
||||
const handleMoveChanged = (result: DualSelectMoveSelectedEvent) => {
|
||||
const target = document.querySelector("#action-button-message-pad");
|
||||
target!.innerHTML = "";
|
||||
result.detail.forEach((key: string) => {
|
||||
result.keys.forEach((key: string) => {
|
||||
target!.append(new DOMParser().parseFromString(`<li>${key}</li>`, "text/xml").firstChild!);
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("ak-dual-select-selected-move-changed", handleMoveChanged);
|
||||
window.addEventListener(DualSelectMoveSelectedEvent.eventName, handleMoveChanged);
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
|
@ -5,6 +5,7 @@ import { TemplateResult, html } from "lit";
|
||||
|
||||
import "../components/ak-pagination";
|
||||
import { AkPagination } from "../components/ak-pagination";
|
||||
import { DualSelectPaginatorNavEvent } from "../events";
|
||||
|
||||
const metadata: Meta<AkPagination> = {
|
||||
title: "Elements / Dual Select / Pagination Control",
|
||||
@ -43,18 +44,18 @@ const container = (testItem: TemplateResult) =>
|
||||
</div>`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleMoveChanged = (result: any) => {
|
||||
const handleMoveChanged = (result: DualSelectPaginatorNavEvent) => {
|
||||
console.debug(result);
|
||||
const target = document.querySelector("#action-button-message-pad");
|
||||
target!.append(
|
||||
new DOMParser().parseFromString(
|
||||
`<li>Request to move to page ${result.detail}</li>`,
|
||||
`<li>Request to move to page ${result.page}</li>`,
|
||||
"text/xml",
|
||||
).firstChild!,
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener("ak-pagination-nav-to", handleMoveChanged);
|
||||
window.addEventListener(DualSelectPaginatorNavEvent.eventName, handleMoveChanged);
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
|
@ -29,10 +29,3 @@ export type DataProvision = {
|
||||
};
|
||||
|
||||
export type DataProvider = (page: number, search?: string) => Promise<DataProvision>;
|
||||
|
||||
export interface SearchbarEvent extends CustomEvent {
|
||||
detail: {
|
||||
source: string;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
Reference in New Issue
Block a user