web: provide dual-list multiselect with pagination (#8004)

* web: revise css-import-maps to need only a single entry, rather than dual-entry

Given that the difference Vite/Storybook cares about is whether or not there's a
sigil at the end of the CSS string, it seemed silly to require devs to enter
both the raw and sigiled string; just do an in-line text-and-replace.

* web: provide a "select / select all" tool for the dual list multiselect

**This commit**

Provides one of several of the sub-controls needed to make the multi-list multi-select thing work.
This is the simplest control, and I decided to go with it first because it's all presentation; all
it does is show the buttons and send events from those buttons.

A Storybook component is provided to show how well it works.

* web: provide a "select / select all" tool for the dual list multiselect

**This commit**

This commit provides the following new features for dual list multiselect:

- The "available" pane, which has all of the entries that are available to be selected.  Items that
  are already selected will remain, but they're marked with a checkmark and can neither be selected
  or moved.
- The "selected" pane, which has *all* of the entries that have been selected.
- The Pagination control, which in this case only sends an event upstream.

**Plan**:

The plan is to have a master control that marries the available-pane, selected-pane,
select-controls, and pagination-controls into a single component that receives the list of
"currently visible" available entries and keeps the list of "currently selected" entries, as well as
a pass-through for the pagination value that allows it to hide the pagination control if there is
only one page.

A master component *above that* will provide the list of currently visible entries and, at need,
read the value of the master control object for the "selected" list. That component will mostly be
data-only; it's render will probably just be `<slot></slot>`; its duty will be only to map entries
to string keys Lit can use, and to provide the lists we want to provide and the pagination ranges we
want to show.

Some judicious use of grid will allow me size the controls properly with/without the pagination
control.

Status and Title are going to be in the master control.

A <slot> will be provided for Search, but I have no plans to integrate that into this control as of
yet.

There is already a planned fallback control; the multi-select experience on mobile is actually
excellent, and we should exploit that appropriately.

* web: provide a "select / select all" tool for the dual list multiselect

**This commit**

1. Re-arrange the contents of the folder so that the sub-components are in their own folder. This
   reduces the clutter and makes it easier to understand where to look for certain things.
2. Re-arranges the contents of the folder so that all the Storybook stories are in their own folder.
   Again, this reduces the clutter; it also helps the compiler understand what not to compile.
3. Strips down the "Available items pane" to a minimal amount of interactivity and annotates the
   passed-in properties as `readonly`, since the purpose of this component is to display those. The
   only internal state kept is the list of items marked-to-move.
4. Does the same thing with the "Selected items pane".
5. Added comments to help guide future maintainers.
6. Restructured the CSS, taking a _lot_ of it into our own hands. Patternfly continues to act as if
   all components are fully available all the time, and that's simply not true in a shadowDOM
   environment. By separating out the global CSS Custom Properties from the grid and style
   definitions of `pf-c-dual-list-selector`, I was able to construct a more simple and
   straightforward grid (with nested grids for the columns inside).
7. Added "Delete ALL Selected" to the controls
8. Added "double-click" as a "move this one NOW" feature.

* web: provide a "select / select all" tool for the dual list multiselect

**This commit**

- Fixes the bug whereby pagination would leave the 'some moves available' state visible by clearing
  the 'to-move' state when the list of options changes.
- Fixes the bug whereby a change of 'options' in available would also cause an update to
  `selectedKeys`, causing the entire selected field to clear. Fixed by making `selectedKeys` a
  static object updated only when `selected` is generated rather than generating it anew with each
  re-rerender. (Hey, kids, can you say "functional programming and immutability" five time fast? I
  knew you could!)
- Fixes the bug whereby the change of outpost type would not cause an update of the `options`
  collection.
- Fixes the bug whereby the CSS was not creating enough whitespace separation between the whole
  component and its siblings. Host components are coded `span:static` unless otherwise styled to be
  `block`; we want `block` most of the time.
- Fixes the bug whereby the list of existing objects wasn't being passed to the handler correctly.
- Updates the Form Handler to recognize this new input object.
- Fixes the bug whereby changing outpost type doesn't handle the list of selected applications well.
- Fixes the bug whereby the identity of the outpost type's associated `fetch()` function loses
  identity -- necessary to maintain the selected outpost type switch.
- Fixes the CSS bug whereby horizontal scrolling would not enable correctly when the application's
  name overflows the listbox.
- Completes this assignment.  :-)

* web: last-minute pre-commit cleanup.

* running localize extract

* web: codeql found an issue with one of my tests.

* 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.

* fix lint error

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update strings slightly

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start on dark theme support

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web: Add searchbar and enable it for "selected"

"Available" requires a round-trip to the provider level, so that's next.

* web: provide a search for the dual list multiselect

**This commit**

- Includes a new widget that represents the basic, Patternfly-designed search bar.  It just emits
  events of search request updates.
- Changes the definition of a data provider to take an optional search string.
- Changes the handler in the *independent* layer so that it catches search requests and those
  requests work on the "selected" collection.
- Changes the handler of the `authentik` interface layer so that it catches search requests and
  those requests are sent to the data provider.
- Provides a debounce function for the `authentik` interface layer to not hammer the Django instance
  too much.
- Updates the data providers in the example for `OutpostForm` to handle search requests.
- Provides a property in the `authentik` interface layer so that the debounce can be tuned.

* web: always trim the search string passed.

* web: code quality pass, extra comments, pre-commit check.

* Serious (and bizarre) merge bug.  I guess it doesn't like XML that much.

* Attempting to reason with whatever eslint GitHub is using.

* Prettier has opinions.

* Enable better dark mode.

There were two issues: the dark mode didn't reach into the "search"
bar, and there were several hover states that weren't handled well.

This commit handles both.  The color scheme mirrors the one we
currently use, but it's a bit backwards from Patternfly 5.  Dunno
how we're gonna reconcile all that.

* Prettier fixes and locale extraction

* web: update pagination type to use generic, provided type

* web: fixed a few comment typos

* Discordant version numbers for @go-authentik/api were causing build failures.

* What is up with CI/CD?

* web: missed a lint issue that prevented the build from running successfully

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Ken Sternberg
2024-01-25 10:08:00 -08:00
committed by GitHub
parent dcbfe73891
commit 5f1ba45966
46 changed files with 3151 additions and 277 deletions

View File

@ -1,18 +1,22 @@
import { DataProvider, DualSelectPair } from "@goauthentik/app/elements/ak-dual-select/types";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { docLink } from "@goauthentik/common/global";
import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import "@goauthentik/elements/forms/SearchSelect";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import YAML from "yaml";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { map } from "lit/directives/map.js";
import {
Outpost,
@ -20,14 +24,70 @@ import {
OutpostTypeEnum,
OutpostsApi,
OutpostsServiceConnectionsAllListRequest,
PaginatedLDAPProviderList,
PaginatedProxyProviderList,
PaginatedRACProviderList,
PaginatedRadiusProviderList,
ProvidersApi,
ServiceConnection,
} from "@goauthentik/api";
interface ProviderBase {
pk: number;
name: string;
assignedBackchannelApplicationName?: string;
assignedApplicationName?: string;
}
const api = () => new ProvidersApi(DEFAULT_CONFIG);
const providerListArgs = (page: number, search = "") => ({
ordering: "name",
applicationIsnull: false,
pageSize: 20,
search: search.trim(),
page,
});
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: PaginatedResponse<ProviderBase>) => ({
pagination: results.pagination,
options: results.results.map(dualSelectPairMaker),
});
const proxyListFetch = async (page: number, search = "") =>
provisionMaker(await api().providersProxyList(providerListArgs(page, search)));
const ldapListFetch = async (page: number, search = "") =>
provisionMaker(await api().providersLdapList(providerListArgs(page, search)));
const radiusListFetch = async (page: number, search = "") =>
provisionMaker(await api().providersRadiusList(providerListArgs(page, search)));
const racListProvider = async (page: number, search = "") =>
provisionMaker(await api().providersRacList(providerListArgs(page, search)));
function providerProvider(type: OutpostTypeEnum): DataProvider {
switch (type) {
case OutpostTypeEnum.Proxy:
return proxyListFetch;
case OutpostTypeEnum.Ldap:
return ldapListFetch;
case OutpostTypeEnum.Radius:
return radiusListFetch;
case OutpostTypeEnum.Rac:
return racListProvider;
default:
throw new Error(`Unrecognized OutputType: ${type}`);
}
}
@customElement("ak-outpost-form")
export class OutpostForm extends ModelForm<Outpost, string> {
@property()
@ -37,12 +97,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
embedded = false;
@state()
providers?:
| PaginatedProxyProviderList
| PaginatedLDAPProviderList
| PaginatedRadiusProviderList
| PaginatedRACProviderList;
providers?: DataProvider;
defaultConfig?: OutpostDefaultConfig;
async loadInstance(pk: string): Promise<Outpost> {
@ -57,34 +112,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
this.defaultConfig = await new OutpostsApi(
DEFAULT_CONFIG,
).outpostsInstancesDefaultSettingsRetrieve();
switch (this.type) {
case OutpostTypeEnum.Proxy:
this.providers = await new ProvidersApi(DEFAULT_CONFIG).providersProxyList({
ordering: "name",
applicationIsnull: false,
});
break;
case OutpostTypeEnum.Ldap:
this.providers = await new ProvidersApi(DEFAULT_CONFIG).providersLdapList({
ordering: "name",
applicationIsnull: false,
});
break;
case OutpostTypeEnum.Radius:
this.providers = await new ProvidersApi(DEFAULT_CONFIG).providersRadiusList({
ordering: "name",
applicationIsnull: false,
});
break;
case OutpostTypeEnum.Rac:
this.providers = await new ProvidersApi(DEFAULT_CONFIG).providersRacList({
ordering: "name",
applicationIsnull: false,
});
break;
case OutpostTypeEnum.UnknownDefaultOpenApi:
this.providers = undefined;
}
this.providers = providerProvider(this.type);
}
getSuccessMessage(): string {
@ -107,6 +135,13 @@ export class OutpostForm extends ModelForm<Outpost, string> {
}
renderForm(): TemplateResult {
const typeOptions = [
[OutpostTypeEnum.Proxy, msg("Proxy")],
[OutpostTypeEnum.Ldap, msg("LDAP")],
[OutpostTypeEnum.Radius, msg("Radius")],
[OutpostTypeEnum.Rac, msg("RAC")],
];
return html` <ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input
type="text"
@ -124,30 +159,16 @@ export class OutpostForm extends ModelForm<Outpost, string> {
this.load();
}}
>
<option
value=${OutpostTypeEnum.Proxy}
?selected=${this.instance?.type === OutpostTypeEnum.Proxy}
>
${msg("Proxy")}
</option>
<option
value=${OutpostTypeEnum.Ldap}
?selected=${this.instance?.type === OutpostTypeEnum.Ldap}
>
${msg("LDAP")}
</option>
<option
value=${OutpostTypeEnum.Radius}
?selected=${this.instance?.type === OutpostTypeEnum.Radius}
>
${msg("Radius")}
</option>
<option
value=${OutpostTypeEnum.Rac}
?selected=${this.instance?.type === OutpostTypeEnum.Rac}
>
${msg("RAC")}
</option>
${map(
typeOptions,
([instanceType, label]) =>
html` <option
value=${instanceType}
?selected=${this.instance?.type === instanceType}
>
${label}
</option>`,
)}
</select>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Integration")} name="serviceConnection">
@ -200,26 +221,12 @@ export class OutpostForm extends ModelForm<Outpost, string> {
?required=${!this.embedded}
name="providers"
>
<select class="pf-c-form-control" multiple>
${this.providers?.results.map((provider) => {
const selected = Array.from(this.instance?.providers || []).some((sp) => {
return sp == provider.pk;
});
let appName = provider.assignedApplicationName;
if (provider.assignedBackchannelApplicationName) {
appName = provider.assignedBackchannelApplicationName;
}
return html`<option value=${ifDefined(provider.pk)} ?selected=${selected}>
${appName} (${provider.name})
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg("You can only select providers that match the type of the outpost.")}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
<ak-dual-select-provider
.provider=${this.providers}
.selected=${(this.instance?.providersObj ?? []).map(dualSelectPairMaker)}
available-label="${msg("Available Applications")}"
selected-label="${msg("Selected Applications")}"
></ak-dual-select-provider>
</ak-form-element-horizontal>
<ak-form-group aria-label="Advanced settings">
<span slot="header"> ${msg("Advanced settings")} </span>

View File

@ -0,0 +1,140 @@
import { AKElement } from "@goauthentik/elements/Base";
import { debounce } from "@goauthentik/elements/utils/debounce";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg } from "@lit/localize";
import { PropertyValues, html } 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 type { Pagination } from "@goauthentik/api";
import "./ak-dual-select";
import { AkDualSelect } from "./ak-dual-select";
import type { DataProvider, DualSelectPair } from "./types";
/**
* @element ak-dual-select-provider
*
* A top-level component that understands how the authentik pagination interface works,
* and can provide new pages based upon navigation requests. This is the interface
* between authentik and the generic ak-dual-select component; aside from knowing that
* the Pagination object "looks like Django," the interior components don't know anything
* about authentik at all and could be dropped into Gravity unchanged.)
*
*/
@customElement("ak-dual-select-provider")
export class AkDualSelectProvider extends CustomListenerElement(AKElement) {
/** A function that takes a page and returns the DualSelectPair[] collection with which to update
* the "Available" pane.
*/
@property({ type: Object })
provider!: DataProvider;
@property({ type: Array })
selected: DualSelectPair[] = [];
@property({ attribute: "available-label" })
availableLabel = msg("Available options");
@property({ attribute: "selected-label" })
selectedLabel = msg("Selected options");
/** The remote lists are debounced by definition. This is the interval for the debounce. */
@property({ attribute: "search-delay", type: Number })
searchDelay = 250;
@state()
private options: DualSelectPair[] = [];
private dualSelector: Ref<AkDualSelect> = createRef();
private isLoading = false;
private pagination?: Pagination;
selectedMap: WeakMap<DataProvider, DualSelectPair[]> = new WeakMap();
constructor() {
super();
setTimeout(() => this.fetch(1), 0);
// Notify AkForElementHorizontal how to handle this thing.
this.dataset.akControl = "true";
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);
}
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("searchDelay")) {
this.doSearch = debounce(this.doSearch.bind(this), this.searchDelay);
}
if (changedProperties.has("provider")) {
this.pagination = undefined;
if (changedProperties.get("provider")) {
this.selectedMap.set(changedProperties.get("provider"), this.selected);
this.selected = this.selectedMap.get(this.provider) ?? [];
}
this.fetch();
}
}
async fetch(page?: number, search = "") {
if (this.isLoading) {
return;
}
this.isLoading = true;
const goto = page ?? this.pagination?.current ?? 1;
const data = await this.provider(goto, search);
this.pagination = data.pagination;
this.options = data.options;
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);
}
onChange(event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
}
this.selected = event.detail.value;
}
onSearch(event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
}
this.doSearch(event.detail);
}
doSearch(search: string) {
this.pagination = undefined;
this.fetch(undefined, search);
}
get value() {
return this.dualSelector.value!.selected.map(([k, _]) => k);
}
render() {
return html`<ak-dual-select
${ref(this.dualSelector)}
.options=${this.options}
.pages=${this.pagination}
.selected=${this.selected}
available-label=${this.availableLabel}
selected-label=${this.selectedLabel}
></ak-dual-select>`;
}
}

View File

@ -0,0 +1,353 @@
import { AKElement } from "@goauthentik/elements/Base";
import {
CustomEmitterElement,
CustomListenerElement,
} from "@goauthentik/elements/utils/eventEmitter";
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";
import { AkDualSelectAvailablePane } from "./components/ak-dual-select-available-pane";
import "./components/ak-dual-select-controls";
import "./components/ak-dual-select-selected-pane";
import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane";
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";
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];
/**
* @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.
*/
const keyfinder =
(key: string) =>
([k]: DualSelectPair) =>
k === key;
@customElement("ak-dual-select")
export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) {
static get styles() {
return styles;
}
/* 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");
@state()
selectedFilter: string = "";
availablePane: Ref<AkDualSelectAvailablePane> = createRef();
selectedPane: Ref<AkDualSelectSelectedPane> = createRef();
selectedKeys: Set<string> = new Set();
constructor() {
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);
}
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();
}
}
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 });
event.stopPropagation();
}
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();
}
addOne(key: string) {
const requested = this.options.find(keyfinder(key));
if (requested && !this.selected.find(keyfinder(requested[0]))) {
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;
addAllVisible() {
// Create a new array of all current options and selected, and de-dupe.
const selected = mapDualPairs([...this.options, ...this.selected]);
this.selected = Array.from(selected.entries());
this.availablePane.value!.clearMove();
}
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();
}
removeOne(key: string) {
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.selectedPane.value!.clearMove();
}
removeAll() {
this.selected = [];
this.selectedPane.value!.clearMove();
}
handleSearch(event: SearchbarEvent) {
switch (event.detail.source) {
case "ak-dual-list-available-search":
return this.handleAvailableSearch(event.detail.value);
case "ak-dual-list-selected-search":
return this.handleSelectedSearch(event.detail.value);
}
event.stopPropagation();
}
handleAvailableSearch(value: string) {
this.dispatchCustomEvent("ak-dual-select-search", value);
}
handleSelectedSearch(value: string) {
this.selectedFilter = value;
this.selectedPane.value!.clearMove();
}
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;
}
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(alphaSort)}
></ak-dual-select-selected-pane>
</div>
</div>
`;
}
}

View File

@ -0,0 +1,159 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { html, nothing } from "lit";
import { customElement, property, state } 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 { EVENT_ADD_ONE } from "../constants";
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"],
];
/**
* @element ak-dual-select-available-panel
*
* The "available options" or "left" pane in a dual-list multi-select. It receives from its parent a
* list of options to show *now*, the list of all "selected" options, and maintains an internal list
* of objects selected to move. "selected" options are marked with a checkmark to show they're
* already in the "selected" collection and would be pointless to move.
*
* @fires ak-dual-select-available-move-changed - When the list of "to move" entries changed.
* Includes the current * `toMove` content.
*
* @fires ak-dual-select-add-one - Double-click with the element clicked on.
*
* It is not expected that the `ak-dual-select-available-move-changed` event will be used; instead,
* the attribute will be read by the parent when a control is clicked.
*
*/
@customElement("ak-dual-select-available-pane")
export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
/* The array of key/value pairs this pane is currently showing */
@property({ type: Array })
readonly options: DualSelectPair[] = [];
/* A set (set being easy for lookups) of keys with all the pairs selected, so that the ones
* currently being shown that have already been selected can be marked and their clicks ignored.
*
*/
@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();
}
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");
// Necessary because updating a map won't trigger a state change
this.requestUpdate();
}
onMove(key: string) {
this.toMove.delete(key);
this.dispatchCustomEvent(EVENT_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() {
return html`
<div class="pf-c-dual-list-selector__menu">
<ul class="pf-c-dual-list-selector__list">
${map(this.options, ([key, label]) => {
const selected = classMap({
"pf-m-selected": this.toMove.has(key),
});
return html` <li
class="pf-c-dual-list-selector__list-item"
aria-selected="false"
@click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)}
role="option"
data-ak-key=${key}
tabindex="-1"
>
<div class="pf-c-dual-list-selector__list-item-row ${selected}">
<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"
><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
>
</div>
</li>`;
})}
</ul>
</div>
`;
}
}
export default AkDualSelectAvailablePane;

View File

@ -0,0 +1,165 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg } from "@lit/localize";
import { css, html, nothing } from "lit";
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";
const styles = [
PFBase,
PFButton,
css`
:host {
align-self: center;
padding-right: var(--pf-c-dual-list-selector__controls--PaddingRight);
padding-left: var(--pf-c-dual-list-selector__controls--PaddingLeft);
}
.pf-c-dual-list-selector {
max-width: 4rem;
}
.ak-dual-list-selector__controls {
display: grid;
justify-content: center;
align-content: center;
height: 100%;
}
`,
];
/**
* @element ak-dual-select-controls
*
* The "control box" for a dual-list multi-select. It's controlled by the parent orchestrator as to
* whether or not any of its controls are enabled. It sends a variety of messages to the parent
* orchestrator which will then reconcile the "available" and "selected" panes at need.
*
*/
@customElement("ak-dual-select-controls")
export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
/* Set to true if any *visible* elements can be added to the selected list
*/
@property({ attribute: "add-active", type: Boolean })
addActive = false;
/* Set to true if any elements can be removed from the selected list (essentially,
* if the selected list is not empty)
*/
@property({ attribute: "remove-active", type: Boolean })
removeActive = false;
/* Set to true if *all* the currently visible elements can be moved
* into the selected list (essentially, if any visible elements are
* not currently selected)
*/
@property({ attribute: "add-all-active", type: Boolean })
addAllActive = false;
/* Set to true if *any* of the elements currently visible in the available
* pane are available to be moved to the selected list, enabling that
* all of those specific elements be moved out of the selected list
*/
@property({ attribute: "remove-all-active", type: Boolean })
removeAllActive = false;
/* if deleteAll is enabled, set to true to show that there are elements in the
* selected list that can be deleted.
*/
@property({ attribute: "delete-all-active", type: Boolean })
enableDeleteAll = false;
/* Set to true if you want the `...AllActive` buttons made available. */
@property({ attribute: "enable-select-all", type: Boolean })
selectAll = false;
/* Set to true if you want the `ClearAllSelected` button made available */
@property({ attribute: "enable-delete-all", type: Boolean })
deleteAll = false;
constructor() {
super();
this.onClick = this.onClick.bind(this);
}
onClick(eventName: string) {
this.dispatchCustomEvent(eventName);
}
renderButton(label: string, event: string, active: boolean, direction: string) {
return html`
<div class="pf-c-dual-list-selector__controls-item">
<button
?aria-disabled=${!active}
?disabled=${!active}
aria-label=${label}
class="pf-c-button pf-m-plain"
type="button"
@click=${() => this.onClick(event)}
data-ouia-component-type="AK/Button"
>
<i class="fa ${direction}"></i>
</button>
</div>
</div>`;
}
render() {
return html`
<div class="ak-dual-list-selector__controls">
${this.renderButton(
msg("Add"),
EVENT_ADD_SELECTED,
this.addActive,
"fa-angle-right",
)}
${this.selectAll
? html`
${this.renderButton(
msg("Add All Available"),
EVENT_ADD_ALL,
this.addAllActive,
"fa-angle-double-right",
)}
${this.renderButton(
msg("Remove All Available"),
EVENT_REMOVE_ALL,
this.removeAllActive,
"fa-angle-double-left",
)}
`
: nothing}
${this.renderButton(
msg("Remove"),
EVENT_REMOVE_SELECTED,
this.removeActive,
"fa-angle-left",
)}
${this.deleteAll
? html`${this.renderButton(
msg("Remove All"),
EVENT_DELETE_ALL,
this.enableDeleteAll,
"fa-times",
)}`
: nothing}
</div>
`;
}
}
export default AkDualSelectControls;

View File

@ -0,0 +1,139 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { html } from "lit";
import { customElement, property, state } 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 { EVENT_REMOVE_ONE } from "../constants";
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"],
];
/**
* @element ak-dual-select-available-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.
*
*/
@customElement("ak-dual-select-selected-pane")
export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
/* 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();
}
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");
// Necessary because updating a map won't trigger a state change
this.requestUpdate();
}
onMove(key: string) {
this.toMove.delete(key);
this.dispatchCustomEvent(EVENT_REMOVE_ONE, key);
this.requestUpdate();
}
get moveable() {
return Array.from(this.toMove.values());
}
render() {
return html`
<div class="pf-c-dual-list-selector__menu">
<ul class="pf-c-dual-list-selector__list">
${map(this.selected, ([key, label]) => {
const selected = classMap({
"pf-m-selected": this.toMove.has(key),
});
return html` <li
class="pf-c-dual-list-selector__list-item"
aria-selected="false"
id="dual-list-selector-basic-selected-pane-list-option-0"
@click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)}
role="option"
data-ak-key=${key}
tabindex="-1"
>
<div class="pf-c-dual-list-selector__list-item-row ${selected}">
<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}</span
></span
></span
>
</div>
</li>`;
})}
</ul>
</div>
`;
}
}
export default AkDualSelectSelectedPane;

View File

@ -0,0 +1,94 @@
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";
import { customElement, property } from "lit/decorators.js";
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 type { BasePagination } from "../types";
const styles = [
PFBase,
PFButton,
PFPagination,
css`
:host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button {
color: var(--pf-c-button--m-plain--disabled--Color);
--pf-c-button--disabled--Color: var(--pf-c-button--m-plain--Color);
}
:host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button:disabled {
color: var(--pf-c-button--disabled--Color);
}
`,
];
@customElement("ak-pagination")
export class AkPagination extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
@property({ attribute: false })
pages?: BasePagination;
constructor() {
super();
this.onClick = this.onClick.bind(this);
}
onClick(nav: number | undefined) {
this.dispatchCustomEvent("ak-pagination-nav-to", nav ?? 0);
}
render() {
return this.pages
? html` <div class="pf-c-pagination pf-m-compact pf-m-hidden pf-m-visible-on-md">
<div
class="pf-c-pagination pf-m-compact pf-m-compact pf-m-hidden pf-m-visible-on-md"
>
<div class="pf-c-options-menu">
<div class="pf-c-options-menu__toggle pf-m-text pf-m-plain">
<span class="pf-c-options-menu__toggle-text">
${msg(
str`${this.pages?.startIndex} - ${this.pages?.endIndex} of ${this.pages?.count}`,
)}
</span>
</div>
</div>
<nav class="pf-c-pagination__nav" aria-label="Pagination">
<div class="pf-c-pagination__nav-control pf-m-prev">
<button
class="pf-c-button pf-m-plain"
@click=${() => {
this.onClick(this.pages?.previous);
}}
?disabled="${(this.pages?.previous ?? 0) < 1}"
aria-label="${msg("Go to previous page")}"
>
<i class="fas fa-angle-left" aria-hidden="true"></i>
</button>
</div>
<div class="pf-c-pagination__nav-control pf-m-next">
<button
class="pf-c-button pf-m-plain"
@click=${() => {
this.onClick(this.pages?.next);
}}
?disabled="${(this.pages?.next ?? 0) <= 0}"
aria-label="${msg("Go to next page")}"
>
<i class="fas fa-angle-right" aria-hidden="true"></i>
</button>
</div>
</nav>
</div>
</div>`
: nothing;
}
}
export default AkPagination;

View File

@ -0,0 +1,69 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { html } 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";
import { globalVariables, searchStyles } from "./search.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import type { SearchbarEvent } from "../types";
const styles = [PFBase, globalVariables, searchStyles];
@customElement("ak-search-bar")
export class AkSearchbar extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
@property({ type: String, reflect: true })
value = "";
/**
* If you're using more than one search, this token can help listeners distinguishing between
* those searches. Lit's own helpers sometimes erase the source and current targets.
*/
@property({ type: String })
name = "";
input: Ref<HTMLInputElement> = createRef();
constructor() {
super();
this.onChange = this.onChange.bind(this);
}
onChange(_event: Event) {
if (this.input.value) {
this.value = this.input.value.value;
}
this.dispatchCustomEvent<SearchbarEvent>("ak-search", {
source: this.name,
value: this.value,
});
}
render() {
return html`
<div class="pf-c-text-input-group">
<div class="pf-c-text-input-group__main pf-m-icon">
<span class="pf-c-text-input-group__text"
><span class="pf-c-text-input-group__icon"
><i class="fa fa-search fa-fw"></i></span
><input
type="search"
class="pf-c-text-input-group__text-input"
${ref(this.input)}
@input=${this.onChange}
value="${this.value}"
/></span>
</div>
</div>
`;
}
}
export default AkSearchbar;

View File

@ -0,0 +1,190 @@
import { css } from "lit";
// The `host` information for the Patternfly dual list selector came with some default settings that
// we do not want in a web component. By isolating what we *really* use into this collection here,
// we get all the benefits of Patternfly without having to wrestle without also having to counteract
// those default settings.
export const globalVariables = css`
:host {
--pf-c-text-input-group--BackgroundColor: var(--pf-global--BackgroundColorg--100);
--pf-c-text-input-group--Color: var(--pf-global--Color--dark-100);
--pf-c-text-input-group__text--before--BorderWidth: var(--pf-global--BorderWidth--sm);
--pf-c-text-input-group__text--before--BorderColor: var(--pf-global--BorderColor--300);
--pf-c-text-input-group__text--after--BorderBottomWidth: var(--pf-global--BorderWidth--sm);
--pf-c-text-input-group__text--after--BorderBottomColor: var(--pf-global--BorderColor--200);
--pf-c-text-input-group--hover__text--after--BorderBottomColor: var(
--pf-global--primary-color--100
);
--pf-c-text-input-group__text--focus-within--after--BorderBottomWidth: var(
--pf-global--BorderWidth--md
);
--pf-c-text-input-group__text--focus-within--after--BorderBottomColor: var(
--pf-global--primary-color--100
);
--pf-c-text-input-group__main--first-child--not--text-input--MarginLeft: var(
--pf-global--spacer--sm
);
--pf-c-text-input-group__main--m-icon__text-input--PaddingLeft: var(
--pf-global--spacer--xl
);
--pf-c-text-input-group__main--RowGap: var(--pf-global--spacer--xs);
--pf-c-text-input-group__main--ColumnGap: var(--pf-global--spacer--sm);
--pf-c-text-input-group--c-chip-group__main--PaddingTop: var(--pf-global--spacer--xs);
--pf-c-text-input-group--c-chip-group__main--PaddingRight: var(--pf-global--spacer--xs);
--pf-c-text-input-group--c-chip-group__main--PaddingBottom: var(--pf-global--spacer--xs);
--pf-c-text-input-group__text-input--PaddingTop: var(--pf-global--spacer--form-element);
--pf-c-text-input-group__text-input--PaddingRight: var(--pf-global--spacer--sm);
--pf-c-text-input-group__text-input--PaddingBottom: var(--pf-global--spacer--form-element);
--pf-c-text-input-group__text-input--PaddingLeft: var(--pf-global--spacer--sm);
--pf-c-text-input-group__text-input--MinWidth: 12ch;
--pf-c-text-input-group__text-input--m-hint--Color: var(--pf-global--Color--dark-200);
--pf-c-text-input-group--placeholder--Color: var(--pf-global--Color--dark-200);
--pf-c-text-input-group__icon--Left: var(--pf-global--spacer--sm);
--pf-c-text-input-group__icon--Color: var(--pf-global--Color--200);
--pf-c-text-input-group__text--hover__icon--Color: var(--pf-global--Color--100);
--pf-c-text-input-group__icon--TranslateY: -50%;
--pf-c-text-input-group__utilities--MarginRight: var(--pf-global--spacer--sm);
--pf-c-text-input-group__utilities--MarginLeft: var(--pf-global--spacer--xs);
--pf-c-text-input-group__utilities--child--MarginLeft: var(--pf-global--spacer--xs);
--pf-c-text-input-group__utilities--c-button--PaddingRight: var(--pf-global--spacer--xs);
--pf-c-text-input-group__utilities--c-button--PaddingLeft: var(--pf-global--spacer--xs);
--pf-c-text-input-group--m-disabled--Color: var(--pf-global--disabled-color--100);
--pf-c-text-input-group--m-disabled--BackgroundColor: var(--pf-global--disabled-color--300);
}
:host([theme="dark"]) {
--pf-c-text-input-group--BackgroundColor: var(--ak-dark-background-light);
--pf-c-text-input-group--Color: var(--ak-dark-foreground);
--pf-c-text-input-group__text--before--BorderColor: var(--ak-dark-background-lighter);
--pf-c-text-input-group__text--before--BorderWidth: 0;
--pf-c-text-input-group--m-disabled--Color: var(--pf-global--disabled-color--300);
--pf-c-text-input-group--m-disabled--BackgroundColor: var(--pf-global--disabled-color--200);
--pf-c-text-input-group__text--before--BorderBottomColor: var(
--pf-global--BorderColor--200
);
}
`;
export const searchStyles = css`
i.fa,
i.fas,
i.far,
i.fal,
i.fab {
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
display: inline-block;
font-style: normal;
font-variant: normal;
text-rendering: auto;
line-height: 1;
}
i.fa-search:before {
content: "\f002";
}
.fa,
.fas {
position: relative;
font-family: "Font Awesome 5 Free";
font-weight: 900;
}
i.fa-fw {
text-align: center;
width: 1.25em;
}
.pf-c-text-input-group {
position: relative;
display: flex;
width: 100%;
color: var(--pf-c-text-input-group--Color, inherit);
background-color: var(--pf-c-text-input-group--BackgroundColor);
}
.pf-c-text-input-group__main {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: var(--pf-c-text-input-group__main--RowGap)
var(--pf-c-text-input-group__main--ColumnGap);
min-width: 0;
}
.pf-c-text-input-group__main.pf-m-icon {
--pf-c-text-input-group__text-input--PaddingLeft: var(
--pf-c-text-input-group__main--m-icon__text-input--PaddingLeft
);
}
.pf-c-text-input-group__text {
display: inline-grid;
grid-template-columns: 1fr;
grid-template-areas: "text-input";
flex: 1;
z-index: 0;
}
.pf-c-text-input-group__text::before {
border-width: var(--pf-c-text-input-group__text--before--BorderWidth);
border-color: var(--pf-c-text-input-group__text--before--BorderColor);
border-bottom-color: var(--pf-c-text-input-group__text--after--BorderBottomColor);
border-bottom-width: var(--pf-c-text-input-group__text--after--BorderBottomWidth);
border-style: solid;
}
.pf-c-text-input-group__text::after {
border-bottom: var(--pf-c-text-input-group__text--after--BorderBottomWidth) solid
var(--pf-c-text-input-group__text--after--BorderBottomColor);
}
.pf-c-text-input-group__text::before,
.pf-c-text-input-group__text::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
content: "";
z-index: 2;
}
.pf-c-text-input-group__icon {
z-index: 4;
position: absolute;
top: 50%;
left: var(--pf-c-text-input-group__icon--Left);
color: var(--pf-c-text-input-group__icon--Color);
transform: translateY(var(--pf-c-text-input-group__icon--TranslateY));
}
.pf-c-text-input-group__text-input,
.pf-c-text-input-group__text-input.pf-m-hint {
grid-area: text-input;
}
.pf-c-text-input-group__text-input {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
width: 100%;
color: var(--pf-c-text-input-group--Color);
background-color: var(--pf-c-text-input-group--BackgroundColor);
min-width: var(--pf-c-text-input-group__text-input--MinWidth);
padding: var(--pf-c-text-input-group__text-input--PaddingTop)
var(--pf-c-text-input-group__text-input--PaddingRight)
var(--pf-c-text-input-group__text-input--PaddingBottom)
var(--pf-c-text-input-group__text-input--PaddingLeft);
border: 0;
}
`;

View File

@ -0,0 +1,219 @@
import { css } from "lit";
// The `host` information for the Patternfly dual list selector came with some default settings that
// we do not want in a web component. By isolating what we *really* use into this collection here,
// we get all the benefits of Patternfly without having to wrestle without also having to counteract
// those default settings.
export const globalVariables = css`
:host {
--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min: 12.5rem;
--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max: 28.125rem;
--pf-c-dual-list-selector__header--MarginBottom: var(--pf-global--spacer--sm);
--pf-c-dual-list-selector__title-text--FontWeight: var(--pf-global--FontWeight--bold);
--pf-c-dual-list-selector__tools--MarginBottom: var(--pf-global--spacer--md);
--pf-c-dual-list-selector__tools-filter--tools-actions--MarginLeft: var(
--pf-global--spacer--sm
);
--pf-c-dual-list-selector__menu--BorderWidth: var(--pf-global--BorderWidth--sm);
--pf-c-dual-list-selector__menu--BorderColor: var(--pf-global--BorderColor--100);
--pf-c-dual-list-selector__menu--MinHeight: 12.5rem;
--pf-c-dual-list-selector__menu--MaxHeight: 20rem;
--pf-c-dual-list-selector__list-item-row--FontSize: var(--pf-global--FontSize--sm);
--pf-c-dual-list-selector__list-item-row--BackgroundColor: transparent;
--pf-c-dual-list-selector__list-item-row--hover--BackgroundColor: var(
--pf-global--BackgroundColor--light-300
);
--pf-c-dual-list-selector__list-item-row--focus-within--BackgroundColor: var(
--pf-global--BackgroundColor--light-300
);
--pf-c-dual-list-selector__list-item-row--m-selected--BackgroundColor: var(
--pf-global--BackgroundColor--light-300
);
--pf-c-dual-list-selector__list-item--m-ghost-row--BackgroundColor: var(
--pf-global--BackgroundColor--100
);
--pf-c-dual-list-selector__list-item--m-ghost-row--Opacity: 0.4;
--pf-c-dual-list-selector__item--PaddingTop: var(--pf-global--spacer--sm);
--pf-c-dual-list-selector__item--PaddingRight: var(--pf-global--spacer--md);
--pf-c-dual-list-selector__item--PaddingBottom: var(--pf-global--spacer--sm);
--pf-c-dual-list-selector__item--PaddingLeft: var(--pf-global--spacer--md);
--pf-c-dual-list-selector__item--m-expandable--PaddingLeft: 0;
--pf-c-dual-list-selector__item--indent--base: calc(
var(--pf-global--spacer--md) + var(--pf-global--spacer--sm) +
var(--pf-c-dual-list-selector__list-item-row--FontSize)
);
--pf-c-dual-list-selector__item--nested-indent--base: calc(
var(--pf-c-dual-list-selector__item--indent--base) - var(--pf-global--spacer--md)
);
--pf-c-dual-list-selector__draggable--item--PaddingLeft: var(--pf-global--spacer--xs);
--pf-c-dual-list-selector__item-text--Color: var(--pf-global--Color--100);
--pf-c-dual-list-selector__list-item-row--m-selected__text--Color: var(
--pf-global--active-color--100
);
--pf-c-dual-list-selector__list-item-row--m-selected__text--FontWeight: var(
--pf-global--FontWeight--bold
);
--pf-c-dual-list-selector__list-item--m-disabled__item-text--Color: var(
--pf-global--disabled-color--100
);
--pf-c-dual-list-selector__status--MarginBottom: var(--pf-global--spacer--sm);
--pf-c-dual-list-selector__status-text--FontSize: var(--pf-global--FontSize--sm);
--pf-c-dual-list-selector__status-text--Color: var(--pf-global--Color--200);
--pf-c-dual-list-selector__controls--PaddingRight: var(--pf-global--spacer--md);
--pf-c-dual-list-selector__controls--PaddingLeft: var(--pf-global--spacer--md);
--pf-c-dual-list-selector__item-toggle--PaddingTop: var(--pf-global--spacer--sm);
--pf-c-dual-list-selector__item-toggle--PaddingRight: var(--pf-global--spacer--sm);
--pf-c-dual-list-selector__item-toggle--PaddingBottom: var(--pf-global--spacer--sm);
--pf-c-dual-list-selector__item-toggle--PaddingLeft: var(--pf-global--spacer--md);
--pf-c-dual-list-selector__item-toggle--MarginTop: calc(var(--pf-global--spacer--sm) * -1);
--pf-c-dual-list-selector__item-toggle--MarginBottom: calc(
var(--pf-global--spacer--sm) * -1
);
--pf-c-dual-list-selector__list__list__item-toggle--Left: 0;
--pf-c-dual-list-selector__list__list__item-toggle--TranslateX: -100%;
--pf-c-dual-list-selector__item-check--MarginRight: var(--pf-global--spacer--sm);
--pf-c-dual-list-selector__item-count--Marginleft: var(--pf-global--spacer--sm);
--pf-c-dual-list-selector__item--c-badge--m-read--BackgroundColor: var(
--pf-global--disabled-color--200
);
--pf-c-dual-list-selector__item-toggle-icon--Rotate: 0;
--pf-c-dual-list-selector__list-item--m-expanded__item-toggle-icon--Rotate: 90deg;
--pf-c-dual-list-selector__item-toggle-icon--Transition: var(--pf-global--Transition);
--pf-c-dual-list-selector__item-toggle-icon--MinWidth: var(
--pf-c-dual-list-selector__list-item-row--FontSize
);
--pf-c-dual-list-selector__list-item--m-disabled__item-toggle-icon--Color: var(
--pf-global--disabled-color--200
);
/* Unique to authentik */
--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);
--pf-c-dual-list-selector__status--top-padding: var(--pf-global--spacer--xs);
--pf-c-dual-list-panels__gap: var(--pf-global--spacer--xs);
}
:host([theme="dark"]) {
--pf-c-dual-list-selector__menu--BorderColor: var(--ak-dark-background-lighter);
--pf-c-dual-list-selector__item-text--Color: var(--ak-dark-foreground);
--pf-c-dual-list-selector__list-item-row--BackgroundColor: var(
--ak-dark-background-light-ish
);
--pf-c-dual-list-selector__list-item-row--hover--BackgroundColor: var(
--ak-dark-background-lighter;
);
--pf-c-dual-list-selector__list-item-row--hover--BackgroundColor: var(
--pf-global--BackgroundColor--400
);
}
`;
export const mainStyles = css`
:host {
--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--min: 12.5rem;
--pf-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max: 28.125rem;
}
:host {
display: block grid;
}
.pf-c-dual-list-selector__title-text {
font-weight: var(--pf-c-dual-list-selector__title-text--FontWeight);
}
.pf-c-dual-list-selector__status {
padding-top: var(--pf-c-dual-list-selector__status--top-padding);
}
.pf-c-dual-list-selector__status-text {
font-size: var(--pf-c-dual-list-selector__status-text--FontSize);
color: var(--pf-c-dual-list-selector__status-text--Color);
}
.ak-dual-list-selector {
display: grid;
grid-template-columns: minmax(0, 1fr) min-content minmax(0, 1fr);
}
.ak-available-pane,
.ak-selected-pane {
display: grid;
grid-template-rows: auto auto auto 1fr auto;
gap: var(--pf-c-dual-list-panels__gap);
max-width: 100%;
overflow: hidden;
}
ak-dual-select-controls {
height: 100%;
}
`;
export const listStyles = css`
:host {
display: block;
overflow: hidden;
max-width: 100%;
}
.pf-c-dual-list-selector__menu {
max-width: 100%;
height: 100%;
}
.pf-c-dual-list-selector__list {
max-width: 100%;
display: block;
}
.pf-c-dual-list-selector__item {
padding: 0.25rem;
width: auto;
}
.pf-c-dual-list-selector__item-text {
user-select: none;
flex-grow: 0;
}
.pf-c-dual-list-selector__item-text .selection-main {
color: var(--pf-c-dual-list-selector__item-text--Color);
}
.pf-c-dual-list-selector__item-text .selection-main:hover {
color: var(--pf-c-dual-list-selector__item-text--Color);
}
.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`
input[type="checkbox"][readonly] {
pointer-events: none;
}
`;
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;
padding-left: 1rem;
font-weight: 200;
color: var(--pf-c-dual-list-selector--selection-desc--Color);
font-size: var(--pf-global--FontSize--xs);
}
`;

View File

@ -0,0 +1,7 @@
export const EVENT_ADD_SELECTED = "ak-dual-select-add";
export const EVENT_REMOVE_SELECTED = "ak-dual-select-remove";
export const EVENT_ADD_ALL = "ak-dual-select-add-all";
export const EVENT_REMOVE_ALL = "ak-dual-select-remove-all";
export const EVENT_DELETE_ALL = "ak-dual-select-remove-everything";
export const EVENT_ADD_ONE = "ak-dual-select-add-one";
export const EVENT_REMOVE_ONE = "ak-dual-select-remove-one";

View File

@ -0,0 +1,7 @@
import { AkDualSelect } from "./ak-dual-select";
import "./ak-dual-select";
import { AkDualSelectProvider } from "./ak-dual-select-provider";
import "./ak-dual-select-provider";
export { AkDualSelect, AkDualSelectProvider };
export default AkDualSelect;

View File

@ -0,0 +1,115 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta, StoryObj } from "@storybook/web-components";
import { slug } from "github-slugger";
import { TemplateResult, html } from "lit";
import "../components/ak-dual-select-available-pane";
import { AkDualSelectAvailablePane } from "../components/ak-dual-select-available-pane";
import "./sb-host-provider";
const metadata: Meta<AkDualSelectAvailablePane> = {
title: "Elements / Dual Select / Available Items Pane",
component: "ak-dual-select-available-pane",
parameters: {
docs: {
description: {
component: "The vertical panel separating two dual-select elements.",
},
},
},
argTypes: {
options: {
type: "string",
description: "An array of [key, label] pairs of what to show",
},
selected: {
type: "string",
description: "An array of [key] of what has already been selected",
},
toMove: {
type: "string",
description: "An array of items which are to be moved to the receiving pane.",
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
<ak-message-container></ak-message-container>
<sb-dual-select-host-provider> ${testItem} </sb-dual-select-host-provider>
<p>Messages received from the button:</p>
<ul id="action-button-message-pad" style="margin-top: 1em"></ul>
</div>`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleMoveChanged = (result: any) => {
const target = document.querySelector("#action-button-message-pad");
target!.innerHTML = "";
result.detail.forEach((key: string) => {
target!.append(new DOMParser().parseFromString(`<li>${key}</li>`, "text/xml").firstChild!);
});
};
window.addEventListener("ak-dual-select-available-move-changed", handleMoveChanged);
type Story = StoryObj;
const goodForYou = [
"Apple",
"Arrowroot",
"Artichoke",
"Arugula",
"Asparagus",
"Avocado",
"Bamboo",
"Banana",
"Basil",
"Beet Root",
"Blackberry",
"Blueberry",
"Bok Choy",
"Broccoli",
"Brussels sprouts",
"Cabbage",
"Cantaloupes",
"Carrot",
"Cauliflower",
];
const goodForYouPairs = goodForYou.map((key) => [slug(key), key]);
export const Default: Story = {
render: () =>
container(
html` <ak-dual-select-available-pane
.options=${goodForYouPairs}
></ak-dual-select-available-pane>`,
),
};
const someSelected = new Set([
goodForYouPairs[2][0],
goodForYouPairs[8][0],
goodForYouPairs[14][0],
]);
export const SomeSelected: Story = {
render: () =>
container(
html` <ak-dual-select-available-pane
.options=${goodForYouPairs}
.selected=${someSelected}
></ak-dual-select-available-pane>`,
),
};

View File

@ -0,0 +1,101 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "../components/ak-dual-select-controls";
import { AkDualSelectControls } from "../components/ak-dual-select-controls";
const metadata: Meta<AkDualSelectControls> = {
title: "Elements / Dual Select / Control Panel",
component: "ak-dual-select-controls",
parameters: {
docs: {
description: {
component: "The vertical panel separating two dual-select elements.",
},
},
},
argTypes: {
addActive: {
type: "boolean",
description:
"Highlighted if the sample panel has something to move to the result panel.",
},
removeActive: {
type: "boolean",
description:
"Highlighted if the result panel has something to move to the sample panel.",
},
selectAll: {
type: "boolean",
description: "Enable if you want both the 'move all visible' buttons.",
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
<ak-message-container></ak-message-container>
${testItem}
<p>Messages received from the button:</p>
<ul id="action-button-message-pad" style="margin-top: 1em"></ul>
</div>`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayMessage = (result: any) => {
const doc = new DOMParser().parseFromString(`<li><i>Event</i>: ${result}</li>`, "text/xml");
const target = document.querySelector("#action-button-message-pad");
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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"));
type Story = StoryObj;
export const Default: Story = {
render: () => container(html` <ak-dual-select-controls></ak-dual-select-controls>`),
};
export const AddActive: Story = {
render: () => container(html` <ak-dual-select-controls add-active></ak-dual-select-controls>`),
};
export const RemoveActive: Story = {
render: () =>
container(html` <ak-dual-select-controls remove-active></ak-dual-select-controls>`),
};
export const AddAllActive: Story = {
render: () =>
container(
html` <ak-dual-select-controls
enable-select-all
add-all-active
></ak-dual-select-controls>`,
),
};
export const RemoveAllActive: Story = {
render: () =>
container(
html` <ak-dual-select-controls
enable-select-all
remove-all-active
></ak-dual-select-controls>`,
),
};

View File

@ -0,0 +1,156 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta, StoryObj } from "@storybook/web-components";
import { slug } from "github-slugger";
import { LitElement, TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Pagination } from "@goauthentik/api";
import "../ak-dual-select";
import { AkDualSelect } from "../ak-dual-select";
import type { DualSelectPair } from "../types";
const goodForYouRaw = `
Apple, Arrowroot, Artichoke, Arugula, Asparagus, Avocado, Bamboo, Banana, Basil, Beet Root,
Blackberry, Blueberry, Bok Choy, Broccoli, Brussels sprouts, Cabbage, Cantaloupes, Carrot,
Cauliflower, Celery, Chayote, Chives, Cilantro, Coconut, Collard Greens, Corn, Cucumber, Daikon,
Date, Dill, Eggplant, Endive, Fennel, Fig, Garbanzo Bean, Garlic, Ginger, Gourds, Grape, Guava,
Honeydew, Horseradish, Iceberg Lettuce, Jackfruit, Jicama, Kale, Kangkong, Kiwi, Kohlrabi, Leek,
Lentils, Lychee, Macadamia, Mango, Mushroom, Mustard, Nectarine, Okra, Onion, Papaya, Parsley,
Parsley root, Parsnip, Passion Fruit, Peach, Pear, Peas, Peppers, Persimmon, Pimiento, Pineapple,
Plum, Plum, Pomegranate, Potato, Pumpkin, Radicchio, Radish, Raspberry, Rhubarb, Romaine Lettuce,
Rosemary, Rutabaga, Shallot, Soybeans, Spinach, Squash, Strawberries, Sweet potato, Swiss Chard,
Thyme, Tomatillo, Tomato, Turnip, Waterchestnut, Watercress, Watermelon, Yams
`;
const keyToPair = (key: string): DualSelectPair => [slug(key), key];
const goodForYou: DualSelectPair[] = goodForYouRaw
.split("\n")
.join(" ")
.split(",")
.map((a: string) => a.trim())
.map(keyToPair);
const metadata: Meta<AkDualSelect> = {
title: "Elements / Dual Select / Dual Select With Pagination",
component: "ak-dual-select",
parameters: {
docs: {
description: {
component: "The three-panel assembly",
},
},
},
argTypes: {
options: {
type: "string",
description: "An array of [key, label] pairs of what to show",
},
selected: {
type: "string",
description: "An array of [key] of what has already been selected",
},
pages: {
type: "string",
description: "An authentik pagination object.",
},
},
};
export default metadata;
@customElement("ak-sb-fruity")
export class AkSbFruity extends LitElement {
@property({ type: Array })
options: DualSelectPair[] = goodForYou;
@property({ attribute: "page-length", type: Number })
pageLength = 20;
@state()
page: Pagination;
constructor() {
super();
this.page = {
count: this.options.length,
current: 1,
startIndex: 1,
endIndex: this.options.length > this.pageLength ? this.pageLength : this.options.length,
next: this.options.length > this.pageLength ? 2 : 0,
previous: 0,
totalPages: Math.ceil(this.options.length / this.pageLength),
};
this.onNavigation = this.onNavigation.bind(this);
this.addEventListener("ak-pagination-nav-to", this.onNavigation);
}
onNavigation(evt: Event) {
const current: number = (evt as CustomEvent).detail;
const index = current - 1;
if (index * this.pageLength > this.options.length) {
console.warn(
`Attempted to index from ${index} for options length ${this.options.length}`,
);
return;
}
const endCount = this.pageLength * (index + 1);
const endIndex = Math.min(endCount, this.options.length);
this.page = {
...this.page,
current,
startIndex: this.pageLength * index + 1,
endIndex,
next: (index + 1) * this.pageLength > this.options.length ? 0 : current + 1,
previous: index,
};
}
get pageoptions() {
return this.options.slice(
this.pageLength * (this.page.current - 1),
this.pageLength * this.page.current,
);
}
render() {
return html`<ak-dual-select
.options=${this.pageoptions}
.pages=${this.page}
></ak-dual-select>`;
}
}
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
<ak-message-container></ak-message-container>
${testItem}
<p>Messages received from the button:</p>
<div id="action-button-message-pad" style="margin-top: 1em"></div>
</div>`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleMoveChanged = (result: any) => {
const target = document.querySelector("#action-button-message-pad");
target!.innerHTML = "";
// @ts-ignore
target!.append(result.detail.value.map(([k, _]) => k).join(", "));
};
window.addEventListener("change", handleMoveChanged);
type Story = StoryObj;
export const Default: Story = {
render: () => container(html` <ak-sb-fruity .options=${goodForYou}></ak-sb-fruity>`),
};

View File

@ -0,0 +1,70 @@
import "@goauthentik/elements/messages/MessageContainer";
import { debounce } from "@goauthentik/elements/utils/debounce";
import { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "../components/ak-search-bar";
import { AkSearchbar } from "../components/ak-search-bar";
const metadata: Meta<AkSearchbar> = {
title: "Elements / Dual Select / Search Bar",
component: "ak-dual-select-search",
parameters: {
docs: {
description: {
component: "A search input bar",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
<ak-message-container></ak-message-container>
${testItem}
<p>Messages received from the button:</p>
<div id="action-button-message-pad" style="margin-top: 1em"></div>
<div id="action-button-message-pad-2" style="margin-top: 1em"></div>
</div>`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayMessage = (result: any) => {
const doc = new DOMParser().parseFromString(`<p><i>Content</i>: ${result}</p>`, "text/xml");
const target = document.querySelector("#action-button-message-pad");
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
target!.replaceChildren(doc.firstChild!);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayMessage2 = (result: any) => {
console.log("Huh.");
const doc = new DOMParser().parseFromString(`<p><i>Behavior</i>: ${result}</p>`, "text/xml");
const target = document.querySelector("#action-button-message-pad-2");
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
target!.replaceChildren(doc.firstChild!);
};
const displayMessage2b = debounce(displayMessage2, 250);
window.addEventListener("input", (event: Event) => {
const message = (event.target as HTMLInputElement | undefined)?.value ?? "-- undefined --";
displayMessage(message);
displayMessage2b(message);
});
type Story = StoryObj;
export const Default: Story = {
render: () => container(html` <ak-search-bar></ak-search-bar>`),
};

View File

@ -0,0 +1,96 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta, StoryObj } from "@storybook/web-components";
import { slug } from "github-slugger";
import { TemplateResult, html } from "lit";
import "../components/ak-dual-select-selected-pane";
import { AkDualSelectSelectedPane } from "../components/ak-dual-select-selected-pane";
import "./sb-host-provider";
const metadata: Meta<AkDualSelectSelectedPane> = {
title: "Elements / Dual Select / Selected Items Pane",
component: "ak-dual-select-selected-pane",
parameters: {
docs: {
description: {
component: "The vertical panel separating two dual-select elements.",
},
},
},
argTypes: {
// @ts-ignore
options: {
type: "string",
description: "An array of [key, label] pairs of what to show",
},
toMove: {
type: "string",
description: "An array of items which are to be moved to the receiving pane.",
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
<ak-message-container></ak-message-container>
<sb-dual-select-host-provider> ${testItem} </sb-dual-select-host-provider>
<p>Messages received from the button:</p>
<ul id="action-button-message-pad" style="margin-top: 1em"></ul>
</div>`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleMoveChanged = (result: any) => {
const target = document.querySelector("#action-button-message-pad");
target!.innerHTML = "";
result.detail.forEach((key: string) => {
target!.append(new DOMParser().parseFromString(`<li>${key}</li>`, "text/xml").firstChild!);
});
};
window.addEventListener("ak-dual-select-selected-move-changed", handleMoveChanged);
type Story = StoryObj;
const goodForYou = [
"Apple",
"Arrowroot",
"Artichoke",
"Arugula",
"Asparagus",
"Avocado",
"Bamboo",
"Banana",
"Basil",
"Beet Root",
"Blackberry",
"Blueberry",
"Bok Choy",
"Broccoli",
"Brussels sprouts",
"Cabbage",
"Cantaloupes",
"Carrot",
"Cauliflower",
];
const goodForYouPairs = goodForYou.map((key) => [slug(key), key]);
export const Default: Story = {
render: () =>
container(
html` <ak-dual-select-selected-pane
.selected=${goodForYouPairs}
></ak-dual-select-selected-pane>`,
),
};

View File

@ -0,0 +1,93 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta, StoryObj } from "@storybook/web-components";
import { slug } from "github-slugger";
import { TemplateResult, html } from "lit";
import "../ak-dual-select";
import { AkDualSelect } from "../ak-dual-select";
const metadata: Meta<AkDualSelect> = {
title: "Elements / Dual Select / Dual Select",
component: "ak-dual-select",
parameters: {
docs: {
description: {
component: "The three-panel assembly",
},
},
},
argTypes: {
options: {
type: "string",
description: "An array of [key, label] pairs of what to show",
},
selected: {
type: "string",
description: "An array of [key] of what has already been selected",
},
pages: {
type: "string",
description: "An authentik pagination object.",
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
<ak-message-container></ak-message-container>
${testItem}
<p>Messages received from the button:</p>
<ul id="action-button-message-pad" style="margin-top: 1em"></ul>
</div>`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleMoveChanged = (result: any) => {
const target = document.querySelector("#action-button-message-pad");
target!.innerHTML = "";
result.detail.value.forEach((key: string) => {
target!.append(new DOMParser().parseFromString(`<li>${key}</li>`, "text/xml").firstChild!);
});
};
window.addEventListener("change", handleMoveChanged);
type Story = StoryObj;
const goodForYou = [
"Apple",
"Arrowroot",
"Artichoke",
"Arugula",
"Asparagus",
"Avocado",
"Bamboo",
"Banana",
"Basil",
"Beet Root",
"Blackberry",
"Blueberry",
"Bok Choy",
"Broccoli",
"Brussels sprouts",
"Cabbage",
"Cantaloupes",
"Carrot",
"Cauliflower",
];
const goodForYouPairs = goodForYou.map((key) => [slug(key), key]);
export const Default: Story = {
render: () => container(html` <ak-dual-select .options=${goodForYouPairs}></ak-dual-select>`),
};

View File

@ -0,0 +1,83 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "../components/ak-pagination";
import { AkPagination } from "../components/ak-pagination";
const metadata: Meta<AkPagination> = {
title: "Elements / Dual Select / Pagination Control",
component: "ak-pagination",
parameters: {
docs: {
description: {
component: "The Pagination Control",
},
},
},
argTypes: {
pages: {
type: "string",
description: "An authentik Pagination struct",
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
<ak-message-container></ak-message-container>
${testItem}
<p>Messages received from the button:</p>
<ul id="action-button-message-pad" style="margin-top: 1em"></ul>
</div>`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleMoveChanged = (result: any) => {
console.log(result);
const target = document.querySelector("#action-button-message-pad");
target!.append(
new DOMParser().parseFromString(
`<li>Request to move to page ${result.detail}</li>`,
"text/xml",
).firstChild!,
);
};
window.addEventListener("ak-pagination-nav-to", handleMoveChanged);
type Story = StoryObj;
const pages = {
count: 44,
startIndex: 1,
endIndex: 20,
next: 2,
previous: 0,
};
export const Default: Story = {
render: () => container(html` <ak-pagination .pages=${pages}></ak-pagination>`),
};
const morePages = {
count: 86,
startIndex: 21,
endIndex: 40,
next: 3,
previous: 1,
};
export const More: Story = {
render: () => container(html` <ak-pagination .pages=${morePages}></ak-pagination>`),
};

View File

@ -0,0 +1,22 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { globalVariables } from "../components/styles.css";
/**
* @element sb-dual-select-host-provider
*
* A *very simple* wrapper which provides the CSS Custom Properties used by the components when
* being displayed in Storybook or Vite. Not needed for the parent widget since it provides these by itself.
*/
@customElement("sb-dual-select-host-provider")
export class SbHostProvider extends LitElement {
static get styles() {
return globalVariables;
}
render() {
return html`<slot></slot>`;
}
}

View File

@ -0,0 +1,26 @@
import { TemplateResult } from "lit";
import { Pagination } from "@goauthentik/api";
// 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,
"startIndex" | "endIndex" | "count" | "previous" | "next"
>;
export type DataProvision = {
pagination: Pagination;
options: DualSelectPair[];
};
export type DataProvider = (page: number, search?: string) => Promise<DataProvision>;
export interface SearchbarEvent extends CustomEvent {
detail: {
source: string;
value: string;
};
}

View File

@ -69,7 +69,6 @@ export function serializeForm<T extends KeyUnknown>(
return;
}
// TODO: Tighten up the typing so that we can handle both.
if ("akControl" in element.dataset) {
assignValue(element, (element as unknown as AkControlElement).json(), json);
return;
@ -79,6 +78,12 @@ export function serializeForm<T extends KeyUnknown>(
if (element.hidden || !inputElement) {
return;
}
if ("akControl" in inputElement.dataset) {
assignValue(element, inputElement.value, json);
return;
}
// Skip elements that are writeOnly where the user hasn't clicked on the value
if (element.writeOnly && !element.writeOnlyActivated) {
return;

View File

@ -36,6 +36,22 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
*
*/
const isAkControl = (el: unknown): boolean =>
el instanceof HTMLElement &&
"dataset" in el &&
el.dataset instanceof DOMStringMap &&
"akControl" in el.dataset;
const nameables = new Set([
"input",
"textarea",
"select",
"ak-codemirror",
"ak-chip-group",
"ak-search-select",
"ak-radio",
]);
@customElement("ak-form-element-horizontal")
export class HorizontalFormElement extends AKElement {
static get styles(): CSSResult[] {
@ -112,19 +128,18 @@ export class HorizontalFormElement extends AKElement {
});
}
this.querySelectorAll("*").forEach((input) => {
switch (input.tagName.toLowerCase()) {
case "input":
case "textarea":
case "select":
case "ak-codemirror":
case "ak-chip-group":
case "ak-search-select":
case "ak-radio":
input.setAttribute("name", this.name);
break;
default:
return;
if (isAkControl(input) && !input.getAttribute("name")) {
input.setAttribute("name", this.name);
// This is fine; writeOnly won't apply to anything built this way.
return;
}
if (nameables.has(input.tagName.toLowerCase())) {
input.setAttribute("name", this.name);
} else {
return;
}
if (this.writeOnly && !this.writeOnlyActivated) {
const i = input as HTMLInputElement;
i.setAttribute("hidden", "true");

View File

@ -0,0 +1,13 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Callback = (...args: any[]) => any;
export function debounce<F extends Callback, T extends object>(callback: F, wait: number) {
let timeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<F>) => {
// @ts-ignore
const context: T = this satisfies object;
if (timeout !== undefined) {
clearTimeout(timeout);
}
timeout = setTimeout(() => callback.apply(context, args), wait);
};
}

View File

@ -9,22 +9,26 @@ export const isCustomEvent = (v: any): v is CustomEvent =>
export function CustomEmitterElement<T extends Constructor<LitElement>>(superclass: T) {
return class EmmiterElementHandler extends superclass {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
dispatchCustomEvent(eventName: string, detail: any = {}, options = {}) {
dispatchCustomEvent<F extends CustomEvent>(
eventName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
detail: any = {},
options = {},
) {
const fullDetail =
typeof detail === "object" && !Array.isArray(detail)
? {
target: this,
...detail,
}
: detail;
this.dispatchEvent(
new CustomEvent(eventName, {
composed: true,
bubbles: true,
...options,
detail: fullDetail,
}),
}) as F,
);
}
};