web: add visualizing and testing for the FieldRenderers

This commit is contained in:
Ken Sternberg 2023-06-08 13:43:13 -07:00
parent 0d94373f10
commit c48eee0ebf
4 changed files with 187 additions and 52 deletions

View file

@ -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`<div style="background: #fff; padding: 4em; max-width: 24em;">
<style>
input,
textarea,
select,
button,
.pf-c-form__helper-text:not(.pf-m-error),
input + label.pf-c-check__label {
color: #000;
}
input[readonly],
textarea[readonly] {
color: #fff;
}
</style>
${prompt}
</div>`;
function renderer(kind: PromptTypeEnum, prompt: Partial<StagePrompt>) {
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",
};

View file

@ -207,6 +207,24 @@ export function renderRadioButtonGroup(prompt: StagePrompt) {
})}`; })}`;
} }
export function renderCheckbox(prompt: StagePrompt) {
return html`<div class="pf-c-check">
<input
type="checkbox"
class="pf-c-check__input"
id="${prompt.fieldKey}"
name="${prompt.fieldKey}"
?checked=${prompt.initialValue !== ""}
?required=${prompt.required}
/>
<label class="pf-c-check__label" for="${prompt.fieldKey}">${prompt.label}</label>
${prompt.required
? html`<p class="pf-c-form__helper-text">${msg("Required.")}</p>`
: html``}
<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>
</div>`;
}
export function renderAkLocale(prompt: StagePrompt) { export function renderAkLocale(prompt: StagePrompt) {
// TODO: External reference. // TODO: External reference.
const inDebug = rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanDebug); const inDebug = rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanDebug);
@ -247,6 +265,7 @@ export const promptRenderers = new Map<PromptTypeEnum, Renderer>([
[PromptTypeEnum.Static, renderStatic], [PromptTypeEnum.Static, renderStatic],
[PromptTypeEnum.Dropdown, renderDropdown], [PromptTypeEnum.Dropdown, renderDropdown],
[PromptTypeEnum.RadioButtonGroup, renderRadioButtonGroup], [PromptTypeEnum.RadioButtonGroup, renderRadioButtonGroup],
[PromptTypeEnum.Checkbox, renderCheckbox],
[PromptTypeEnum.AkLocale, renderAkLocale], [PromptTypeEnum.AkLocale, renderAkLocale],
]); ]);

View file

@ -6,7 +6,6 @@ import { BaseStage } from "@goauthentik/flow/stages/base";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement } from "lit/decorators.js"; import { customElement } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css"; import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
@ -24,7 +23,13 @@ import {
StagePrompt, StagePrompt,
} from "@goauthentik/api"; } from "@goauthentik/api";
import promptRenderers from "./FieldRenderers"; import { renderCheckbox } from "./FieldRenderers";
import {
renderContinue,
renderPromptHelpText,
renderPromptInner,
shouldRenderInWrapper,
} from "./helpers";
@customElement("ak-stage-prompt") @customElement("ak-stage-prompt")
export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeResponseRequest> { export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeResponseRequest> {
@ -48,70 +53,35 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
]; ];
} }
renderPromptInner(prompt: StagePrompt): TemplateResult { /* TODO: Legacy: None of these refer to the `this` field. Static fields are a code smell. */
const renderer = promptRenderers.get(prompt.type);
if (!renderer) {
return html`<p>invalid type '${prompt.type}'</p>`;
}
return renderer(prompt);
}
renderPromptHelpText(prompt: StagePrompt): TemplateResult { renderPromptInner(prompt: StagePrompt) {
if (prompt.subText === "") { return renderPromptInner(prompt);
return html``;
}
return html`<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>`;
} }
renderPromptHelpText(prompt: StagePrompt) {
shouldRenderInWrapper(prompt: StagePrompt): boolean { return renderPromptHelpText(prompt);
// Special types that aren't rendered in a wrapper }
const specialTypes = [ shouldRenderInWrapper(prompt: StagePrompt) {
PromptTypeEnum.Static, return shouldRenderInWrapper(prompt);
PromptTypeEnum.Hidden,
PromptTypeEnum.Separator,
];
const special = specialTypes.find((s) => s === prompt.type);
return !special;
} }
renderField(prompt: StagePrompt): TemplateResult { 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) { if (prompt.type === PromptTypeEnum.Checkbox) {
return html`<div class="pf-c-check"> return renderCheckbox(prompt);
<input
type="checkbox"
class="pf-c-check__input"
id="${prompt.fieldKey}"
name="${prompt.fieldKey}"
?checked=${prompt.initialValue !== ""}
?required=${prompt.required}
/>
<label class="pf-c-check__label" for="${prompt.fieldKey}">${prompt.label}</label>
${prompt.required
? html`<p class="pf-c-form__helper-text">${msg("Required.")}</p>`
: html``}
<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>
</div>`;
} }
if (this.shouldRenderInWrapper(prompt)) {
if (shouldRenderInWrapper(prompt)) {
return html`<ak-form-element return html`<ak-form-element
label="${prompt.label}" label="${prompt.label}"
?required="${prompt.required}" ?required="${prompt.required}"
class="pf-c-form__group" class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {})[prompt.fieldKey]} .errors=${(this.challenge?.responseErrors || {})[prompt.fieldKey]}
> >
${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)} ${renderPromptInner(prompt)} ${renderPromptHelpText(prompt)}
</ak-form-element>`; </ak-form-element>`;
} }
return html` ${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}`; return html` ${renderPromptInner(prompt)} ${renderPromptHelpText(prompt)}`;
}
renderContinue(): TemplateResult {
return html` <div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
${msg("Continue")}
</button>
</div>`;
} }
render(): TemplateResult { render(): TemplateResult {
@ -119,6 +89,7 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}> return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
</ak-empty-state>`; </ak-empty-state>`;
} }
return html`<header class="pf-c-login__main-header"> return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1> <h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
</header> </header>
@ -137,7 +108,7 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
this.challenge?.responseErrors?.non_field_errors || [], this.challenge?.responseErrors?.non_field_errors || [],
) )
: html``} : html``}
${this.renderContinue()} ${renderContinue()}
</form> </form>
</div> </div>
<footer class="pf-c-login__main-footer"> <footer class="pf-c-login__main-footer">

View file

@ -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`<p>invalid type '${JSON.stringify(prompt.type, null, 2)}'</p>`;
}
return renderer(prompt);
}
export function renderPromptHelpText(prompt: StagePrompt) {
if (prompt.subText === "") {
return html``;
}
return html`<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>`;
}
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` <div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
${msg("Continue")}
</button>
</div>`;
}