Files
authentik/web/src/elements/ak-checkbox-group/ak-checkbox-group.ts
Ken Sternberg ee58cf0c1c web: add HTMLTagNameElementMaps to everything to activate lit analyzer (#10217)
* 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: add more linting

* A reliable test for the extra code needed in analyzer, passing shellcheck

* web: re-enable custom-element-manifest and enable component checking in Typescript

This commit includes a monkeypatch to allow custom-element-manifest (CEM) to work correctly again
despite our rich collection of mixins, reactive controllers, symbol-oriented event handlers, and the
like. With that monkeypatch in place, we can now create the CEM manifest file and then exploit it so
that IDEs and the Typescript compilation pass can tell when a component is being used incorrectly;
when the wrong types are being passed to it, or when a required attribute is not initialized.

* Added building the manifest to the build process, rather than storing it.  It is not appreciably slow.

* web: the most boring PR in the universe: Add HTMLTagNameElementMap to everyhing

This commit adds HTMLTagNameElementMap entries to every web component in the front end. Activating
and associating the HTMLTagNamElementMap with its class has enabled
[LitAnalyzer](https://github.com/runem/lit-analyzer/tree/master/packages/lit-analyzer) to reveal a
*lot* of basic problems within the UI, the most popular of which is "missing import." We usually get
away with it because the object being imported was already registered with the browser elsewhere,
but it still surprises me that we haven't gotten any complaints over things like:

```
./src/flow/stages/base.ts
Missing import for <ak-form-static>
96:  <ak-form-static
no-missing-import
```

Given how early and fundamental that seems to be in our code, I'd have expected to hear _something_
about it.

I have not enabled most of the possible checks because, well, there are just a ton of warnings when
I do.  I'd like to get in and fix those.

Aside from this, I have also _removed_ `customElement` declarations from anything declared as an
`abstract class`. It makes no sense to try and instantiate something that cannot, by definition, be
instantiated.  If the class is capable of running on its own, it's not abstract, it just needs to be
overridden in child classes.  Before removing the declaration I did check to make sure no other
piece of code was even *trying* to instantiate it, and so far I have detected no failures.  Those
elements were:

- elements/forms/Form.ts
- element-/wizard/WizardFormPage.ts

The one that blows my mind, though, is this:

```
src/elements/forms/ProxyForm.ts
6-@customElement("ak-proxy-form")
7:export abstract class ProxyForm extends Form<unknown> {
```

Which, despite being `abstract`, is somehow instantiable?

```
src/admin/outposts/ServiceConnectionListPage.ts:    <ak-proxy-form
src/admin/providers/ProviderListPage.ts:    <ak-proxy-form
src/admin/sources/SourceWizard.ts:    <ak-proxy-form
src/admin/sources/SourceListPage.ts:    <ak-proxy-form
src/admin/providers/ProviderWizard.ts:    <ak-proxy-form type=${type.component}></ak-proxy-form>
src/admin/stages/StageListPage.ts:    <ak-proxy-form
```

I've made a note to investigate.

I've started a new folder where all of my one-off tools for *how* a certain PR was run.  It has a
README describing what it's for, and the first tool, `add-htmlelementtagnamemaps-to-everything`, is
its first entry.  That tool is also documented internally.

``` Gilbert & Sullivan

I've got a little list,
I've got a little list,
Of all the code that would never be missed,
The duplicate code of cute-and-paste,
The weak abstractions that lead to waste,
The embedded templates-- you get the gist,
There ain't none of 'em that will ever be missed,
And that's why I've got them on my list!

```
2024-07-15 10:54:22 -07:00

234 lines
7.8 KiB
TypeScript

import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg } from "@lit/localize";
import { PropertyValues } from "@lit/reactive-element/reactive-element";
import { TemplateResult, css, html } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators.js";
import { map } from "lit/directives/map.js";
import PFCheck from "@patternfly/patternfly/components/Check/check.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
type CheckboxKv = { name: string; label: string | TemplateResult };
type CheckboxPr = [string, string | TemplateResult];
export type CheckboxPair = CheckboxKv | CheckboxPr;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isCheckboxPr = (t: any): t is CheckboxPr => Array.isArray(t);
function* kvToPairs(items: CheckboxPair[]): Iterable<CheckboxPr> {
for (const item of items) {
yield isCheckboxPr(item) ? item : [item.name, item.label];
}
}
const AkElementWithCustomEvents = CustomEmitterElement(AKElement);
/**
* @element ak-checkbox-group
*
* @class CheckboxGroup
*
* @description
* CheckboxGroup renders a collection of checkboxes in a linear list. Multiple
* checkboxes may be picked.
*
* @attr {options} - An array of either `[string, string | TemplateResult]` or
* `{ name: string, label: string | TemplateResult }`. The first value or
* `name` field must be a valid HTML identifier compatible with the HTML
* `name` attribute.
*
* @attr {value} - An array of `name` values corresponding to the options that
* are selected when the element is rendered.
*
* @attr {name} - The name of this element as it will appear in any <form>
* transaction
*
* @attr {required} - If true, and if name is set, and no values are chosen,
* will automatically fail a form `submit` event, providing a warning
* message for any labeling. Note: if `name` is not set, this has no effect,
* and a warn() will appear on the console.
*
* @event {input} - Fired when the component's value has changed. Current value
* as an array of `name` will be in the `Event.detail` field.
*
* @event {change} - Fired when the component's value has changed. Current value
* as an array of `name` will be in the `Event.detail` field.
*
* @csspart checkbox - The div containing the checkbox item and the label
* @csspart label - the label
* @csspart input - the input item
* @csspart checkbox-group - the wrapper div with flexbox control
*
* ## Bigger hit area
*
* Providing properly formatted names for selections allows the element to
* associate the label with the event, so the entire horizontal area from
* checkbox to end-of-label will be the hit area.
*
* ## FormAssociated compliance
*
* If a <form> component is a parent, this component will correctly send its
* values to the form for `x-form-encoded` data; multiples will appear in the
* form of `name=value1&name=value2` format, and must be unpacked into an array
* correctly on the server side according to the CGI (common gateway interface)
* protocol.
*
*/
@customElement("ak-checkbox-group")
export class CheckboxGroup extends AkElementWithCustomEvents {
static get styles() {
return [
PFBase,
PFForm,
PFCheck,
css`
.pf-c-form__group-control {
padding-top: calc(
var(--pf-c-form--m-horizontal__group-label--md--PaddingTop) * 1.3
);
}
`,
];
}
static get formAssociated() {
return true;
}
@property({ type: Array })
options: CheckboxPair[] = [];
@property({ type: Array })
value: string[] = [];
@property({ type: String })
name?: string;
@property({ type: Boolean })
required = false;
@queryAll('input[type="checkbox"]')
checkboxes!: NodeListOf<HTMLInputElement>;
@state()
values: string[] = [];
internals?: ElementInternals;
doneFirstUpdate = false;
json() {
return this.values;
}
private get formValue() {
if (this.name === undefined) {
throw new Error("This cannot be called without having the name set.");
}
const name = this.name;
const entries = new FormData();
this.values.forEach((v) => entries.append(name, v));
return entries;
}
constructor() {
super();
this.onClick = this.onClick.bind(this);
this.dataset.akControl = "true";
}
onClick(ev: Event) {
ev.stopPropagation();
this.values = Array.from(this.checkboxes)
.filter((checkbox) => checkbox.checked)
.map((checkbox) => checkbox.name);
this.dispatchCustomEvent("change", this.values);
this.dispatchCustomEvent("input", this.values);
if (this.internals) {
this.internals.setValidity({});
if (this.required && this.values.length === 0) {
this.internals.setValidity(
{
valueMissing: true,
},
msg("A selection is required"),
this,
);
}
this.internals.setFormValue(this.formValue);
}
// Doing a write-back so anyone examining the checkbox.value field will get something
// meaningful. Doesn't do anything for anyone, usually, but it's nice to have.
this.value = this.values;
}
willUpdate(changed: PropertyValues<this>) {
if (changed.has("value") && !this.doneFirstUpdate) {
this.doneFirstUpdate = true;
this.values = this.value;
}
}
connectedCallback() {
super.connectedCallback();
if (this.name && !this.internals) {
this.internals = this.attachInternals();
}
if (this.internals && this.name) {
this.internals.ariaRequired = this.required ? "true" : "false";
}
if (this.required && !this.internals) {
console.warn(
"Setting `required` on ak-checkbox-group has no effect when the `name` attribute is unset",
);
}
// These are necessary to prevent the input components' own events from
// leaking out. This helps maintain the illusion that this component
// behaves similarly to the multiple selection behavior of, well,
// <select multiple>.
this.addEventListener("input", (ev) => {
ev.stopPropagation();
});
this.addEventListener("change", (ev) => {
ev.stopPropagation();
});
}
render() {
const renderOne = ([name, label]: CheckboxPr) => {
const selected = this.values.includes(name);
const blockFwd = (e: Event) => {
e.stopImmediatePropagation();
};
return html` <div part="checkbox" class="pf-c-check" @click=${this.onClick}>
<input
part="input"
@change=${blockFwd}
@input=${blockFwd}
name="${name}"
class="pf-c-check__input"
type="checkbox"
?checked=${selected}
id="ak-check-${name}"
/>
<label part="label" class="pf-c-check__label" for="ak-check-${name}"
>${label}</label
>
</div>`;
};
return html`<div part="checkbox-group" class="pf-c-form__group-control pf-m-stack">
${map(kvToPairs(this.options), renderOne)}
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-checkbox-group": CheckboxGroup;
}
}