From b7e7835ce1f74ccee2a7e104d5063c30807ebf91 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Thu, 4 Jan 2024 11:10:41 -0800 Subject: [PATCH] web: Add searchbar and enable it for "selected" "Available" requires a round-trip to the provider level, so that's next. --- web/.storybook/css-import-maps.ts | 1 + .../elements/ak-dual-select/ak-dual-select.ts | 46 ++++- .../components/ak-search-bar.ts | 65 +++++++ .../ak-dual-select/components/search.css.ts | 166 ++++++++++++++++++ .../ak-dual-select/components/styles.css.ts | 11 +- .../stories/ak-dual-select-search.stories.ts | 70 ++++++++ web/src/elements/ak-dual-select/types.ts | 7 + web/src/elements/utils/debounce.ts | 13 ++ web/src/elements/utils/eventEmitter.ts | 9 +- 9 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 web/src/elements/ak-dual-select/components/ak-search-bar.ts create mode 100644 web/src/elements/ak-dual-select/components/search.css.ts create mode 100644 web/src/elements/ak-dual-select/stories/ak-dual-select-search.stories.ts create mode 100644 web/src/elements/utils/debounce.ts diff --git a/web/.storybook/css-import-maps.ts b/web/.storybook/css-import-maps.ts index 762f1471b..49e260df6 100644 --- a/web/.storybook/css-import-maps.ts +++ b/web/.storybook/css-import-maps.ts @@ -62,6 +62,7 @@ const cssImportMapSources = [ 'import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css";', 'import PFTable from "@patternfly/patternfly/components/Table/table.css";', 'import PFTabs from "@patternfly/patternfly/components/Tabs/tabs.css";', + 'import PFTextInputGroup from "@patternfly/patternfly/components/TextInputGroup/text-input-group.css";', 'import PFTitle from "@patternfly/patternfly/components/Title/title.css";', 'import PFToggleGroup from "@patternfly/patternfly/components/ToggleGroup/toggle-group.css";', 'import PFToolbar from "@patternfly/patternfly/components/Toolbar/toolbar.css";', diff --git a/web/src/elements/ak-dual-select/ak-dual-select.ts b/web/src/elements/ak-dual-select/ak-dual-select.ts index d42fb1bf7..d0f8f655e 100644 --- a/web/src/elements/ak-dual-select/ak-dual-select.ts +++ b/web/src/elements/ak-dual-select/ak-dual-select.ts @@ -6,7 +6,7 @@ import { import { msg, str } from "@lit/localize"; import { PropertyValues, html, nothing } from "lit"; -import { customElement, property } from "lit/decorators.js"; +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"; @@ -21,6 +21,7 @@ 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, @@ -30,7 +31,7 @@ import { EVENT_REMOVE_ONE, EVENT_REMOVE_SELECTED, } from "./constants"; -import type { BasePagination, DualSelectPair } from "./types"; +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]; @@ -82,6 +83,9 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE @property({ attribute: "selected-label" }) selectedLabel = msg("Selected options"); + @state() + selectedFilter: string = ""; + availablePane: Ref = createRef(); selectedPane: Ref = createRef(); @@ -91,6 +95,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE constructor() { super(); this.handleMove = this.handleMove.bind(this); + this.handleSearch = this.handleSearch.bind(this); [ EVENT_ADD_ALL, EVENT_ADD_SELECTED, @@ -105,6 +110,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE this.addCustomListener("ak-dual-select-move", () => { this.requestUpdate(); }); + this.addCustomListener("ak-search", this.handleSearch); } get value() { @@ -221,6 +227,25 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE 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); + } + + } + + handleAvailbleSearch(value: string) { + console.log(value); + } + + handleSelectedSearch(value: string) { + this.selectedFilter = value; + this.selectedPane.value!.clearMove(); + } + get canAddAll() { // False unless any visible option cannot be found in the selected list, so can still be // added. @@ -243,9 +268,18 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE } 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 = this.selected.length; + const selectedTotal = selected.length; const availableStatus = availableCount > 0 ? msg(str`${availableCount} items marked to add.`) : " "; const selectedTotalStatus = msg(str`${selectedTotal} items selected.`); @@ -263,7 +297,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE - +
- +
diff --git a/web/src/elements/ak-dual-select/components/ak-search-bar.ts b/web/src/elements/ak-dual-select/components/ak-search-bar.ts new file mode 100644 index 000000000..247f34b84 --- /dev/null +++ b/web/src/elements/ak-dual-select/components/ak-search-bar.ts @@ -0,0 +1,65 @@ +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; + } + + input: Ref = createRef(); + + @property({ type: String, reflect: true }) + value = ""; + + @property({ type: String }) + name = ""; + + constructor() { + super(); + this.onChange = this.onChange.bind(this); + } + + onChange(_event: Event) { + if (this.input.value) { + this.value = this.input.value.value; + } + this.dispatchCustomEvent("ak-search", { + source: this.name, + value: this.value + }); + } + + render() { + return html` +
+
+ +
+
+ `; + } +} + +export default AkSearchbar; diff --git a/web/src/elements/ak-dual-select/components/search.css.ts b/web/src/elements/ak-dual-select/components/search.css.ts new file mode 100644 index 000000000..d3be3b008 --- /dev/null +++ b/web/src/elements/ak-dual-select/components/search.css.ts @@ -0,0 +1,166 @@ +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--BackgroundColor--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); + } +`; + +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: var(--pf-c-text-input-group__text--before--BorderWidth) solid + var(--pf-c-text-input-group__text--before--BorderColor); + } + + .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%; + 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; + } +`; diff --git a/web/src/elements/ak-dual-select/components/styles.css.ts b/web/src/elements/ak-dual-select/components/styles.css.ts index f2c057bf6..eb7fbdcdd 100644 --- a/web/src/elements/ak-dual-select/components/styles.css.ts +++ b/web/src/elements/ak-dual-select/components/styles.css.ts @@ -86,8 +86,12 @@ export const globalVariables = css` --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); } `; @@ -104,6 +108,10 @@ export const mainStyles = css` 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); @@ -117,7 +125,8 @@ export const mainStyles = css` .ak-available-pane, .ak-selected-pane { display: grid; - grid-template-rows: auto auto 1fr auto; + grid-template-rows: auto auto auto 1fr auto; + gap: var(--pf-c-dual-list-panels__gap); max-width: 100%; overflow: hidden; } diff --git a/web/src/elements/ak-dual-select/stories/ak-dual-select-search.stories.ts b/web/src/elements/ak-dual-select/stories/ak-dual-select-search.stories.ts new file mode 100644 index 000000000..43f311c20 --- /dev/null +++ b/web/src/elements/ak-dual-select/stories/ak-dual-select-search.stories.ts @@ -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 = { + 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`
+ + + ${testItem} +

Messages received from the button:

+
+
+
`; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const displayMessage = (result: any) => { + const doc = new DOMParser().parseFromString(`

Content: ${result}

`, "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(`

Behavior: ${result}

`, "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` `), +}; diff --git a/web/src/elements/ak-dual-select/types.ts b/web/src/elements/ak-dual-select/types.ts index fa19b984c..c873db62e 100644 --- a/web/src/elements/ak-dual-select/types.ts +++ b/web/src/elements/ak-dual-select/types.ts @@ -17,3 +17,10 @@ export type DataProvision = { }; export type DataProvider = (page: number) => Promise; + +export interface SearchbarEvent extends CustomEvent { + detail: { + source: string; + value: string; + } +} diff --git a/web/src/elements/utils/debounce.ts b/web/src/elements/utils/debounce.ts new file mode 100644 index 000000000..ab9ac90a8 --- /dev/null +++ b/web/src/elements/utils/debounce.ts @@ -0,0 +1,13 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Callback = (...args: any[]) => any; +export function debounce(callback: F, wait: number) { + let timeout: ReturnType; + return (...args: Parameters) => { + // @ts-ignore + const context: T = this satisfies object; + if (timeout !== undefined) { + clearTimeout(timeout); + } + timeout = setTimeout(() => callback.apply(context, args), wait); + }; +} diff --git a/web/src/elements/utils/eventEmitter.ts b/web/src/elements/utils/eventEmitter.ts index 6184fe0cd..dcd549316 100644 --- a/web/src/elements/utils/eventEmitter.ts +++ b/web/src/elements/utils/eventEmitter.ts @@ -10,20 +10,25 @@ export const isCustomEvent = (v: any): v is CustomEvent => export function CustomEmitterElement>(superclass: T) { return class EmmiterElementHandler extends superclass { // eslint-disable-next-line @typescript-eslint/no-explicit-any - dispatchCustomEvent(eventName: string, detail: any = {}, options = {}) { + dispatchCustomEvent( + eventName: string, + detail: any = {}, + options = {}, + ) { const fullDetail = typeof detail === "object" && !Array.isArray(detail) ? { ...detail, } : detail; + this.dispatchEvent( new CustomEvent(eventName, { composed: true, bubbles: true, ...options, detail: fullDetail, - }), + }) as F, ); } };