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 { 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);
}
}

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 "@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();
}
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...
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;
if (
providerModel &&
typeof providerModel === "string" &&
providerModel !== this.wizardState.providerModel
) {
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;
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.")}

View file

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

View file

@ -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) {

View file

@ -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({
provider: {
[target.name]: value,
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();
}

View file

@ -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,
},
];

View file

@ -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,
};

View file

@ -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>`;
}

View file

@ -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;
}
}
}

View file

@ -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>`,
);
};

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
// 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