Skip to content

Commit

Permalink
web: enhance search select with portal, overflow, and keyboard contro…
Browse files Browse the repository at this point in the history
…ls (goauthentik#9517)

* web: fix esbuild issue with style sheets

Getting ESBuild, Lit, and Storybook to all agree on how to read and parse stylesheets is a serious
pain. This fix better identifies the value types (instances) being passed from various sources in
the repo to the three *different* kinds of style processors we're using (the native one, the
polyfill one, and whatever the heck Storybook does internally).

Falling back to using older CSS instantiating techniques one era at a time seems to do the trick.
It's ugly, but in the face of the aggressive styling we use to avoid Flashes of Unstyled Content
(FLoUC), it's the logic with which we're left.

In standard mode, the following warning appears on the console when running a Flow:

```
Autofocus processing was blocked because a document already has a focused element.
```

In compatibility mode, the following **error** appears on the console when running a Flow:

```
crawler-inject.js:1106 Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'.
    at initDomMutationObservers (crawler-inject.js:1106:18)
    at crawler-inject.js:1114:24
    at Array.forEach (<anonymous>)
    at initDomMutationObservers (crawler-inject.js:1114:10)
    at crawler-inject.js:1549:1
initDomMutationObservers @ crawler-inject.js:1106
(anonymous) @ crawler-inject.js:1114
initDomMutationObservers @ crawler-inject.js:1114
(anonymous) @ crawler-inject.js:1549
```

Despite this error, nothing seems to be broken and flows work as anticipated.

* web: enhance search select

Patternfly doesn't even *have* a setting for "selected but not hovered," so I had to invent one. I
borrowed a trick from MUI and used the light blue "info" color, making it darker on
"selected+hovered."

This commit starts the revision process for search select. The goal is to have it broken down into
four major components: The inline-DOM component, which just shows the current value (or placeholder,
if none) and creates the portal for the floating component, then have a higher-level component for
the SearchSelect behavior, and a sidecar to manage the keyboard interactivity.

This is the portaled component: the actual list.

* web: enhance search select. Break menu and Input items into separate handlers.

* web: search select: added keyboard controller.

* web: search select - the isolation continues

This commit brings us to the position of having an independently rendered menu that listens for
click events on its contents, records the value internally *and* sends it upstream as an event.

This commit also includes a KeyboardController reactor that listens for keyboard events on a list of
objects, moving the focus up and down and sending a both a "selected" event when the user presses
Enter or Space, and a "close" event when the user presses Escape.

A lot of this is just infrastructure.  None of these *do* very much; they're just tools for making
SearchSelect better.

AkSearchSelectView is next: it's responsible for rendering the input and menu items, and then for
forwarding the `value` component up to whoever cares.

`ak-search-select` will ultimately be responsible for fetching the data and mapping the string
tokens from AkSearchSelectView back into the objects that Authentik cares about.

* web: search select - a functioning search select

So search select is now separated into the following components:

- SearchSelectView: Takes the renderables and the selected() Value and draws the Value in a
  box, then forwards the Options to a portaled drop-down component.
- SearchSelectMenuPosition: A web component that renders the Menu into the <BODY> element and
  positions it with respect to an anchor provided by SearchSelectView.
- SearchSelectMenu: Renders the Menu and listens for events indicating an Item has been selected.
  Sends events through a reference to the View.
- SearchKeyboardController: A specialized listener that keeps an independent list of indices and
  tabstops, and listens for keyboard events to move the index forward or backward, as well as for
  Event or Space for "select" and Escape for "close". Doesn't actually _do_ these things; they're
  just semantics implied by the event names, it just sends an event up to the host, which can do
  what it wants with them.

What's not done:

- SearchSelect: The interface with the API.  Maps to and from API values to renderable Options.

One thing of note: of the 35 uses of SearchSelect in our product, 28 of them have `renderElement`
annotations of a single field. Six of them use the same annotation (renderFlow), and only one (in
EventMatcherPolicyForm) is at all complex.  The 28 are:

- 7: group.name;
- 1: item.label;
- 5: item.name;
- 1: policy.name;
- 1: role.name;
- 1: source.name;
- 3: stage.name;
- 9: user.username;

I propose to modify `.renderElement` to take a string of `keyof T`, where T is the type passed to the
SearchSelect; it will simply look that up in the object passed in and use that as the Label.

`.renderDescription` is more or less similar, except it has _no_ special cases:

- 6: html`${flow.name}`;
- 1: html`${source.verboseName}`;
- 9: html`${user.name}`;
- 2: html`${flow.slug}`;

Given that, it makes sense to modify this as well to take a field key as a look up and render it,
making all that function calling somewhat moot.

Selected has a similar issue; passing it a value that is _not_ a function would be a signal to find
this specific element in the corresponding 'pk'.  Or we could pass a tuple of [keyof T] and value,
so we didn't have to hard-code 'pk' into the thing.

- 1             return item.pk === this.instance?.createUsersGroup;
- 1             return item.pk === this.instance?.filterGroup;
- 2             return item.pk === this.instance?.group;
- 1             return item.pk === this.instance?.parent;
- 1             return item.pk === this.instance?.searchGroup;
- 1             return item.pk === this.instance?.syncParentGroup;
- 1             return item.pk === this.instance?.policy;
- 1             return item.pk === this.instance?.source;
- 1             return item.pk === this.instance?.passwordStage;
- 1             return item.pk === this.instance?.stage;
- 1             return item.pk === this.instance?.user;
- 2             return item.pk === this.previewUser?.pk;
- 5             return item.pk === this.instance?.configureFlow;
- 1             return item.pk === this.instance?.mapping;
- 1             return item.pk === this.instance?.nameIdMapping;
- 1             return item.pk === this.instance?.user;
- 1             return item.pk === this.instance?.webhookMapping;
- 1             return item.component === this.instance?.action;
- 1             return item.path === this.instance?.path;
- 1             return item.name === this.instance?.model;
- 1             return item.name === this.instance?.app;
- 1             return user.pk.toString() === this.request?.toString();
- 2             return this.request?.user.toString() === user.pk.toString();

And of course, `.value` kinda sorta has the same thing going on:

- 6: flow?.pk;
- 3: group ? group.pk : undefined;
- 4: group?.pk;
- 1: item?.component;
- 2: item?.name;
- 1: item?.path;
- 4: item?.pk;
- 1: policy?.pk;
- 1: role?.pk;
- 1: source?.pk;
- 3: stage?.pk;
- 8: user?.pk;
- 1: user?.username;

All in all, the _protocol_ for SearchSelect could be streamlined. A _lot_. And still retain the
existing power.

* Old take; not keeping.

* Didn't need this either.

* web: search select - a functioning search select with API interface

So many edge cases!

Because the propagation here is sometimes KeyboardEvent -> MenuEvent -> SearchSelectEvent, I had to
rename some of the events to prevent them from creating infinite loops of event handling.  This
resulted in having to define separate events for Input, Close, and Select.

I struggled like heck to get the `<input>` object to show the value after updating. Ultimately, I
had to special case the `updated()` method to make sure it was showing the currently chosen display
value.  Looking through Stack Overflow, there's a lot of contention about the meaning of the `value`
field on HTMLInputElements.

The API layer distinguishes between a "search" event, which triggers the query to run, and the
"select" event, which triggers the component to pick an object as _the_ `.value`.

The API layer handles the conversion to GroupedItems and makes sure that the View receives either
FlatSelect or GroupedSelect options collections (see ./types, but in practice users should never
care too much about this.)

* web: completed the search select update

* web: search-select reveals a weakness in our plans

While testing SearchSelect, I realized that the protocol for our "custom input elements" was
neither specified nor documented.  I have attempted to fix that, and am finding edge cases
and buggy implementations that required addressing.

I've described the protocol by creating a class that implements it: AkControlElement.  It
extends the constructor to always provide the "this is an data-ak-control element," and
provides a `json()` method that throws an exception in the base class, so it must always
be overriden and never called as super().

I've also fixed ak-dual-select so it carries its name properly into the Forms parser.

* web: search select (and friends)

This commit finalizes the search select quest! Headline: Search Select is now keyboard-friendly
*and* CSS friendly; the styling needed for position is small enough to fit in a `styleMap`, and the
styling for the menu itself can be safely locked into a web component.

Primarily, I was forgetting to map the value to its displayValue whenever the value was changed from
an external source. It should have been an easy catch, but I missed it the first dozen times
through.

* Not using this yet.  ESLint-9 experiment that was loosely left here for some reason.

* Added lots of comments.

* Added new comments, fixed error message.

* Removing a console.log

* Fixed an incorrect comment.

* Added comments about workaround.

* web: focus fixes.

Fixes several issues with the drop-down, including primarily how "loss of focus"
does not result in the pop-up being banished. Also, the type definition for the
attribute `hidden` is inconsistent between Typescript, the attribute, and the
related property; I've chosen to route around that problem by using a custom
attribute and setting `hidden` in the template, where `lit-analyze` has a workable
definition and allows it to pass. Finally, on `open` the focus is passed to the
current value, if any.
  • Loading branch information
kensternberg-authentik authored and gergosimonyi committed Jul 17, 2024
1 parent 49e2eab commit 5edafa9
Show file tree
Hide file tree
Showing 21 changed files with 1,720 additions and 453 deletions.
30 changes: 0 additions & 30 deletions web/.eslintrc.precommit.json

This file was deleted.

1 change: 1 addition & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/legacy-modes": "^6.4.0",
"@codemirror/theme-one-dark": "^6.1.2",
"@floating-ui/dom": "^1.6.3",
"@formatjs/intl-listformat": "^7.5.7",
"@fortawesome/fontawesome-free": "^6.5.2",
"@goauthentik/api": "^2024.6.1-1720888668",
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/ak-multi-select.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AKElement } from "@goauthentik/elements/Base";
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
import "@goauthentik/elements/forms/HorizontalFormElement";

import { TemplateResult, css, html, nothing } from "lit";
Expand All @@ -25,7 +25,7 @@ const selectStyles = css`
* @part select - The select itself, to override the height specified above.
*/
@customElement("ak-multi-select")
export class AkMultiSelect extends AKElement {
export class AkMultiSelect extends AkControlElement {
constructor() {
super();
this.dataset.akControl = "true";
Expand Down
145 changes: 0 additions & 145 deletions web/src/components/stories/ak-search-select.stories.ts

This file was deleted.

20 changes: 20 additions & 0 deletions web/src/elements/AkControlElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { AKElement } from "./Base";

/**
* @class - prototype for all of our hand-made input elements
*
* Ensures that the `data-ak-control` property is always set, so that
* scrapers can find it easily, and adds a corresponding method for
* extracting the value.
*
*/
export class AkControlElement extends AKElement {
constructor() {
super();
this.dataset.akControl = "true";
}

json() {
throw new Error("Controllers using this protocol must override this method");
}
}
4 changes: 2 additions & 2 deletions web/src/elements/ak-checkbox-group/ak-checkbox-group.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AKElement } from "@goauthentik/elements/Base";
import { AkControlElement } from "@goauthentik/elements/AkControlElement";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";

import { msg } from "@lit/localize";
Expand All @@ -23,7 +23,7 @@ function* kvToPairs(items: CheckboxPair[]): Iterable<CheckboxPr> {
}
}

const AkElementWithCustomEvents = CustomEmitterElement(AKElement);
const AkElementWithCustomEvents = CustomEmitterElement(AkControlElement);

/**
* @element ak-checkbox-group
Expand Down
9 changes: 3 additions & 6 deletions web/src/elements/ak-dual-select/ak-dual-select-provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AKElement } from "@goauthentik/elements/Base";
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
import { debounce } from "@goauthentik/elements/utils/debounce";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";

Expand Down Expand Up @@ -26,9 +26,8 @@ import type { DataProvider, DualSelectPair } from "./types";
*/

@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
export class AkDualSelectProvider extends CustomListenerElement(AkControlElement) {
/** A function that takes a page and returns the DualSelectPair[] collection with which to update
* the "Available" pane.
*
* @attr
Expand Down Expand Up @@ -84,8 +83,6 @@ export class AkDualSelectProvider extends CustomListenerElement(AKElement) {
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);
Expand Down
20 changes: 2 additions & 18 deletions web/src/elements/forms/Form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { MessageLevel } from "@goauthentik/common/messages";
import { camelToSnake, convertToSlug, dateToUTC } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement";
import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect";
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";

Expand Down Expand Up @@ -74,8 +73,8 @@ export function serializeForm<T extends KeyUnknown>(
return;
}

const inputElement = element.querySelector<HTMLInputElement>("[name]");
if (element.hidden || !inputElement) {
const inputElement = element.querySelector<AkControlElement>("[name]");
if (element.hidden || !inputElement || (element.writeOnly && !element.writeOnlyActivated)) {
return;
}

Expand All @@ -84,10 +83,6 @@ export function serializeForm<T extends KeyUnknown>(
return;
}

// Skip elements that are writeOnly where the user hasn't clicked on the value
if (element.writeOnly && !element.writeOnlyActivated) {
return;
}
if (
inputElement.tagName.toLowerCase() === "select" &&
"multiple" in inputElement.attributes
Expand Down Expand Up @@ -120,17 +115,6 @@ export function serializeForm<T extends KeyUnknown>(
assignValue(inputElement, inputElement.checked, json);
} else if ("selectedFlow" in inputElement) {
assignValue(inputElement, inputElement.value, json);
} else if (inputElement.tagName.toLowerCase() === "ak-search-select") {
const select = inputElement as unknown as SearchSelect<unknown>;
try {
const value = select.toForm();
assignValue(inputElement, value, json);
} catch (exc) {
if (exc instanceof PreventFormSubmit) {
throw new PreventFormSubmit(exc.message, element);
}
throw exc;
}
} else {
assignValue(inputElement, inputElement.value, json);
}
Expand Down
Loading

0 comments on commit 5edafa9

Please sign in to comment.