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) {
// TODO: External reference.
const inDebug = rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanDebug);
@ -247,6 +265,7 @@ export const promptRenderers = new Map<PromptTypeEnum, Renderer>([
[PromptTypeEnum.Static, renderStatic],
[PromptTypeEnum.Dropdown, renderDropdown],
[PromptTypeEnum.RadioButtonGroup, renderRadioButtonGroup],
[PromptTypeEnum.Checkbox, renderCheckbox],
[PromptTypeEnum.AkLocale, renderAkLocale],
]);

View file

@ -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<PromptChallenge, PromptChallengeResponseRequest> {
@ -48,70 +53,35 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
];
}
renderPromptInner(prompt: StagePrompt): TemplateResult {
const renderer = promptRenderers.get(prompt.type);
if (!renderer) {
return html`<p>invalid type '${prompt.type}'</p>`;
}
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`<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>`;
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`<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>`;
return renderCheckbox(prompt);
}
if (this.shouldRenderInWrapper(prompt)) {
if (shouldRenderInWrapper(prompt)) {
return html`<ak-form-element
label="${prompt.label}"
?required="${prompt.required}"
class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {})[prompt.fieldKey]}
>
${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}
${renderPromptInner(prompt)} ${renderPromptHelpText(prompt)}
</ak-form-element>`;
}
return html` ${this.renderPromptInner(prompt)} ${this.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>`;
return html` ${renderPromptInner(prompt)} ${renderPromptHelpText(prompt)}`;
}
render(): TemplateResult {
@ -119,6 +89,7 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
</ak-empty-state>`;
}
return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
</header>
@ -137,7 +108,7 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
this.challenge?.responseErrors?.non_field_errors || [],
)
: html``}
${this.renderContinue()}
${renderContinue()}
</form>
</div>
<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>`;
}