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:
Ken Sternberg 2023-09-02 08:14:56 -07:00
parent 3b4530fb7f
commit 217505d0c9
11 changed files with 116 additions and 65 deletions

View file

@ -8,7 +8,7 @@ import { query } from "@lit/reactive-element/decorators.js";
import { styles as AwadStyles } from "./BasePanel.css"; import { styles as AwadStyles } from "./BasePanel.css";
import { applicationWizardContext } from "./ak-application-wizard-context-name"; import { applicationWizardContext } from "./ak-application-wizard-context-name";
import type { WizardState } from "./types"; import type { WizardState, WizardStateUpdate } from "./types";
export class ApplicationWizardPageBase export class ApplicationWizardPageBase
extends CustomEmitterElement(AKElement) extends CustomEmitterElement(AKElement)
@ -27,8 +27,8 @@ export class ApplicationWizardPageBase
public wizard!: WizardState; public wizard!: WizardState;
// This used to be more complex; now it just establishes the event name. // This used to be more complex; now it just establishes the event name.
dispatchWizardUpdate(update: Partial<WizardState>) { dispatchWizardUpdate(update: WizardStateUpdate) {
this.dispatchCustomEvent("ak-application-wizard-update", { update }); this.dispatchCustomEvent("ak-application-wizard-update", update);
} }
} }

View file

@ -1,3 +1,4 @@
import { type AkWizardMain } from "@goauthentik/app/components/ak-wizard-main/ak-wizard-main";
import { merge } from "@goauthentik/common/merge"; import { merge } from "@goauthentik/common/merge";
import "@goauthentik/components/ak-wizard-main"; import "@goauthentik/components/ak-wizard-main";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
@ -7,6 +8,7 @@ import { ContextProvider, ContextRoot } from "@lit-labs/context";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, html } from "lit"; import { CSSResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js"; 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 PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFRadio from "@patternfly/patternfly/components/Radio/radio.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 { steps } from "./steps";
import { OneOfProvider, WizardState, WizardStateEvent } from "./types"; 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") @customElement("ak-application-wizard")
export class ApplicationWizard extends CustomListenerElement(AKElement) { export class ApplicationWizard extends CustomListenerElement(AKElement) {
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
@ -29,7 +26,6 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
@state() @state()
wizardState: WizardState = { wizardState: WizardState = {
step: 0,
providerModel: "", providerModel: "",
app: {}, app: {},
provider: {}, provider: {},
@ -43,6 +39,7 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
initialValue: this.wizardState, initialValue: this.wizardState,
}); });
@state()
steps = steps; steps = steps;
@property() @property()
@ -50,6 +47,12 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
providerCache: Map<string, OneOfProvider> = new Map(); providerCache: Map<string, OneOfProvider> = new Map();
wizardRef: Ref<AkWizardMain> = createRef();
get step() {
return this.wizardRef.value?.currentStep ?? -1;
}
constructor() { constructor() {
super(); super();
this.handleUpdate = this.handleUpdate.bind(this); this.handleUpdate = this.handleUpdate.bind(this);
@ -66,30 +69,38 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
super.disconnectedCallback(); super.disconnectedCallback();
} }
maybeProviderSwap(providerModel: string | undefined): boolean {
if (
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 method = this.steps.find(({ id }) => id === "provider-details");
if (!method) {
throw new Error("Could not find Authentication Method page?");
}
method.disabled = false;
}
// And this is where all the special cases go... // And this is where all the special cases go...
handleUpdate(event: CustomEvent<WizardStateEvent>) { handleUpdate(event: CustomEvent<WizardStateEvent>) {
const update = event.detail.update; const update = event.detail.update;
// Are we changing provider type? If so, swap the caches of the various provider types the if (this.maybeProviderSwap(update.providerModel)) {
// user may have filled in, and enable the next step. this.steps = [...this.steps];
const providerModel = update.providerModel; }
if (
providerModel && if (event.detail.status === "valid" && this.steps[this.step + 1]) {
typeof providerModel === "string" && this.steps[this.step + 1].disabled = false;
providerModel !== this.wizardState.providerModel this.steps = [...this.steps];
) {
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");
if (!method) {
throw new Error("Could not find Authentication Method page?");
}
method.disabled = false;
this.steps = newSteps;
} }
this.wizardState = merge(this.wizardState, update) as WizardState; this.wizardState = merge(this.wizardState, update) as WizardState;
@ -99,6 +110,7 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
render() { render() {
return html` return html`
<ak-wizard-main <ak-wizard-main
${ref(this.wizardRef)}
.steps=${this.steps} .steps=${this.steps}
header=${msg("New application")} header=${msg("New application")}
description=${msg("Create a new application.")} description=${msg("Create a new application.")}

View file

@ -26,17 +26,19 @@ export class ApplicationWizardApplicationDetails extends BasePanel {
const target = ev.target as HTMLInputElement; const target = ev.target as HTMLInputElement;
const value = target.type === "checkbox" ? target.checked : target.value; const value = target.type === "checkbox" ? target.checked : target.value;
this.dispatchWizardUpdate({ this.dispatchWizardUpdate({
app: { update: {
[target.name]: value, app: {
[target.name]: value,
},
}, },
status: this.form.checkValidity() ? "valid" : "invalid",
}); });
} }
validator() { validator() {
return true; return this.form.reportValidity();
// return this.form.reportValidity();
} }
render(): TemplateResult { render(): TemplateResult {
return html` <form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}> return html` <form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}>
<ak-text-input <ak-text-input

View file

@ -24,7 +24,10 @@ export class ApplicationWizardAuthenticationMethodChoice extends BasePanel {
handleChoice(ev: InputEvent) { handleChoice(ev: InputEvent) {
const target = ev.target as HTMLInputElement; const target = ev.target as HTMLInputElement;
this.dispatchWizardUpdate({ providerModel: target.value }); this.dispatchWizardUpdate({
update: { providerModel: target.value },
status: this.validator() ? "valid" : "invalid",
});
} }
validator() { validator() {
@ -32,7 +35,7 @@ export class ApplicationWizardAuthenticationMethodChoice extends BasePanel {
const chosen = radios.find( const chosen = radios.find(
(radio: Element) => radio instanceof HTMLInputElement && radio.checked, (radio: Element) => radio instanceof HTMLInputElement && radio.checked,
); );
return chosen; return !!chosen;
} }
renderProvider(type: LocalTypeCreate) { renderProvider(type: LocalTypeCreate) {

View file

@ -9,12 +9,20 @@ export class ApplicationWizardProviderPageBase extends BasePanel {
const target = ev.target as HTMLInputElement; const target = ev.target as HTMLInputElement;
const value = target.type === "checkbox" ? target.checked : target.value; const value = target.type === "checkbox" ? target.checked : target.value;
this.dispatchWizardUpdate({ this.dispatchWizardUpdate({
provider: { update: {
[target.name]: value, 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() { validator() {
return this.form.reportValidity(); return this.form.reportValidity();
} }

View file

@ -2,12 +2,10 @@ import { WizardStep } from "@goauthentik/components/ak-wizard-main";
import { import {
BackStep, BackStep,
CancelWizard, CancelWizard,
CloseWizard,
NextStep, NextStep,
SubmitStep, SubmitStep,
} from "@goauthentik/components/ak-wizard-main/commonWizardButtons"; } from "@goauthentik/components/ak-wizard-main/commonWizardButtons";
import { msg } from "@lit/localize";
import { html } from "lit"; import { html } from "lit";
import "./application/ak-application-wizard-application-details"; 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 "./commit/ak-application-wizard-commit-application";
import "./methods/ak-application-wizard-authentication-method"; import "./methods/ak-application-wizard-authentication-method";
export const steps: WizardStep[] = [ type NamedStep = WizardStep & {
id: string,
valid: boolean,
};
export const steps: NamedStep[] = [
{ {
id: "application", id: "application",
label: "Application Details", label: "Application Details",
renderer: () => render: () =>
html`<ak-application-wizard-application-details></ak-application-wizard-application-details>`, html`<ak-application-wizard-application-details></ak-application-wizard-application-details>`,
disabled: false, disabled: false,
valid: false,
buttons: [NextStep, CancelWizard], buttons: [NextStep, CancelWizard],
valid: true,
}, },
{ {
id: "auth-method-choice", id: "provider-method",
label: "Authentication Method", label: "Authentication Method",
renderer: () => render: () =>
html`<ak-application-wizard-authentication-method-choice></ak-application-wizard-authentication-method-choice>`, html`<ak-application-wizard-authentication-method-choice></ak-application-wizard-authentication-method-choice>`,
disabled: false, disabled: false,
valid: false,
buttons: [NextStep, BackStep, CancelWizard], buttons: [NextStep, BackStep, CancelWizard],
valid: true,
}, },
{ {
id: "auth-method", id: "provider-details",
label: "Authentication Details", label: "Authentication Details",
renderer: () => render: () =>
html`<ak-application-wizard-authentication-method></ak-application-wizard-authentication-method>`, html`<ak-application-wizard-authentication-method></ak-application-wizard-authentication-method>`,
disabled: true, disabled: true,
valid: false,
buttons: [SubmitStep, BackStep, CancelWizard], buttons: [SubmitStep, BackStep, CancelWizard],
valid: true,
}, },
{ {
id: "commit-application", id: "submit",
label: "Submit New Application", label: "Submit New Application",
renderer: () => render: () =>
html`<ak-application-wizard-commit-application></ak-application-wizard-commit-application>`, html`<ak-application-wizard-commit-application></ak-application-wizard-commit-application>`,
disabled: true, disabled: true,
valid: false,
buttons: [BackStep, CancelWizard], buttons: [BackStep, CancelWizard],
valid: true,
}, },
]; ];

View file

@ -17,10 +17,14 @@ export type OneOfProvider =
| Partial<LDAPProviderRequest>; | Partial<LDAPProviderRequest>;
export interface WizardState { export interface WizardState {
step: number;
providerModel: string; providerModel: string;
app: Partial<ApplicationRequest>; app: Partial<ApplicationRequest>;
provider: OneOfProvider; provider: OneOfProvider;
} }
export type WizardStateEvent = { update: Partial<WizardState> }; type StatusType = "invalid" | "valid" | "submitted" | "failed";
export type WizardStateUpdate = {
update: Partial<WizardState>,
status?: StatusType,
};

View file

@ -104,14 +104,19 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
} }
renderNavigation() { renderNavigation() {
let disabled = false;
return html`<nav class="pf-c-wizard__nav"> return html`<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.renderNavigationStep(step, idx))} ${this.steps.map((step, idx) => {
disabled = disabled || this.step.disabled;
return this.renderNavigationStep(step, disabled, idx);
})}
</ol> </ol>
</nav>`; </nav>`;
} }
renderNavigationStep(step: WizardStep, idx: number) { renderNavigationStep(step: WizardStep, disabled: boolean, idx: number) {
const buttonClasses = { const buttonClasses = {
"pf-c-wizard__nav-link": true, "pf-c-wizard__nav-link": true,
"pf-m-current": idx === this.currentStep, "pf-m-current": idx === this.currentStep,
@ -121,7 +126,7 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
<li class="pf-c-wizard__nav-item"> <li class="pf-c-wizard__nav-item">
<button <button
class=${classMap(buttonClasses)} class=${classMap(buttonClasses)}
?disabled=${step.disabled} ?disabled=${disabled}
@click=${() => @click=${() =>
this.dispatchCustomEvent(this.eventName, { command: "goto", step: idx })} this.dispatchCustomEvent(this.eventName, { command: "goto", step: idx })}
> >
@ -135,7 +140,7 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
// independent context. // independent context.
renderMainSection() { renderMainSection() {
return html`<main class="pf-c-wizard__main"> 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>`; </main>`;
} }

View file

@ -131,9 +131,12 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
return this.currentStep > 0 ? this.currentStep - 1 : undefined; return this.currentStep > 0 ? this.currentStep - 1 : undefined;
} }
get step() {
return this.steps[this.currentStep];
}
handleNavigation(event: CustomEvent<{ command: string; step?: number }>) { handleNavigation(event: CustomEvent<{ command: string; step?: number }>) {
const command = event.detail.command; const command = event.detail.command;
console.log(command);
switch (command) { switch (command) {
case "back": { case "back": {
if (this.backStep !== undefined && this.steps[this.backStep]) { if (this.backStep !== undefined && this.steps[this.backStep]) {
@ -151,6 +154,11 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
return; return;
} }
case "next": { case "next": {
console.log(this.nextStep,
this.steps[this.nextStep],
!this.steps[this.nextStep].disabled,
this.validated);
if ( if (
this.nextStep && this.nextStep &&
this.steps[this.nextStep] && this.steps[this.nextStep] &&
@ -162,7 +170,7 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
return; return;
} }
case "close": { case "close": {
this.frame.open = this.open; this.frame.open = false;
} }
} }
} }

View file

@ -1,10 +1,11 @@
import "@goauthentik/elements/messages/MessageContainer"; import "@goauthentik/elements/messages/MessageContainer";
import { Meta } from "@storybook/web-components"; import { Meta } from "@storybook/web-components";
import { NextStep, BackStep, CancelWizard, CloseWizard } from "../commonWizardButtons";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import "../ak-wizard-main"; import "../ak-wizard-main";
import AkWizard from "../ak-wizard-main"; import AkWizard from "../ak-wizard-main";
import { BackStep, CancelWizard, CloseWizard, NextStep } from "../commonWizardButtons";
import type { WizardStep } from "../types"; import type { WizardStep } from "../types";
const metadata: Meta<AkWizard> = { const metadata: Meta<AkWizard> = {
@ -38,13 +39,13 @@ const container = (testItem: TemplateResult) =>
const dummySteps: WizardStep[] = [ const dummySteps: WizardStep[] = [
{ {
label: "Test Step1", 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, disabled: false,
buttons: [NextStep, CancelWizard], buttons: [NextStep, CancelWizard],
}, },
{ {
label: "Test Step 2", 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, disabled: false,
buttons: [BackStep, CloseWizard], buttons: [BackStep, CloseWizard],
}, },
@ -52,6 +53,11 @@ const dummySteps: WizardStep[] = [
export const OnePageWizard = () => { export const OnePageWizard = () => {
return container( 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>`,
); );
}; };

View file

@ -14,7 +14,7 @@ export interface WizardStep {
// A function which returns the html for rendering the actual content of the step, its form and // A function which returns the html for rendering the actual content of the step, its form and
// such. // such.
renderer: () => TemplateResult; render: () => TemplateResult;
// A collection of buttons, in render order, that are to be shown in the button bar. The // 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 // semantics of the buttons are simple: 'next' will navigate to currentStep + 1, 'back' will