web/elements: only render form once instance is loaded (#5049)

* web/elements: only render form once instance is loaded

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* use radio for transport

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* only wait for instance to be loaded if set

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add hook to load additional data in form

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make send an abstract function instead of attribute

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* ensure form is updated after data is loaded

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remove until for select and multi-selects in forms

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* don't use until for file uploads

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remove last until from form

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remove deprecated import

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* prevent form double load, add error handling for PreventFormSubmit

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix double creation of inner element in proxy form

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make PreventFormSubmit work correctly

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L
2023-03-23 14:05:14 +01:00
committed by GitHub
parent 20522558fe
commit 14f0034a0a
29 changed files with 900 additions and 995 deletions

View File

@ -1,6 +1,6 @@
import { tenant } from "@goauthentik/common/api/config";
import { config, tenant } from "@goauthentik/common/api/config";
import { EVENT_LOCALE_CHANGE, EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
import { uiConfig } from "@goauthentik/common/ui/config";
import { UIConfig, uiConfig } from "@goauthentik/common/ui/config";
import { LitElement } from "lit";
import { state } from "lit/decorators.js";
@ -9,13 +9,13 @@ import AKGlobal from "@goauthentik/common/styles/authentik.css";
import ThemeDark from "@goauthentik/common/styles/theme-dark.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { CurrentTenant, UiThemeEnum } from "@goauthentik/api";
import { Config, CurrentTenant, UiThemeEnum } from "@goauthentik/api";
export function rootInterface(): Interface | undefined {
export function rootInterface<T extends Interface>(): T | undefined {
const el = Array.from(document.body.querySelectorAll("*")).filter(
(el) => el instanceof Interface,
);
return el[0] as Interface;
return el[0] as T;
}
let css: Promise<string[]> | undefined;
@ -171,10 +171,17 @@ export class Interface extends AKElement {
@state()
tenant?: CurrentTenant;
@state()
uiConfig?: UIConfig;
@state()
config?: Config;
constructor() {
super();
document.adoptedStyleSheets = [...document.adoptedStyleSheets, PFBase];
tenant().then((tenant) => (this.tenant = tenant));
config().then((config) => (this.config = config));
}
_activateTheme(root: AdoptedStyleSheetsElement, theme: UiThemeEnum): void {
@ -183,7 +190,9 @@ export class Interface extends AKElement {
}
async getTheme(): Promise<UiThemeEnum> {
const config = await uiConfig();
return config.theme?.base || UiThemeEnum.Automatic;
if (!this.uiConfig) {
this.uiConfig = await uiConfig();
}
return this.uiConfig.theme?.base || UiThemeEnum.Automatic;
}
}

View File

@ -7,7 +7,7 @@ import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
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";
@ -22,7 +22,7 @@ import { ResponseError, ValidationError } from "@goauthentik/api";
export class PreventFormSubmit {
// Stub class which can be returned by form elements to prevent the form from submitting
constructor(public message: string) {}
constructor(public message: string, public element?: HorizontalFormElement) {}
}
export class APIError extends Error {
@ -36,16 +36,15 @@ export interface KeyUnknown {
}
@customElement("ak-form")
export class Form<T> extends AKElement {
export abstract class Form<T> extends AKElement {
abstract send(data: T): Promise<unknown>;
viewportCheck = true;
@property()
successMessage = "";
@property()
send!: (data: T) => Promise<unknown>;
@property({ attribute: false })
@state()
nonFieldErrors?: string[];
static get styles(): CSSResult[] {
@ -177,17 +176,15 @@ export class Form<T> extends AKElement {
json[element.name] = inputElement.checked;
} else if (inputElement.tagName.toLowerCase() === "ak-search-select") {
const select = inputElement as unknown as SearchSelect<unknown>;
let value: unknown;
try {
value = select.toForm();
} catch {
console.debug("authentik/form: SearchSelect.value error");
return;
const value = select.toForm();
json[element.name] = value;
} catch (exc) {
if (exc instanceof PreventFormSubmit) {
throw new PreventFormSubmit(exc.message, element);
}
throw exc;
}
if (value instanceof PreventFormSubmit) {
throw new Error(value.message);
}
json[element.name] = value;
} else {
this.serializeFieldRecursive(inputElement, inputElement.value, json);
}
@ -215,30 +212,27 @@ export class Form<T> extends AKElement {
parent[nameElements[nameElements.length - 1]] = value;
}
submit(ev: Event): Promise<unknown> | undefined {
async submit(ev: Event): Promise<unknown | undefined> {
ev.preventDefault();
const data = this.serializeForm();
if (!data) {
return;
}
return this.send(data)
.then((r) => {
showMessage({
level: MessageLevel.success,
message: this.getSuccessMessage(),
});
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
return r;
})
.catch(async (ex: Error | ResponseError) => {
if (!(ex instanceof ResponseError)) {
throw ex;
}
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: ValidationError = await ex.response.json();
@ -277,9 +271,14 @@ export class Form<T> extends AKElement {
message: msg,
level: MessageLevel.error,
});
// rethrow the error so the form doesn't close
throw ex;
});
}
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;
}
}
renderForm(): TemplateResult {

View File

@ -1,27 +1,44 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import "@goauthentik/elements/EmptyState";
import { Form } from "@goauthentik/elements/forms/Form";
import { TemplateResult } from "lit";
import { TemplateResult, html } from "lit";
import { property } from "lit/decorators.js";
export abstract class ModelForm<T, PKT extends string | number> extends Form<T> {
abstract loadInstance(pk: PKT): Promise<T>;
async load(): Promise<void> {
return Promise.resolve();
}
@property({ attribute: false })
set instancePk(value: PKT) {
this._instancePk = value;
if (this.viewportCheck && !this.isInViewport) {
return;
}
this.loadInstance(value).then((instance) => {
this.instance = instance;
this.requestUpdate();
if (this._isLoading) {
return;
}
this._isLoading = true;
this.load().then(() => {
this.loadInstance(value).then((instance) => {
this.instance = instance;
this._isLoading = false;
this.requestUpdate();
});
});
}
private _instancePk?: PKT;
// Keep track if we've loaded the model instance
private _initialLoad = false;
// Keep track if we've done the general data loading of load()
private _initialDataLoad = false;
private _isLoading = false;
@property({ attribute: false })
instance?: T = this.defaultInstance;
@ -45,17 +62,29 @@ export abstract class ModelForm<T, PKT extends string | number> extends Form<T>
this._initialLoad = false;
}
renderVisible(): TemplateResult {
if ((this._instancePk && !this.instance) || !this._initialDataLoad) {
return html`<ak-empty-state ?loading=${true}></ak-empty-state>`;
}
return super.renderVisible();
}
render(): TemplateResult {
if (this._instancePk && !this._initialLoad) {
if (
// if we're in viewport now and haven't loaded AND have a PK set, load now
this.isInViewport ||
// Or if we don't check for viewport in some cases
!this.viewportCheck
) {
this.instancePk = this._instancePk;
this._initialLoad = true;
}
// if we're in viewport now and haven't loaded AND have a PK set, load now
// Or if we don't check for viewport in some cases
const viewportVisible = this.isInViewport || !this.viewportCheck;
if (this._instancePk && !this._initialLoad && viewportVisible) {
this.instancePk = this._instancePk;
this._initialLoad = true;
} else if (!this._initialDataLoad && viewportVisible) {
// else if since if the above case triggered that will also call this.load(), so
// ensure we don't load again
this.load().then(() => {
this._initialDataLoad = true;
// Class attributes changed in this.load() might not be @property()
// or @state() so let's trigger a re-render to be sure we get updated
this.requestUpdate();
});
}
return super.render();
}

View File

@ -4,7 +4,7 @@ import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-proxy-form")
export class ProxyForm extends Form<unknown> {
export abstract class ProxyForm extends Form<unknown> {
@property()
type!: string;
@ -16,7 +16,7 @@ export class ProxyForm extends Form<unknown> {
innerElement?: Form<unknown>;
submit(ev: Event): Promise<unknown> | undefined {
async submit(ev: Event): Promise<unknown | undefined> {
return this.innerElement?.submit(ev);
}
@ -39,7 +39,9 @@ export class ProxyForm extends Form<unknown> {
if (this.type in this.typeMap) {
elementName = this.typeMap[this.type];
}
this.innerElement = document.createElement(elementName) as Form<unknown>;
if (!this.innerElement) {
this.innerElement = document.createElement(elementName) as Form<unknown>;
}
this.innerElement.viewportCheck = this.viewportCheck;
for (const k in this.args) {
this.innerElement.setAttribute(k, this.args[k] as string);

View File

@ -25,11 +25,6 @@ export class Radio<T> extends AKElement {
@property()
value?: T;
@property({ attribute: false })
onChange: (value: T) => void = () => {
return;
};
static get styles(): CSSResult[] {
return [
PFBase,
@ -63,7 +58,13 @@ export class Radio<T> extends AKElement {
id=${elId}
@change=${() => {
this.value = opt.value;
this.onChange(opt.value);
this.dispatchEvent(
new CustomEvent("change", {
bubbles: true,
composed: true,
detail: opt.value,
}),
);
}}
.checked=${opt.value === this.value}
/>

View File

@ -94,7 +94,7 @@ export class SearchSelect<T> extends AKElement {
toForm(): unknown {
if (!this.objects) {
return new PreventFormSubmit(t`Loading options...`);
throw new PreventFormSubmit(t`Loading options...`);
}
return this.value(this.selectedObject) || "";
}

View File

@ -13,13 +13,13 @@ import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-gro
import PFBase from "@patternfly/patternfly/patternfly-base.css";
@customElement("ak-wizard-form")
export class WizardForm extends Form<KeyUnknown> {
export abstract class WizardForm extends Form<KeyUnknown> {
viewportCheck = false;
@property({ attribute: false })
nextDataCallback!: (data: KeyUnknown) => Promise<boolean>;
submit(): Promise<boolean> | undefined {
async submit(): Promise<boolean | undefined> {
const data = this.serializeForm();
if (!data) {
return;