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 {
return html`
<ak-wizard
@ -62,23 +77,10 @@ export class PolicyWizard extends AKElement {
<ak-wizard-page-type-create
slot="initial"
.types=${this.policyTypes}
@select=${(ev: CustomEvent<TypeCreate>) => {
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;
}}
@select=${this.selectListener}
>
</ak-wizard-page-type-create>
${this.policyTypes.map((type) => {
return html`
<ak-wizard-page-form

View File

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

View File

@ -5,6 +5,7 @@ import { WizardPage } from "@goauthentik/elements/wizard/WizardPage";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
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 PFForm from "@patternfly/patternfly/components/Form/form.css";
@ -21,6 +22,8 @@ export enum TypeCreateWizardPageLayouts {
@customElement("ak-wizard-page-type-create")
export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
//#region Properties
@property({ attribute: false })
types: TypeCreate[] = [];
@ -30,6 +33,8 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
@property({ type: String })
layout: TypeCreateWizardPageLayouts = TypeCreateWizardPageLayouts.list;
//#endregion
static get styles(): CSSResult[] {
return [
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) {
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-name=${componentName}
@click=${() => {
if (requiresEnterprise) {
return;
}
if (requiresEnterprise) return;
this.selectDispatch(type);
this.selectedType = type;
}}
@ -120,11 +139,13 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
renderList(): TemplateResult {
return html`<form
${ref(this.formRef)}
class="pf-c-form pf-m-horizontal"
data-ouid-component-type="ak-type-create-list"
>
${this.types.map((type) => {
const requiresEnterprise = type.requiresEnterprise && !this.hasEnterpriseLicense;
return html`<div
class="pf-c-radio"
data-ouid-component-type="ak-type-create-list-card"
@ -160,6 +181,8 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
return this.renderGrid();
case TypeCreateWizardPageLayouts.list:
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 { customElement } from "@lit/reactive-element/decorators/custom-element.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 { classMap } from "lit/directives/class-map.js";
import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css";
@ -20,21 +21,6 @@ export const ApplyActionsSlot = "apply-actions";
@customElement("ak-wizard")
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[] {
return super.styles.concat(
PFWizard,
@ -46,65 +32,138 @@ export class Wizard extends ModalButton {
);
}
@state()
_steps: string[] = [];
//#region Properties
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);
this._steps = steps;
if (addApplyActionsSlot) {
this.steps.push(ApplyActionsSlot);
}
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();
}
/**
* Whether the wizard can go back to the previous step.
*/
@property({ type: Boolean })
canBack = true;
_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 })
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 })
finalHandler: () => Promise<void> = () => {
finalHandler = () => {
return Promise.resolve();
};
@property({ attribute: false })
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 {
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 {
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 {
this.actions.push({
@ -123,17 +184,60 @@ export class Wizard extends ModalButton {
});
}
renderModalInner(): TemplateResult {
const firstPage = this.querySelector<WizardPage>(`[slot=${this.steps[0]}]`);
if (!this.currentStep && firstPage) {
this.currentStep = firstPage;
/**
* Reset the wizard to it's initial state.
*/
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) {
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">
<div class="pf-c-wizard__header">
${this.canCancel
@ -141,13 +245,11 @@ export class Wizard extends ModalButton {
class="pf-c-button pf-m-plain pf-c-wizard__close"
type="button"
aria-label="${msg("Close")}"
@click=${() => {
this.reset();
}}
@click=${this.reset}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>`
: html``}
: nothing}
<h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">${this.header}</h1>
<p class="pf-c-wizard__description">${this.description}</p>
</div>
@ -156,28 +258,25 @@ export class Wizard extends ModalButton {
<nav class="pf-c-wizard__nav">
<ol class="pf-c-wizard__nav-list">
${this.steps.map((step, idx) => {
const currentIdx = this.currentStep
? this.steps.indexOf(this.currentStep.slot)
: 0;
const stepEl = this.getStepElementByName(step);
if (!stepEl) return html`<p>Unexpected missing step: ${step}</p>`;
const sidebarLabel = stepEl.sidebarLabel();
return html`
<li class="pf-c-wizard__nav-item">
<button
class="pf-c-wizard__nav-link ${idx === currentIdx
? "pf-m-current"
: ""}"
?disabled=${currentIdx < idx}
class=${classMap({
"pf-c-wizard__nav-link": true,
"pf-m-current": idx === activeStepIndex,
})}
?disabled=${activeStepIndex < idx}
@click=${() => {
const stepEl = this.querySelector<WizardPage>(
`[slot=${step}]`,
);
if (stepEl) {
this.currentStep = stepEl;
}
this.activeStepElement = stepEl;
}}
>
${this.querySelector<WizardPage>(
`[slot=${step}]`,
)?.sidebarLabel()}
${sidebarLabel}
</button>
</li>
`;
@ -186,7 +285,7 @@ export class Wizard extends ModalButton {
</nav>
<main class="pf-c-wizard__main">
<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>
</main>
</div>
@ -196,44 +295,38 @@ export class Wizard extends ModalButton {
type="submit"
?disabled=${!this.isValid}
@click=${async () => {
const cb = await this.currentStep?.nextCallback();
if (!cb) {
return;
}
const completedStep = await this.activeStepElement?.nextCallback();
if (!completedStep) return;
if (lastPage) {
await this.finalHandler();
this.reset();
} else {
const nextPage = this.querySelector<WizardPage>(
`[slot=${this.steps[currentIndex + 1]}]`,
);
if (nextPage) {
this.currentStep = nextPage;
}
return;
}
const nextPage = this.getStepElementByIndex(activeStepIndex + 1);
if (nextPage) {
this.activeStepElement = nextPage;
}
}}
>
${lastPage ? msg("Finish") : msg("Next")}
</button>
${(this.currentStep ? this.steps.indexOf(this.currentStep.slot) : 0) > 0 &&
this.canBack
${(this.activeStepElement
? this.steps.indexOf(this.activeStepElement.slot)
: 0) > 0 && this.canBack
? html`
<button
class="pf-c-button pf-m-secondary"
type="button"
@click=${() => {
const prevPage = this.querySelector<WizardPage>(
`[slot=${this.steps[currentIndex - 1]}]`,
);
if (prevPage) {
this.currentStep = prevPage;
}
}}
@click=${navigateToPreviousStep}
>
${msg("Back")}
</button>
`
: html``}
: nothing}
${this.canCancel
? html`<div class="pf-c-wizard__footer-cancel">
<button
@ -246,24 +339,13 @@ export class Wizard extends ModalButton {
${msg("Cancel")}
</button>
</div>`
: html``}
: nothing}
</footer>
</div>
</div>`;
}
reset(): void {
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;
}
//#endregion
}
declare global {

View File

@ -39,24 +39,24 @@ export class WizardFormPage extends WizardPage {
inputCallback(): void {
const form = this.shadowRoot?.querySelector<HTMLFormElement>("form");
if (!form) {
return;
}
if (!form) return;
const state = form.checkValidity();
this.host.isValid = state;
}
nextCallback = async (): Promise<boolean> => {
const form = this.shadowRoot?.querySelector<WizardForm>("ak-wizard-form");
if (!form) {
console.warn("authentik/wizard: could not find form element");
return false;
}
const response = await form.submit();
if (response === undefined) {
return false;
}
return response;
return Boolean(response);
};
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";
/**
* 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")
export class WizardPage extends AKElement {
static get styles(): CSSResult[] {
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()
sidebarLabel: () => string = () => {
sidebarLabel = (): string => {
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;
};
@ -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
* 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.
*
* @returns `true` if the wizard can proceed to the next page, `false` otherwise.
*/
nextCallback: () => Promise<boolean> = async () => {
return true;
nextCallback: WizardPageNextCallback = () => {
return Promise.resolve(true);
};
requestUpdate(