web: Fix issues surrounding wizard step behavior. (#12779)

This resolves a few stateful situations which may arise when opening and
closing wizard pages.
This commit is contained in:
Teffen Ellis
2025-02-14 02:12:46 +01:00
committed by GitHub
parent 46a968d1dd
commit 3d2bd4d8dd
6 changed files with 302 additions and 153 deletions

View File

@ -52,6 +52,21 @@ export class PolicyWizard extends AKElement {
}); });
} }
selectListener = ({ detail }: CustomEvent<TypeCreate>) => {
if (!this.wizard) return;
const { component, modelName } = detail;
const idx = this.wizard.steps.indexOf("initial") + 1;
// Exclude all current steps starting with type-,
// this happens when the user selects a type and then goes back
this.wizard.steps = this.wizard.steps.filter((step) => !step.startsWith("type-"));
this.wizard.steps.splice(idx, 0, `type-${component}-${modelName}`);
this.wizard.isValid = true;
};
render(): TemplateResult { render(): TemplateResult {
return html` return html`
<ak-wizard <ak-wizard
@ -62,23 +77,10 @@ export class PolicyWizard extends AKElement {
<ak-wizard-page-type-create <ak-wizard-page-type-create
slot="initial" slot="initial"
.types=${this.policyTypes} .types=${this.policyTypes}
@select=${(ev: CustomEvent<TypeCreate>) => { @select=${this.selectListener}
if (!this.wizard) return;
const idx = this.wizard.steps.indexOf("initial") + 1;
// Exclude all current steps starting with type-,
// this happens when the user selects a type and then goes back
this.wizard.steps = this.wizard.steps.filter(
(step) => !step.startsWith("type-"),
);
this.wizard.steps.splice(
idx,
0,
`type-${ev.detail.component}-${ev.detail.modelName}`,
);
this.wizard.isValid = true;
}}
> >
</ak-wizard-page-type-create> </ak-wizard-page-type-create>
${this.policyTypes.map((type) => { ${this.policyTypes.map((type) => {
return html` return html`
<ak-wizard-page-form <ak-wizard-page-form

View File

@ -41,6 +41,7 @@ export class ActionWizardPage extends WizardPage {
activeCallback = async (): Promise<void> => { activeCallback = async (): Promise<void> => {
this.states = []; this.states = [];
this.host.actions.map((act, idx) => { this.host.actions.map((act, idx) => {
this.states.push({ this.states.push({
action: act, action: act,
@ -48,9 +49,12 @@ export class ActionWizardPage extends WizardPage {
idx: idx, idx: idx,
}); });
}); });
this.host.canBack = false; this.host.canBack = false;
this.host.canCancel = false; this.host.canCancel = false;
await this.run(); await this.run();
// Ensure wizard is closable, even when run() failed // Ensure wizard is closable, even when run() failed
this.host.isValid = true; this.host.isValid = true;
}; };
@ -59,15 +63,20 @@ export class ActionWizardPage extends WizardPage {
async run(): Promise<void> { async run(): Promise<void> {
this.currentStep = this.states[0]; this.currentStep = this.states[0];
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
for await (const bundle of this.states) { for await (const bundle of this.states) {
this.currentStep = bundle; this.currentStep = bundle;
this.currentStep.state = ActionState.running; this.currentStep.state = ActionState.running;
this.requestUpdate(); this.requestUpdate();
try { try {
await bundle.action.run(); await bundle.action.run();
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
this.currentStep.state = ActionState.done; this.currentStep.state = ActionState.done;
this.requestUpdate(); this.requestUpdate();
} catch (exc) { } catch (exc) {
if (exc instanceof ResponseError) { if (exc instanceof ResponseError) {
@ -75,12 +84,16 @@ export class ActionWizardPage extends WizardPage {
} else { } else {
this.currentStep.action.subText = (exc as Error).toString(); this.currentStep.action.subText = (exc as Error).toString();
} }
this.currentStep.state = ActionState.failed; this.currentStep.state = ActionState.failed;
this.requestUpdate(); this.requestUpdate();
return; return;
} }
} }
this.host.isValid = true; this.host.isValid = true;
this.dispatchEvent( this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, { new CustomEvent(EVENT_REFRESH, {
bubbles: true, bubbles: true,

View File

@ -5,6 +5,7 @@ import { WizardPage } from "@goauthentik/elements/wizard/WizardPage";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit"; import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from "lit/directives/ref.js";
import PFCard from "@patternfly/patternfly/components/Card/card.css"; import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFForm from "@patternfly/patternfly/components/Form/form.css";
@ -21,6 +22,8 @@ export enum TypeCreateWizardPageLayouts {
@customElement("ak-wizard-page-type-create") @customElement("ak-wizard-page-type-create")
export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) { export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
//#region Properties
@property({ attribute: false }) @property({ attribute: false })
types: TypeCreate[] = []; types: TypeCreate[] = [];
@ -30,6 +33,8 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
@property({ type: String }) @property({ type: String })
layout: TypeCreateWizardPageLayouts = TypeCreateWizardPageLayouts.list; layout: TypeCreateWizardPageLayouts = TypeCreateWizardPageLayouts.list;
//#endregion
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [ return [
PFBase, PFBase,
@ -49,10 +54,25 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
]; ];
} }
sidebarLabel = () => msg("Select type"); //#region Refs
formRef: Ref<HTMLFormElement> = createRef();
//#endregion
public sidebarLabel = () => msg("Select type");
public reset = () => {
super.reset();
this.selectedType = undefined;
this.formRef.value?.reset();
};
activeCallback = (): void => {
const form = this.formRef.value;
this.host.isValid = form?.checkValidity() ?? false;
activeCallback: () => Promise<void> = async () => {
this.host.isValid = false;
if (this.selectedType) { if (this.selectedType) {
this.selectDispatch(this.selectedType); this.selectDispatch(this.selectedType);
} }
@ -92,9 +112,8 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
data-ouid-component-type="ak-type-create-grid-card" data-ouid-component-type="ak-type-create-grid-card"
data-ouid-component-name=${componentName} data-ouid-component-name=${componentName}
@click=${() => { @click=${() => {
if (requiresEnterprise) { if (requiresEnterprise) return;
return;
}
this.selectDispatch(type); this.selectDispatch(type);
this.selectedType = type; this.selectedType = type;
}} }}
@ -120,11 +139,13 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
renderList(): TemplateResult { renderList(): TemplateResult {
return html`<form return html`<form
${ref(this.formRef)}
class="pf-c-form pf-m-horizontal" class="pf-c-form pf-m-horizontal"
data-ouid-component-type="ak-type-create-list" data-ouid-component-type="ak-type-create-list"
> >
${this.types.map((type) => { ${this.types.map((type) => {
const requiresEnterprise = type.requiresEnterprise && !this.hasEnterpriseLicense; const requiresEnterprise = type.requiresEnterprise && !this.hasEnterpriseLicense;
return html`<div return html`<div
class="pf-c-radio" class="pf-c-radio"
data-ouid-component-type="ak-type-create-list-card" data-ouid-component-type="ak-type-create-list-card"
@ -160,6 +181,8 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
return this.renderGrid(); return this.renderGrid();
case TypeCreateWizardPageLayouts.list: case TypeCreateWizardPageLayouts.list:
return this.renderList(); return this.renderList();
default:
throw new Error(`Unknown layout: ${this.layout}`) as never;
} }
} }
} }

View File

@ -5,8 +5,9 @@ import { WizardPage } from "@goauthentik/elements/wizard/WizardPage";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { property } from "@lit/reactive-element/decorators/property.js"; import { property } from "@lit/reactive-element/decorators/property.js";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { state } from "lit/decorators.js"; import { state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css"; import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css";
@ -20,21 +21,6 @@ export const ApplyActionsSlot = "apply-actions";
@customElement("ak-wizard") @customElement("ak-wizard")
export class Wizard extends ModalButton { export class Wizard extends ModalButton {
@property({ type: Boolean })
canCancel = true;
@property({ type: Boolean })
canBack = true;
@property()
header?: string;
@property()
description?: string;
@property({ type: Boolean })
isValid = false;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return super.styles.concat( return super.styles.concat(
PFWizard, PFWizard,
@ -46,65 +32,138 @@ export class Wizard extends ModalButton {
); );
} }
@state() //#region Properties
_steps: string[] = [];
get steps(): string[] { /**
return this._steps; * Whether the wizard can be cancelled.
} */
@property({ type: Boolean })
canCancel = true;
set steps(steps: string[]) { /**
const addApplyActionsSlot = this.steps.includes(ApplyActionsSlot); * Whether the wizard can go back to the previous step.
this._steps = steps; */
if (addApplyActionsSlot) { @property({ type: Boolean })
this.steps.push(ApplyActionsSlot); canBack = true;
}
this.steps.forEach((step) => {
const exists = this.querySelector(`[slot=${step}]`) !== null;
if (!exists) {
const el = document.createElement(step);
el.slot = step;
el.dataset["wizardmanaged"] = "true";
this.appendChild(el);
}
});
this.requestUpdate();
}
_initialSteps: string[] = []; /**
* Header title of the wizard.
*/
@property()
header?: string;
/**
* Description of the wizard.
*/
@property()
description?: string;
/**
* Whether the wizard is valid and can proceed to the next step.
*/
@property({ type: Boolean })
isValid = false;
/**
* Actions to display at the end of the wizard.
*/
@property({ attribute: false }) @property({ attribute: false })
actions: WizardAction[] = []; actions: WizardAction[] = [];
@state()
_currentStep?: WizardPage;
set currentStep(value: WizardPage | undefined) {
this._currentStep = value;
if (this._currentStep) {
this._currentStep.activeCallback();
this._currentStep.requestUpdate();
}
}
get currentStep(): WizardPage | undefined {
return this._currentStep;
}
@property({ attribute: false }) @property({ attribute: false })
finalHandler: () => Promise<void> = () => { finalHandler = () => {
return Promise.resolve(); return Promise.resolve();
}; };
@property({ attribute: false }) @property({ attribute: false })
state: { [key: string]: unknown } = {}; state: { [key: string]: unknown } = {};
//#endregion
//#region State
/**
* Memoized step tag names.
*/
@state()
_steps: string[] = [];
/**
* Step tag names present in the wizard.
*/
get steps(): string[] {
return this._steps;
}
set steps(nextSteps: string[]) {
const addApplyActionsSlot = this._steps.includes(ApplyActionsSlot);
this._steps = nextSteps;
if (addApplyActionsSlot) {
this.steps.push(ApplyActionsSlot);
}
for (const step of this._steps) {
const existingStepElement = this.getStepElementByName(step);
if (existingStepElement) continue;
const stepElement = document.createElement(step);
stepElement.slot = step;
stepElement.dataset["wizardmanaged"] = "true";
this.appendChild(stepElement);
}
this.requestUpdate();
}
/**
* Initial steps to reset to.
*/
_initialSteps: string[] = [];
@state()
_activeStep?: WizardPage;
set activeStepElement(nextActiveStepElement: WizardPage | undefined) {
this._activeStep = nextActiveStepElement;
if (!this._activeStep) return;
this._activeStep.activeCallback();
this._activeStep.requestUpdate();
}
/**
* The active step element being displayed.
*/
get activeStepElement(): WizardPage | undefined {
return this._activeStep;
}
getStepElementByIndex(stepIndex: number): WizardPage | null {
const stepName = this._steps[stepIndex];
return this.querySelector<WizardPage>(`[slot=${stepName}]`);
}
getStepElementByName(stepName: string): WizardPage | null {
return this.querySelector<WizardPage>(`[slot=${stepName}]`);
}
//#endregion
//#region Lifecycle
firstUpdated(): void { firstUpdated(): void {
this._initialSteps = this._steps; this._initialSteps = this._steps;
} }
/** /**
* Add action to the beginning of the list * Add action to the beginning of the list.
*/ */
addActionBefore(displayName: string, run: () => Promise<boolean>): void { addActionBefore(displayName: string, run: () => Promise<boolean>): void {
this.actions.unshift({ this.actions.unshift({
@ -114,7 +173,9 @@ export class Wizard extends ModalButton {
} }
/** /**
* Add action at the end of the list * Add action at the end of the list.
*
* @todo: Is this used?
*/ */
addActionAfter(displayName: string, run: () => Promise<boolean>): void { addActionAfter(displayName: string, run: () => Promise<boolean>): void {
this.actions.push({ this.actions.push({
@ -123,17 +184,60 @@ export class Wizard extends ModalButton {
}); });
} }
renderModalInner(): TemplateResult { /**
const firstPage = this.querySelector<WizardPage>(`[slot=${this.steps[0]}]`); * Reset the wizard to it's initial state.
if (!this.currentStep && firstPage) { */
this.currentStep = firstPage; reset = () => {
this.open = false;
this.querySelectorAll("[data-wizardmanaged=true]").forEach((el) => {
el.remove();
});
for (const step of this.steps) {
const stepElement = this.getStepElementByName(step);
stepElement?.reset?.();
} }
const currentIndex = this.currentStep ? this.steps.indexOf(this.currentStep.slot) : 0;
let lastPage = currentIndex === this.steps.length - 1; this.steps = this._initialSteps;
this.actions = [];
this.state = {};
this.activeStepElement = undefined;
this.canBack = true;
this.canCancel = true;
};
//#endregion
//#region Rendering
renderModalInner(): TemplateResult {
const firstPage = this.getStepElementByIndex(0);
if (!this.activeStepElement && firstPage) {
this.activeStepElement = firstPage;
}
const activeStepIndex = this.activeStepElement
? this.steps.indexOf(this.activeStepElement.slot)
: 0;
let lastPage = activeStepIndex === this.steps.length - 1;
if (lastPage && !this.steps.includes("ak-wizard-page-action") && this.actions.length > 0) { if (lastPage && !this.steps.includes("ak-wizard-page-action") && this.actions.length > 0) {
this.steps = this.steps.concat("ak-wizard-page-action"); this.steps = this.steps.concat("ak-wizard-page-action");
lastPage = currentIndex === this.steps.length - 1; lastPage = activeStepIndex === this.steps.length - 1;
} }
const navigateToPreviousStep = () => {
const prevPage = this.getStepElementByIndex(activeStepIndex - 1);
if (prevPage) {
this.activeStepElement = prevPage;
}
};
return html`<div class="pf-c-wizard"> return html`<div class="pf-c-wizard">
<div class="pf-c-wizard__header"> <div class="pf-c-wizard__header">
${this.canCancel ${this.canCancel
@ -141,13 +245,11 @@ export class Wizard extends ModalButton {
class="pf-c-button pf-m-plain pf-c-wizard__close" class="pf-c-button pf-m-plain pf-c-wizard__close"
type="button" type="button"
aria-label="${msg("Close")}" aria-label="${msg("Close")}"
@click=${() => { @click=${this.reset}
this.reset();
}}
> >
<i class="fas fa-times" aria-hidden="true"></i> <i class="fas fa-times" aria-hidden="true"></i>
</button>` </button>`
: html``} : nothing}
<h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">${this.header}</h1> <h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">${this.header}</h1>
<p class="pf-c-wizard__description">${this.description}</p> <p class="pf-c-wizard__description">${this.description}</p>
</div> </div>
@ -156,28 +258,25 @@ export class Wizard extends ModalButton {
<nav class="pf-c-wizard__nav"> <nav class="pf-c-wizard__nav">
<ol class="pf-c-wizard__nav-list"> <ol class="pf-c-wizard__nav-list">
${this.steps.map((step, idx) => { ${this.steps.map((step, idx) => {
const currentIdx = this.currentStep const stepEl = this.getStepElementByName(step);
? this.steps.indexOf(this.currentStep.slot)
: 0; if (!stepEl) return html`<p>Unexpected missing step: ${step}</p>`;
const sidebarLabel = stepEl.sidebarLabel();
return html` return html`
<li class="pf-c-wizard__nav-item"> <li class="pf-c-wizard__nav-item">
<button <button
class="pf-c-wizard__nav-link ${idx === currentIdx class=${classMap({
? "pf-m-current" "pf-c-wizard__nav-link": true,
: ""}" "pf-m-current": idx === activeStepIndex,
?disabled=${currentIdx < idx} })}
?disabled=${activeStepIndex < idx}
@click=${() => { @click=${() => {
const stepEl = this.querySelector<WizardPage>( this.activeStepElement = stepEl;
`[slot=${step}]`,
);
if (stepEl) {
this.currentStep = stepEl;
}
}} }}
> >
${this.querySelector<WizardPage>( ${sidebarLabel}
`[slot=${step}]`,
)?.sidebarLabel()}
</button> </button>
</li> </li>
`; `;
@ -186,7 +285,7 @@ export class Wizard extends ModalButton {
</nav> </nav>
<main class="pf-c-wizard__main"> <main class="pf-c-wizard__main">
<div class="pf-c-wizard__main-body"> <div class="pf-c-wizard__main-body">
<slot name=${this.currentStep?.slot || this.steps[0]}></slot> <slot name=${this.activeStepElement?.slot || this.steps[0]}></slot>
</div> </div>
</main> </main>
</div> </div>
@ -196,44 +295,38 @@ export class Wizard extends ModalButton {
type="submit" type="submit"
?disabled=${!this.isValid} ?disabled=${!this.isValid}
@click=${async () => { @click=${async () => {
const cb = await this.currentStep?.nextCallback(); const completedStep = await this.activeStepElement?.nextCallback();
if (!cb) { if (!completedStep) return;
return;
}
if (lastPage) { if (lastPage) {
await this.finalHandler(); await this.finalHandler();
this.reset(); this.reset();
} else {
const nextPage = this.querySelector<WizardPage>( return;
`[slot=${this.steps[currentIndex + 1]}]`,
);
if (nextPage) {
this.currentStep = nextPage;
} }
const nextPage = this.getStepElementByIndex(activeStepIndex + 1);
if (nextPage) {
this.activeStepElement = nextPage;
} }
}} }}
> >
${lastPage ? msg("Finish") : msg("Next")} ${lastPage ? msg("Finish") : msg("Next")}
</button> </button>
${(this.currentStep ? this.steps.indexOf(this.currentStep.slot) : 0) > 0 && ${(this.activeStepElement
this.canBack ? this.steps.indexOf(this.activeStepElement.slot)
: 0) > 0 && this.canBack
? html` ? html`
<button <button
class="pf-c-button pf-m-secondary" class="pf-c-button pf-m-secondary"
type="button" type="button"
@click=${() => { @click=${navigateToPreviousStep}
const prevPage = this.querySelector<WizardPage>(
`[slot=${this.steps[currentIndex - 1]}]`,
);
if (prevPage) {
this.currentStep = prevPage;
}
}}
> >
${msg("Back")} ${msg("Back")}
</button> </button>
` `
: html``} : nothing}
${this.canCancel ${this.canCancel
? html`<div class="pf-c-wizard__footer-cancel"> ? html`<div class="pf-c-wizard__footer-cancel">
<button <button
@ -246,24 +339,13 @@ export class Wizard extends ModalButton {
${msg("Cancel")} ${msg("Cancel")}
</button> </button>
</div>` </div>`
: html``} : nothing}
</footer> </footer>
</div> </div>
</div>`; </div>`;
} }
reset(): void { //#endregion
this.open = false;
this.querySelectorAll("[data-wizardmanaged=true]").forEach((el) => {
el.remove();
});
this.steps = this._initialSteps;
this.actions = [];
this.state = {};
this.currentStep = undefined;
this.canBack = true;
this.canCancel = true;
}
} }
declare global { declare global {

View File

@ -39,24 +39,24 @@ export class WizardFormPage extends WizardPage {
inputCallback(): void { inputCallback(): void {
const form = this.shadowRoot?.querySelector<HTMLFormElement>("form"); const form = this.shadowRoot?.querySelector<HTMLFormElement>("form");
if (!form) {
return; if (!form) return;
}
const state = form.checkValidity(); const state = form.checkValidity();
this.host.isValid = state; this.host.isValid = state;
} }
nextCallback = async (): Promise<boolean> => { nextCallback = async (): Promise<boolean> => {
const form = this.shadowRoot?.querySelector<WizardForm>("ak-wizard-form"); const form = this.shadowRoot?.querySelector<WizardForm>("ak-wizard-form");
if (!form) { if (!form) {
console.warn("authentik/wizard: could not find form element"); console.warn("authentik/wizard: could not find form element");
return false; return false;
} }
const response = await form.submit(); const response = await form.submit();
if (response === undefined) {
return false; return Boolean(response);
}
return response;
}; };
nextDataCallback: (data: KeyUnknown) => Promise<boolean> = async (): Promise<boolean> => { nextDataCallback: (data: KeyUnknown) => Promise<boolean> = async (): Promise<boolean> => {

View File

@ -6,14 +6,32 @@ import { customElement, property } from "lit/decorators.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
/**
* Callback for when the page is brought into view.
*/
export type WizardPageActiveCallback = () => void | Promise<void>;
/**
* Callback for when the next button is pressed.
*
* @returns `true` if the wizard can proceed to the next page, `false` otherwise.
*/
export type WizardPageNextCallback = () => boolean | Promise<boolean>;
@customElement("ak-wizard-page") @customElement("ak-wizard-page")
export class WizardPage extends AKElement { export class WizardPage extends AKElement {
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [PFBase]; return [PFBase];
} }
/**
* The label to display in the sidebar for this page.
*
* Override this to provide a custom label.
* @todo: Should this be a getter or static property?
*/
@property() @property()
sidebarLabel: () => string = () => { sidebarLabel = (): string => {
return "UNNAMED"; return "UNNAMED";
}; };
@ -22,9 +40,18 @@ export class WizardPage extends AKElement {
} }
/** /**
* Called when this is the page brought into view * Reset the page to its initial state.
*
* @abstract
*/ */
activeCallback: () => Promise<void> = async () => { public reset(): void | Promise<void> {
console.debug(`authentik/wizard ${this.localName}: reset)`);
}
/**
* Called when this is the page brought into view.
*/
activeCallback: WizardPageActiveCallback = () => {
this.host.isValid = false; this.host.isValid = false;
}; };
@ -32,9 +59,11 @@ export class WizardPage extends AKElement {
* Called when the `next` button on the wizard is pressed. For forms, results in the submission * Called when the `next` button on the wizard is pressed. For forms, results in the submission
* of the current form to the back-end before being allowed to proceed to the next page. This is * of the current form to the back-end before being allowed to proceed to the next page. This is
* sub-optimal if we want to collect multiple bits of data before finishing the whole course. * sub-optimal if we want to collect multiple bits of data before finishing the whole course.
*
* @returns `true` if the wizard can proceed to the next page, `false` otherwise.
*/ */
nextCallback: () => Promise<boolean> = async () => { nextCallback: WizardPageNextCallback = () => {
return true; return Promise.resolve(true);
}; };
requestUpdate( requestUpdate(