web: application wizard refactor & completion

This commit refactors the various components of the Wizard and ApplicationWizard, creating a much
more maintainable and satisfying Wizard experience for both developers (i.e, *me* and *Jens* so
far), and for the customer.

The Wizard base has been refactored into three components:

**AkWizardController**

The `AkWizardController` provides the event listenters for the wizard; it hooks them up, recevies the
events, and forwards them to the wizard.  It unwraps the event objects and forwards the relevant
messages contained in the events.  It knows of three event categories:

- Navigation requests (move to a different step)
- Update requests (the current step has updated the business content)
- Close requests (close or cancel the wizard).

**ak-wizard-frame**

The `ak-wizard-frame` is the ModalButton interface.  It provides the Header, Breadcrumbs (nee`
"navigation block"), Buttons, and a DIV into which the main content is rendered.

**AkWizard**

`AkWizard` is an *incomplete* implementation of the wizard. It's meant to be inherited by a child
class, which will implement the rest. It extends `AKElement`. It provides the basic content needed,
such as steps, currentStep (as an index), an accessor for the step itself, an accessor for the
frame, and the interface to the `AkWizardController`.

**ApplicationWizard**

The `ApplicationWizard` itself has been refactored to accommodate these changes. It inherits from
`AkWizard` and provides the business logic for what to do when a form updates, some custom logic for
preventing moving through the wizard when the forms are incomplete, and a persistence layer for
filling out different providers in the same session. It's simplified a *lot*.

The types specified for `AkWizard` are pretty nifty, I think. I could wish the types being passed
via the custom events were more robust, but [strongly typed custom
events](https://github.com/lit/lit-element/issues/808) turn out to be quite the pain in the, er,
neck. As it is, the `precommit` pass did very good at preventing the worst disasters.

The steps themselves were re-written as objects so that they could take advantage of their `valid`
and `disabled` states and provide more meaningful buttons and labels. I think it's a solid
compromise, and it moved a lot of display logic out of the core `handleUpdate()` business method.

The tests, such as they are, are passing.
This commit is contained in:
Ken Sternberg 2023-09-29 15:58:41 -07:00
parent 998615dbcc
commit fbb6fb0a8e
16 changed files with 494 additions and 430 deletions

View file

@ -7,8 +7,8 @@ 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 "./ContextIdentity";
import type { WizardState, WizardStateUpdate } from "./types"; import type { ApplicationWizardState, ApplicationWizardStateUpdate } from "./types";
export class ApplicationWizardPageBase export class ApplicationWizardPageBase
extends CustomEmitterElement(AKElement) extends CustomEmitterElement(AKElement)
@ -24,11 +24,11 @@ export class ApplicationWizardPageBase
rendered = false; rendered = false;
@consume({ context: applicationWizardContext }) @consume({ context: applicationWizardContext })
public wizard!: WizardState; public wizard!: ApplicationWizardState;
// 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: WizardStateUpdate) { dispatchWizardUpdate(update: ApplicationWizardStateUpdate) {
this.dispatchCustomEvent("ak-application-wizard-update", update); this.dispatchCustomEvent("ak-wizard-update", update);
} }
} }

View file

@ -1,8 +1,9 @@
import { createContext } from "@lit-labs/context"; import { createContext } from "@lit-labs/context";
import { WizardState } from "./types"; import { ApplicationWizardState } from "./types";
export const applicationWizardContext = createContext<WizardState>( export const applicationWizardContext = createContext<ApplicationWizardState>(
Symbol("ak-application-wizard-state-context"), Symbol("ak-application-wizard-state-context"),
); );
export default applicationWizardContext; export default applicationWizardContext;

View file

@ -1,23 +1,19 @@
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 { AkWizard } from "@goauthentik/components/ak-wizard-main/AkWizard";
import { CloseWizard } from "@goauthentik/components/ak-wizard-main/commonWizardButtons";
import { AKElement } from "@goauthentik/elements/Base";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { ContextProvider, ContextRoot } from "@lit-labs/context"; import { ContextProvider } from "@lit-labs/context";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, html } from "lit"; import { customElement, 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 applicationWizardContext from "./ContextIdentity";
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import applicationWizardContext from "./ak-application-wizard-context-name";
import { newSteps } from "./steps"; import { newSteps } from "./steps";
import { OneOfProvider, WizardState, WizardStateUpdate } from "./types"; import {
ApplicationStep,
ApplicationWizardState,
ApplicationWizardStateUpdate,
OneOfProvider,
} from "./types";
const freshWizardState = () => ({ const freshWizardState = () => ({
providerModel: "", providerModel: "",
@ -26,55 +22,41 @@ const freshWizardState = () => ({
}); });
@customElement("ak-application-wizard") @customElement("ak-application-wizard")
export class ApplicationWizard extends CustomListenerElement(AKElement) { export class ApplicationWizard extends CustomListenerElement(
static get styles(): CSSResult[] { AkWizard<ApplicationWizardStateUpdate, ApplicationStep>,
return [PFBase, PFButton, PFRadio]; ) {
constructor() {
super(msg("Create"), msg("New application"), msg("Create a new application"));
this.steps = newSteps();
} }
@state()
wizardState: WizardState = freshWizardState();
/** /**
* Providing a context at the root element * We're going to be managing the content of the forms by percolating all of the data up to this
* class, which will ultimately transmit all of it to the server as a transaction. The
* WizardFramework doesn't know anything about the nature of the data itself; it just forwards
* valid updates to us. So here we maintain a state object *and* update it so all child
* components can access the wizard state.
*
*/ */
@state()
wizardState: ApplicationWizardState = freshWizardState();
wizardStateProvider = new ContextProvider(this, { wizardStateProvider = new ContextProvider(this, {
context: applicationWizardContext, context: applicationWizardContext,
initialValue: this.wizardState, initialValue: this.wizardState,
}); });
@state() /**
steps = newSteps(); * One of our steps has multiple display variants, one for each type of service provider. We
* want to *preserve* a customer's decisions about different providers; never make someone "go
@property() * back and type it all back in," even if it's probably rare that someone will chose one
prompt = msg("Create"); * provider, realize it's the wrong one, and go back to chose a different one, *and then go
* back*. Nonetheless, strive to *never* lose customer input.
*
*/
providerCache: Map<string, OneOfProvider> = new Map(); providerCache: Map<string, OneOfProvider> = new Map();
wizardRef: Ref<AkWizardMain> = createRef();
constructor() {
super();
this.handleUpdate = this.handleUpdate.bind(this);
this.handleClosed = this.handleClosed.bind(this);
}
get step() {
return this.wizardRef.value?.currentStep ?? -1;
}
connectedCallback() {
super.connectedCallback();
new ContextRoot().attach(this.parentElement!);
this.addCustomListener("ak-application-wizard-update", this.handleUpdate);
this.addCustomListener("ak-wizard-closed", this.handleClosed);
}
disconnectedCallback() {
this.removeCustomListener("ak-application-wizard-update", this.handleUpdate);
this.removeCustomListener("ak-wizard-closed", this.handleClosed);
super.disconnectedCallback();
}
maybeProviderSwap(providerModel: string | undefined): boolean { maybeProviderSwap(providerModel: string | undefined): boolean {
if ( if (
providerModel === undefined || providerModel === undefined ||
@ -98,51 +80,46 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
} }
// And this is where all the special cases go... // And this is where all the special cases go...
handleUpdate(event: CustomEvent<WizardStateUpdate>) { handleUpdate(detail: ApplicationWizardStateUpdate) {
if (event.detail.status === "submitted") { if (detail.status === "submitted") {
const submitStep = this.steps.find(({ id }) => id === "submit"); this.step.valid = true;
if (!submitStep) { this.requestUpdate();
throw new Error("Could not find submit step?");
}
submitStep.buttons = [CloseWizard];
this.steps = [...this.steps];
return; return;
} }
const update = event.detail.update; this.step.valid = this.step.valid || detail.status === "valid";
const update = detail.update;
if (!update) { if (!update) {
return; return;
} }
if (this.maybeProviderSwap(update.providerModel)) { if (this.maybeProviderSwap(update.providerModel)) {
this.steps = [...this.steps]; this.requestUpdate();
} }
if (event.detail.status === "valid" && this.steps[this.step + 1]) { this.wizardState = merge(this.wizardState, update) as ApplicationWizardState;
this.steps[this.step + 1].disabled = false;
this.steps = [...this.steps];
}
this.wizardState = merge(this.wizardState, update) as WizardState;
this.wizardStateProvider.setValue(this.wizardState); this.wizardStateProvider.setValue(this.wizardState);
this.requestUpdate();
} }
handleClosed() { close() {
this.steps = newSteps(); this.steps = newSteps();
this.currentStep = 0;
this.wizardState = freshWizardState(); this.wizardState = freshWizardState();
this.providerCache = new Map();
this.wizardStateProvider.setValue(this.wizardState); this.wizardStateProvider.setValue(this.wizardState);
this.frame.value!.open = false;
} }
render() { handleNav(stepId: number | undefined) {
return html` if (stepId === undefined || this.steps[stepId] === undefined) {
<ak-wizard-main throw new Error(`Attempt to navigate to undefined step: ${stepId}`);
${ref(this.wizardRef)} }
.steps=${this.steps} if (stepId > this.currentStep && !this.step.valid) {
header=${msg("New application")} return;
description=${msg("Create a new application.")} }
prompt=${this.prompt} this.currentStep = stepId;
> this.requestUpdate();
</ak-wizard-main>
`;
} }
} }

View file

@ -1,7 +1,8 @@
import { WizardStep } from "@goauthentik/components/ak-wizard-main";
import { import {
BackStep, BackStep,
CancelWizard, CancelWizard,
CloseWizard,
DisabledNextStep,
NextStep, NextStep,
SubmitStep, SubmitStep,
} from "@goauthentik/components/ak-wizard-main/commonWizardButtons"; } from "@goauthentik/components/ak-wizard-main/commonWizardButtons";
@ -12,47 +13,70 @@ import "./application/ak-application-wizard-application-details";
import "./auth-method-choice/ak-application-wizard-authentication-method-choice"; 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";
import { ApplicationStep as ApplicationStepType } from "./types";
type NamedStep = WizardStep & { class ApplicationStep implements ApplicationStepType {
id: string; id = "application";
valid: boolean; label = "Application Details";
}; disabled = false;
valid = false;
get buttons() {
return [this.valid ? NextStep : DisabledNextStep, CancelWizard];
}
render() {
return html`<ak-application-wizard-application-details></ak-application-wizard-application-details>`;
}
}
export const newSteps = (): NamedStep[] => [ class ProviderMethodStep implements ApplicationStepType {
{ id = "provider-method";
id: "application", label = "Authentication Method";
label: "Application Details", disabled = false;
render: () => valid = false;
html`<ak-application-wizard-application-details></ak-application-wizard-application-details>`,
disabled: false, get buttons() {
valid: false, return [BackStep, this.valid ? NextStep : DisabledNextStep, CancelWizard];
buttons: [NextStep, CancelWizard], }
},
{ render() {
id: "provider-method", // prettier-ignore
label: "Authentication Method", return html`<ak-application-wizard-authentication-method-choice
render: () => ></ak-application-wizard-authentication-method-choice> `;
html`<ak-application-wizard-authentication-method-choice></ak-application-wizard-authentication-method-choice>`, }
disabled: false, }
valid: false,
buttons: [NextStep, BackStep, CancelWizard], class ProviderStepDetails implements ApplicationStepType {
}, id = "provider-details";
{ label = "Authentication Details";
id: "provider-details", disabled = true;
label: "Authentication Details", valid = false;
render: () => get buttons() {
html`<ak-application-wizard-authentication-method></ak-application-wizard-authentication-method>`, return [BackStep, this.valid ? SubmitStep : DisabledNextStep, CancelWizard];
disabled: true, }
valid: false,
buttons: [SubmitStep, BackStep, CancelWizard], render() {
}, return html`<ak-application-wizard-authentication-method></ak-application-wizard-authentication-method>`;
{ }
id: "submit", }
label: "Submit New Application",
render: () => class SubmitApplicationStep implements ApplicationStepType {
html`<ak-application-wizard-commit-application></ak-application-wizard-commit-application>`, id = "submit";
disabled: true, label = "Submit New Application";
valid: false, disabled = true;
buttons: [BackStep, CancelWizard], valid = false;
},
get buttons() {
return this.valid ? [CloseWizard] : [BackStep, CancelWizard];
}
render() {
return html`<ak-application-wizard-commit-application></ak-application-wizard-commit-application>`;
}
}
export const newSteps = (): ApplicationStep[] => [
new ApplicationStep(),
new ProviderMethodStep(),
new ProviderStepDetails(),
new SubmitApplicationStep(),
]; ];

View file

@ -3,14 +3,14 @@ import { customElement } from "@lit/reactive-element/decorators/custom-element.j
import { state } from "@lit/reactive-element/decorators/state.js"; import { state } from "@lit/reactive-element/decorators/state.js";
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import applicationWizardContext from "../ak-application-wizard-context-name"; import applicationWizardContext from "../ContextIdentity";
import type { WizardState } from "../types"; import type { ApplicationWizardState } from "../types";
@customElement("ak-application-context-display-for-test") @customElement("ak-application-context-display-for-test")
export class ApplicationContextDisplayForTest extends LitElement { export class ApplicationContextDisplayForTest extends LitElement {
@consume({ context: applicationWizardContext, subscribe: true }) @consume({ context: applicationWizardContext, subscribe: true })
@state() @state()
private wizard!: WizardState; private wizard!: ApplicationWizardState;
render() { render() {
return html`<div><pre>${JSON.stringify(this.wizard, null, 2)}</pre></div>`; return html`<div><pre>${JSON.stringify(this.wizard, null, 2)}</pre></div>`;

View file

@ -1,12 +1,14 @@
import { type WizardStep } from "@goauthentik/components/ak-wizard-main/types";
import { import {
ApplicationRequest, type ApplicationRequest,
LDAPProviderRequest, type LDAPProviderRequest,
OAuth2ProviderRequest, type OAuth2ProviderRequest,
ProvidersSamlImportMetadataCreateRequest, type ProvidersSamlImportMetadataCreateRequest,
ProxyProviderRequest, type ProxyProviderRequest,
RadiusProviderRequest, type RadiusProviderRequest,
SAMLProviderRequest, type SAMLProviderRequest,
SCIMProviderRequest, type SCIMProviderRequest,
} from "@goauthentik/api"; } from "@goauthentik/api";
export type OneOfProvider = export type OneOfProvider =
@ -18,7 +20,7 @@ export type OneOfProvider =
| Partial<OAuth2ProviderRequest> | Partial<OAuth2ProviderRequest>
| Partial<LDAPProviderRequest>; | Partial<LDAPProviderRequest>;
export interface WizardState { export interface ApplicationWizardState {
providerModel: string; providerModel: string;
app: Partial<ApplicationRequest>; app: Partial<ApplicationRequest>;
provider: OneOfProvider; provider: OneOfProvider;
@ -26,7 +28,12 @@ export interface WizardState {
type StatusType = "invalid" | "valid" | "submitted" | "failed"; type StatusType = "invalid" | "valid" | "submitted" | "failed";
export type WizardStateUpdate = { export type ApplicationWizardStateUpdate = {
update?: Partial<WizardState>; update?: Partial<ApplicationWizardState>;
status?: StatusType; status?: StatusType;
}; };
export type ApplicationStep = WizardStep & {
id: string;
valid: boolean;
};

View file

@ -0,0 +1,121 @@
import "@goauthentik/app/components/ak-wizard-main/ak-wizard-frame";
import { AKElement } from "@goauthentik/elements/Base";
import { msg } from "@lit/localize";
import { ReactiveControllerHost, html } from "lit";
import { state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { Ref, createRef, ref } from "lit/directives/ref.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { AkWizardController } from "./AkWizardController";
import { AkWizardFrame } from "./ak-wizard-frame";
import { type WizardStep, type WizardStepLabel } from "./types";
/**
* Abstract parent class for wizards. This Class activates the Controller, provides the default
* renderer and handleNav() functions, and organizes the various texts used to describe a Modal
* Wizard's interaction: its prompt, header, and description.
*/
export class AkWizard<D, Step extends WizardStep = WizardStep>
extends AKElement
implements ReactiveControllerHost
{
// prettier-ignore
static get styles() { return [PFBase, PFButton]; }
@state()
steps: Step[] = [];
@state()
currentStep = 0;
/**
* A reference to the frame. Since the frame implements and inherits from ModalButton,
* you will need either a reference to or query to the frame in order to call
* `.close()` on it.
*/
frame: Ref<AkWizardFrame> = createRef();
get step() {
return this.steps[this.currentStep];
}
prompt = msg("Create");
header: string;
description?: string;
wizard: AkWizardController<D>;
constructor(prompt: string, header: string, description?: string) {
super();
this.header = header;
this.prompt = prompt;
this.description = description;
this.wizard = new AkWizardController(this);
}
/**
* Derive the labels used by the frame's Breadcrumbs display.
*/
get stepLabels(): WizardStepLabel[] {
let disabled = false;
return this.steps.map((step, index) => {
disabled = disabled || step.disabled;
return {
label: step.label,
active: index === this.currentStep,
index,
disabled,
};
});
}
/**
* You should still consider overriding this if you need to consider details like "Is the step
* requested valid?"
*/
handleNav(stepId: number | undefined) {
if (stepId === undefined || this.steps[stepId] === undefined) {
throw new Error(`Attempt to navigate to undefined step: ${stepId}`);
}
this.currentStep = stepId;
this.requestUpdate();
}
close() {
throw new Error("This function must be overridden in the child class.");
}
/**
* This is where all the business logic and special cases go. The Wizard Controller intercepts
* updates tagged `ak-wizard-update` and forwards the event content here. Business logic about
* "is the current step valid?" and "should the Next button be made enabled" are controlled
* here. (Any step implementing WizardStep can do it anyhow it pleases, putting "is the current
* form valid" and so forth into the step object itself.)
*/
handleUpdate(_detail: D) {
throw new Error("This function must be overridden in the child class.");
}
render() {
return html`
<ak-wizard-frame
${ref(this.frame)}
header=${this.header}
description=${ifDefined(this.description)}
prompt=${this.prompt}
.buttons=${this.step.buttons}
.stepLabels=${this.stepLabels}
.form=${this.step.render.bind(this.step)}
>
<button slot="trigger" class="pf-c-button pf-m-primary">${this.prompt}</button>
</ak-wizard-frame>
`;
}
}

View file

@ -0,0 +1,104 @@
import { type ReactiveController } from "lit";
import { type AkWizard, type WizardNavCommand } from "./types";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isCustomEvent = (v: any): v is CustomEvent =>
v instanceof CustomEvent && "detail" in v;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isNavEvent = (v: any): v is CustomEvent<WizardNavCommand> =>
isCustomEvent(v) && "command" in v.detail;
/**
* AkWizardController
*
* A ReactiveController that plugs into any wizard and provides a somewhat more convenient API for
* interacting with that wizard. It expects three different events from the wizard frame, each of
* which has a corresponding method that then forwards the necessary information to the host:
*
* - nav: A request to navigate to different step. Calls the host's `handleNav()` with the requested
step number.
* - update: A request to update the content of the current step. Forwarded to the host's
* `handleUpdate()` method.
* - close: A request to end the wizard interaction. Forwarded to the host's `close()` method.
*
*/
export class AkWizardController<Data> implements ReactiveController {
private host: AkWizard<Data>;
constructor(host: AkWizard<Data>) {
this.host = host;
this.handleNavRequest = this.handleNavRequest.bind(this);
this.handleUpdateRequest = this.handleUpdateRequest.bind(this);
host.addController(this);
}
get maxStep() {
return this.host.steps.length - 1;
}
get nextStep() {
return this.host.currentStep < this.maxStep ? this.host.currentStep + 1 : undefined;
}
get backStep() {
return this.host.currentStep > 0 ? this.host.currentStep - 1 : undefined;
}
get step() {
return this.host.steps[this.host.currentStep];
}
hostConnected() {
this.host.addEventListener("ak-wizard-nav", this.handleNavRequest);
this.host.addEventListener("ak-wizard-update", this.handleUpdateRequest);
this.host.addEventListener("ak-wizard-closed", this.handleCloseRequest);
}
hostDisconnected() {
this.host.removeEventListener("ak-wizard-nav", this.handleNavRequest);
this.host.removeEventListener("ak-wizard-update", this.handleUpdateRequest);
this.host.removeEventListener("ak-wizard-closed", this.handleCloseRequest);
}
handleNavRequest(event: Event) {
if (!isNavEvent(event)) {
throw new Error(`Unexpected event received by nav handler: ${event}`);
}
if (event.detail.command === "close") {
this.host.close();
return;
}
const navigate = (): number | undefined => {
switch (event.detail.command) {
case "next":
return this.nextStep;
case "back":
return this.backStep;
case "goto":
return event.detail.step;
default:
throw new Error(
`Unrecognized command passed to ak-wizard-controller:handleNavRequest: ${event.detail.command}`,
);
}
};
this.host.handleNav(navigate());
}
handleUpdateRequest(event: Event) {
if (!isCustomEvent(event)) {
throw new Error(`Unexpected event received by nav handler: ${event}`);
}
this.host.handleUpdate(event.detail);
}
handleCloseRequest() {
this.host.close();
}
}

View file

@ -3,13 +3,13 @@ import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { customElement, property, query } from "@lit/reactive-element/decorators.js"; import { customElement, property, query } from "@lit/reactive-element/decorators.js";
import { html, nothing } from "lit"; import { TemplateResult, html, nothing } from "lit";
import { classMap } from "lit/directives/class-map.js"; import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js"; import { map } from "lit/directives/map.js";
import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css"; import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css";
import { type WizardButton, type WizardStep } from "./types"; import { type WizardButton, WizardStepLabel } from "./types";
/** /**
* AKWizardFrame is the main container for displaying Wizard pages. * AKWizardFrame is the main container for displaying Wizard pages.
@ -22,10 +22,9 @@ import { type WizardButton, type WizardStep } from "./types";
* *
* @element ak-wizard-frame * @element ak-wizard-frame
* *
* @fires ak-wizard-nav - Tell the orchestrator what page the user wishes to move to. This is the * @slot - Where the form itself should go
* only event that causes this wizard to change its appearance.
* *
* NOTE: The event name is configurable as an attribute. * @fires ak-wizard-nav - Tell the orchestrator what page the user wishes to move to.
* *
*/ */
@ -35,47 +34,58 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
return [...super.styles, PFWizard]; return [...super.styles, PFWizard];
} }
/* Prop-drilled. Do not alter. */ /**
* The text for the title of the wizard
*/
@property() @property()
header?: string; header?: string;
/**
* The text for a descriptive subtitle for the wizard
*/
@property() @property()
description?: string; description?: string;
@property() /**
eventName: string = "ak-wizard-nav"; * The labels for all current steps, including their availability
*/
@property({ attribute: false, type: Array }) @property({ attribute: false, type: Array })
steps!: WizardStep[]; stepLabels!: WizardStepLabel[];
@property({ attribute: false, type: Number }) /**
currentStep!: number; * What buttons to Show
*/
@property({ attribute: false, type: Array })
buttons: WizardButton[] = [];
/**
* Show the [Cancel] icon and offer the [Cancel] button
*/
@property({ type: Boolean, attribute: "can-cancel" })
canCancel = false;
/**
* The form renderer, passed as a function
*/
@property({ type: Object })
form!: () => TemplateResult;
@query("#main-content *:first-child") @query("#main-content *:first-child")
content!: HTMLElement; content!: HTMLElement;
@property({ type: Boolean })
canCancel!: boolean;
get step() {
const step = this.steps[this.currentStep];
if (!step) {
throw new Error(`Request for step that does not exist: ${this.currentStep}`);
}
return step;
}
constructor() { constructor() {
super(); super();
this.renderButtons = this.renderButtons.bind(this); this.renderButtons = this.renderButtons.bind(this);
} }
renderModalInner() { renderModalInner() {
// prettier-ignore
return html`<div class="pf-c-wizard"> return html`<div class="pf-c-wizard">
${this.renderHeader()} ${this.renderHeader()}
<div class="pf-c-wizard__outer-wrap"> <div class="pf-c-wizard__outer-wrap">
<div class="pf-c-wizard__inner-wrap"> <div class="pf-c-wizard__inner-wrap">
${this.renderNavigation()} ${this.renderMainSection()} ${this.renderNavigation()}
${this.renderMainSection()}
</div> </div>
${this.renderFooter()} ${this.renderFooter()}
</div> </div>
@ -95,38 +105,38 @@ export class AkWizardFrame extends CustomEmitterElement(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=${() => this.dispatchCustomEvent(this.eventName, { command: "close" })} @click=${() => this.dispatchCustomEvent("ak-wizard-nav", { command: "close" })}
> >
<i class="fas fa-times" aria-hidden="true"></i> <i class="fas fa-times" aria-hidden="true"></i>
</button>`; </button>`;
} }
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.stepLabels.map((step) => {
disabled = disabled || this.step.disabled; return this.renderNavigationStep(step);
return this.renderNavigationStep(step, disabled, idx);
})} })}
</ol> </ol>
</nav>`; </nav>`;
} }
renderNavigationStep(step: WizardStep, disabled: boolean, idx: number) { renderNavigationStep(step: WizardStepLabel) {
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": step.active,
}; };
return html` return html`
<li class="pf-c-wizard__nav-item"> <li class="pf-c-wizard__nav-item">
<button <button
class=${classMap(buttonClasses)} class=${classMap(buttonClasses)}
?disabled=${disabled} ?disabled=${step.disabled}
@click=${() => @click=${() =>
this.dispatchCustomEvent(this.eventName, { command: "goto", step: idx })} this.dispatchCustomEvent("ak-wizard-nav", {
command: "goto",
step: step.index,
})}
> >
${step.label} ${step.label}
</button> </button>
@ -138,24 +148,22 @@ 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.render()}</div> <div id="main-content" class="pf-c-wizard__main-body">${this.form()}</div>
</main>`; </main>`;
} }
renderFooter() { renderFooter() {
return html` return html`
<footer class="pf-c-wizard__footer"> <footer class="pf-c-wizard__footer">${map(this.buttons, this.renderButtons)}</footer>
${map(this.step.buttons, this.renderButtons)}
</footer>
`; `;
} }
renderButtons([label, command]: WizardButton) { renderButtons([label, command]: WizardButton) {
switch (command) { switch (command.command) {
case "next": case "next":
return this.renderButton(label, "pf-m-primary", command); return this.renderButton(label, "pf-m-primary", command.command);
case "back": case "back":
return this.renderButton(label, "pf-m-secondary", command); return this.renderButton(label, "pf-m-secondary", command.command);
case "close": case "close":
return this.renderLink(label, "pf-m-link"); return this.renderLink(label, "pf-m-link");
default: default:
@ -169,7 +177,7 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
class=${classMap(buttonClasses)} class=${classMap(buttonClasses)}
type="button" type="button"
@click=${() => { @click=${() => {
this.dispatchCustomEvent(this.eventName, { command }); this.dispatchCustomEvent("ak-wizard-nav", { command });
}} }}
> >
${label} ${label}
@ -182,7 +190,7 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
<button <button
class=${classMap(buttonClasses)} class=${classMap(buttonClasses)}
type="button" type="button"
@click=${() => this.dispatchCustomEvent(this.eventName, { command: "close" })} @click=${() => this.dispatchCustomEvent("ak-wizard-nav", { command: "close" })}
> >
${label} ${label}
</button> </button>

View file

@ -1,201 +0,0 @@
import { AKElement } from "@goauthentik/elements/Base";
import {
CustomEmitterElement,
CustomListenerElement,
} from "@goauthentik/elements/utils/eventEmitter";
import { html } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import "./ak-wizard-frame";
import { AkWizardFrame } from "./ak-wizard-frame";
import type { WizardPanel, WizardStep } from "./types";
// Not just a check that it has a validator, but a check that satisfies Typescript that we're using
// it correctly; anything within the hasValidator conditional block will know it's dealing with
// a fully operational WizardPanel.
//
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasValidator = (v: any): v is Required<Pick<WizardPanel, "validator">> =>
"validator" in v && typeof v.validator === "function";
/**
* AKWizardMain
*
* @element ak-wizard-main
*
* This is the controller for a multi-form wizard. It provides an interface for describing a pop-up
* (modal) wizard, the contents of which are independent of the navigation. This controller only
* handles the navigation.
*
* Each step (see the `types.ts` file) provides label, a "currently valid" boolean, a "disabled"
* boolean, a function that returns the HTML of the object to be rendered, a `disabled` flag
* indicating
Its tasks are:
* - keep the collection of steps
* - maintain the open/close status of the modal
* - listens for navigation events
* - if a navigation event is valid, switch to the panel requested
*
*
*/
@customElement("ak-wizard-main")
export class AkWizardMain extends CustomEmitterElement(CustomListenerElement(AKElement)) {
static get styles() {
return [PFBase, PFButton, PFRadio];
}
@property()
eventName: string = "ak-wizard-nav";
/**
* The steps of the Wizard.
*
* @attribute
*/
@property({ attribute: false })
steps: WizardStep[] = [];
/**
* The current step of the wizard.
*
* @attribute
*/
@state()
currentStep: number = 0;
constructor() {
super();
this.handleNavigation = this.handleNavigation.bind(this);
}
/**
* The text of the modal button
*
* @attribute
*/
@property({ type: String })
prompt = "Show Wizard";
/**
* The text of the header on the wizard, upper bar.
*
* @attribute
*/
@property()
header!: string;
/**
* The text of the description under the header.
*
* @attribute
*/
@property()
description?: string;
/**
* Whether or not to show the "cancel" button in the wizard.
*
* @attribute
*/
@property({ type: Boolean })
canCancel!: boolean;
@query("ak-wizard-frame")
frame!: AkWizardFrame;
connectedCallback() {
super.connectedCallback();
this.addCustomListener(this.eventName, this.handleNavigation);
}
disconnectedCallback() {
this.removeCustomListener(this.eventName, this.handleNavigation);
super.disconnectedCallback();
}
get maxStep() {
return this.steps.length - 1;
}
get nextStep() {
return this.currentStep < this.maxStep ? this.currentStep + 1 : undefined;
}
get backStep() {
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;
switch (command) {
case "back": {
if (this.backStep !== undefined && this.steps[this.backStep]) {
this.currentStep = this.backStep;
}
return;
}
case "goto": {
if (
typeof event.detail.step === "number" &&
event.detail.step >= 0 &&
event.detail.step <= this.maxStep
)
this.currentStep = event.detail.step;
return;
}
case "next": {
if (
this.nextStep &&
this.steps[this.nextStep] &&
!this.steps[this.nextStep].disabled &&
this.validated
) {
this.currentStep = this.nextStep;
}
return;
}
case "close": {
this.currentStep = 0;
this.frame.open = false;
this.dispatchCustomEvent("ak-wizard-closed");
}
}
}
get validated() {
if (hasValidator(this.frame.content)) {
return this.frame.content.validator();
}
return true;
}
render() {
return html`
<ak-wizard-frame
?canCancel=${this.canCancel}
header=${this.header}
description=${ifDefined(this.description)}
eventName=${this.eventName}
.steps=${this.steps}
.currentStep=${this.currentStep}
>
<button slot="trigger" class="pf-c-button pf-m-primary">${this.prompt}</button>
</ak-wizard-frame>
`;
}
}
export default AkWizardMain;

View file

@ -1,9 +0,0 @@
import { createContext } from "@lit-labs/context";
import { WizardStep } from "./types";
export const akWizardCurrentStepContextName = createContext<WizardStep>(
Symbol("ak-wizard-current-step"),
);
export default akWizardCurrentStepContextName;

View file

@ -1,7 +0,0 @@
import { createContext } from "@lit-labs/context";
import { WizardStep } from "./types";
export const akWizardStepsContextName = createContext<WizardStep[]>(Symbol("ak-wizard-steps"));
export default akWizardStepsContextName;

View file

@ -2,12 +2,14 @@ import { msg } from "@lit/localize";
import { WizardButton } from "./types"; import { WizardButton } from "./types";
export const NextStep: WizardButton = [msg("Next"), "next"]; export const NextStep: WizardButton = [msg("Next"), { command: "next" }];
export const BackStep: WizardButton = [msg("Back"), "back"]; export const BackStep: WizardButton = [msg("Back"), { command: "back" }];
export const SubmitStep: WizardButton = [msg("Submit"), "next"]; export const SubmitStep: WizardButton = [msg("Submit"), { command: "next" }];
export const CancelWizard: WizardButton = [msg("Cancel"), "close"]; export const CancelWizard: WizardButton = [msg("Cancel"), { command: "close" }];
export const CloseWizard: WizardButton = [msg("Close"), "close"]; export const CloseWizard: WizardButton = [msg("Close"), { command: "close" }];
export const DisabledNextStep: WizardButton = [msg("Next"), { command: "next" }, true];

View file

@ -1,4 +0,0 @@
import "./ak-wizard-main";
import type { WizardStep } from "./types";
export { WizardStep };

View file

@ -3,8 +3,8 @@ import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import AkWizard from "../ak-wizard-frame";
import "../ak-wizard-main"; import "../ak-wizard-main";
import AkWizard from "../ak-wizard-main";
import { BackStep, CancelWizard, CloseWizard, NextStep } from "../commonWizardButtons"; import { BackStep, CancelWizard, CloseWizard, NextStep } from "../commonWizardButtons";
import type { WizardStep } from "../types"; import type { WizardStep } from "../types";

View file

@ -1,11 +1,61 @@
import { TemplateResult } from "lit"; import { type LitElement, type ReactiveControllerHost, type TemplateResult } from "lit";
export type WizardNavCommand = "next" | "back" | "close" | ["goto", number]; /** These are the navigation commands that the frame will send up to the controller. In the
* accompanying file, `./commonWizardButtons.ts`, you'll find a variety of Next, Back, Close,
* Cancel, and Submit buttons that can be used to send these, but these commands are also
* used by the breadcrumbs to hop around the wizard (if the wizard client so chooses to allow),
*/
// The label of the button, the command the button should execute, and if the button export type WizardNavCommand =
// should be marked "disabled." | { command: "next" }
| { command: "back" }
| { command: "close" }
| { command: "goto"; step: number };
/**
* The pattern for buttons being passed to the wizard. See `./commonWizardButtons.ts` for
* example implementations. The details are: Label, Command, and Disabled.
*/
export type WizardButton = [string, WizardNavCommand, boolean?]; export type WizardButton = [string, WizardNavCommand, boolean?];
/**
* Objects of this type are produced by the Controller, and are used in the Breadcrumbs to
* indicate the name of the step, whether or not it is the current step ("active"), and
* whether or not it is disabled. It is up to WizardClients to ensure that a step is
* not both "active" and "disabled".
*/
export type WizardStepLabel = {
label: string;
index: number;
active: boolean;
disabled: boolean;
};
type LitControllerHost = ReactiveControllerHost & LitElement;
export interface AkWizard<D> extends LitControllerHost {
// Every wizard must provide a list of the steps to show. This list can change, but if it does,
// note that the *first* page must never change, and it's the responsibility of the developer to
// ensure that if the list changes that the currentStep points to the right place.
steps: WizardStep[];
// The index of the current step;
currentStep: number;
// An accessor to the current step;
step: WizardStep;
// Handle pressing the "close," "cancel," or "done" buttons.
close: () => void;
// When a navigation event such as "next," "back," or "go to" (from the breadcrumbs) occurs.
handleNav: (_1: number | undefined) => void;
// When a notification that the data on the live form has changed.
handleUpdate: (_1: D) => void;
}
export interface WizardStep { export interface WizardStep {
// The name of the step, as shown in the navigation. // The name of the step, as shown in the navigation.
label: string; label: string;
@ -14,19 +64,10 @@ export interface WizardStep {
// such. // such.
render: () => 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. If you can,
// semantics of the buttons are simple: 'next' will navigate to currentStep + 1, 'back' will // always lead with the [Back] button and ensure it's in the same place every time. The
// navigate to currentStep - 1, 'close' will close the window, and ['goto', number] will // controller's current behavior is to call the host's `handleNav()` command with the index of
// navigate to a specific step in order. // the requested step, or undefined if the command is nonsensical.
//
// It is possible for the controlling component that wraps ak-wizard-main to supply a modified
// collection of steps at any time, thus altering the behavior of future steps, or providing a
// tree-like structure to the wizard.
//
// Note that if you change the steps radically (inserting some in front of the currentStep,
// which is something you should never, ever do... never, ever make the customer go backward to
// solve a problem that was your responsibility. "Going back" to fix their own mistakes is, of
// course, their responsibility) you may have to set the currentStep as well.
buttons: WizardButton[]; buttons: WizardButton[];
// If this step is "disabled," the prior step's next button will be disabled. // If this step is "disabled," the prior step's next button will be disabled.