diff --git a/web/src/elements/forms/SearchSelect.ts b/web/src/elements/forms/SearchSelect.ts deleted file mode 100644 index 6c8865ca1..000000000 --- a/web/src/elements/forms/SearchSelect.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { PreventFormSubmit } from "@goauthentik/app/elements/forms/helpers"; -import { EVENT_REFRESH } from "@goauthentik/common/constants"; -import { ascii_letters, digits, groupBy, randomString } from "@goauthentik/common/utils"; -import { adaptCSS } from "@goauthentik/common/utils"; -import { AKElement } from "@goauthentik/elements/Base"; -import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; - -import { msg } from "@lit/localize"; -import { CSSResult, TemplateResult, html, render } from "lit"; -import { customElement, property } from "lit/decorators.js"; - -import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css"; -import PFForm from "@patternfly/patternfly/components/Form/form.css"; -import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; -import PFSelect from "@patternfly/patternfly/components/Select/select.css"; -import PFBase from "@patternfly/patternfly/patternfly-base.css"; - -@customElement("ak-search-select") -export class SearchSelect extends CustomEmitterElement(AKElement) { - @property() - query?: string; - - @property({ attribute: false }) - objects?: T[]; - - @property({ attribute: false }) - selectedObject?: T; - - @property() - name?: string; - - @property({ type: Boolean }) - open = false; - - @property({ type: Boolean }) - blankable = false; - - @property() - placeholder: string = msg("Select an object."); - - static get styles(): CSSResult[] { - return [PFBase, PFForm, PFFormControl, PFSelect]; - } - - @property({ attribute: false }) - fetchObjects!: (query?: string) => Promise; - - @property({ attribute: false }) - renderElement!: (element: T) => string; - - @property({ attribute: false }) - renderDescription?: (element: T) => TemplateResult; - - @property({ attribute: false }) - value!: (element: T | undefined) => unknown; - - @property({ attribute: false }) - selected?: (element: T, elements: T[]) => boolean; - - @property() - emptyOption = "---------"; - - @property({ attribute: false }) - groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => { - return groupBy(items, () => { - return ""; - }); - }; - - scrollHandler?: () => void; - observer: IntersectionObserver; - dropdownUID: string; - dropdownContainer: HTMLDivElement; - isFetchingData = false; - - constructor() { - super(); - if (!document.adoptedStyleSheets.includes(PFDropdown)) { - document.adoptedStyleSheets = adaptCSS([...document.adoptedStyleSheets, PFDropdown]); - } - this.dropdownContainer = document.createElement("div"); - this.observer = new IntersectionObserver(() => { - this.open = false; - this.shadowRoot - ?.querySelectorAll( - ".pf-c-form-control.pf-c-select__toggle-typeahead", - ) - .forEach((input) => { - input.blur(); - }); - }); - this.observer.observe(this); - this.dropdownUID = `dropdown-${randomString(10, ascii_letters + digits)}`; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - shouldUpdate(changedProperties: Map) { - if (changedProperties.has("selectedObject")) { - this.dispatchCustomEvent("ak-change", { - value: this.selectedObject, - }); - } - return true; - } - - toForm(): unknown { - if (!this.objects) { - throw new PreventFormSubmit(msg("Loading options...")); - } - return this.value(this.selectedObject) || ""; - } - - firstUpdated(): void { - this.updateData(); - } - - updateData(): void { - if (this.isFetchingData) { - return; - } - this.isFetchingData = true; - this.fetchObjects(this.query).then((objects) => { - objects.forEach((obj) => { - if (this.selected && this.selected(obj, objects || [])) { - this.selectedObject = obj; - } - }); - this.objects = objects; - this.isFetchingData = false; - }); - } - - connectedCallback(): void { - super.connectedCallback(); - this.dropdownContainer = document.createElement("div"); - this.dropdownContainer.dataset["managedBy"] = "ak-search-select"; - if (this.name) { - this.dropdownContainer.dataset["managedFor"] = this.name; - } - document.body.append(this.dropdownContainer); - this.updateData(); - this.addEventListener(EVENT_REFRESH, this.updateData); - this.scrollHandler = () => { - this.requestUpdate(); - }; - window.addEventListener("scroll", this.scrollHandler); - } - - disconnectedCallback(): void { - super.disconnectedCallback(); - this.removeEventListener(EVENT_REFRESH, this.updateData); - if (this.scrollHandler) { - window.removeEventListener("scroll", this.scrollHandler); - } - this.dropdownContainer.remove(); - this.observer.disconnect(); - } - - /* - * This is a little bit hacky. Because we mainly want to use this field in modal-based forms, - * rendering this menu inline makes the menu not overlay over top of the modal, and cause - * the modal to scroll. - * Hence, we render the menu into the document root, hide it when this menu isn't open - * and remove it on disconnect - * Also to move it to the correct position we're getting this elements's position and use that - * to position the menu - * The other downside this has is that, since we're rendering outside of a shadow root, - * the pf-c-dropdown CSS needs to be loaded on the body. - */ - renderMenu(): void { - if (!this.objects) { - return; - } - const pos = this.getBoundingClientRect(); - let groupedItems = this.groupBy(this.objects); - let shouldRenderGroups = true; - if (groupedItems.length === 1) { - if (groupedItems[0].length < 1 || groupedItems[0][0] === "") { - shouldRenderGroups = false; - } - } - if (groupedItems.length === 0) { - shouldRenderGroups = false; - groupedItems = [["", []]]; - } - const renderGroup = (items: T[], tabIndexStart: number): TemplateResult => { - return html`${items.map((obj, index) => { - let desc = undefined; - if (this.renderDescription) { - desc = this.renderDescription(obj); - } - return html` -
  • - -
  • - `; - })}`; - }; - render( - html`
    -
      - ${this.blankable - ? html` -
    • - -
    • - ` - : html``} - ${shouldRenderGroups - ? html`${groupedItems.map(([group, items], idx) => { - return html` -
      -

      ${group}

      -
        - ${renderGroup(items, idx)} -
      -
      - `; - })}` - : html`${renderGroup(groupedItems[0][1], 0)}`} -
    -
    `, - this.dropdownContainer, - { host: this }, - ); - } - - render(): TemplateResult { - this.renderMenu(); - let value = ""; - if (!this.objects) { - value = msg("Loading..."); - } else if (this.selectedObject) { - value = this.renderElement(this.selectedObject); - } else if (this.blankable) { - value = this.emptyOption; - } - return html`
    -
    -
    - { - this.query = (ev.target as HTMLInputElement).value; - this.updateData(); - }} - @focus=${() => { - this.open = true; - this.renderMenu(); - }} - @blur=${(ev: FocusEvent) => { - // For Safari, we get the
      element itself here when clicking on one of - // it's buttons, as the container has tabindex set - if ( - ev.relatedTarget && - (ev.relatedTarget as HTMLElement).id === this.dropdownUID - ) { - return; - } - // Check if we're losing focus to one of our dropdown items, and if such don't blur - if (ev.relatedTarget instanceof HTMLButtonElement) { - const parentMenu = ev.relatedTarget.closest( - "ul.pf-c-dropdown__menu.pf-m-static", - ); - if (parentMenu && parentMenu.id === this.dropdownUID) { - return; - } - } - this.open = false; - this.renderMenu(); - }} - .value=${value} - /> -
    -
    -
    `; - } -} diff --git a/web/src/elements/forms/SearchSelect/ak-search-select.ts b/web/src/elements/forms/SearchSelect/ak-search-select.ts new file mode 100644 index 000000000..f67cf1c30 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/ak-search-select.ts @@ -0,0 +1,366 @@ +import { EVENT_REFRESH } from "@goauthentik/common/constants"; +import { ascii_letters, digits, groupBy, randomString } from "@goauthentik/common/utils"; +import { AKElement } from "@goauthentik/elements/Base"; +import { PreventFormSubmit } from "@goauthentik/elements/forms/Form"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html, render } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { styleMap } from "lit/directives/style-map.js"; + +import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFSelect from "@patternfly/patternfly/components/Select/select.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +type Group = [string, T[]]; + +@customElement("ak-search-select") +export class SearchSelect extends AKElement { + // A function which takes the query above (accepting that it may be empty) and + // returns a new collection of objects. + @property({ attribute: false }) + fetchObjects!: (query?: string) => Promise; + + // A function passed to this object that extracts a string representation of items of the + // collection under search. + @property({ attribute: false }) + renderElement!: (element: T) => string; + + // A function passed to this object that extracts an HTML representation of additional + // information for items of the collection under search. + @property({ attribute: false }) + renderDescription?: (element: T) => TemplateResult; + + // A function which returns the currently selected object's primary key, used for serialization + // into forms. + @property({ attribute: false }) + value!: (element: T | undefined) => unknown; + + // A function passed to this object that determines an object in the collection under search + // should be automatically selected. Only used when the search itself is responsible for + // fetching the data; sets an initial default value. + @property({ attribute: false }) + selected?: (element: T, elements: T[]) => boolean; + + // A function passed to this object (or using the default below) that groups objects in the + // collection under search into categories. + @property({ attribute: false }) + groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => { + return groupBy(items, () => { + return ""; + }); + }; + + // Whether or not the dropdown component can be left blank + @property({ type: Boolean }) + blankable = false; + + // An initial string to filter the search contents, and the value of the input which further + // serves to restrict the search + @property() + query?: string; + + // The objects currently available under search + @property({ attribute: false }) + objects?: T[]; + + // The currently selected object + @property({ attribute: false }) + selectedObject?: T; + + // Not used in this object. No known purpose. + @property() + name?: string; + + // Whether or not the dropdown component is visible. + @property({ type: Boolean }) + open = false; + + // The textual placeholder for the search's object, if currently empty. Used as the + // native object's `placeholder` field. + @property() + placeholder: string = msg("Select an object."); + + // A textual string representing "The user has affirmed they want to leave the selection blank." + // Only used if `blankable` above is true. + @property() + emptyOption = "---------"; + + // Handle the behavior of the drop-down when the :host scrolls off the page. + scrollHandler?: () => void; + observer: IntersectionObserver; + + // Handle communication between the :host and the portal + dropdownUID: string; + dropdownContainer: HTMLDivElement; + + isFetchingData = false; + + static styles = [PFBase, PFForm, PFFormControl, PFSelect]; + + constructor() { + super(); + if (!document.adoptedStyleSheets.includes(PFDropdown)) { + document.adoptedStyleSheets = [...document.adoptedStyleSheets, PFDropdown]; + } + this.dropdownContainer = document.createElement("div"); + this.observer = new IntersectionObserver(() => { + this.open = false; + this.shadowRoot + ?.querySelectorAll( + ".pf-c-form-control.pf-c-select__toggle-typeahead", + ) + .forEach((input) => { + input.blur(); + }); + }); + this.observer.observe(this); + this.dropdownUID = `dropdown-${randomString(10, ascii_letters + digits)}`; + this.onMenuItemClick = this.onMenuItemClick.bind(this); + } + + toForm(): unknown { + if (!this.objects) { + throw new PreventFormSubmit(msg("Loading options...")); + } + return this.value(this.selectedObject) || ""; + } + + firstUpdated(): void { + this.updateData(); + } + + updateData(): void { + if (this.isFetchingData) { + return; + } + this.isFetchingData = true; + this.fetchObjects(this.query).then((objects) => { + objects.forEach((obj) => { + if (this.selected && this.selected(obj, objects || [])) { + this.selectedObject = obj; + } + }); + this.objects = objects; + this.isFetchingData = false; + }); + } + + connectedCallback(): void { + super.connectedCallback(); + this.dropdownContainer = document.createElement("div"); + this.dropdownContainer.dataset["managedBy"] = "ak-search-select"; + document.body.append(this.dropdownContainer); + this.updateData(); + this.addEventListener(EVENT_REFRESH, this.updateData); + this.scrollHandler = () => { + this.requestUpdate(); + }; + window.addEventListener("scroll", this.scrollHandler); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener(EVENT_REFRESH, this.updateData); + if (this.scrollHandler) { + window.removeEventListener("scroll", this.scrollHandler); + } + this.dropdownContainer.remove(); + this.observer.disconnect(); + } + + renderMenuItemWithDescription(obj: T, desc: TemplateResult, index: number) { + return html` +
  • + +
  • + `; + } + + renderMenuItemWithoutDescription(obj: T, index: number) { + return html` +
  • + +
  • + `; + } + + renderEmptyMenuItem() { + return html`
  • + +
  • `; + } + + onMenuItemClick(obj: T | undefined) { + return () => { + this.selectedObject = obj; + this.open = false; + }; + } + + renderMenuGroup(items: T[], tabIndexStart: number) { + const renderedItems = items.map((obj, index) => { + const desc = this.renderDescription ? this.renderDescription(obj) : null; + const tabIndex = index + tabIndexStart; + return desc + ? this.renderMenuItemWithDescription(obj, desc, tabIndex) + : this.renderMenuItemWithoutDescription(obj, tabIndex); + }); + return html`${renderedItems}`; + } + + renderWithMenuGroupTitle([group, items]: Group, idx: number) { + return html` +
    +

    ${group}

    +
      + ${this.renderMenuGroup(items, idx)} +
    +
    + `; + } + + get groupedItems(): [boolean, Group[]] { + const items = this.groupBy(this.objects || []); + if (items.length === 0) { + return [false, [["", []]]]; + } + if (items.length === 1 && (items[0].length < 1 || items[0][0] === "")) { + return [false, items]; + } + return [true, items]; + } + + /* + * This is a little bit hacky. Because we mainly want to use this field in modal-based forms, + * rendering this menu inline makes the menu not overlay over top of the modal, and cause + * the modal to scroll. + * Hence, we render the menu into the document root, hide it when this menu isn't open + * and remove it on disconnect + * Also to move it to the correct position we're getting this elements's position and use that + * to position the menu + * The other downside this has is that, since we're rendering outside of a shadow root, + * the pf-c-dropdown CSS needs to be loaded on the body. + */ + + renderMenu(): void { + if (!this.objects) { + return; + } + const [shouldRenderGroups, groupedItems] = this.groupedItems; + + const pos = this.getBoundingClientRect(); + const position = { + "position": "fixed", + "inset": "0px auto auto 0px", + "z-index": "9999", + "transform": `translate(${pos.x}px, ${pos.y + this.offsetHeight}px)`, + "width": `${pos.width}px`, + ...(this.open ? {} : { visibility: "hidden" }), + }; + + render( + html`
    +
      + ${this.blankable ? this.renderEmptyMenuItem() : html``} + ${shouldRenderGroups + ? html`groupedItems.map(this.renderWithMenuGroupTitle)` + : html`${this.renderMenuGroup(groupedItems[0][1], 0)}`} +
    +
    `, + this.dropdownContainer, + { host: this }, + ); + } + + get renderedValue() { + // prettier-ignore + return (!this.objects) ? msg("Loading...") + : (this.selectedObject) ? this.renderElement(this.selectedObject) + : (this.blankable) ? this.emptyOption + : ""; + } + + render(): TemplateResult { + this.renderMenu(); + + const onFocus = (ev: FocusEvent) => { + this.open = true; + this.renderMenu(); + if (this.blankable && this.renderedValue === this.emptyOption) { + if (ev.target && ev.target instanceof HTMLInputElement) { + ev.target.value = ""; + } + } + }; + + const onInput = (ev: InputEvent) => { + this.query = (ev.target as HTMLInputElement).value; + this.updateData(); + }; + + const onBlur = (ev: FocusEvent) => { + // For Safari, we get the
      element itself here when clicking on one of + // it's buttons, as the container has tabindex set + if (ev.relatedTarget && (ev.relatedTarget as HTMLElement).id === this.dropdownUID) { + return; + } + // Check if we're losing focus to one of our dropdown items, and if such don't blur + if (ev.relatedTarget instanceof HTMLButtonElement) { + const parentMenu = ev.relatedTarget.closest("ul.pf-c-dropdown__menu.pf-m-static"); + if (parentMenu && parentMenu.id === this.dropdownUID) { + return; + } + } + this.open = false; + this.renderMenu(); + }; + + return html`
      +
      +
      + +
      +
      +
      `; + } +} diff --git a/web/src/elements/forms/SearchSelect/index.ts b/web/src/elements/forms/SearchSelect/index.ts new file mode 100644 index 000000000..f887f8344 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/index.ts @@ -0,0 +1,4 @@ +import { SearchSelect } from "./ak-search-select"; + +export { SearchSelect }; +export default SearchSelect;