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:
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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) || "";
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user