Merge pull request 'Give builder fields persistent labels, treat description as optional, and validate save-progress email with a real form.' (#77) from adilallo/fix/CR-185-builder-inputs into main

Reviewed-on: #77
This commit was merged in pull request #77.
This commit is contained in:
2026-09-10 17:40:55 +00:00
33 changed files with 437 additions and 125 deletions
+33 -7
View File
@@ -33,6 +33,7 @@ import {
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
} from "./utils/flowSteps";
import {
CREATE_FLOW_COMMUNITY_SAVE_FORM_ID,
CREATE_FLOW_SYNC_DRAFT_QUERY,
CREATE_FLOW_SYNC_DRAFT_VALUE,
CREATE_ROUTES,
@@ -482,12 +483,28 @@ function CreateFlowLayoutContent({
return () => window.clearTimeout(timeoutId);
}, [currentStep, sessionResolved, sessionUser, openLogin]);
const handleCommunitySaveMagicLinkSubmit = useCallback(async () => {
const handleCommunitySaveMagicLinkSubmit = useCallback(async (
formEl?: HTMLFormElement | null,
) => {
setCommunitySaveMagicLinkError(null);
setCommunitySaveMagicLinkSuccess(false);
const raw = state.communitySaveEmail;
const trimmed = typeof raw === "string" ? raw.trim().toLowerCase() : "";
if (!isValidCreateFlowSaveEmail(trimmed)) return;
const namedEmail = formEl?.elements.namedItem("email");
const emailField =
namedEmail instanceof HTMLInputElement
? namedEmail
: typeof document === "undefined"
? null
: document.querySelector<HTMLInputElement>(
`input[name="email"][form="${CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}"]`,
);
if (emailField != null && !emailField.checkValidity()) {
emailField.reportValidity();
return;
}
const raw =
emailField != null ? emailField.value : state.communitySaveEmail;
if (typeof raw !== "string" || !isValidCreateFlowSaveEmail(raw)) return;
const trimmed = raw.trim().toLowerCase();
setCommunitySaveMagicLinkSubmitting(true);
try {
@@ -770,6 +787,16 @@ function CreateFlowLayoutContent({
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
}`.trim()}
/>
{currentStep === "community-save" ? (
<form
id={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
className="hidden"
onSubmit={(e) => {
e.preventDefault();
void handleCommunitySaveMagicLinkSubmit(e.currentTarget);
}}
/>
) : null}
<main
className={`flex min-h-0 min-w-0 flex-1 w-full max-w-full overflow-x-hidden ${mainContentClass} ${mainResponsiveLayout}`}
>
@@ -841,6 +868,8 @@ function CreateFlowLayoutContent({
{footer.saveLater}
</Button>
<Button
type="submit"
form={CREATE_FLOW_COMMUNITY_SAVE_FORM_ID}
buttonType="filled"
palette="default"
size="xsmall"
@@ -851,9 +880,6 @@ function CreateFlowLayoutContent({
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
}
className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
void handleCommunitySaveMagicLinkSubmit();
}}
>
{communitySaveMagicLinkSubmitting
? footer.submitEmailSending
@@ -80,6 +80,8 @@ function CustomMethodCardWizardViewComponent({
<TextArea
appearance="default"
formHeader={false}
showHelpIcon={false}
label={copy.step2.title}
placeholder={copy.step2.fieldPlaceholder}
value={policyDescription}
maxLength={maxDescriptionChars}
@@ -1,6 +1,6 @@
"use client";
import { memo } from "react";
import { memo, useId } from "react";
import { ASSETS, getAssetPath } from "../../../../../lib/assetUtils";
import InputWithCounter from "../../../../components/controls/InputWithCounter";
import TextArea from "../../../../components/controls/TextArea";
@@ -39,6 +39,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
onProportionBlockTitleChange,
onProportionDefaultChange,
}: CustomMethodCardWizardFieldBodiesViewProps) {
const placeholderFieldId = useId();
const uploadPreviewTrimmed = uploadAssetPreviewUrl?.trim() ?? "";
const hasUploadPreview = uploadPreviewTrimmed.length > 0;
@@ -53,10 +54,14 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
maxLength={CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS}
/>
<div className="flex flex-col gap-2">
<label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]">
<label
htmlFor={placeholderFieldId}
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
>
{copy.text.placeholderLabel}
</label>
<TextArea
id={placeholderFieldId}
formHeader={false}
appearance="embedded"
value={textPlaceholderBody}
@@ -84,6 +89,7 @@ function CustomMethodCardWizardFieldBodiesViewComponent({
/>
<TextInput
formHeader={false}
label={copy.badges.blockTitleLabel}
placeholder={copy.badges.blockTitlePlaceholder}
value={badgeBlockTitle}
onChange={(e) => onBadgeBlockTitleChange(e.target.value)}
@@ -8,7 +8,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard";
import Create from "../../../components/modals/Create";
import TextInput from "../../../components/controls/TextInput";
import TextArea from "../../../components/controls/TextArea";
import ContentLockup from "../../../components/type/ContentLockup";
import { useTranslation } from "../../../contexts/MessagesContext";
@@ -95,18 +95,20 @@ export function FinalReviewCommunityContextEditModal({
ariaLabel={tModal("title")}
>
<div className="pb-2">
<TextInput
<TextArea
className="!transition-none"
type="text"
label={tModal("title")}
placeholder={tField("placeholder")}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
}}
inputSize="medium"
size="medium"
formHeader={false}
showHelpIcon={false}
textHint={characterHint}
maxLength={COMMUNITY_CONTEXT_FIELD_MAX_LENGTH}
rows={4}
/>
</div>
</Create>
@@ -93,6 +93,7 @@ export function FinalReviewTitleEditModal({
<TextInput
className="!transition-none"
type="text"
label={tModal("title")}
placeholder={tField("placeholder")}
value={draft}
onChange={(e) => {
@@ -100,8 +101,10 @@ export function FinalReviewTitleEditModal({
}}
inputSize="medium"
formHeader={false}
showHelpIcon={false}
textHint={characterHint}
maxLength={COMMUNITY_TITLE_FIELD_MAX_LENGTH}
required
/>
</div>
</Create>
@@ -115,6 +115,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
messageNamespace="create.community.communityName"
stateField="title"
maxLength={48}
required
/>
);
case "community-structure":
@@ -126,6 +127,7 @@ export function renderCreateFlowScreen(screenId: CreateFlowStep): ReactNode {
stateField="communityContext"
maxLength={200}
mainAlign="center"
multiline
/>
);
case "community-size":
@@ -1,6 +1,5 @@
"use client";
import type { ReactNode } from "react";
import NumberedList from "../../../../components/type/NumberedList";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
@@ -31,22 +30,6 @@ export function InformationalScreen() {
},
];
const description: ReactNode = (
<>
{copy.descriptionLead}{" "}
<a
href="#"
className="font-normal text-[var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] cursor-pointer"
onClick={(e) => {
e.preventDefault();
}}
>
{copy.workshopLabel}
</a>{" "}
{copy.descriptionTrail}
</>
);
return (
<CreateFlowStepShell
variant="centeredNarrow"
@@ -57,7 +40,7 @@ export function InformationalScreen() {
>
<CreateFlowHeaderLockup
title={copy.title}
description={description}
description={copy.description}
justification="left"
/>
<NumberedList items={items} size={mdUp ? "M" : "S"} />
@@ -2,6 +2,7 @@
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
import TextInput from "../../../../components/controls/TextInput";
import TextArea from "../../../../components/controls/TextArea";
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
import { useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -12,6 +13,7 @@ import {
type CreateFlowContentTopBelowMd,
} from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../utils/createFlowPaths";
import type { CreateFlowTextStateField } from "../../types";
type Props = {
@@ -21,6 +23,10 @@ type Props = {
/** Figma Flow — Text (`20094:41243`): main column `items-center` + horizontal padding token. */
mainAlign?: "start" | "center";
inputType?: HTMLInputTypeAttribute;
/** Multi-line control (community description). */
multiline?: boolean;
/** Native `required` (community name and save-progress email). */
required?: boolean;
showCharacterCount?: boolean;
headerJustification?: HeaderLockupJustificationValue;
/** Top spacing under top chrome (`CreateFlowStepShell` / `CreateFlowContentTopBelowMd`). */
@@ -28,7 +34,7 @@ type Props = {
};
/**
* Shared narrow-column + TextInput pattern for Create Community text frames.
* Shared narrow-column + labelled field pattern for Create Community text frames.
*/
export function CreateFlowTextFieldScreen({
messageNamespace,
@@ -36,6 +42,8 @@ export function CreateFlowTextFieldScreen({
maxLength,
mainAlign = "start",
inputType = "text",
multiline = false,
required = false,
showCharacterCount = true,
headerJustification = "left",
contentTopBelowMd = "space-1400",
@@ -69,6 +77,15 @@ export function CreateFlowTextFieldScreen({
const mainItems =
mainAlign === "center" ? "items-center" : "items-start";
const isEmail = inputType === "email";
const persistValue = (next: string) => {
setValue(next);
markCreateFlowInteraction();
updateState({ [stateField]: next } as Record<string, string>);
};
const fieldLabel = t("inputLabel");
return (
<CreateFlowStepShell
variant="centeredNarrow"
@@ -85,22 +102,60 @@ export function CreateFlowTextFieldScreen({
/>
</div>
<div className="w-full">
{multiline ? (
<TextArea
className="!transition-none"
label={fieldLabel}
placeholder={t("placeholder")}
value={value}
onChange={(e) => persistValue(e.target.value)}
onBlur={() => {
const trimmed = value.trim();
if (trimmed !== value) persistValue(trimmed);
}}
size={mdUp ? "medium" : "small"}
formHeader={false}
showHelpIcon={false}
textHint={hint}
maxLength={maxLength}
required={required}
rows={4}
/>
) : (
<TextInput
className="!transition-none"
type={inputType}
name={isEmail ? "email" : undefined}
label={fieldLabel}
placeholder={t("placeholder")}
value={value}
onChange={(e) => {
const v = e.target.value;
setValue(v);
markCreateFlowInteraction();
updateState({ [stateField]: v } as Record<string, string>);
onChange={(e) => persistValue(e.target.value)}
onBlur={() => {
const trimmed = isEmail
? value.trim().toLowerCase()
: value.trim();
if (trimmed !== value) persistValue(trimmed);
}}
onKeyDown={
isEmail
? (e) => {
if (e.key !== "Enter") return;
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
: undefined
}
inputSize={mdUp ? "medium" : "small"}
formHeader={false}
showHelpIcon={false}
textHint={hint}
maxLength={maxLength}
required={required || isEmail}
autoComplete={isEmail ? "email" : undefined}
inputMode={isEmail ? "email" : undefined}
form={isEmail ? CREATE_FLOW_COMMUNITY_SAVE_FORM_ID : undefined}
/>
)}
</div>
</div>
</CreateFlowStepShell>
@@ -10,6 +10,10 @@ import {
FIRST_STEP,
} from "./flowSteps";
/** Associates the save-progress email field with the layout footer submit. */
export const CREATE_FLOW_COMMUNITY_SAVE_FORM_ID =
"create-flow-community-save";
export const CREATE_ROUTES = {
root: "/",
createRoot: "/create",
@@ -1,3 +1,6 @@
"use client";
import { useId } from "react";
import type { InputWithCounterProps } from "./InputWithCounter.types";
export function InputWithCounterView({
@@ -10,12 +13,16 @@ export function InputWithCounterView({
className = "",
inputClassName = "",
}: InputWithCounterProps) {
const inputId = useId();
return (
<div className={`space-y-[var(--spacing-scale-008)] ${className}`}>
{/* Label with help icon */}
{label && (
<div className="flex items-center gap-[var(--spacing-scale-002)]">
<label className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]">
<label
htmlFor={inputId}
className="text-[14px] leading-[20px] font-medium text-[var(--color-content-default-primary)]"
>
{label}
</label>
{showHelpIcon && (
@@ -52,6 +59,7 @@ export function InputWithCounterView({
{/* Input field */}
<div className="relative">
<input
id={inputId}
type="text"
placeholder={placeholder}
value={value}
@@ -36,9 +36,8 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
},
ref,
) => {
return (
<div className={containerClasses}>
{formHeader && label && (
const labelEl = label ? (
formHeader ? (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
@@ -60,7 +59,16 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
)}
</div>
</div>
)}
) : (
<label id={labelId} htmlFor={textareaId} className="sr-only">
{label}
</label>
)
) : null;
return (
<div className={containerClasses}>
{labelEl}
<div className={disabled ? "opacity-40" : ""}>
<textarea
ref={ref}
@@ -31,6 +31,11 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
textHint = false,
formHeader = true,
maxLength,
autoComplete,
inputMode,
required = false,
form,
onKeyDown,
...props
},
ref,
@@ -250,6 +255,11 @@ const TextInputContainer = forwardRef<HTMLInputElement, TextInputProps>(
maxLength={maxLength}
helpIconAlt={t("helpIconAlt")}
hintDefault={t("hintDefault")}
autoComplete={autoComplete}
inputMode={inputMode}
required={required}
form={form}
onKeyDown={onKeyDown}
{...props}
/>
);
@@ -67,4 +67,9 @@ export interface TextInputViewProps {
maxLength?: number;
helpIconAlt: string;
hintDefault: string;
autoComplete?: string;
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
required?: boolean;
form?: string;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
}
@@ -13,7 +13,7 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
name,
type,
disabled,
error: _error,
error = false,
className: _className,
containerClasses,
labelClasses,
@@ -31,12 +31,18 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
maxLength,
helpIconAlt,
hintDefault,
autoComplete,
inputMode,
required = false,
form,
onKeyDown,
state: _state,
isFilled: _isFilled,
},
ref,
) => {
return (
<div className={containerClasses}>
{formHeader && label && (
const labelEl = label ? (
formHeader ? (
<div className="flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative shrink-0 w-full">
<div className="flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0">
<label
@@ -58,7 +64,16 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
)}
</div>
</div>
)}
) : (
<label id={labelId} htmlFor={inputId} className="sr-only">
{label}
</label>
)
) : null;
return (
<div className={containerClasses}>
{labelEl}
<div className={inputWrapperClasses}>
<div className={disabled ? "opacity-40" : ""}>
<input
@@ -71,9 +86,16 @@ export const TextInputView = forwardRef<HTMLInputElement, TextInputViewProps>(
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={onKeyDown}
onMouseDown={handleMouseDown}
disabled={disabled}
maxLength={maxLength}
autoComplete={autoComplete}
inputMode={inputMode}
required={required}
form={form}
aria-invalid={error || undefined}
aria-required={required || undefined}
className={inputClasses}
style={{ borderRadius }}
/>
+25 -4
View File
@@ -1,10 +1,31 @@
/** RFC 5321 max mailbox length (no angle brackets). */
export const EMAIL_MAX_LEN = 254;
/** Pragmatic check for the create-flow “save progress” email field (draft + footer enablement). */
/**
* Fallback when `HTMLInputElement.checkValidity()` is unavailable (SSR).
* Matches the HTML living standards type=email pattern closely enough
* for the save-progress field; the live input is the source of truth in
* the browser.
*/
const EMAIL_FALLBACK_PATTERN =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
/**
* Whether a create-flow save-progress email would pass a required
* `type="email"` inputs `checkValidity()`. Does not trim: native
* validity rejects leading or trailing spaces.
*/
export function isValidCreateFlowSaveEmail(value: unknown): boolean {
if (typeof value !== "string") return false;
const t = value.trim();
if (t.length === 0 || t.length > EMAIL_MAX_LEN) return false;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t);
if (value.length === 0 || value.length > EMAIL_MAX_LEN) return false;
// Native type=email rejects whitespace; jsdoms checkValidity does not.
if (/\s/.test(value)) return false;
if (typeof document === "undefined") {
return EMAIL_FALLBACK_PATTERN.test(value);
}
const input = document.createElement("input");
input.type = "email";
input.required = true;
input.value = value;
return input.checkValidity();
}
@@ -1,6 +1,7 @@
{
"title": "Why does your community exist?",
"description": "Edit or change the description to match how youd like the organization to be described to other users. Try and describe your mission, goals, and scope.",
"description": "Optional. Write a short paragraph about your mission, goals, and scope. You can skip this and add it later.",
"inputLabel": "Community description",
"placeholder": "Describe your community",
"characterCountTemplate": "{current}/{max}"
}
@@ -1,6 +1,7 @@
{
"title": "What is your community called?",
"description": "This will be the name of your community",
"description": "This will be the name of your community.",
"inputLabel": "Community name",
"placeholder": "Enter community name",
"characterCountTemplate": "{current}/{max}"
}
@@ -1,6 +1,7 @@
{
"title": "Save your progress",
"description": "We need your email to save your CommunityRule progress\nand make it accessible to you later.",
"inputLabel": "Email address",
"placeholder": "email@domain.com",
"characterCountTemplate": "{current}/{max}",
"magicLinkSuccessTitle": "Check your email to log in",
@@ -1,7 +1,7 @@
{
"header": {
"title": "What kind of community would you like to improve?",
"description": "Choose tags the describe your community. You can also combine or add new values to the list."
"description": "Choose tags that describe your community. You can also combine or add new values to the list."
},
"organizationMultiSelect": {
"label": "Organization Type",
@@ -1,6 +1,6 @@
{
"title": "Add a photo to identify your group",
"description": "This photo be used as a profile picture for your group and will be editable later. If possible, try to use a simple logo or graphic.",
"description": "This photo will be used as a profile picture for your group and will be editable later. If possible, try to use a simple logo or graphic.",
"hintText": "Add image from your device",
"signInToUploadNote": "Your photo will upload after you sign in (use Save progress from the next step, or Log in from the header).",
"uploadingLabel": "Uploading your photo…",
@@ -1,8 +1,6 @@
{
"title": "How CommunityRule helps groups like yours",
"descriptionLead": "This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider. Alternatively, there is a",
"workshopLabel": "workshop",
"descriptionTrail": "that your group can use to go through the process it together.",
"description": "This flow will give you recommendations to improve your community and help you put together a proposal for your group to consider.",
"steps": {
"0": {
"title": "Tell us about your organization",
@@ -11,7 +11,7 @@
},
"2": {
"title": "How do you want to describe your new policy?",
"description": "This description will show up with the title to offer additional context about what the policy is how it should be applied.",
"description": "This description will show up with the title to offer additional context about what the policy is and how it should be applied.",
"fieldPlaceholder": "Policy description"
},
"3": {
+23
View File
@@ -11,5 +11,28 @@ export const Default = {
messageNamespace: "create.community.communityName",
stateField: "title",
maxLength: 48,
required: true,
},
};
export const CommunityDescription = {
args: {
messageNamespace: "create.community.communityContext",
stateField: "communityContext",
maxLength: 200,
mainAlign: "center",
multiline: true,
},
};
export const CommunitySave = {
args: {
messageNamespace: "create.community.communitySave",
stateField: "communitySaveEmail",
maxLength: 254,
mainAlign: "center",
inputType: "email",
showCharacterCount: false,
headerJustification: "center",
},
};
@@ -11,6 +11,9 @@ describe("CommunityStructureSelectScreen", () => {
expect(screen.getByText("Organization Type")).toBeInTheDocument();
expect(screen.getByText("Scale")).toBeInTheDocument();
expect(screen.getByText("Maturity")).toBeInTheDocument();
expect(
screen.getByText(/Choose tags that describe your community/i),
).toBeInTheDocument();
expect(screen.queryByAltText("Help")).not.toBeInTheDocument();
});
});
+6 -6
View File
@@ -1044,8 +1044,9 @@ describe("FinalReviewScreen — edit published title and description", () => {
fireEvent.click(await screen.findByTestId("rule-title-edit"));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText(/Community name/i)).toBeInTheDocument();
const input = within(dialog).getByRole("textbox");
const input = within(dialog).getByRole("textbox", {
name: /Community name/i,
});
fireEvent.change(input, { target: { value: "Renamed Commons" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
@@ -1076,10 +1077,9 @@ describe("FinalReviewScreen — edit published title and description", () => {
fireEvent.click(await screen.findByTestId("rule-description-edit"));
const dialog = await screen.findByRole("dialog");
expect(
within(dialog).getByText(/Community description/i),
).toBeInTheDocument();
const input = within(dialog).getByRole("textbox");
const input = within(dialog).getByRole("textbox", {
name: /Community description/i,
});
fireEvent.change(input, { target: { value: "Updated copy" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Save" }));
+2 -4
View File
@@ -22,11 +22,9 @@ describe("InformationalScreen", () => {
).toBeInTheDocument();
});
it("renders workshop as a link (URL TBD) with underline per Figma", () => {
it("does not offer a workshop link until a destination exists", () => {
render(<InformationalScreen />);
const workshop = screen.getByRole("link", { name: "workshop" });
expect(workshop).toHaveAttribute("href", "#");
expect(workshop.className).toMatch(/underline/);
expect(screen.queryByRole("link", { name: "workshop" })).not.toBeInTheDocument();
});
it("renders first numbered list item title", () => {
+21 -1
View File
@@ -1,4 +1,9 @@
import { describe, vi } from "vitest";
import { describe, it, expect, vi } from "vitest";
import {
renderWithProviders as render,
screen,
} from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
import {
componentTestSuite,
type ComponentTestSuiteConfig,
@@ -28,4 +33,19 @@ const config: ComponentTestSuiteConfig<Props> = {
describe("InputWithCounter", () => {
componentTestSuite<Props>(config);
it("associates the visible label with the input", () => {
render(
<InputWithCounter
label="Community name"
placeholder="Enter a name"
value=""
onChange={vi.fn()}
maxLength={50}
/>,
);
expect(
screen.getByRole("textbox", { name: "Community name" }),
).toBeInTheDocument();
});
});
+10
View File
@@ -43,6 +43,16 @@ describe("TextArea appearance", () => {
expect(textarea).toHaveClass("border-0");
});
it("keeps a programmatic label when the visible header is off", () => {
const { container } = renderWithProviders(
<TextArea label="Community description" formHeader={false} value="" />,
);
expect(
screen.getByRole("textbox", { name: "Community description" }),
).toBeInTheDocument();
expect(container.querySelector("label")).toHaveClass("sr-only");
});
it("uses tertiary text in the embedded default state and primary on focus", () => {
renderWithProviders(
<TextArea label="Notes" value="Some text" appearance="embedded" />,
+26
View File
@@ -55,4 +55,30 @@ describe("TextInput (size tests)", () => {
const input = container.querySelector("input");
expect(input).toHaveAttribute("maxLength", "200");
});
it("keeps a programmatic label when the visible header is off", () => {
const { getByRole, container } = render(
<TextInput label="Community name" formHeader={false} />,
);
expect(getByRole("textbox", { name: "Community name" })).toBeInTheDocument();
expect(container.querySelector("label")).toHaveClass("sr-only");
});
it("forwards autocomplete, inputMode, required, and form", () => {
const { container } = render(
<TextInput
label="Email address"
type="email"
autoComplete="email"
inputMode="email"
required
form="create-flow-community-save"
/>,
);
const input = container.querySelector("input");
expect(input).toHaveAttribute("autocomplete", "email");
expect(input).toHaveAttribute("inputmode", "email");
expect(input).toBeRequired();
expect(input).toHaveAttribute("form", "create-flow-community-save");
});
});
+49 -3
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
import { CreateFlowTextFieldScreen } from "../../app/(app)/create/screens/text/CreateFlowTextFieldScreen";
import { CREATE_FLOW_COMMUNITY_SAVE_FORM_ID } from "../../app/(app)/create/utils/createFlowPaths";
describe("CreateFlowTextFieldScreen (community name)", () => {
it("renders main heading", () => {
@@ -10,6 +11,7 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
messageNamespace="create.community.communityName"
stateField="title"
maxLength={48}
required
/>,
);
expect(
@@ -19,19 +21,63 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
).toBeInTheDocument();
});
it("renders description and text field", () => {
it("renders description and a labelled text field", () => {
render(
<CreateFlowTextFieldScreen
messageNamespace="create.community.communityName"
stateField="title"
maxLength={48}
required
/>,
);
expect(
screen.getByText("This will be the name of your community"),
screen.getByText("This will be the name of your community."),
).toBeInTheDocument();
const input = screen.getByRole("textbox", { name: "Community name" });
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute("placeholder", "Enter community name");
expect(input).toBeRequired();
});
});
describe("CreateFlowTextFieldScreen (community description)", () => {
it("uses a textarea and treats the field as optional", () => {
render(
<CreateFlowTextFieldScreen
messageNamespace="create.community.communityContext"
stateField="communityContext"
maxLength={200}
multiline
/>,
);
expect(
screen.getByPlaceholderText("Enter community name"),
screen.getByText(/Optional\. Write a short paragraph/i),
).toBeInTheDocument();
const field = screen.getByRole("textbox", {
name: "Community description",
});
expect(field.tagName).toBe("TEXTAREA");
expect(field).not.toBeRequired();
});
});
describe("CreateFlowTextFieldScreen (save progress email)", () => {
it("uses a labelled email field with native email attributes", () => {
render(
<CreateFlowTextFieldScreen
messageNamespace="create.community.communitySave"
stateField="communitySaveEmail"
maxLength={254}
inputType="email"
showCharacterCount={false}
/>,
);
const input = screen.getByRole("textbox", { name: "Email address" });
expect(input).toHaveAttribute("type", "email");
expect(input).toHaveAttribute("autocomplete", "email");
expect(input).toHaveAttribute("inputmode", "email");
expect(input).toHaveAttribute("name", "email");
expect(input).toHaveAttribute("form", CREATE_FLOW_COMMUNITY_SAVE_FORM_ID);
expect(input).toBeRequired();
});
});
+1 -1
View File
@@ -18,7 +18,7 @@ describe("CommunityUploadScreen", () => {
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
expect(
screen.getByText(
/This photo be used as a profile picture for your group/i,
/This photo will be used as a profile picture for your group/i,
),
).toBeInTheDocument();
});
+7
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
CREATE_FLOW_COMMUNITY_SAVE_FORM_ID,
CREATE_FLOW_SYNC_DRAFT_QUERY,
CREATE_FLOW_SYNC_DRAFT_VALUE,
CREATE_ROUTES,
@@ -42,4 +43,10 @@ describe("createFlowPaths (CR-92 §2)", () => {
expect(CREATE_ROUTES.review).toBe("/create/review");
expect(CREATE_ROUTES.completed).toBe("/create/completed");
});
it("exposes a stable form id for the save-progress email step", () => {
expect(CREATE_FLOW_COMMUNITY_SAVE_FORM_ID).toBe(
"create-flow-community-save",
);
});
});
@@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { isValidCreateFlowSaveEmail } from "../../lib/create/isValidCreateFlowSaveEmail";
describe("isValidCreateFlowSaveEmail", () => {
it("rejects empty, whitespace, and non-string values", () => {
expect(isValidCreateFlowSaveEmail("")).toBe(false);
expect(isValidCreateFlowSaveEmail(" ")).toBe(false);
expect(isValidCreateFlowSaveEmail(null)).toBe(false);
expect(isValidCreateFlowSaveEmail(undefined)).toBe(false);
});
it("rejects values the native email control would reject", () => {
expect(isValidCreateFlowSaveEmail("not-an-email")).toBe(false);
expect(isValidCreateFlowSaveEmail("user@example.com ")).toBe(false);
expect(isValidCreateFlowSaveEmail(" user@example.com")).toBe(false);
});
it("accepts a required type=email value", () => {
expect(isValidCreateFlowSaveEmail("user@example.com")).toBe(true);
});
});