diff --git a/web/src/admin/applications/wizard/BasePanel.ts b/web/src/admin/applications/wizard/BasePanel.ts index f05396b8b..a395fc4b3 100644 --- a/web/src/admin/applications/wizard/BasePanel.ts +++ b/web/src/admin/applications/wizard/BasePanel.ts @@ -7,8 +7,8 @@ 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, WizardStateUpdate } from "./types"; +import { applicationWizardContext } from "./ContextIdentity"; +import type { ApplicationWizardState, ApplicationWizardStateUpdate } from "./types"; export class ApplicationWizardPageBase extends CustomEmitterElement(AKElement) @@ -24,11 +24,11 @@ export class ApplicationWizardPageBase rendered = false; @consume({ context: applicationWizardContext }) - public wizard!: WizardState; + public wizard!: ApplicationWizardState; // This used to be more complex; now it just establishes the event name. - dispatchWizardUpdate(update: WizardStateUpdate) { - this.dispatchCustomEvent("ak-application-wizard-update", update); + dispatchWizardUpdate(update: ApplicationWizardStateUpdate) { + this.dispatchCustomEvent("ak-wizard-update", update); } } diff --git a/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts b/web/src/admin/applications/wizard/ContextIdentity.ts similarity index 53% rename from web/src/admin/applications/wizard/ak-application-wizard-context-name.ts rename to web/src/admin/applications/wizard/ContextIdentity.ts index 5a8c95eb9..f03f147a6 100644 --- a/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts +++ b/web/src/admin/applications/wizard/ContextIdentity.ts @@ -1,8 +1,9 @@ import { createContext } from "@lit-labs/context"; -import { WizardState } from "./types"; +import { ApplicationWizardState } from "./types"; -export const applicationWizardContext = createContext( +export const applicationWizardContext = createContext( Symbol("ak-application-wizard-state-context"), ); + export default applicationWizardContext; diff --git a/web/src/admin/applications/wizard/ak-application-wizard.ts b/web/src/admin/applications/wizard/ak-application-wizard.ts index e844e0be2..387fd82ce 100644 --- a/web/src/admin/applications/wizard/ak-application-wizard.ts +++ b/web/src/admin/applications/wizard/ak-application-wizard.ts @@ -1,23 +1,19 @@ -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 { CloseWizard } from "@goauthentik/components/ak-wizard-main/commonWizardButtons"; -import { AKElement } from "@goauthentik/elements/Base"; +import { AkWizard } from "@goauthentik/components/ak-wizard-main/AkWizard"; 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 { CSSResult, html } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { type Ref, createRef, ref } from "lit/directives/ref.js"; +import { customElement, state } from "lit/decorators.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 applicationWizardContext from "./ak-application-wizard-context-name"; +import applicationWizardContext from "./ContextIdentity"; import { newSteps } from "./steps"; -import { OneOfProvider, WizardState, WizardStateUpdate } from "./types"; +import { + ApplicationStep, + ApplicationWizardState, + ApplicationWizardStateUpdate, + OneOfProvider, +} from "./types"; const freshWizardState = () => ({ providerModel: "", @@ -26,55 +22,41 @@ const freshWizardState = () => ({ }); @customElement("ak-application-wizard") -export class ApplicationWizard extends CustomListenerElement(AKElement) { - static get styles(): CSSResult[] { - return [PFBase, PFButton, PFRadio]; +export class ApplicationWizard extends CustomListenerElement( + AkWizard, +) { + 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, { context: applicationWizardContext, initialValue: this.wizardState, }); - @state() - steps = newSteps(); - - @property() - prompt = msg("Create"); - + /** + * 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 + * back and type it all back in," even if it's probably rare that someone will chose one + * 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 = new Map(); - wizardRef: Ref = 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 { if ( providerModel === undefined || @@ -98,51 +80,46 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) { } // And this is where all the special cases go... - handleUpdate(event: CustomEvent) { - if (event.detail.status === "submitted") { - const submitStep = this.steps.find(({ id }) => id === "submit"); - if (!submitStep) { - throw new Error("Could not find submit step?"); - } - submitStep.buttons = [CloseWizard]; - this.steps = [...this.steps]; + handleUpdate(detail: ApplicationWizardStateUpdate) { + if (detail.status === "submitted") { + this.step.valid = true; + this.requestUpdate(); return; } - const update = event.detail.update; + this.step.valid = this.step.valid || detail.status === "valid"; + + const update = detail.update; if (!update) { return; } if (this.maybeProviderSwap(update.providerModel)) { - this.steps = [...this.steps]; + this.requestUpdate(); } - 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; + this.wizardState = merge(this.wizardState, update) as ApplicationWizardState; this.wizardStateProvider.setValue(this.wizardState); + this.requestUpdate(); } - handleClosed() { + close() { this.steps = newSteps(); + this.currentStep = 0; this.wizardState = freshWizardState(); + this.providerCache = new Map(); this.wizardStateProvider.setValue(this.wizardState); + this.frame.value!.open = false; } - render() { - return html` - - - `; + handleNav(stepId: number | undefined) { + if (stepId === undefined || this.steps[stepId] === undefined) { + throw new Error(`Attempt to navigate to undefined step: ${stepId}`); + } + if (stepId > this.currentStep && !this.step.valid) { + return; + } + this.currentStep = stepId; + this.requestUpdate(); } } diff --git a/web/src/admin/applications/wizard/steps.ts b/web/src/admin/applications/wizard/steps.ts index 410a2b00a..a04cc0bee 100644 --- a/web/src/admin/applications/wizard/steps.ts +++ b/web/src/admin/applications/wizard/steps.ts @@ -1,7 +1,8 @@ -import { WizardStep } from "@goauthentik/components/ak-wizard-main"; import { BackStep, CancelWizard, + CloseWizard, + DisabledNextStep, NextStep, SubmitStep, } 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 "./commit/ak-application-wizard-commit-application"; import "./methods/ak-application-wizard-authentication-method"; +import { ApplicationStep as ApplicationStepType } from "./types"; -type NamedStep = WizardStep & { - id: string; - valid: boolean; -}; +class ApplicationStep implements ApplicationStepType { + id = "application"; + label = "Application Details"; + disabled = false; + valid = false; + get buttons() { + return [this.valid ? NextStep : DisabledNextStep, CancelWizard]; + } + render() { + return html``; + } +} -export const newSteps = (): NamedStep[] => [ - { - id: "application", - label: "Application Details", - render: () => - html``, - disabled: false, - valid: false, - buttons: [NextStep, CancelWizard], - }, - { - id: "provider-method", - label: "Authentication Method", - render: () => - html``, - disabled: false, - valid: false, - buttons: [NextStep, BackStep, CancelWizard], - }, - { - id: "provider-details", - label: "Authentication Details", - render: () => - html``, - disabled: true, - valid: false, - buttons: [SubmitStep, BackStep, CancelWizard], - }, - { - id: "submit", - label: "Submit New Application", - render: () => - html``, - disabled: true, - valid: false, - buttons: [BackStep, CancelWizard], - }, +class ProviderMethodStep implements ApplicationStepType { + id = "provider-method"; + label = "Authentication Method"; + disabled = false; + valid = false; + + get buttons() { + return [BackStep, this.valid ? NextStep : DisabledNextStep, CancelWizard]; + } + + render() { + // prettier-ignore + return html` `; + } +} + +class ProviderStepDetails implements ApplicationStepType { + id = "provider-details"; + label = "Authentication Details"; + disabled = true; + valid = false; + get buttons() { + return [BackStep, this.valid ? SubmitStep : DisabledNextStep, CancelWizard]; + } + + render() { + return html``; + } +} + +class SubmitApplicationStep implements ApplicationStepType { + id = "submit"; + label = "Submit New Application"; + disabled = true; + valid = false; + + get buttons() { + return this.valid ? [CloseWizard] : [BackStep, CancelWizard]; + } + + render() { + return html``; + } +} + +export const newSteps = (): ApplicationStep[] => [ + new ApplicationStep(), + new ProviderMethodStep(), + new ProviderStepDetails(), + new SubmitApplicationStep(), ]; diff --git a/web/src/admin/applications/wizard/stories/ak-application-context-display-for-test.ts b/web/src/admin/applications/wizard/stories/ak-application-context-display-for-test.ts index 6abd7563e..fa418199e 100644 --- a/web/src/admin/applications/wizard/stories/ak-application-context-display-for-test.ts +++ b/web/src/admin/applications/wizard/stories/ak-application-context-display-for-test.ts @@ -3,14 +3,14 @@ import { customElement } from "@lit/reactive-element/decorators/custom-element.j import { state } from "@lit/reactive-element/decorators/state.js"; import { LitElement, html } from "lit"; -import applicationWizardContext from "../ak-application-wizard-context-name"; -import type { WizardState } from "../types"; +import applicationWizardContext from "../ContextIdentity"; +import type { ApplicationWizardState } from "../types"; @customElement("ak-application-context-display-for-test") export class ApplicationContextDisplayForTest extends LitElement { @consume({ context: applicationWizardContext, subscribe: true }) @state() - private wizard!: WizardState; + private wizard!: ApplicationWizardState; render() { return html`
${JSON.stringify(this.wizard, null, 2)}
`; diff --git a/web/src/admin/applications/wizard/types.ts b/web/src/admin/applications/wizard/types.ts index 95126b56d..0ebe7aa8a 100644 --- a/web/src/admin/applications/wizard/types.ts +++ b/web/src/admin/applications/wizard/types.ts @@ -1,12 +1,14 @@ +import { type WizardStep } from "@goauthentik/components/ak-wizard-main/types"; + import { - ApplicationRequest, - LDAPProviderRequest, - OAuth2ProviderRequest, - ProvidersSamlImportMetadataCreateRequest, - ProxyProviderRequest, - RadiusProviderRequest, - SAMLProviderRequest, - SCIMProviderRequest, + type ApplicationRequest, + type LDAPProviderRequest, + type OAuth2ProviderRequest, + type ProvidersSamlImportMetadataCreateRequest, + type ProxyProviderRequest, + type RadiusProviderRequest, + type SAMLProviderRequest, + type SCIMProviderRequest, } from "@goauthentik/api"; export type OneOfProvider = @@ -18,7 +20,7 @@ export type OneOfProvider = | Partial | Partial; -export interface WizardState { +export interface ApplicationWizardState { providerModel: string; app: Partial; provider: OneOfProvider; @@ -26,7 +28,12 @@ export interface WizardState { type StatusType = "invalid" | "valid" | "submitted" | "failed"; -export type WizardStateUpdate = { - update?: Partial; +export type ApplicationWizardStateUpdate = { + update?: Partial; status?: StatusType; }; + +export type ApplicationStep = WizardStep & { + id: string; + valid: boolean; +}; diff --git a/web/src/components/ak-wizard-main/AkWizard.ts b/web/src/components/ak-wizard-main/AkWizard.ts new file mode 100644 index 000000000..48e827c70 --- /dev/null +++ b/web/src/components/ak-wizard-main/AkWizard.ts @@ -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 + 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 = createRef(); + + get step() { + return this.steps[this.currentStep]; + } + + prompt = msg("Create"); + + header: string; + + description?: string; + + wizard: AkWizardController; + + 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` + + + + `; + } +} diff --git a/web/src/components/ak-wizard-main/AkWizardController.ts b/web/src/components/ak-wizard-main/AkWizardController.ts new file mode 100644 index 000000000..f5c2a23d3 --- /dev/null +++ b/web/src/components/ak-wizard-main/AkWizardController.ts @@ -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 => + 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 implements ReactiveController { + private host: AkWizard; + + constructor(host: AkWizard) { + 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(); + } +} diff --git a/web/src/components/ak-wizard-main/ak-wizard-frame.ts b/web/src/components/ak-wizard-main/ak-wizard-frame.ts index c950631d5..a0f320095 100644 --- a/web/src/components/ak-wizard-main/ak-wizard-frame.ts +++ b/web/src/components/ak-wizard-main/ak-wizard-frame.ts @@ -3,13 +3,13 @@ import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { msg } from "@lit/localize"; 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 { map } from "lit/directives/map.js"; 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. @@ -22,10 +22,9 @@ import { type WizardButton, type WizardStep } from "./types"; * * @element ak-wizard-frame * - * @fires ak-wizard-nav - Tell the orchestrator what page the user wishes to move to. This is the - * only event that causes this wizard to change its appearance. + * @slot - Where the form itself should go * - * 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]; } - /* Prop-drilled. Do not alter. */ + /** + * The text for the title of the wizard + */ @property() header?: string; + /** + * The text for a descriptive subtitle for the wizard + */ @property() description?: string; - @property() - eventName: string = "ak-wizard-nav"; - + /** + * The labels for all current steps, including their availability + */ @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") 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() { super(); this.renderButtons = this.renderButtons.bind(this); } renderModalInner() { + // prettier-ignore return html`
${this.renderHeader()}
- ${this.renderNavigation()} ${this.renderMainSection()} + ${this.renderNavigation()} + ${this.renderMainSection()}
${this.renderFooter()}
@@ -95,38 +105,38 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) { class="pf-c-button pf-m-plain pf-c-wizard__close" type="button" aria-label="${msg("Close")}" - @click=${() => this.dispatchCustomEvent(this.eventName, { command: "close" })} + @click=${() => this.dispatchCustomEvent("ak-wizard-nav", { command: "close" })} > `; } renderNavigation() { - let disabled = false; - return html``; } - renderNavigationStep(step: WizardStep, disabled: boolean, idx: number) { + renderNavigationStep(step: WizardStepLabel) { const buttonClasses = { "pf-c-wizard__nav-link": true, - "pf-m-current": idx === this.currentStep, + "pf-m-current": step.active, }; return html`
  • @@ -138,24 +148,22 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) { // independent context. renderMainSection() { return html`
    -
    ${this.step.render()}
    +
    ${this.form()}
    `; } renderFooter() { return html` -
    - ${map(this.step.buttons, this.renderButtons)} -
    +
    ${map(this.buttons, this.renderButtons)}
    `; } renderButtons([label, command]: WizardButton) { - switch (command) { + switch (command.command) { case "next": - return this.renderButton(label, "pf-m-primary", command); + return this.renderButton(label, "pf-m-primary", command.command); case "back": - return this.renderButton(label, "pf-m-secondary", command); + return this.renderButton(label, "pf-m-secondary", command.command); case "close": return this.renderLink(label, "pf-m-link"); default: @@ -169,7 +177,7 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) { class=${classMap(buttonClasses)} type="button" @click=${() => { - this.dispatchCustomEvent(this.eventName, { command }); + this.dispatchCustomEvent("ak-wizard-nav", { command }); }} > ${label} @@ -182,7 +190,7 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) { diff --git a/web/src/components/ak-wizard-main/ak-wizard-main.ts b/web/src/components/ak-wizard-main/ak-wizard-main.ts deleted file mode 100644 index bb6296a5e..000000000 --- a/web/src/components/ak-wizard-main/ak-wizard-main.ts +++ /dev/null @@ -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> => - "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` - - - - `; - } -} - -export default AkWizardMain; diff --git a/web/src/components/ak-wizard-main/akWizardCurrentStepContextName.ts b/web/src/components/ak-wizard-main/akWizardCurrentStepContextName.ts deleted file mode 100644 index 35ebe97a9..000000000 --- a/web/src/components/ak-wizard-main/akWizardCurrentStepContextName.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createContext } from "@lit-labs/context"; - -import { WizardStep } from "./types"; - -export const akWizardCurrentStepContextName = createContext( - Symbol("ak-wizard-current-step"), -); - -export default akWizardCurrentStepContextName; diff --git a/web/src/components/ak-wizard-main/akWizardStepsContextName.ts b/web/src/components/ak-wizard-main/akWizardStepsContextName.ts deleted file mode 100644 index 43ef4867c..000000000 --- a/web/src/components/ak-wizard-main/akWizardStepsContextName.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createContext } from "@lit-labs/context"; - -import { WizardStep } from "./types"; - -export const akWizardStepsContextName = createContext(Symbol("ak-wizard-steps")); - -export default akWizardStepsContextName; diff --git a/web/src/components/ak-wizard-main/commonWizardButtons.ts b/web/src/components/ak-wizard-main/commonWizardButtons.ts index 7bd302754..e9b531f36 100644 --- a/web/src/components/ak-wizard-main/commonWizardButtons.ts +++ b/web/src/components/ak-wizard-main/commonWizardButtons.ts @@ -2,12 +2,14 @@ import { msg } from "@lit/localize"; 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]; diff --git a/web/src/components/ak-wizard-main/index.ts b/web/src/components/ak-wizard-main/index.ts deleted file mode 100644 index 572b3befe..000000000 --- a/web/src/components/ak-wizard-main/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import "./ak-wizard-main"; -import type { WizardStep } from "./types"; - -export { WizardStep }; diff --git a/web/src/components/ak-wizard-main/stories/ak-wizard-main.stories.ts b/web/src/components/ak-wizard-main/stories/ak-wizard-main.stories.ts index 99fe0e11f..89b783061 100644 --- a/web/src/components/ak-wizard-main/stories/ak-wizard-main.stories.ts +++ b/web/src/components/ak-wizard-main/stories/ak-wizard-main.stories.ts @@ -3,8 +3,8 @@ import { Meta } from "@storybook/web-components"; import { TemplateResult, html } from "lit"; +import AkWizard from "../ak-wizard-frame"; import "../ak-wizard-main"; -import AkWizard from "../ak-wizard-main"; import { BackStep, CancelWizard, CloseWizard, NextStep } from "../commonWizardButtons"; import type { WizardStep } from "../types"; diff --git a/web/src/components/ak-wizard-main/types.ts b/web/src/components/ak-wizard-main/types.ts index 6d09ba9fd..bd29ca89b 100644 --- a/web/src/components/ak-wizard-main/types.ts +++ b/web/src/components/ak-wizard-main/types.ts @@ -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 -// should be marked "disabled." +export type WizardNavCommand = + | { 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?]; +/** + * 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 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 { // The name of the step, as shown in the navigation. label: string; @@ -14,19 +64,10 @@ export interface WizardStep { // such. 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 - // navigate to currentStep - 1, 'close' will close the window, and ['goto', number] will - // navigate to a specific step in order. - // - // 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. + // A collection of buttons, in render order, that are to be shown in the button bar. If you can, + // always lead with the [Back] button and ensure it's in the same place every time. The + // controller's current behavior is to call the host's `handleNav()` command with the index of + // the requested step, or undefined if the command is nonsensical. buttons: WizardButton[]; // If this step is "disabled," the prior step's next button will be disabled.