web: Simplify, simplify, simplify
Sort-of. This commit changes the way the "wizard step coordinator" layer works, giving the wizard writer much more power over button bar. It still assumes there are only three actions the wizard frame wants to commit: next, back, and close. This empowers the steps themselves to re-arrange their buttons and describe the rules through which transitions occur.
This commit is contained in:
parent
3b4530fb7f
commit
217505d0c9
|
@ -8,7 +8,7 @@ import { query } from "@lit/reactive-element/decorators.js";
|
|||
import { styles as AwadStyles } from "./BasePanel.css";
|
||||
|
||||
import { applicationWizardContext } from "./ak-application-wizard-context-name";
|
||||
import type { WizardState } from "./types";
|
||||
import type { WizardState, WizardStateUpdate } from "./types";
|
||||
|
||||
export class ApplicationWizardPageBase
|
||||
extends CustomEmitterElement(AKElement)
|
||||
|
@ -27,8 +27,8 @@ export class ApplicationWizardPageBase
|
|||
public wizard!: WizardState;
|
||||
|
||||
// This used to be more complex; now it just establishes the event name.
|
||||
dispatchWizardUpdate(update: Partial<WizardState>) {
|
||||
this.dispatchCustomEvent("ak-application-wizard-update", { update });
|
||||
dispatchWizardUpdate(update: WizardStateUpdate) {
|
||||
this.dispatchCustomEvent("ak-application-wizard-update", update);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { type AkWizardMain } from "@goauthentik/app/components/ak-wizard-main/ak-wizard-main";
|
||||
import { merge } from "@goauthentik/common/merge";
|
||||
import "@goauthentik/components/ak-wizard-main";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
|
@ -7,6 +8,7 @@ import { ContextProvider, ContextRoot } from "@lit-labs/context";
|
|||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { type Ref, createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
|
||||
|
@ -16,11 +18,6 @@ import applicationWizardContext from "./ak-application-wizard-context-name";
|
|||
import { steps } from "./steps";
|
||||
import { OneOfProvider, WizardState, WizardStateEvent } from "./types";
|
||||
|
||||
// my-context.ts
|
||||
|
||||
// All this thing is doing is recording the input the user makes to the forms. It should NOT be
|
||||
// triggering re-renders; that's the wizard frame's jobs.
|
||||
|
||||
@customElement("ak-application-wizard")
|
||||
export class ApplicationWizard extends CustomListenerElement(AKElement) {
|
||||
static get styles(): CSSResult[] {
|
||||
|
@ -29,7 +26,6 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
|
|||
|
||||
@state()
|
||||
wizardState: WizardState = {
|
||||
step: 0,
|
||||
providerModel: "",
|
||||
app: {},
|
||||
provider: {},
|
||||
|
@ -43,6 +39,7 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
|
|||
initialValue: this.wizardState,
|
||||
});
|
||||
|
||||
@state()
|
||||
steps = steps;
|
||||
|
||||
@property()
|
||||
|
@ -50,6 +47,12 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
|
|||
|
||||
providerCache: Map<string, OneOfProvider> = new Map();
|
||||
|
||||
wizardRef: Ref<AkWizardMain> = createRef();
|
||||
|
||||
get step() {
|
||||
return this.wizardRef.value?.currentStep ?? -1;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.handleUpdate = this.handleUpdate.bind(this);
|
||||
|
@ -66,30 +69,38 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
|
|||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
// And this is where all the special cases go...
|
||||
handleUpdate(event: CustomEvent<WizardStateEvent>) {
|
||||
const update = event.detail.update;
|
||||
|
||||
// Are we changing provider type? If so, swap the caches of the various provider types the
|
||||
// user may have filled in, and enable the next step.
|
||||
const providerModel = update.providerModel;
|
||||
maybeProviderSwap(providerModel: string | undefined): boolean {
|
||||
if (
|
||||
providerModel &&
|
||||
typeof providerModel === "string" &&
|
||||
providerModel !== this.wizardState.providerModel
|
||||
providerModel === undefined ||
|
||||
typeof providerModel !== "string" ||
|
||||
providerModel === this.wizardState.providerModel
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.providerCache.set(this.wizardState.providerModel, this.wizardState.provider);
|
||||
const prevProvider = this.providerCache.get(providerModel);
|
||||
this.wizardState.provider = prevProvider ?? {
|
||||
name: `Provider for ${this.wizardState.app.name}`,
|
||||
};
|
||||
const newSteps = [...this.steps];
|
||||
const method = newSteps.find(({ id }) => id === "auth-method");
|
||||
const method = this.steps.find(({ id }) => id === "provider-details");
|
||||
if (!method) {
|
||||
throw new Error("Could not find Authentication Method page?");
|
||||
}
|
||||
method.disabled = false;
|
||||
this.steps = newSteps;
|
||||
}
|
||||
|
||||
// And this is where all the special cases go...
|
||||
handleUpdate(event: CustomEvent<WizardStateEvent>) {
|
||||
const update = event.detail.update;
|
||||
|
||||
if (this.maybeProviderSwap(update.providerModel)) {
|
||||
this.steps = [...this.steps];
|
||||
}
|
||||
|
||||
if (event.detail.status === "valid" && this.steps[this.step + 1]) {
|
||||
this.steps[this.step + 1].disabled = false;
|
||||
this.steps = [...this.steps];
|
||||
}
|
||||
|
||||
this.wizardState = merge(this.wizardState, update) as WizardState;
|
||||
|
@ -99,6 +110,7 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
|
|||
render() {
|
||||
return html`
|
||||
<ak-wizard-main
|
||||
${ref(this.wizardRef)}
|
||||
.steps=${this.steps}
|
||||
header=${msg("New application")}
|
||||
description=${msg("Create a new application.")}
|
||||
|
|
|
@ -26,15 +26,17 @@ export class ApplicationWizardApplicationDetails extends BasePanel {
|
|||
const target = ev.target as HTMLInputElement;
|
||||
const value = target.type === "checkbox" ? target.checked : target.value;
|
||||
this.dispatchWizardUpdate({
|
||||
update: {
|
||||
app: {
|
||||
[target.name]: value,
|
||||
},
|
||||
},
|
||||
status: this.form.checkValidity() ? "valid" : "invalid",
|
||||
});
|
||||
}
|
||||
|
||||
validator() {
|
||||
return true;
|
||||
// return this.form.reportValidity();
|
||||
return this.form.reportValidity();
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
|
|
|
@ -24,7 +24,10 @@ export class ApplicationWizardAuthenticationMethodChoice extends BasePanel {
|
|||
|
||||
handleChoice(ev: InputEvent) {
|
||||
const target = ev.target as HTMLInputElement;
|
||||
this.dispatchWizardUpdate({ providerModel: target.value });
|
||||
this.dispatchWizardUpdate({
|
||||
update: { providerModel: target.value },
|
||||
status: this.validator() ? "valid" : "invalid",
|
||||
});
|
||||
}
|
||||
|
||||
validator() {
|
||||
|
@ -32,7 +35,7 @@ export class ApplicationWizardAuthenticationMethodChoice extends BasePanel {
|
|||
const chosen = radios.find(
|
||||
(radio: Element) => radio instanceof HTMLInputElement && radio.checked,
|
||||
);
|
||||
return chosen;
|
||||
return !!chosen;
|
||||
}
|
||||
|
||||
renderProvider(type: LocalTypeCreate) {
|
||||
|
|
|
@ -9,12 +9,20 @@ export class ApplicationWizardProviderPageBase extends BasePanel {
|
|||
const target = ev.target as HTMLInputElement;
|
||||
const value = target.type === "checkbox" ? target.checked : target.value;
|
||||
this.dispatchWizardUpdate({
|
||||
update: {
|
||||
provider: {
|
||||
[target.name]: value,
|
||||
},
|
||||
},
|
||||
status: this.form.checkValidity() ? "valid" : "invalid"
|
||||
});
|
||||
}
|
||||
|
||||
shouldUpdate(changed: Map<string, any>) {
|
||||
console.log("CHANGED:", JSON.stringify(Array.from(changed.entries()), null, 2));
|
||||
return true;
|
||||
}
|
||||
|
||||
validator() {
|
||||
return this.form.reportValidity();
|
||||
}
|
||||
|
|
|
@ -2,12 +2,10 @@ import { WizardStep } from "@goauthentik/components/ak-wizard-main";
|
|||
import {
|
||||
BackStep,
|
||||
CancelWizard,
|
||||
CloseWizard,
|
||||
NextStep,
|
||||
SubmitStep,
|
||||
} from "@goauthentik/components/ak-wizard-main/commonWizardButtons";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
|
||||
import "./application/ak-application-wizard-application-details";
|
||||
|
@ -15,41 +13,46 @@ import "./auth-method-choice/ak-application-wizard-authentication-method-choice"
|
|||
import "./commit/ak-application-wizard-commit-application";
|
||||
import "./methods/ak-application-wizard-authentication-method";
|
||||
|
||||
export const steps: WizardStep[] = [
|
||||
type NamedStep = WizardStep & {
|
||||
id: string,
|
||||
valid: boolean,
|
||||
};
|
||||
|
||||
export const steps: NamedStep[] = [
|
||||
{
|
||||
id: "application",
|
||||
label: "Application Details",
|
||||
renderer: () =>
|
||||
render: () =>
|
||||
html`<ak-application-wizard-application-details></ak-application-wizard-application-details>`,
|
||||
disabled: false,
|
||||
valid: false,
|
||||
buttons: [NextStep, CancelWizard],
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
id: "auth-method-choice",
|
||||
id: "provider-method",
|
||||
label: "Authentication Method",
|
||||
renderer: () =>
|
||||
render: () =>
|
||||
html`<ak-application-wizard-authentication-method-choice></ak-application-wizard-authentication-method-choice>`,
|
||||
disabled: false,
|
||||
valid: false,
|
||||
buttons: [NextStep, BackStep, CancelWizard],
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
id: "auth-method",
|
||||
id: "provider-details",
|
||||
label: "Authentication Details",
|
||||
renderer: () =>
|
||||
render: () =>
|
||||
html`<ak-application-wizard-authentication-method></ak-application-wizard-authentication-method>`,
|
||||
disabled: true,
|
||||
valid: false,
|
||||
buttons: [SubmitStep, BackStep, CancelWizard],
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
id: "commit-application",
|
||||
id: "submit",
|
||||
label: "Submit New Application",
|
||||
renderer: () =>
|
||||
render: () =>
|
||||
html`<ak-application-wizard-commit-application></ak-application-wizard-commit-application>`,
|
||||
disabled: true,
|
||||
valid: false,
|
||||
buttons: [BackStep, CancelWizard],
|
||||
valid: true,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -17,10 +17,14 @@ export type OneOfProvider =
|
|||
| Partial<LDAPProviderRequest>;
|
||||
|
||||
export interface WizardState {
|
||||
step: number;
|
||||
providerModel: string;
|
||||
app: Partial<ApplicationRequest>;
|
||||
provider: OneOfProvider;
|
||||
}
|
||||
|
||||
export type WizardStateEvent = { update: Partial<WizardState> };
|
||||
type StatusType = "invalid" | "valid" | "submitted" | "failed";
|
||||
|
||||
export type WizardStateUpdate = {
|
||||
update: Partial<WizardState>,
|
||||
status?: StatusType,
|
||||
};
|
||||
|
|
|
@ -104,14 +104,19 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
|
|||
}
|
||||
|
||||
renderNavigation() {
|
||||
let disabled = false;
|
||||
|
||||
return html`<nav class="pf-c-wizard__nav">
|
||||
<ol class="pf-c-wizard__nav-list">
|
||||
${this.steps.map((step, idx) => this.renderNavigationStep(step, idx))}
|
||||
${this.steps.map((step, idx) => {
|
||||
disabled = disabled || this.step.disabled;
|
||||
return this.renderNavigationStep(step, disabled, idx);
|
||||
})}
|
||||
</ol>
|
||||
</nav>`;
|
||||
}
|
||||
|
||||
renderNavigationStep(step: WizardStep, idx: number) {
|
||||
renderNavigationStep(step: WizardStep, disabled: boolean, idx: number) {
|
||||
const buttonClasses = {
|
||||
"pf-c-wizard__nav-link": true,
|
||||
"pf-m-current": idx === this.currentStep,
|
||||
|
@ -121,7 +126,7 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
|
|||
<li class="pf-c-wizard__nav-item">
|
||||
<button
|
||||
class=${classMap(buttonClasses)}
|
||||
?disabled=${step.disabled}
|
||||
?disabled=${disabled}
|
||||
@click=${() =>
|
||||
this.dispatchCustomEvent(this.eventName, { command: "goto", step: idx })}
|
||||
>
|
||||
|
@ -135,7 +140,7 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
|
|||
// independent context.
|
||||
renderMainSection() {
|
||||
return html`<main class="pf-c-wizard__main">
|
||||
<div id="main-content" class="pf-c-wizard__main-body">${this.step.renderer()}</div>
|
||||
<div id="main-content" class="pf-c-wizard__main-body">${this.step.render()}</div>
|
||||
</main>`;
|
||||
}
|
||||
|
||||
|
|
|
@ -131,9 +131,12 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
|
|||
return this.currentStep > 0 ? this.currentStep - 1 : undefined;
|
||||
}
|
||||
|
||||
get step() {
|
||||
return this.steps[this.currentStep];
|
||||
}
|
||||
|
||||
handleNavigation(event: CustomEvent<{ command: string; step?: number }>) {
|
||||
const command = event.detail.command;
|
||||
console.log(command);
|
||||
switch (command) {
|
||||
case "back": {
|
||||
if (this.backStep !== undefined && this.steps[this.backStep]) {
|
||||
|
@ -151,6 +154,11 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
|
|||
return;
|
||||
}
|
||||
case "next": {
|
||||
console.log(this.nextStep,
|
||||
this.steps[this.nextStep],
|
||||
!this.steps[this.nextStep].disabled,
|
||||
this.validated);
|
||||
|
||||
if (
|
||||
this.nextStep &&
|
||||
this.steps[this.nextStep] &&
|
||||
|
@ -162,7 +170,7 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
|
|||
return;
|
||||
}
|
||||
case "close": {
|
||||
this.frame.open = this.open;
|
||||
this.frame.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import "@goauthentik/elements/messages/MessageContainer";
|
||||
import { Meta } from "@storybook/web-components";
|
||||
import { NextStep, BackStep, CancelWizard, CloseWizard } from "../commonWizardButtons";
|
||||
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
import "../ak-wizard-main";
|
||||
import AkWizard from "../ak-wizard-main";
|
||||
import { BackStep, CancelWizard, CloseWizard, NextStep } from "../commonWizardButtons";
|
||||
import type { WizardStep } from "../types";
|
||||
|
||||
const metadata: Meta<AkWizard> = {
|
||||
|
@ -38,13 +39,13 @@ const container = (testItem: TemplateResult) =>
|
|||
const dummySteps: WizardStep[] = [
|
||||
{
|
||||
label: "Test Step1",
|
||||
renderer: () => html`<h2>This space intentionally left blank today</h2>`,
|
||||
render: () => html`<h2>This space intentionally left blank today</h2>`,
|
||||
disabled: false,
|
||||
buttons: [NextStep, CancelWizard],
|
||||
},
|
||||
{
|
||||
label: "Test Step 2",
|
||||
renderer: () => html`<h2>This space also intentionally left blank</h2>`,
|
||||
render: () => html`<h2>This space also intentionally left blank</h2>`,
|
||||
disabled: false,
|
||||
buttons: [BackStep, CloseWizard],
|
||||
},
|
||||
|
@ -52,6 +53,11 @@ const dummySteps: WizardStep[] = [
|
|||
|
||||
export const OnePageWizard = () => {
|
||||
return container(
|
||||
html` <ak-wizard-main .steps=${dummySteps} canCancel header="The Grand Illusion" prompt="Start the show!"></ak-wizard-main>`,
|
||||
html` <ak-wizard-main
|
||||
.steps=${dummySteps}
|
||||
canCancel
|
||||
header="The Grand Illusion"
|
||||
prompt="Start the show!"
|
||||
></ak-wizard-main>`,
|
||||
);
|
||||
};
|
||||
|
|
|
@ -14,7 +14,7 @@ export interface WizardStep {
|
|||
|
||||
// A function which returns the html for rendering the actual content of the step, its form and
|
||||
// such.
|
||||
renderer: () => TemplateResult;
|
||||
render: () => TemplateResult;
|
||||
|
||||
// A collection of buttons, in render order, that are to be shown in the button bar. The
|
||||
// semantics of the buttons are simple: 'next' will navigate to currentStep + 1, 'back' will
|
||||
|
|
Reference in a new issue