web: Revised navigation

After working with the navigation for awhile, I realized that it's a poor map; what I really wanted was
a controller/view pair, where events flow up to the controller and then messages on "what to draw" flow
down to the view.  It work quite well, and the wizard frame is smaller and smarter for it.

I've also moved the WDIO-driven tests into the 'tests' folder, because it (a) makes more sense to put
them there, and (b) it prevents any confusion about who's in charge of node_modules.
This commit is contained in:
Ken Sternberg 2023-09-01 14:58:33 -07:00
parent c8512c3116
commit 3b4530fb7f
20 changed files with 296 additions and 121 deletions

112
tests/wdio/.gitignore vendored Normal file
View File

@ -0,0 +1,112 @@
reports/
# Created by https://www.gitignore.io/api/node
# Edit at https://www.gitignore.io/?templates=node
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
dist
# Uncomment the public line if your project uses Gatsby
# https://nextjs.org/blog/next-9-1#public-directory-support
# https://create-react-app.dev/docs/using-the-public-folder/#docsNav
# public
# Storybook build outputs
.out
.storybook-out
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Temporary folders
tmp/
temp/
# End of https://www.gitignore.io/api/node
api/**
storybook-static/

View File

@ -4,8 +4,6 @@ help: ## Print out this help message.
sort -nr | head -1) && \
perl -ne "m/^((\w|-)*):.*##\s*(.*)/ && print(sprintf(\"%s: %s\t%s\n\", \$$1, \" \"x($$M-length(\$$1)), \$$3))" Makefile
@echo ""
@echo "Set env NODE_ENV to 'development' to run this against the dev server"
@echo ""
.PHONY: update-local-chromedriver
update-local-chromedriver: ## Update the chrome driver to match the local chrome version, restoring package.json

View File

@ -1 +0,0 @@
reports/

View File

@ -103,7 +103,6 @@ export class ApplicationWizardCommitApplication extends BasePanel {
Promise.allSettled([network, timeout]).then(([network_resolution]) => {
if (network_resolution.status === "rejected") {
this.commitState = errorState;
console.log(network_resolution.reason);
return;
}

View File

@ -1,4 +1,11 @@
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";
@ -15,7 +22,7 @@ export const steps: WizardStep[] = [
renderer: () =>
html`<ak-application-wizard-application-details></ak-application-wizard-application-details>`,
disabled: false,
nextButtonLabel: msg("Next"),
buttons: [NextStep, CancelWizard],
valid: true,
},
{
@ -24,8 +31,7 @@ export const steps: WizardStep[] = [
renderer: () =>
html`<ak-application-wizard-authentication-method-choice></ak-application-wizard-authentication-method-choice>`,
disabled: false,
nextButtonLabel: msg("Next"),
backButtonLabel: msg("Back"),
buttons: [NextStep, BackStep, CancelWizard],
valid: true,
},
{
@ -34,8 +40,7 @@ export const steps: WizardStep[] = [
renderer: () =>
html`<ak-application-wizard-authentication-method></ak-application-wizard-authentication-method>`,
disabled: true,
nextButtonLabel: msg("Next"),
backButtonLabel: msg("Back"),
buttons: [SubmitStep, BackStep, CancelWizard],
valid: true,
},
{
@ -44,8 +49,7 @@ export const steps: WizardStep[] = [
renderer: () =>
html`<ak-application-wizard-commit-application></ak-application-wizard-commit-application>`,
disabled: true,
nextButtonLabel: msg("Submit"),
backButtonLabel: msg("Back"),
buttons: [BackStep, CancelWizard],
valid: true,
},
];

View File

@ -5,15 +5,16 @@ import { msg } from "@lit/localize";
import { customElement, property, query } from "@lit/reactive-element/decorators.js";
import { 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 { WizardStep } from "./types";
import { type WizardButton, type WizardStep } from "./types";
/**
* AKWizardFrame is the main container for displaying Wizard pages.
*
* AKWizardFrame is one component of a total Wizard development environment. It provides the header,
* AKWizardFrame is one component of a Wizard development environment. It provides the header,
* titled navigation sidebar, and bottom row button bar. It takes its cues about what to render from
* two data structure, `this.steps: WizardStep[]`, which lists all the current steps *in order* and
* doesn't care otherwise about their structure, and `this.currentStep: WizardStep` which must be a
@ -34,9 +35,7 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
return [...super.styles, PFWizard];
}
@property({ type: Boolean })
canCancel = true;
/* Prop-drilled. Do not alter. */
@property()
header?: string;
@ -49,28 +48,26 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
@property({ attribute: false, type: Array })
steps!: WizardStep[];
@property({ attribute: false, type: Object })
currentStep!: WizardStep;
@property({ attribute: false, type: Number })
currentStep!: number;
@query("#main-content *:first-child")
content!: HTMLElement;
reset() {
this.open = false;
@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;
}
get maxStep() {
return this.steps.length - 1;
}
get nextStep() {
const idx = this.steps.findIndex((step) => step === this.currentStep);
return idx < this.maxStep ? this.steps[idx + 1] : undefined;
}
get backStep() {
const idx = this.steps.findIndex((step) => step === this.currentStep);
return idx > 0 ? this.steps[idx - 1] : undefined;
constructor() {
super();
this.renderButtons = this.renderButtons.bind(this);
}
renderModalInner() {
@ -100,7 +97,7 @@ 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.reset()}
@click=${() => this.dispatchCustomEvent(this.eventName, { command: "close" })}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>`;
@ -109,15 +106,15 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
renderNavigation() {
return html`<nav class="pf-c-wizard__nav">
<ol class="pf-c-wizard__nav-list">
${this.steps.map((step) => this.renderNavigationStep(step))}
${this.steps.map((step, idx) => this.renderNavigationStep(step, idx))}
</ol>
</nav>`;
}
renderNavigationStep(step: WizardStep) {
renderNavigationStep(step: WizardStep, idx: number) {
const buttonClasses = {
"pf-c-wizard__nav-link": true,
"pf-m-current": step.id === this.currentStep.id,
"pf-m-current": idx === this.currentStep,
};
return html`
@ -125,7 +122,8 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
<button
class=${classMap(buttonClasses)}
?disabled=${step.disabled}
@click=${() => this.dispatchCustomEvent(this.eventName, { step: step.id })}
@click=${() =>
this.dispatchCustomEvent(this.eventName, { command: "goto", step: idx })}
>
${step.label}
</button>
@ -137,48 +135,53 @@ 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.currentStep.renderer()}
</div>
<div id="main-content" class="pf-c-wizard__main-body">${this.step.renderer()}</div>
</main>`;
}
renderFooter() {
return html`
<footer class="pf-c-wizard__footer">
${this.nextStep ? this.renderFooterNextButton(this.nextStep) : nothing}
${this.backStep ? this.renderFooterBackButton(this.backStep) : nothing}
${this.canCancel ? this.renderFooterCancelButton() : nothing}
${map(this.step.buttons, this.renderButtons)}
</footer>
`;
}
renderFooterNextButton(nextStep: WizardStep) {
renderButtons([label, command]: WizardButton) {
switch (command) {
case "next":
return this.renderButton(label, "pf-m-primary", command);
case "back":
return this.renderButton(label, "pf-m-secondary", command);
case "close":
return this.renderLink(label, "pf-m-link");
default:
throw new Error(`Button type not understood: ${command} for ${label}`);
}
}
renderButton(label: string, classname: string, command: string) {
const buttonClasses = { "pf-c-button": true, [classname]: true };
return html`<button
class="pf-c-button pf-m-primary"
type="submit"
@click=${() =>
this.dispatchCustomEvent(this.eventName, { step: nextStep.id, action: "next" })}
class=${classMap(buttonClasses)}
type="button"
@click=${() => {
this.dispatchCustomEvent(this.eventName, { command });
}}
>
${this.currentStep.nextButtonLabel}
${label}
</button>`;
}
renderFooterBackButton(backStep: WizardStep) {
return html`<button
class="pf-c-button pf-m-secondary"
type="button"
@click=${() =>
this.dispatchCustomEvent(this.eventName, { step: backStep.id, action: "back" })}
>
${this.currentStep.backButtonLabel}
</button> `;
}
renderFooterCancelButton() {
renderLink(label: string, classname: string) {
const buttonClasses = { "pf-c-button": true, [classname]: true };
return html`<div class="pf-c-wizard__footer-cancel">
<button class="pf-c-button pf-m-link" type="button" @click=${() => this.reset()}>
${msg("Cancel")}
<button
class=${classMap(buttonClasses)}
type="button"
@click=${() => this.dispatchCustomEvent(this.eventName, { command: "close" })}
>
${label}
</button>
</div>`;
}

View File

@ -26,11 +26,22 @@ const hasValidator = (v: any): v is Required<Pick<WizardPanel, "validator">> =>
*
* @element ak-wizard-main
*
* This is the entry point for the wizard. Its tasks are:
* 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")
@ -56,7 +67,7 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
* @attribute
*/
@state()
currentStep!: WizardStep;
currentStep: number = 0;
constructor() {
super();
@ -71,14 +82,6 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
@property({ type: String })
prompt = "Show Wizard";
/**
* Mostly a control on the ModalButton that summons the wizard component.
*
* @attribute
*/
@property({ type: Boolean, reflect: true })
open = false;
/**
* The text of the header on the wizard, upper bar.
*
@ -95,18 +98,17 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
@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;
// Guarantee that if the current step was not passed in by the client, that we know
// and set to the first step.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
willUpdate(_changedProperties: Map<string, any>) {
if (this.currentStep === undefined) {
this.currentStep = this.steps[0];
}
}
connectedCallback() {
super.connectedCallback();
this.addCustomListener(this.eventName, this.handleNavigation);
@ -117,30 +119,55 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
super.disconnectedCallback();
}
// Note that we always scan for the valid next step and throw an error if we can't find it.
// There should never be a question that the currentStep is a *valid* step.
//
// TODO: Put a phase in there so that the current step can validate the contents asynchronously
// before setting the currentStep. Especially since setting the currentStep triggers a second
// asynchronous event-- scheduling a re-render of everything interested in the currentStep
// object.
handleNavigation(event: CustomEvent<{ step: string; action: string }>) {
const requestedStep = event.detail.step;
if (!requestedStep) {
throw new Error("Request for next step when no next step is available");
}
const step = this.steps.find(({ id }) => id === requestedStep);
if (!step) {
throw new Error("Request for next step when no next step is available.");
}
if (event.detail.action === "next" && !this.validated()) {
return false;
}
this.currentStep = step;
return true;
get maxStep() {
return this.steps.length - 1;
}
validated() {
get nextStep() {
return this.currentStep < this.maxStep ? this.currentStep + 1 : undefined;
}
get backStep() {
return this.currentStep > 0 ? this.currentStep - 1 : undefined;
}
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]) {
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.frame.open = this.open;
}
}
}
get validated() {
if (hasValidator(this.frame.content)) {
return this.frame.content.validator();
}
@ -150,7 +177,7 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
render() {
return html`
<ak-wizard-frame
?open=${this.open}
?canCancel=${this.canCancel}
header=${this.header}
description=${ifDefined(this.description)}
eventName=${this.eventName}

View File

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

View File

@ -36,7 +36,8 @@ export class AkDemoWizard extends AKElement {
<ak-wizard-context .steps=${this.steps}>
<ak-wizard-frame
?open=${this.open}
header=${this.header}
header=${this.header}
canCancel
description=${ifDefined(this.description)}
>
<button slot="trigger" class="pf-c-button pf-m-primary">Show Wizard</button>

View File

@ -1,6 +1,6 @@
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";
@ -37,27 +37,21 @@ const container = (testItem: TemplateResult) =>
const dummySteps: WizardStep[] = [
{
id: "0",
label: "Test Step1",
renderer: () => html`<h2>This space intentionally left blank today</h2>`,
disabled: false,
valid: true,
nextButtonLabel: "Next",
backButtonLabel: undefined,
buttons: [NextStep, CancelWizard],
},
{
id: "1",
label: "Test Step 2",
renderer: () => html`<h2>This space also intentionally left blank</h2>`,
disabled: false,
valid: true,
nextButtonLabel: undefined,
backButtonLabel: "Back",
buttons: [BackStep, CloseWizard],
},
];
export const OnePageWizard = () => {
return container(
html` <ak-wizard-main .steps=${dummySteps} 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

@ -1,13 +1,38 @@
import { TemplateResult } from "lit";
export type WizardNavCommand = "next" | "back" | "close" | ["goto", number];
// The label of the button, the command the button should execute, and if the button
// should be marked "disabled."
export type WizardButton = [string, WizardNavCommand, boolean?];
export interface WizardStep {
id: string;
label: string;
valid: boolean;
// The name of the step, as shown in the navigation.
label: string;
// A function which returns the html for rendering the actual content of the step, its form and
// such.
renderer: () => 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.
buttons: WizardButton[];
// If this step is "disabled," the prior step's next button will be disabled.
disabled: boolean;
nextButtonLabel?: string;
backButtonLabel?: string;
}
export interface WizardPanel extends HTMLElement {