* 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!
```
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
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.`) : " ";
|
|
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>
|
|
`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"ak-dual-select": AkDualSelect;
|
|
}
|
|
}
|