From bf26e5d11e31566439bd7469ba38f6c85c3ca297 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Mon, 31 Jul 2023 16:33:42 -0700 Subject: [PATCH] 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. --- web/.storybook/manager.ts | 1 + web/src/admin/applications/ApplicationForm.ts | 2 +- .../wizard/InitialApplicationWizardPage.ts | 75 ------------- ...lication-wizard-application-details.css.ts | 26 +++++ ...-application-wizard-application-details.ts | 103 ++++++++++++++++++ .../ak-application-wizard-context-name.ts | 5 + .../wizard/ak-application-wizard-context.ts | 74 +++++++++++++ ...tionWizard.ts => ak-application-wizard.ts} | 23 ++++ ...ak-application-context-display-for-test.ts | 18 +++ ...tion-wizard-application-details.stories.ts | 44 ++++++++ .../components/ak-wizard/ak-wizard.stories.ts | 82 ++++++++++++++ web/src/components/ak-wizard/ak-wizard.ts | 99 +++++++++++++++++ 12 files changed, 476 insertions(+), 76 deletions(-) delete mode 100644 web/src/admin/applications/wizard/InitialApplicationWizardPage.ts create mode 100644 web/src/admin/applications/wizard/ak-application-wizard-application-details.css.ts create mode 100644 web/src/admin/applications/wizard/ak-application-wizard-application-details.ts create mode 100644 web/src/admin/applications/wizard/ak-application-wizard-context-name.ts create mode 100644 web/src/admin/applications/wizard/ak-application-wizard-context.ts rename web/src/admin/applications/wizard/{ApplicationWizard.ts => ak-application-wizard.ts} (78%) create mode 100644 web/src/admin/applications/wizard/stories/ak-application-context-display-for-test.ts create mode 100644 web/src/admin/applications/wizard/stories/ak-application-wizard-application-details.stories.ts create mode 100644 web/src/components/ak-wizard/ak-wizard.stories.ts create mode 100644 web/src/components/ak-wizard/ak-wizard.ts diff --git a/web/.storybook/manager.ts b/web/.storybook/manager.ts index ab364c0c4..2ac3045a4 100644 --- a/web/.storybook/manager.ts +++ b/web/.storybook/manager.ts @@ -5,4 +5,5 @@ import authentikTheme from "./authentikTheme"; addons.setConfig({ theme: authentikTheme, + enableShortcuts: false, }); diff --git a/web/src/admin/applications/ApplicationForm.ts b/web/src/admin/applications/ApplicationForm.ts index f987f7080..2ddd7a725 100644 --- a/web/src/admin/applications/ApplicationForm.ts +++ b/web/src/admin/applications/ApplicationForm.ts @@ -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, diff --git a/web/src/admin/applications/wizard/InitialApplicationWizardPage.ts b/web/src/admin/applications/wizard/InitialApplicationWizardPage.ts deleted file mode 100644 index 1e4388e70..000000000 --- a/web/src/admin/applications/wizard/InitialApplicationWizardPage.ts +++ /dev/null @@ -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 => { - 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 => { - 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` -
- - -

${msg("Application's display Name.")}

-
- - ${msg("Additional UI settings")} -
- - - - - - -
-
-
- `; - } -} diff --git a/web/src/admin/applications/wizard/ak-application-wizard-application-details.css.ts b/web/src/admin/applications/wizard/ak-application-wizard-application-details.css.ts new file mode 100644 index 000000000..141d32dee --- /dev/null +++ b/web/src/admin/applications/wizard/ak-application-wizard-application-details.css.ts @@ -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; +} +`, +]; diff --git a/web/src/admin/applications/wizard/ak-application-wizard-application-details.ts b/web/src/admin/applications/wizard/ak-application-wizard-application-details.ts new file mode 100644 index 000000000..f10cd3006 --- /dev/null +++ b/web/src/admin/applications/wizard/ak-application-wizard-application-details.ts @@ -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`
+ + + + + + ${msg("UI settings")} +
+ + + +
+
+
`; + } +} + +export default ApplicationWizardApplicationDetails; diff --git a/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts b/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts new file mode 100644 index 000000000..b6be30adb --- /dev/null +++ b/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts @@ -0,0 +1,5 @@ +import {createContext} from '@lit-labs/context'; + +export const ApplicationWizardContext = createContext(Symbol('ak-application-wizard-context')); + +export default ApplicationWizardContext; diff --git a/web/src/admin/applications/wizard/ak-application-wizard-context.ts b/web/src/admin/applications/wizard/ak-application-wizard-context.ts new file mode 100644 index 000000000..3279cda78 --- /dev/null +++ b/web/src/admin/applications/wizard/ak-application-wizard-context.ts @@ -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 + | Partial + | Partial + | Partial + | Partial + | Partial; + +export type WizardState = { + step: number; + application: Partial; + 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) { + delete event.detail.target; + this.wizardState = event.detail; + } + + render() { + return html``; + } +} + +export default AkApplicationWizardContext; diff --git a/web/src/admin/applications/wizard/ApplicationWizard.ts b/web/src/admin/applications/wizard/ak-application-wizard.ts similarity index 78% rename from web/src/admin/applications/wizard/ApplicationWizard.ts rename to web/src/admin/applications/wizard/ak-application-wizard.ts index d8ceb9749..4aa5a9ae8 100644 --- a/web/src/admin/applications/wizard/ApplicationWizard.ts +++ b/web/src/admin/applications/wizard/ak-application-wizard.ts @@ -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``, + }, + { + name: msg("Authentication Method"), + view: () => + html``, + }, + { + name: msg("Authentication Details"), + view: () => + html``, + }, + { + name: msg("Save Application"), + view: () => + html``, + }, +]; + @customElement("ak-application-wizard") export class ApplicationWizard extends AKElement { static get styles(): CSSResult[] { 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 new file mode 100644 index 000000000..3f8e0ce26 --- /dev/null +++ b/web/src/admin/applications/wizard/stories/ak-application-context-display-for-test.ts @@ -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`
${JSON.stringify(this.wizard, null, 2)}
`; + } +} diff --git a/web/src/admin/applications/wizard/stories/ak-application-wizard-application-details.stories.ts b/web/src/admin/applications/wizard/stories/ak-application-wizard-application-details.stories.ts new file mode 100644 index 000000000..18beccf20 --- /dev/null +++ b/web/src/admin/applications/wizard/stories/ak-application-wizard-application-details.stories.ts @@ -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 = { + 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`
+ + ${testItem} +
`; + +export const PageOne = () => { + return container( + html` + + + ` + ); +}; diff --git a/web/src/components/ak-wizard/ak-wizard.stories.ts b/web/src/components/ak-wizard/ak-wizard.stories.ts new file mode 100644 index 000000000..7d81d2eeb --- /dev/null +++ b/web/src/components/ak-wizard/ak-wizard.stories.ts @@ -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 = { + title: "Components / Wizard", + component: "ak-wizard", + parameters: { + docs: { + description: { + component: "A Wizard for wrapping multiple steps", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
+ + + ${testItem} +

Messages received from the button:

+
    +
    `; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const displayMessage = (result: any) => { + const doc = new DOMParser().parseFromString( + `
  • Event: ${ + "result" in result.detail ? result.detail.result : result.detail.error + }
  • `, + "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(function (resolve) { + setTimeout(function () { + resolve("Success!"); + }, 3000); + }); + + return container( + html`3 Seconds`, + ); +}; + +export const ButtonWithError = () => { + const run = () => + new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error("This is the error message.")); + }, 3000); + }); + + return container( + html` 3 Seconds`, + ); +}; diff --git a/web/src/components/ak-wizard/ak-wizard.ts b/web/src/components/ak-wizard/ak-wizard.ts new file mode 100644 index 000000000..ca915c4fb --- /dev/null +++ b/web/src/components/ak-wizard/ak-wizard.ts @@ -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`
    + ${this.renderWizardHeader()} +
    +
    ${this.renderWizardNavigation()}
    +
    +
    `; + } + + renderWizardHeader() { + const renderCancelButton = () => + html``; + + return html`
    + ${this.required ? nothing : renderCancelButton()} +

    ${this.wizardtitle}

    +

    ${this.description}

    +
    `; + } + + renderWizardNavigation() { + const currentIdx = this.currentStep ? this.steps.indexOf(this.currentStep.slot) : 0; + + const renderNavStep = (step: string, idx: number) => { + return html` +
  • + +
  • + `; + }; + + return html` `; + } +}