QA pass: layout, create-flow, and About books #68

Merged
an.di merged 38 commits from adilallo/fix/CR-129-create-rule-button into main 2026-08-26 15:46:56 +00:00
10 changed files with 122 additions and 30 deletions
Showing only changes of commit 8bdd5040c6 - Show all commits
@@ -9,6 +9,7 @@ import TextInput from "../../controls/TextInput";
import ContentLockup from "../../type/ContentLockup"; import ContentLockup from "../../type/ContentLockup";
import Alert from "../Alert"; import Alert from "../Alert";
import { requestMagicLink } from "../../../../lib/create/api"; import { requestMagicLink } from "../../../../lib/create/api";
import { EMAIL_MAX_LEN } from "../../../../lib/create/isValidCreateFlowSaveEmail";
import { buildCreateFlowDraftPayload } from "../../../../lib/create/buildCreateFlowDraftPayload"; import { buildCreateFlowDraftPayload } from "../../../../lib/create/buildCreateFlowDraftPayload";
import { safeInternalPath } from "../../../../lib/safeInternalPath"; import { safeInternalPath } from "../../../../lib/safeInternalPath";
import { import {
@@ -232,6 +233,7 @@ export default function LoginForm({
disabled={submitting} disabled={submitting}
error={Boolean(emailError)} error={Boolean(emailError)}
showHelpIcon showHelpIcon
maxLength={EMAIL_MAX_LEN}
/> />
{emailError ? ( {emailError ? (
<p <p
-12
View File
@@ -8,10 +8,8 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import Link from "next/link";
import Login from "../components/modals/Login"; import Login from "../components/modals/Login";
import LoginForm from "../components/modals/Login/LoginForm"; import LoginForm from "../components/modals/Login/LoginForm";
import { useTranslation } from "./MessagesContext";
export type AuthModalLoginVariant = "default" | "saveProgress"; export type AuthModalLoginVariant = "default" | "saveProgress";
@@ -34,7 +32,6 @@ const AuthModalContext = createContext<AuthModalContextValue | null>(null);
export function AuthModalProvider({ children }: { children: ReactNode }) { export function AuthModalProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [opts, setOpts] = useState<OpenLoginOptions>({}); const [opts, setOpts] = useState<OpenLoginOptions>({});
const t = useTranslation("pages.login");
const openLogin = useCallback((o?: OpenLoginOptions) => { const openLogin = useCallback((o?: OpenLoginOptions) => {
setOpts(o ?? {}); setOpts(o ?? {});
@@ -62,15 +59,6 @@ export function AuthModalProvider({ children }: { children: ReactNode }) {
backdropVariant={backdropVariant} backdropVariant={backdropVariant}
usePortal usePortal
ariaLabelledBy="login-modal-heading" ariaLabelledBy="login-modal-heading"
belowCard={
<Link
href="/"
className="text-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
onClick={() => closeLogin()}
>
{t("backToHome")}
</Link>
}
> >
<LoginForm <LoginForm
variant={opts.variant ?? "default"} variant={opts.variant ?? "default"}
@@ -2,7 +2,7 @@
export const CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS = 48; export const CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS = 48;
/** /**
* Max length for the policy description. Wider than the title so Customize can * Max length for persisted method-card `supportText`. Wider than the title so
* seed existing card support text without blocking Next. * Customize can seed existing card copy and catalog blurbs without blocking Next.
*/ */
export const CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS = 200; export const CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS = 200;
+2 -1
View File
@@ -1,4 +1,5 @@
const EMAIL_MAX_LEN = 254; /** 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). */ /** Pragmatic check for the create-flow “save progress” email field (draft + footer enablement). */
export function isValidCreateFlowSaveEmail(value: unknown): boolean { export function isValidCreateFlowSaveEmail(value: unknown): boolean {
+8 -3
View File
@@ -1,6 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps"; import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks"; import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks";
import {
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS,
} from "../../../lib/create/customMethodCardWizardConstants";
import { EMAIL_MAX_LEN } from "../../../lib/create/isValidCreateFlowSaveEmail";
import { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits"; import { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits";
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson"; import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
@@ -62,8 +67,8 @@ const conflictManagementDetailEntrySchema = z.object({
}); });
const customMethodCardMetaEntrySchema = z.object({ const customMethodCardMetaEntrySchema = z.object({
label: z.string().max(48), label: z.string().max(CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS),
supportText: z.string().max(48), supportText: z.string().max(CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS),
}); });
/** Normalized (trim + lowercase) stakeholder email for drafts + publish. */ /** Normalized (trim + lowercase) stakeholder email for drafts + publish. */
@@ -226,7 +231,7 @@ export const putDraftBodySchema = z.object({
export type CreateFlowStateValidated = z.infer<typeof createFlowStateSchema>; export type CreateFlowStateValidated = z.infer<typeof createFlowStateSchema>;
export const magicLinkRequestBodySchema = z.object({ export const magicLinkRequestBodySchema = z.object({
email: z.string(), email: z.string().max(EMAIL_MAX_LEN),
next: z.string().optional(), next: z.string().optional(),
draft: createFlowStateSchema.optional(), draft: createFlowStateSchema.optional(),
}); });
+1 -9
View File
@@ -95,7 +95,7 @@ export const HeaderOverlayBlurred = {
docs: { docs: {
description: { description: {
story: story:
'Same as **Log in** from the site header: `backdropVariant="blurredYellow"`, `usePortal`, card + “Back to home” below.', 'Same as **Log in** from the site header: `backdropVariant="blurredYellow"`, `usePortal`, no “Back to home” under the card.',
}, },
}, },
}, },
@@ -107,14 +107,6 @@ export const HeaderOverlayBlurred = {
backdropVariant="blurredYellow" backdropVariant="blurredYellow"
usePortal usePortal
ariaLabelledBy="login-modal-heading" ariaLabelledBy="login-modal-heading"
belowCard={
<a
href="/"
className="text-center text-small-paragraph text-[var(--color-content-invert-tertiary)] hover:opacity-90"
>
{backToHome}
</a>
}
> >
<Suspense fallback={<p className="p-6 text-small-paragraph">Loading</p>}> <Suspense fallback={<p className="p-6 text-small-paragraph">Loading</p>}>
<LoginForm /> <LoginForm />
+20 -1
View File
@@ -43,6 +43,7 @@ vi.mock("../../app/(app)/create/utils/anonymousDraftStorage", async (importOrigi
}; };
}); });
import { EMAIL_MAX_LEN } from "../../lib/create/isValidCreateFlowSaveEmail";
import { requestMagicLink } from "../../lib/create/api"; import { requestMagicLink } from "../../lib/create/api";
import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDraftStorage"; import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDraftStorage";
@@ -70,7 +71,7 @@ describe("LoginForm", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole("textbox", { name: /email address/i }), screen.getByRole("textbox", { name: /email address/i }),
).toBeInTheDocument(); ).toHaveAttribute("maxLength", String(EMAIL_MAX_LEN));
expect( expect(
screen.getByRole("button", { name: /send me a magic link/i }), screen.getByRole("button", { name: /send me a magic link/i }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -126,6 +127,24 @@ describe("LoginForm", () => {
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument(); expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
}); });
it("submits a long email without treating length as invalid", async () => {
const user = userEvent.setup();
const email =
"very.long.local-part.for.testing@subdomain.example.communityrule.org";
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
email,
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith(email, "/", undefined);
});
});
it("saveProgress variant uses magicLinkNextPath and sets transfer pending on success", async () => { it("saveProgress variant uses magicLinkNextPath and sets transfer pending on success", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true }); vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
+2 -2
View File
@@ -96,8 +96,8 @@ describe("AuthModalProvider (header overlay)", () => {
screen.getByRole("heading", { name: /log in to communityrule/i }), screen.getByRole("heading", { name: /log in to communityrule/i }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole("link", { name: /back to home/i }), screen.queryByRole("link", { name: /back to home/i }),
).toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("closes overlay when closeLogin is called", async () => { it("closes overlay when closeLogin is called", async () => {
@@ -113,6 +113,63 @@ describe("POST /api/auth/magic-link/request", () => {
); );
}); });
it("accepts an email longer than 48 characters", async () => {
const email =
"very.long.local-part.for.testing@subdomain.example.communityrule.org";
expect(email.length).toBeGreaterThan(48);
const res = await POST(
new NextRequest("https://x.test/api/auth/magic-link/request", {
method: "POST",
body: JSON.stringify({ email }),
headers: { "content-type": "application/json" },
}),
undefined,
);
expect(res.status).toBe(200);
expect(sendMagicLinkEmailMock).toHaveBeenCalledWith(
email,
expect.stringContaining("/api/auth/magic-link/verify?token="),
);
});
it("rejects an email longer than 254 characters", async () => {
const email = `${"a".repeat(243)}@example.com`;
expect(email.length).toBeGreaterThan(254);
const res = await POST(
new NextRequest("https://x.test/api/auth/magic-link/request", {
method: "POST",
body: JSON.stringify({ email }),
headers: { "content-type": "application/json" },
}),
undefined,
);
expect(res.status).toBe(400);
expect(createMock).not.toHaveBeenCalled();
});
it("accepts a draft whose method-card support text is longer than 48 characters", async () => {
const res = await POST(
new NextRequest("https://x.test/api/auth/magic-link/request", {
method: "POST",
body: JSON.stringify({
email: "a@b.c",
draft: {
customMethodCardMetaById: {
"00000000-0000-4000-8000-000000000001": {
label: "Signal",
supportText:
"A decision is assumed approved unless objections are raised within a specified timeframe.",
},
},
},
}),
headers: { "content-type": "application/json" },
}),
undefined,
);
expect(res.status).toBe(200);
});
it("returns 502 and rolls back the token when mail fails", async () => { it("returns 502 and rolls back the token when mail fails", async () => {
sendMagicLinkEmailMock.mockRejectedValueOnce(new Error("smtp down")); sendMagicLinkEmailMock.mockRejectedValueOnce(new Error("smtp down"));
const res = await POST( const res = await POST(
+28
View File
@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS } from "../../lib/create/customMethodCardWizardConstants";
import { import {
assertPlainJsonValue, assertPlainJsonValue,
DEFAULT_PLAIN_JSON_LIMITS, DEFAULT_PLAIN_JSON_LIMITS,
@@ -87,6 +88,33 @@ describe("createFlowStateSchema", () => {
expect(r.success).toBe(false); expect(r.success).toBe(false);
}); });
it("accepts custom method card support text longer than 48 characters", () => {
const r = createFlowStateSchema.safeParse({
customMethodCardMetaById: {
"00000000-0000-4000-8000-000000000001": {
label: "Signal",
supportText:
"A decision is assumed approved unless objections are raised within a specified timeframe.",
},
},
});
expect(r.success).toBe(true);
});
it("rejects custom method card support text over the description max", () => {
const r = createFlowStateSchema.safeParse({
customMethodCardMetaById: {
"00000000-0000-4000-8000-000000000001": {
label: "Signal",
supportText: "x".repeat(
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS + 1,
),
},
},
});
expect(r.success).toBe(false);
});
it("accepts communityStructureChipSnapshots with custom chip rows", () => { it("accepts communityStructureChipSnapshots with custom chip rows", () => {
const r = createFlowStateSchema.safeParse({ const r = createFlowStateSchema.safeParse({
communityStructureChipSnapshots: { communityStructureChipSnapshots: {