
* web: revise css-import-maps to need only a single entry, rather than dual-entry Given that the difference Vite/Storybook cares about is whether or not there's a sigil at the end of the CSS string, it seemed silly to require devs to enter both the raw and sigiled string; just do an in-line text-and-replace. * web: provide a "select / select all" tool for the dual list multiselect **This commit** Provides one of several of the sub-controls needed to make the multi-list multi-select thing work. This is the simplest control, and I decided to go with it first because it's all presentation; all it does is show the buttons and send events from those buttons. A Storybook component is provided to show how well it works. * web: provide a "select / select all" tool for the dual list multiselect **This commit** This commit provides the following new features for dual list multiselect: - The "available" pane, which has all of the entries that are available to be selected. Items that are already selected will remain, but they're marked with a checkmark and can neither be selected or moved. - The "selected" pane, which has *all* of the entries that have been selected. - The Pagination control, which in this case only sends an event upstream. **Plan**: The plan is to have a master control that marries the available-pane, selected-pane, select-controls, and pagination-controls into a single component that receives the list of "currently visible" available entries and keeps the list of "currently selected" entries, as well as a pass-through for the pagination value that allows it to hide the pagination control if there is only one page. A master component *above that* will provide the list of currently visible entries and, at need, read the value of the master control object for the "selected" list. That component will mostly be data-only; it's render will probably just be `<slot></slot>`; its duty will be only to map entries to string keys Lit can use, and to provide the lists we want to provide and the pagination ranges we want to show. Some judicious use of grid will allow me size the controls properly with/without the pagination control. Status and Title are going to be in the master control. A <slot> will be provided for Search, but I have no plans to integrate that into this control as of yet. There is already a planned fallback control; the multi-select experience on mobile is actually excellent, and we should exploit that appropriately. * web: provide a "select / select all" tool for the dual list multiselect **This commit** 1. Re-arrange the contents of the folder so that the sub-components are in their own folder. This reduces the clutter and makes it easier to understand where to look for certain things. 2. Re-arranges the contents of the folder so that all the Storybook stories are in their own folder. Again, this reduces the clutter; it also helps the compiler understand what not to compile. 3. Strips down the "Available items pane" to a minimal amount of interactivity and annotates the passed-in properties as `readonly`, since the purpose of this component is to display those. The only internal state kept is the list of items marked-to-move. 4. Does the same thing with the "Selected items pane". 5. Added comments to help guide future maintainers. 6. Restructured the CSS, taking a _lot_ of it into our own hands. Patternfly continues to act as if all components are fully available all the time, and that's simply not true in a shadowDOM environment. By separating out the global CSS Custom Properties from the grid and style definitions of `pf-c-dual-list-selector`, I was able to construct a more simple and straightforward grid (with nested grids for the columns inside). 7. Added "Delete ALL Selected" to the controls 8. Added "double-click" as a "move this one NOW" feature. * web: provide a "select / select all" tool for the dual list multiselect **This commit** - Fixes the bug whereby pagination would leave the 'some moves available' state visible by clearing the 'to-move' state when the list of options changes. - Fixes the bug whereby a change of 'options' in available would also cause an update to `selectedKeys`, causing the entire selected field to clear. Fixed by making `selectedKeys` a static object updated only when `selected` is generated rather than generating it anew with each re-rerender. (Hey, kids, can you say "functional programming and immutability" five time fast? I knew you could!) - Fixes the bug whereby the change of outpost type would not cause an update of the `options` collection. - Fixes the bug whereby the CSS was not creating enough whitespace separation between the whole component and its siblings. Host components are coded `span:static` unless otherwise styled to be `block`; we want `block` most of the time. - Fixes the bug whereby the list of existing objects wasn't being passed to the handler correctly. - Updates the Form Handler to recognize this new input object. - Fixes the bug whereby changing outpost type doesn't handle the list of selected applications well. - Fixes the bug whereby the identity of the outpost type's associated `fetch()` function loses identity -- necessary to maintain the selected outpost type switch. - Fixes the CSS bug whereby horizontal scrolling would not enable correctly when the application's name overflows the listbox. - Completes this assignment. :-) * web: last-minute pre-commit cleanup. * running localize extract * web: codeql found an issue with one of my tests. * web: multi-select Modified the display so that if it's a template we display it correctly opposite the text, and provide classes that can be used in the display to differentiate between the main label and the descriptive label. Added a sort key, so the select can sort the right-hand pane correctly. Fixed the `this.selected` setters to use Arrays instead of maps. Theoretically, this is terribly inefficient, as it makes it theoretically O(n^2) rather than O(1), but in practice even if both lists were 10,000 elements long a modern desktop could perform the entire scan in 150ms or so. * fix lint error Signed-off-by: Jens Langhammer <jens@goauthentik.io> * update strings slightly Signed-off-by: Jens Langhammer <jens@goauthentik.io> * start on dark theme support Signed-off-by: Jens Langhammer <jens@goauthentik.io> * web: Add searchbar and enable it for "selected" "Available" requires a round-trip to the provider level, so that's next. * web: provide a search for the dual list multiselect **This commit** - Includes a new widget that represents the basic, Patternfly-designed search bar. It just emits events of search request updates. - Changes the definition of a data provider to take an optional search string. - Changes the handler in the *independent* layer so that it catches search requests and those requests work on the "selected" collection. - Changes the handler of the `authentik` interface layer so that it catches search requests and those requests are sent to the data provider. - Provides a debounce function for the `authentik` interface layer to not hammer the Django instance too much. - Updates the data providers in the example for `OutpostForm` to handle search requests. - Provides a property in the `authentik` interface layer so that the debounce can be tuned. * web: always trim the search string passed. * web: code quality pass, extra comments, pre-commit check. * Serious (and bizarre) merge bug. I guess it doesn't like XML that much. * Attempting to reason with whatever eslint GitHub is using. * Prettier has opinions. * Enable better dark mode. There were two issues: the dark mode didn't reach into the "search" bar, and there were several hover states that weren't handled well. This commit handles both. The color scheme mirrors the one we currently use, but it's a bit backwards from Patternfly 5. Dunno how we're gonna reconcile all that. * Prettier fixes and locale extraction * web: update pagination type to use generic, provided type * web: fixed a few comment typos * Discordant version numbers for @go-authentik/api were causing build failures. * What is up with CI/CD? * web: missed a lint issue that prevented the build from running successfully --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io> Co-authored-by: Jens Langhammer <jens@goauthentik.io>
417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
import { PreventFormSubmit } from "@goauthentik/app/elements/forms/helpers";
|
|
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
|
import { MessageLevel } from "@goauthentik/common/messages";
|
|
import { camelToSnake, convertToSlug } 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 { showMessage } from "@goauthentik/elements/messages/MessageContainer";
|
|
|
|
import { CSSResult, TemplateResult, css, html } from "lit";
|
|
import { customElement, property, state } from "lit/decorators.js";
|
|
|
|
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
|
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
|
import PFCard from "@patternfly/patternfly/components/Card/card.css";
|
|
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
|
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
|
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
|
|
import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css";
|
|
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
|
|
|
import { ResponseError, ValidationError, ValidationErrorFromJSON } from "@goauthentik/api";
|
|
|
|
export class APIError extends Error {
|
|
constructor(public response: ValidationError) {
|
|
super();
|
|
}
|
|
}
|
|
|
|
export interface KeyUnknown {
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
// Literally the only field `assignValue()` cares about.
|
|
type HTMLNamedElement = Pick<HTMLInputElement, "name">;
|
|
|
|
type AkControlElement = HTMLInputElement & { json: () => string | string[] };
|
|
|
|
/**
|
|
* Recursively assign `value` into `json` while interpreting the dot-path of `element.name`
|
|
*/
|
|
function assignValue(element: HTMLNamedElement, value: unknown, json: KeyUnknown): void {
|
|
let parent = json;
|
|
if (!element.name?.includes(".")) {
|
|
parent[element.name] = value;
|
|
return;
|
|
}
|
|
const nameElements = element.name.split(".");
|
|
for (let index = 0; index < nameElements.length - 1; index++) {
|
|
const nameEl = nameElements[index];
|
|
// Ensure all nested structures exist
|
|
if (!(nameEl in parent)) parent[nameEl] = {};
|
|
parent = parent[nameEl] as { [key: string]: unknown };
|
|
}
|
|
parent[nameElements[nameElements.length - 1]] = value;
|
|
}
|
|
|
|
/**
|
|
* Convert the elements of the form to JSON.[4]
|
|
*
|
|
*/
|
|
export function serializeForm<T extends KeyUnknown>(
|
|
elements: NodeListOf<HorizontalFormElement>,
|
|
): T | undefined {
|
|
const json: { [key: string]: unknown } = {};
|
|
elements.forEach((element) => {
|
|
element.requestUpdate();
|
|
if (element.hidden) {
|
|
return;
|
|
}
|
|
|
|
if ("akControl" in element.dataset) {
|
|
assignValue(element, (element as unknown as AkControlElement).json(), json);
|
|
return;
|
|
}
|
|
|
|
const inputElement = element.querySelector<HTMLInputElement>("[name]");
|
|
if (element.hidden || !inputElement) {
|
|
return;
|
|
}
|
|
|
|
if ("akControl" in inputElement.dataset) {
|
|
assignValue(element, inputElement.value, json);
|
|
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
|
|
) {
|
|
const selectElement = inputElement as unknown as HTMLSelectElement;
|
|
assignValue(
|
|
inputElement,
|
|
Array.from(selectElement.selectedOptions).map((v) => v.value),
|
|
json,
|
|
);
|
|
} else if (inputElement.tagName.toLowerCase() === "input" && inputElement.type === "date") {
|
|
assignValue(inputElement, inputElement.valueAsDate, json);
|
|
} else if (
|
|
inputElement.tagName.toLowerCase() === "input" &&
|
|
inputElement.type === "datetime-local"
|
|
) {
|
|
assignValue(inputElement, new Date(inputElement.valueAsNumber), json);
|
|
} else if (
|
|
inputElement.tagName.toLowerCase() === "input" &&
|
|
"type" in inputElement.dataset &&
|
|
inputElement.dataset["type"] === "datetime-local"
|
|
) {
|
|
// Workaround for Firefox <93, since 92 and older don't support
|
|
// datetime-local fields
|
|
assignValue(inputElement, new Date(inputElement.value), json);
|
|
} else if (
|
|
inputElement.tagName.toLowerCase() === "input" &&
|
|
inputElement.type === "checkbox"
|
|
) {
|
|
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);
|
|
}
|
|
});
|
|
return json as unknown as T;
|
|
}
|
|
|
|
/**
|
|
* Form
|
|
*
|
|
* The base form element for interacting with user inputs.
|
|
*
|
|
* All forms either[1] inherit from this class and implement the `renderForm()` method to
|
|
* produce the actual form, or include the form in-line as a slotted element. Bizarrely, this form
|
|
* will not render at all if it's not actually in the viewport?[2]
|
|
*
|
|
* @element ak-form
|
|
*
|
|
* @slot - Where the form goes if `renderForm()` returns undefined.
|
|
* @fires eventname - description
|
|
*
|
|
* @csspart partname - description
|
|
*/
|
|
|
|
/* TODO:
|
|
*
|
|
* 1. Specialization: Separate this component into three different classes:
|
|
* - The base class
|
|
* - The "use `renderForm` class
|
|
* - The slotted class.
|
|
* 2. There is already specialization-by-type throughout all of our code.
|
|
* Consider refactoring serializeForm() so that the conversions are on
|
|
* the input types, rather than here. (i.e. "Polymorphism is better than
|
|
* switch.")
|
|
*
|
|
*
|
|
*/
|
|
|
|
@customElement("ak-form")
|
|
export abstract class Form<T> extends AKElement {
|
|
abstract send(data: T): Promise<unknown>;
|
|
|
|
viewportCheck = true;
|
|
|
|
@property()
|
|
successMessage = "";
|
|
|
|
@state()
|
|
nonFieldErrors?: string[];
|
|
|
|
static get styles(): CSSResult[] {
|
|
return [
|
|
PFBase,
|
|
PFCard,
|
|
PFButton,
|
|
PFForm,
|
|
PFAlert,
|
|
PFInputGroup,
|
|
PFFormControl,
|
|
PFSwitch,
|
|
css`
|
|
select[multiple] {
|
|
height: 15em;
|
|
}
|
|
`,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Called by the render function. Blocks rendering the form if the form is not within the
|
|
* viewport.
|
|
*/
|
|
get isInViewport(): boolean {
|
|
const rect = this.getBoundingClientRect();
|
|
return !(rect.x + rect.y + rect.width + rect.height === 0);
|
|
}
|
|
|
|
getSuccessMessage(): string {
|
|
return this.successMessage;
|
|
}
|
|
|
|
/**
|
|
* After rendering the form, if there is both a `name` and `slug` element within the form,
|
|
* events the `name` element so that the slug will always have a slugified version of the
|
|
* `name.`. This duplicates functionality within ak-form-element-horizontal.
|
|
*/
|
|
updated(): void {
|
|
this.shadowRoot
|
|
?.querySelectorAll("ak-form-element-horizontal[name=name]")
|
|
.forEach((nameInput) => {
|
|
const input = nameInput.firstElementChild as HTMLInputElement;
|
|
const form = nameInput.closest("form");
|
|
if (form === null) {
|
|
return;
|
|
}
|
|
const slugFieldWrapper = form.querySelector(
|
|
"ak-form-element-horizontal[name=slug]",
|
|
);
|
|
if (!slugFieldWrapper) {
|
|
return;
|
|
}
|
|
const slugField = slugFieldWrapper.firstElementChild as HTMLInputElement;
|
|
// Only attach handler if the slug is already equal to the name
|
|
// if not, they are probably completely different and shouldn't update
|
|
// each other
|
|
if (convertToSlug(input.value) !== slugField.value) {
|
|
return;
|
|
}
|
|
nameInput.addEventListener("input", () => {
|
|
slugField.value = convertToSlug(input.value);
|
|
});
|
|
});
|
|
}
|
|
|
|
resetForm(): void {
|
|
const form = this.shadowRoot?.querySelector<HTMLFormElement>("form");
|
|
form?.reset();
|
|
}
|
|
|
|
/**
|
|
* Return the form elements that may contain filenames. Not sure why this is quite so
|
|
* convoluted. There is exactly one case where this is used:
|
|
* `./flow/stages/prompt/PromptStage: 147: case PromptTypeEnum.File.`
|
|
* Consider moving this functionality to there.
|
|
*/
|
|
getFormFiles(): { [key: string]: File } {
|
|
const files: { [key: string]: File } = {};
|
|
const elements =
|
|
this.shadowRoot?.querySelectorAll<HorizontalFormElement>(
|
|
"ak-form-element-horizontal",
|
|
) || [];
|
|
for (let i = 0; i < elements.length; i++) {
|
|
const element = elements[i];
|
|
element.requestUpdate();
|
|
const inputElement = element.querySelector<HTMLInputElement>("[name]");
|
|
if (!inputElement) {
|
|
continue;
|
|
}
|
|
if (inputElement.tagName.toLowerCase() === "input" && inputElement.type === "file") {
|
|
if ((inputElement.files || []).length < 1) {
|
|
continue;
|
|
}
|
|
files[element.name] = (inputElement.files || [])[0];
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Convert the elements of the form to JSON.[4]
|
|
*
|
|
*/
|
|
serializeForm(): T | undefined {
|
|
const elements = this.shadowRoot?.querySelectorAll<HorizontalFormElement>(
|
|
"ak-form-element-horizontal",
|
|
);
|
|
if (!elements) {
|
|
return {} as T;
|
|
}
|
|
return serializeForm(elements) as T;
|
|
}
|
|
|
|
/**
|
|
* Serialize and send the form to the destination. The `send()` method must be overridden for
|
|
* this to work. If processing the data results in an error, we catch the error, distribute
|
|
* field-levels errors to the fields, and send the rest of them to the Notifications.
|
|
*
|
|
*/
|
|
async submit(ev: Event): Promise<unknown | undefined> {
|
|
ev.preventDefault();
|
|
try {
|
|
const data = this.serializeForm();
|
|
if (!data) {
|
|
return;
|
|
}
|
|
const response = await this.send(data);
|
|
showMessage({
|
|
level: MessageLevel.success,
|
|
message: this.getSuccessMessage(),
|
|
});
|
|
this.dispatchEvent(
|
|
new CustomEvent(EVENT_REFRESH, {
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
return response;
|
|
} catch (ex) {
|
|
if (ex instanceof ResponseError) {
|
|
let msg = ex.response.statusText;
|
|
if (ex.response.status > 399 && ex.response.status < 500) {
|
|
const errorMessage = ValidationErrorFromJSON(await ex.response.json());
|
|
if (!errorMessage) return errorMessage;
|
|
if (errorMessage instanceof Error) {
|
|
throw errorMessage;
|
|
}
|
|
// assign all input-related errors to their elements
|
|
const elements =
|
|
this.shadowRoot?.querySelectorAll<HorizontalFormElement>(
|
|
"ak-form-element-horizontal",
|
|
) || [];
|
|
elements.forEach((element) => {
|
|
element.requestUpdate();
|
|
const elementName = element.name;
|
|
if (!elementName) return;
|
|
if (camelToSnake(elementName) in errorMessage) {
|
|
element.errorMessages = errorMessage[camelToSnake(elementName)];
|
|
element.invalid = true;
|
|
} else {
|
|
element.errorMessages = [];
|
|
element.invalid = false;
|
|
}
|
|
});
|
|
if (errorMessage.nonFieldErrors) {
|
|
this.nonFieldErrors = errorMessage.nonFieldErrors;
|
|
}
|
|
// Only change the message when we have `detail`.
|
|
// Everything else is handled in the form.
|
|
if ("detail" in errorMessage) {
|
|
msg = errorMessage.detail;
|
|
}
|
|
}
|
|
// error is local or not from rest_framework
|
|
showMessage({
|
|
message: msg,
|
|
level: MessageLevel.error,
|
|
});
|
|
}
|
|
if (ex instanceof PreventFormSubmit && ex.element) {
|
|
ex.element.errorMessages = [ex.message];
|
|
ex.element.invalid = true;
|
|
}
|
|
// rethrow the error so the form doesn't close
|
|
throw ex;
|
|
}
|
|
}
|
|
|
|
renderFormWrapper(): TemplateResult {
|
|
const inline = this.renderForm();
|
|
if (inline) {
|
|
return html`<form
|
|
class="pf-c-form pf-m-horizontal"
|
|
@submit=${(ev: Event) => {
|
|
ev.preventDefault();
|
|
}}
|
|
>
|
|
${inline}
|
|
</form>`;
|
|
}
|
|
return html`<slot></slot>`;
|
|
}
|
|
|
|
renderForm(): TemplateResult | undefined {
|
|
return undefined;
|
|
}
|
|
|
|
renderNonFieldErrors(): TemplateResult {
|
|
if (!this.nonFieldErrors) {
|
|
return html``;
|
|
}
|
|
return html`<div class="pf-c-form__alert">
|
|
${this.nonFieldErrors.map((err) => {
|
|
return html`<div class="pf-c-alert pf-m-inline pf-m-danger">
|
|
<div class="pf-c-alert__icon">
|
|
<i class="fas fa-exclamation-circle"></i>
|
|
</div>
|
|
<h4 class="pf-c-alert__title">${err}</h4>
|
|
</div>`;
|
|
})}
|
|
</div>`;
|
|
}
|
|
|
|
renderVisible(): TemplateResult {
|
|
return html` ${this.renderNonFieldErrors()} ${this.renderFormWrapper()}`;
|
|
}
|
|
|
|
render(): TemplateResult {
|
|
if (this.viewportCheck && !this.isInViewport) {
|
|
return html``;
|
|
}
|
|
return this.renderVisible();
|
|
}
|
|
}
|