web: Baby steps

I become frustrated with my inability to make any progress on this project, so I decided to reach
for a tool that I consider highly reliable but also incredibly time-consuming and boring: test
driven development.

In this case, I wrote a story about how I wanted to see the first page rendered: just put the HTML
tag, completely unadorned, that will handle the first page of the wizard. Then, add an event handler
that will send the updated content to some parent object, since what we really want is to
orchestrate the state of the user's input with a centralized location. Then, rather than fiddling
with the attributes and properties of the various pages, I wanted them to be able to "look up" the
values they want, much as we'd expect a standalone form to be able to pull its values from the
server, so I added a context object that receives the update event and incorporates the new
knowledge about the state of the process into itself.

The result is surprisingly satisfying: the first page renders cleanly, displays the content that we
want, and as we fiddle with, we can *watch in real time* as the results of the context are updated
and retransmitted to all receiving objects. And the sending object gets the results so it
re-renders, but it ends up looking the same as it was before the render.
This commit is contained in:
Ken Sternberg 2023-07-31 16:33:42 -07:00
parent bbc47e0fce
commit bf26e5d11e
12 changed files with 476 additions and 76 deletions

View File

@ -5,4 +5,5 @@ import authentikTheme from "./authentikTheme";
addons.setConfig({
theme: authentikTheme,
enableShortcuts: false,
});

View File

@ -32,7 +32,7 @@ import {
import "./components/ak-backchannel-input";
import "./components/ak-provider-search-input";
const policyOptions = [
export const policyOptions = [
{
label: "any",
value: PolicyEngineMode.Any,

View File

@ -1,75 +0,0 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { convertToSlug } from "@goauthentik/common/utils";
import { KeyUnknown } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { WizardFormPage } from "@goauthentik/elements/wizard/WizardFormPage";
import { msg } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { TemplateResult, html } from "lit";
import { ApplicationRequest, CoreApi, Provider } from "@goauthentik/api";
@customElement("ak-application-wizard-initial")
export class InitialApplicationWizardPage extends WizardFormPage {
sidebarLabel = () => msg("Application details");
nextDataCallback = async (data: KeyUnknown): Promise<boolean> => {
const name = data.name as string;
let slug = convertToSlug(name || "");
// Check if an application with the generated slug already exists
const apps = await new CoreApi(DEFAULT_CONFIG).coreApplicationsList({
search: slug,
});
if (apps.results.filter((app) => app.slug == slug)) {
slug += "-1";
}
this.host.state["slug"] = slug;
this.host.state["name"] = name;
this.host.addActionBefore(msg("Create application"), async (): Promise<boolean> => {
const req: ApplicationRequest = {
name: name || "",
slug: slug,
metaPublisher: data.metaPublisher as string,
metaDescription: data.metaDescription as string,
};
if ("provider" in this.host.state) {
req.provider = (this.host.state["provider"] as Provider).pk;
}
if ("link" in this.host.state) {
req.metaLaunchUrl = this.host.state["link"] as string;
}
this.host.state["app"] = await new CoreApi(DEFAULT_CONFIG).coreApplicationsCreate({
applicationRequest: req,
});
return true;
});
return true;
};
renderForm(): TemplateResult {
return html`
<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input type="text" value="" class="pf-c-form-control" required />
<p class="pf-c-form__helper-text">${msg("Application's display Name.")}</p>
</ak-form-element-horizontal>
<ak-form-group ?expanded=${true}>
<span slot="header"> ${msg("Additional UI settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Description")}
name="metaDescription"
>
<textarea class="pf-c-form-control"></textarea>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Publisher")} name="metaPublisher">
<input type="text" value="" class="pf-c-form-control" />
</ak-form-element-horizontal>
</div>
</ak-form-group>
</form>
`;
}
}

View File

@ -0,0 +1,26 @@
import { css } from "lit";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
export const styles = [
PFBase,
PFCard,
PFButton,
PFForm,
PFAlert,
PFInputGroup,
PFFormControl,
PFSwitch,
css`
select[multiple] {
height: 15em;
}
`,
];

View File

@ -0,0 +1,103 @@
import { policyOptions } from "@goauthentik/admin/applications/ApplicationForm";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { consume } from "@lit-labs/context";
import { msg } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { state } from "@lit/reactive-element/decorators/state.js";
import { TemplateResult, html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { styles as AwadStyles } from "./ak-application-wizard-application-details.css";
import type { WizardState } from "./ak-application-wizard-context";
import applicationWizardContext from "./ak-application-wizard-context-name";
@customElement("ak-application-wizard-application-details")
export class ApplicationWizardApplicationDetails extends CustomEmitterElement(AKElement) {
static get styles() {
return AwadStyles;
}
@consume({ context: applicationWizardContext, subscribe: true })
@state()
private wizard!: WizardState;
handleChange(ev: Event) {
const value = ev.target.type === "checkbox" ? ev.target.checked : ev.target.value;
this.dispatchCustomEvent("ak-wizard-update", {
...this.wizard,
application: {
...this.wizard.application,
[ev.target.name]: value,
},
});
}
render(): TemplateResult {
return html` <form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}>
<ak-text-input
name="name"
value=${this.wizard.application?.name}
label=${msg("Name")}
required
help=${msg("Application's display Name.")}
></ak-text-input>
<ak-text-input
name="slug"
value=${this.wizard.application?.slug}
label=${msg("Slug")}
required
help=${msg("Internal application name used in URLs.")}
></ak-text-input>
<ak-text-input
name="group"
value=${this.wizard.application?.group}
label=${msg("Group")}
help=${msg(
"Optionally enter a group name. Applications with identical groups are shown grouped together."
)}
></ak-text-input>
<ak-radio-input
label=${msg("Policy engine mode")}
required
name="policyEngineMode"
.options=${policyOptions}
.value=${this.wizard.application?.policyEngineMode}
></ak-radio-input>
<ak-form-group>
<span slot="header"> ${msg("UI settings")} </span>
<div slot="body" class="pf-c-form">
<ak-text-input
name="metaLaunchUrl"
label=${msg("Launch URL")}
value=${ifDefined(this.wizard.application?.metaLaunchUrl)}
help=${msg(
"If left empty, authentik will try to extract the launch URL based on the selected provider."
)}
></ak-text-input>
<ak-switch-input
name="openInNewTab"
?checked=${first(this.wizard.application?.openInNewTab, false)}
label=${msg("Open in new tab")}
help=${msg(
"If checked, the launch URL will open in a new browser tab or window from the user's application library."
)}
>
</ak-switch-input>
</div>
</ak-form-group>
</form>`;
}
}
export default ApplicationWizardApplicationDetails;

View File

@ -0,0 +1,5 @@
import {createContext} from '@lit-labs/context';
export const ApplicationWizardContext = createContext(Symbol('ak-application-wizard-context'));
export default ApplicationWizardContext;

View File

@ -0,0 +1,74 @@
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { provide } from "@lit-labs/context";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { property } from "@lit/reactive-element/decorators/property.js";
import { LitElement, html } from "lit";
import {
Application,
LDAPProvider,
OAuth2Provider,
ProxyProvider,
RadiusProvider,
SAMLProvider,
SCIMProvider,
} from "@goauthentik/api";
import applicationWizardContext from "./ak-application-wizard-context-name";
// my-context.ts
type OneOfProvider =
| Partial<SCIMProvider>
| Partial<SAMLProvider>
| Partial<RadiusProvider>
| Partial<ProxyProvider>
| Partial<OAuth2Provider>
| Partial<LDAPProvider>;
export type WizardState = {
step: number;
application: Partial<Application>;
provider: OneOfProvider;
};
@customElement("ak-application-wizard-context")
export class AkApplicationWizardContext extends CustomListenerElement(LitElement) {
/**
* Providing a context at the root element
*/
@provide({ context: applicationWizardContext })
@property({ attribute: false })
wizardState: WizardState = {
step: 0,
application: {},
provider: {},
};
constructor() {
super();
this.handleUpdate = this.handleUpdate.bind(this);
}
connectedCallback() {
super.connectedCallback();
this.addCustomListener("ak-wizard-update", this.handleUpdate);
}
disconnectedCallback() {
this.removeCustomListener("ak-wizard-update", this.handleUpdate);
super.disconnectedCallback();
}
handleUpdate(event: CustomEvent<WizardState>) {
delete event.detail.target;
this.wizardState = event.detail;
}
render() {
return html`<slot></slot>`;
}
}
export default AkApplicationWizardContext;

View File

@ -22,6 +22,29 @@ 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";
const steps = [
{
name: msg("Application Details"),
view: () =>
html`<ak-application-wizard-application-details></ak-application-wizard-application-details>`,
},
{
name: msg("Authentication Method"),
view: () =>
html`<ak-application-wizard-authentication-choice></ak-application-wizard-authentication-choice>`,
},
{
name: msg("Authentication Details"),
view: () =>
html`<ak-application-wizard-authentication-details></ak-application-wizard-authentication-details>`,
},
{
name: msg("Save Application"),
view: () =>
html`<ak-application-wizard-application-commit></ak-application-wizard-application-commit>`,
},
];
@customElement("ak-application-wizard")
export class ApplicationWizard extends AKElement {
static get styles(): CSSResult[] {

View File

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

View File

@ -0,0 +1,44 @@
import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "../ak-application-wizard-application-details";
import AkApplicationWizardApplicationDetails from "../ak-application-wizard-application-details";
import "../ak-application-wizard-context";
import "./ak-application-context-display-for-test";
const metadata: Meta<AkApplicationWizardApplicationDetails> = {
title: "Elements / Application Wizard / Page 1",
component: "ak-application-wizard-application-details",
parameters: {
docs: {
description: {
component: "The first page of the application wizard",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 1em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
${testItem}
</div>`;
export const PageOne = () => {
return container(
html`<ak-application-wizard-context>
<ak-application-wizard-application-details></ak-application-wizard-application-details>
<ak-application-context-display-for-test></ak-application-context-display-for-test>
</ak-application-wizard-context>`
);
};

View File

@ -0,0 +1,82 @@
import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "./ak-wizard";
import AkWizard from "./ak-wizard";
const metadata: Meta<AkWizard> = {
title: "Components / Wizard",
component: "ak-wizard",
parameters: {
docs: {
description: {
component: "A Wizard for wrapping multiple steps",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
<ak-message-container></ak-message-container>
${testItem}
<p>Messages received from the button:</p>
<ul id="action-button-message-pad" style="margin-top: 1em"></ul>
</div>`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayMessage = (result: any) => {
const doc = new DOMParser().parseFromString(
`<li><i>Event</i>: ${
"result" in result.detail ? result.detail.result : result.detail.error
}</li>`,
"text/xml",
);
const target = document.querySelector("#action-button-message-pad");
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
target!.appendChild(doc.firstChild!);
};
window.addEventListener("ak-button-success", displayMessage);
window.addEventListener("ak-button-failure", displayMessage);
export const ButtonWithSuccess = () => {
const run = () =>
new Promise<string>(function (resolve) {
setTimeout(function () {
resolve("Success!");
}, 3000);
});
return container(
html`<ak-action-button class="pf-m-primary" .apiRequest=${run}
>3 Seconds</ak-action-button
>`,
);
};
export const ButtonWithError = () => {
const run = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error("This is the error message."));
}, 3000);
});
return container(
html` <ak-action-button class="pf-m-secondary" .apiRequest=${run}
>3 Seconds</ak-action-button
>`,
);
};

View File

@ -0,0 +1,99 @@
import { ModalButton } from "@goauthentik/elements/buttons/ModalButton";
import { msg } from "@lit/localize";
import { property } from "@lit/reactive-element/decorators/property.js";
import { state } from "@lit/reactive-element/decorators/state.js";
import { html, nothing } from "lit";
import type { TemplateResult } from "lit";
/**
* @class AkWizard
*
* @element ak-wizard
*
* The ak-wizard element exists to guide users through a complex task by dividing it into sections
* and granting them successive access to future sections. Our wizard has four "zones": The header,
* the breadcrumb toolbar, the navigation controls, and the content of the panel.
*
*/
type WizardStep = {
name: string;
constructor: () => TemplateResult;
};
export class AkWizard extends ModalButton {
@property({ type: Boolean })
required = false;
@property()
wizardtitle?: string;
@property()
description?: string;
constructor() {
super();
this.handleClose = this.handleClose.bind(this);
}
handleClose() {
this.open = false;
}
renderModalInner() {
return html`<div class="pf-c-wizard">
${this.renderWizardHeader()}
<div class="pf-c-wizard__outer-wrap">
<div class="pf-c-wizard__inner-wrap">${this.renderWizardNavigation()}</div>
</div>
</div> `;
}
renderWizardHeader() {
const renderCancelButton = () =>
html`<button
class="pf-c-button pf-m-plain pf-c-wizard__close"
type="button"
aria-label="${msg("Close")}"
@click=${this.handleClose}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>`;
return html`<div class="pf-c-wizard__header">
${this.required ? nothing : renderCancelButton()}
<h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">${this.wizardtitle}</h1>
<p class="pf-c-wizard__description">${this.description}</p>
</div>`;
}
renderWizardNavigation() {
const currentIdx = this.currentStep ? this.steps.indexOf(this.currentStep.slot) : 0;
const renderNavStep = (step: string, idx: number) => {
return html`
<li class="pf-c-wizard__nav-item">
<button
class="pf-c-wizard__nav-link ${idx === currentIdx ? "pf-m-current" : ""}"
?disabled=${currentIdx < idx}
@click=${() => {
const stepEl = this.querySelector<WizardPage>(`[slot=${step}]`);
if (stepEl) {
this.currentStep = stepEl;
}
}}
>
${this.querySelector<WizardPage>(`[slot=${step}]`)?.sidebarLabel()}
</button>
</li>
`;
};
return html` <nav class="pf-c-wizard__nav">
<ol class="pf-c-wizard__nav-list">
${map(this.steps, renderNavStep)}
</ol>
</nav>`;
}
}