web: add visualizing and testing for the FieldRenderers
This commit is contained in:
parent
0d94373f10
commit
c48eee0ebf
108
web/src/flow/stages/prompt/FieldRenderers.stories.ts
Normal file
108
web/src/flow/stages/prompt/FieldRenderers.stories.ts
Normal 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",
|
||||||
|
};
|
|
@ -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],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
37
web/src/flow/stages/prompt/helpers.ts
Normal file
37
web/src/flow/stages/prompt/helpers.ts
Normal 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>`;
|
||||||
|
}
|
Reference in a new issue