From c48eee0ebf76003393019e9cfb05956c3469a9ea Mon Sep 17 00:00:00 2001
From: Ken Sternberg
Date: Thu, 8 Jun 2023 13:43:13 -0700
Subject: [PATCH] web: add visualizing and testing for the FieldRenderers
---
.../stages/prompt/FieldRenderers.stories.ts | 108 ++++++++++++++++++
web/src/flow/stages/prompt/FieldRenderers.ts | 19 +++
web/src/flow/stages/prompt/PromptStage.ts | 75 ++++--------
web/src/flow/stages/prompt/helpers.ts | 37 ++++++
4 files changed, 187 insertions(+), 52 deletions(-)
create mode 100644 web/src/flow/stages/prompt/FieldRenderers.stories.ts
create mode 100644 web/src/flow/stages/prompt/helpers.ts
diff --git a/web/src/flow/stages/prompt/FieldRenderers.stories.ts b/web/src/flow/stages/prompt/FieldRenderers.stories.ts
new file mode 100644
index 000000000..4ed541904
--- /dev/null
+++ b/web/src/flow/stages/prompt/FieldRenderers.stories.ts
@@ -0,0 +1,108 @@
+import { TemplateResult, html } from "lit";
+
+import "@patternfly/patternfly/components/Alert/alert.css";
+import "@patternfly/patternfly/components/Button/button.css";
+import "@patternfly/patternfly/components/Check/check.css";
+import "@patternfly/patternfly/components/Form/form.css";
+import "@patternfly/patternfly/components/FormControl/form-control.css";
+import "@patternfly/patternfly/components/Login/login.css";
+import "@patternfly/patternfly/components/Title/title.css";
+import "@patternfly/patternfly/patternfly-base.css";
+
+import { PromptTypeEnum } from "@goauthentik/api";
+import type { StagePrompt } from "@goauthentik/api";
+
+import promptRenderers from "./FieldRenderers";
+import { renderContinue, renderPromptHelpText, renderPromptInner } from "./helpers";
+
+// Storybook stories are meant to show not just that the objects work, but to document good
+// practices around using them. Because of their uniform signature, the renderers can easily
+// be encapsulated into containers that show them at their most functional, even without
+// building Shadow DOMs with which to do it. This is 100% Light DOM work, and they still
+// work well.
+
+const baseRenderer = (prompt: TemplateResult) =>
+ html`
+
+ ${prompt}
+
`;
+
+function renderer(kind: PromptTypeEnum, prompt: Partial) {
+ const renderer = promptRenderers.get(kind);
+ if (!renderer) {
+ throw new Error(`A renderer of type ${kind} does not exist.`);
+ }
+ return baseRenderer(html`${renderer(prompt as StagePrompt)}`);
+}
+
+const textPrompt = {
+ fieldKey: "test_text_field",
+ placeholder: "This is the placeholder",
+ required: false,
+ initialValue: "initial value",
+};
+
+export const Text = () => renderer(PromptTypeEnum.Text, textPrompt);
+export const TextArea = () => renderer(PromptTypeEnum.TextArea, textPrompt);
+export const TextReadOnly = () => renderer(PromptTypeEnum.TextReadOnly, textPrompt);
+export const TextAreaReadOnly = () => renderer(PromptTypeEnum.TextAreaReadOnly, textPrompt);
+export const Username = () => renderer(PromptTypeEnum.Username, textPrompt);
+export const Password = () => renderer(PromptTypeEnum.Password, textPrompt);
+
+const emailPrompt = { ...textPrompt, initialValue: "example@example.fun" };
+export const Email = () => renderer(PromptTypeEnum.Email, emailPrompt);
+
+const numberPrompt = { ...textPrompt, initialValue: "10" };
+export const Number = () => renderer(PromptTypeEnum.Number, numberPrompt);
+
+const datePrompt = { ...textPrompt, initialValue: "2018-06-12T19:30" };
+export const Date = () => renderer(PromptTypeEnum.Date, datePrompt);
+export const DateTime = () => renderer(PromptTypeEnum.DateTime, datePrompt);
+
+const separatorPrompt = { placeholder: "😊" };
+export const Separator = () => renderer(PromptTypeEnum.Separator, separatorPrompt);
+
+const staticPrompt = { initialValue: "😊" };
+export const Static = () => renderer(PromptTypeEnum.Static, staticPrompt);
+
+const choicePrompt = {
+ fieldKey: "test_text_field",
+ placeholder: "This is the placeholder",
+ required: false,
+ initialValue: "first",
+ choices: ["first", "second", "third"],
+};
+
+export const Dropdown = () => renderer(PromptTypeEnum.Dropdown, choicePrompt);
+export const RadioButtonGroup = () => renderer(PromptTypeEnum.RadioButtonGroup, choicePrompt);
+
+const checkPrompt = { ...textPrompt, label: "Favorite Subtext?", subText: "(Xena & Gabrielle)" };
+export const Checkbox = () => renderer(PromptTypeEnum.Checkbox, checkPrompt);
+
+const localePrompt = { ...textPrompt, initialValue: "en" };
+export const Locale = () => renderer(PromptTypeEnum.AkLocale, localePrompt);
+
+export const PromptFailure = () =>
+ baseRenderer(renderPromptInner({ type: null } as unknown as StagePrompt));
+
+export const HelpText = () =>
+ baseRenderer(renderPromptHelpText({ subText: "There is no subtext here." } as StagePrompt));
+
+export const Continue = () => baseRenderer(renderContinue());
+
+export default {
+ title: "Flow Components/Field Renderers",
+};
diff --git a/web/src/flow/stages/prompt/FieldRenderers.ts b/web/src/flow/stages/prompt/FieldRenderers.ts
index 535605195..8ce18669d 100644
--- a/web/src/flow/stages/prompt/FieldRenderers.ts
+++ b/web/src/flow/stages/prompt/FieldRenderers.ts
@@ -207,6 +207,24 @@ export function renderRadioButtonGroup(prompt: StagePrompt) {
})}`;
}
+export function renderCheckbox(prompt: StagePrompt) {
+ return html`
+
+
${prompt.label}
+ ${prompt.required
+ ? html`
${msg("Required.")}
`
+ : html``}
+
${unsafeHTML(prompt.subText)}
+
`;
+}
+
export function renderAkLocale(prompt: StagePrompt) {
// TODO: External reference.
const inDebug = rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanDebug);
@@ -247,6 +265,7 @@ export const promptRenderers = new Map([
[PromptTypeEnum.Static, renderStatic],
[PromptTypeEnum.Dropdown, renderDropdown],
[PromptTypeEnum.RadioButtonGroup, renderRadioButtonGroup],
+ [PromptTypeEnum.Checkbox, renderCheckbox],
[PromptTypeEnum.AkLocale, renderAkLocale],
]);
diff --git a/web/src/flow/stages/prompt/PromptStage.ts b/web/src/flow/stages/prompt/PromptStage.ts
index 4d422e4ce..704be1b5c 100644
--- a/web/src/flow/stages/prompt/PromptStage.ts
+++ b/web/src/flow/stages/prompt/PromptStage.ts
@@ -6,7 +6,6 @@ import { BaseStage } from "@goauthentik/flow/stages/base";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement } from "lit/decorators.js";
-import { unsafeHTML } from "lit/directives/unsafe-html.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@@ -24,7 +23,13 @@ import {
StagePrompt,
} from "@goauthentik/api";
-import promptRenderers from "./FieldRenderers";
+import { renderCheckbox } from "./FieldRenderers";
+import {
+ renderContinue,
+ renderPromptHelpText,
+ renderPromptInner,
+ shouldRenderInWrapper,
+} from "./helpers";
@customElement("ak-stage-prompt")
export class PromptStage extends BaseStage {
@@ -48,70 +53,35 @@ export class PromptStage extends BaseStageinvalid type '${prompt.type}'
`;
- }
- return renderer(prompt);
- }
+ /* TODO: Legacy: None of these refer to the `this` field. Static fields are a code smell. */
- renderPromptHelpText(prompt: StagePrompt): TemplateResult {
- if (prompt.subText === "") {
- return html``;
- }
- return html`${unsafeHTML(prompt.subText)}
`;
+ renderPromptInner(prompt: StagePrompt) {
+ return renderPromptInner(prompt);
}
-
- shouldRenderInWrapper(prompt: StagePrompt): boolean {
- // Special types that aren't rendered in a wrapper
- const specialTypes = [
- PromptTypeEnum.Static,
- PromptTypeEnum.Hidden,
- PromptTypeEnum.Separator,
- ];
- const special = specialTypes.find((s) => s === prompt.type);
- return !special;
+ renderPromptHelpText(prompt: StagePrompt) {
+ return renderPromptHelpText(prompt);
+ }
+ shouldRenderInWrapper(prompt: StagePrompt) {
+ return shouldRenderInWrapper(prompt);
}
renderField(prompt: StagePrompt): TemplateResult {
- // Checkbox is rendered differently
+ // Checkbox has a slightly different layout, so it must be intercepted early.
if (prompt.type === PromptTypeEnum.Checkbox) {
- return html`
-
-
${prompt.label}
- ${prompt.required
- ? html`
${msg("Required.")}
`
- : html``}
-
${unsafeHTML(prompt.subText)}
-
`;
+ return renderCheckbox(prompt);
}
- if (this.shouldRenderInWrapper(prompt)) {
+
+ if (shouldRenderInWrapper(prompt)) {
return html`
- ${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}
+ ${renderPromptInner(prompt)} ${renderPromptHelpText(prompt)}
`;
}
- return html` ${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}`;
- }
-
- renderContinue(): TemplateResult {
- return html`
-
- ${msg("Continue")}
-
-
`;
+ return html` ${renderPromptInner(prompt)} ${renderPromptHelpText(prompt)}`;
}
render(): TemplateResult {
@@ -119,6 +89,7 @@ export class PromptStage extends BaseStage
`;
}
+
return html`
${this.challenge.flowInfo?.title}
@@ -137,7 +108,7 @@ export class PromptStage extends BaseStage
diff --git a/web/src/flow/stages/prompt/helpers.ts b/web/src/flow/stages/prompt/helpers.ts
new file mode 100644
index 000000000..08727e05e
--- /dev/null
+++ b/web/src/flow/stages/prompt/helpers.ts
@@ -0,0 +1,37 @@
+import { msg } from "@lit/localize";
+import { html } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+
+import { PromptTypeEnum, StagePrompt } from "@goauthentik/api";
+
+import promptRenderers from "./FieldRenderers";
+
+export function renderPromptInner(prompt: StagePrompt) {
+ const renderer = promptRenderers.get(prompt.type);
+ if (!renderer) {
+ return html`invalid type '${JSON.stringify(prompt.type, null, 2)}'
`;
+ }
+ return renderer(prompt);
+}
+
+export function renderPromptHelpText(prompt: StagePrompt) {
+ if (prompt.subText === "") {
+ return html``;
+ }
+ return html`${unsafeHTML(prompt.subText)}
`;
+}
+
+export function shouldRenderInWrapper(prompt: StagePrompt) {
+ // Special types that aren't rendered in a wrapper
+ const specialTypes = [PromptTypeEnum.Static, PromptTypeEnum.Hidden, PromptTypeEnum.Separator];
+ const special = specialTypes.find((s) => s === prompt.type);
+ return !special;
+}
+
+export function renderContinue() {
+ return html`
+
+ ${msg("Continue")}
+
+
`;
+}