From 8f932e95cdfc172f06b0b0d4f29f63c8805f3c41 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:26:25 -0600 Subject: [PATCH] Wire Publish rule from create flow --- CONTRIBUTING.md | 4 +- app/components/modals/Login/LoginForm.tsx | 11 +- app/create/CreateFlowLayoutClient.tsx | 285 ++++++++++++++++++++++ app/create/CreateFlowLayoutGate.tsx | 26 ++ app/create/PostLoginDraftTransfer.tsx | 54 +++- app/create/completed/page.tsx | 37 ++- app/create/final-review/page.tsx | 32 ++- app/create/layout.tsx | 195 +-------------- lib/create/api.ts | 72 ++++-- lib/create/buildPublishPayload.ts | 84 +++++++ lib/create/lastPublishedRule.ts | 50 ++++ messages/en/create/publish.json | 6 + messages/en/index.ts | 2 + tests/components/FinalReviewPage.test.tsx | 50 +++- tests/unit/buildPublishPayload.test.ts | 112 +++++++++ tests/unit/publishRule.test.ts | 71 ++++++ 16 files changed, 839 insertions(+), 252 deletions(-) create mode 100644 app/create/CreateFlowLayoutClient.tsx create mode 100644 app/create/CreateFlowLayoutGate.tsx create mode 100644 lib/create/buildPublishPayload.ts create mode 100644 lib/create/lastPublishedRule.ts create mode 100644 messages/en/create/publish.json create mode 100644 tests/unit/buildPublishPayload.test.ts create mode 100644 tests/unit/publishRule.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84832bf..909ba75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ Use `npx prisma studio` to inspect the database. | GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect | | POST | `/api/auth/logout` | Clear session | | GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) | -| GET / POST | `/api/rules` | List or publish rules | +| GET / POST | `/api/rules` | List or publish rules (each **Finalize** creates a new published row until an update/edit-published API exists) | | GET | `/api/templates` | List curated templates | ### Email magic link (sign-in) @@ -39,7 +39,7 @@ Use `npx prisma studio` to inspect the database. ### Optional draft sync -Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **signed-in** users can persist create-flow drafts to Postgres via **Save & Exit** and so **anonymous** progress can be **uploaded after magic-link sign-in** from the save-progress exit modal. Without it, server **PUT** `/api/drafts/me` is skipped; anonymous work stays in **browser `localStorage`** only. +Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **signed-in** users can persist create-flow drafts to Postgres via **Save & Exit** and so **anonymous** progress can be **uploaded after magic-link sign-in** from the save-progress exit modal. Without it, server **PUT** `/api/drafts/me` is skipped; anonymous work stays in **browser `localStorage`**, but after sign-in with a `?syncDraft=1` return URL the app still **merges that local draft into the in-memory create flow** (no server write) so you can continue and publish. ## Frontend / tests diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx index 8d0eb5b..250c437 100644 --- a/app/components/modals/Login/LoginForm.tsx +++ b/app/components/modals/Login/LoginForm.tsx @@ -103,7 +103,7 @@ export default function LoginForm({ } return; } - if (isSaveProgress) { + if (isSaveProgress || nextPath.includes("syncDraft=1")) { setTransferPendingFlag(); } setEmail(trimmed); @@ -113,7 +113,14 @@ export default function LoginForm({ } finally { setSubmitting(false); } - }, [email, isSaveProgress, magicLinkNextPath, nextParam, stripErrorQuery, t]); + }, [ + email, + isSaveProgress, + magicLinkNextPath, + nextParam, + stripErrorQuery, + t, + ]); const urlErrorMessage = errorParam === "expired_link" diff --git a/app/create/CreateFlowLayoutClient.tsx b/app/create/CreateFlowLayoutClient.tsx new file mode 100644 index 0000000..290c433 --- /dev/null +++ b/app/create/CreateFlowLayoutClient.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { + Suspense, + useCallback, + useEffect, + useState, + type ReactNode, +} from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext"; +import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; +import { useCreateFlowExit } from "./hooks/useCreateFlowExit"; +import CreateFlowTopNav from "../components/utility/CreateFlowTopNav"; +import { getStepIndex } from "./utils/flowSteps"; +import CreateFlowFooter from "../components/utility/CreateFlowFooter"; +import Button from "../components/buttons/Button"; +import { buildPublishPayload } from "../../lib/create/buildPublishPayload"; +import { fetchAuthSession, publishRule } from "../../lib/create/api"; +import { writeLastPublishedRule } from "../../lib/create/lastPublishedRule"; +import messages from "../../messages/en/index"; +import { useAuthModal } from "../contexts/AuthModalContext"; +import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; +import { SignedInDraftHydration } from "./SignedInDraftHydration"; +import Alert from "../components/modals/Alert"; +import { + CreateFlowDraftSaveBannerProvider, + useCreateFlowDraftSaveBanner, +} from "./context/CreateFlowDraftSaveBannerContext"; + +/** First step where Save & Exit is offered (after informational + name / `text`). */ +const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select"); + +function CreateFlowSessionShell({ children }: { children: ReactNode }) { + const [sessionUser, setSessionUser] = useState< + { id: string; email: string } | null | undefined + >(undefined); + + useEffect(() => { + let cancelled = false; + void fetchAuthSession().then(({ user }) => { + if (!cancelled) setSessionUser(user); + }); + return () => { + cancelled = true; + }; + }, []); + + const sessionResolved = sessionUser !== undefined; + const enableAnonymousPersistence = sessionResolved && sessionUser === null; + + return ( + + + + {children} + + + + ); +} + +function CreateFlowLayoutContent({ + children, + sessionUser, + sessionResolved, +}: { + children: ReactNode; + sessionUser: { id: string; email: string } | null | undefined; + sessionResolved: boolean; +}) { + const router = useRouter(); + const pathname = usePathname(); + const { openLogin } = useAuthModal(); + const { + currentStep, + nextStep, + previousStep, + goToNextStep, + goToPreviousStep, + } = useCreateFlowNavigation(); + const { state, clearState } = useCreateFlow(); + const { draftSaveBannerMessage, setDraftSaveBannerMessage } = + useCreateFlowDraftSaveBanner(); + const [publishBannerMessage, setPublishBannerMessage] = useState< + string | null + >(null); + const [isPublishing, setIsPublishing] = useState(false); + + const handleFinalize = useCallback(async () => { + setPublishBannerMessage(null); + const payloadResult = buildPublishPayload(state); + if (payloadResult.ok === false) { + setPublishBannerMessage( + payloadResult.error === "missingCommunityName" + ? messages.create.publish.missingCommunityName + : payloadResult.error, + ); + return; + } + const { title, summary, document: ruleDocument } = payloadResult; + setIsPublishing(true); + const publishResult = await publishRule({ + title, + summary, + document: ruleDocument, + }); + setIsPublishing(false); + if (publishResult.ok === true) { + writeLastPublishedRule({ + id: publishResult.id, + title, + summary: summary ?? null, + document: ruleDocument, + }); + router.push("/create/completed"); + return; + } + if (publishResult.status === 401) { + openLogin({ + variant: "default", + nextPath: "/create/final-review?syncDraft=1", + backdropVariant: "blurredYellow", + }); + return; + } + setPublishBannerMessage( + publishResult.error.trim() !== "" + ? publishResult.error + : messages.create.publish.genericPublishFailed, + ); + }, [state, router, openLogin]); + + const runAuthenticatedExit = useCreateFlowExit({ + state, + currentStep, + clearState, + router, + user: sessionUser ?? null, + setDraftSaveBannerMessage, + }); + + const handleExit = async (opts?: { saveDraft?: boolean }) => { + const saveDraft = opts?.saveDraft ?? false; + if (!sessionResolved) return; + + if (sessionUser === null) { + if (saveDraft) return; + openLogin({ + variant: "saveProgress", + nextPath: `${pathname ?? "/create/informational"}?syncDraft=1`, + backdropVariant: "blurredYellow", + }); + return; + } + + if (!sessionUser) return; + await runAuthenticatedExit(opts); + }; + + const isCompletedStep = currentStep === "completed"; + const isRightRailStep = currentStep === "right-rail"; + const useFullHeightMain = isCompletedStep || isRightRailStep; + const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1; + const saveDraftOnExit = + Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX; + + const hasErrorOverlays = + Boolean(draftSaveBannerMessage) || Boolean(publishBannerMessage); + + return ( +
+ {hasErrorOverlays ? ( +
+ {draftSaveBannerMessage ? ( +
+ setDraftSaveBannerMessage(null)} + className="w-full" + /> +
+ ) : null} + {publishBannerMessage ? ( +
+ setPublishBannerMessage(null)} + className="w-full" + /> +
+ ) : null} +
+ ) : null} + + + + + + + router.push("/create/final-review") + : undefined + } + onExit={(opts) => void handleExit(opts)} + buttonPalette={isCompletedStep ? "inverse" : undefined} + className={`shrink-0 ${ + isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : "" + }`.trim()} + /> +
+ {children} +
+ {!isCompletedStep && ( + { + if (currentStep === "final-review") { + void handleFinalize(); + } else { + goToNextStep(); + } + }} + > + {currentStep === "final-review" + ? isPublishing + ? messages.create.publish.finalizeButtonPublishing + : "Finalize CommunityRule" + : currentStep === "confirm-stakeholders" + ? "Confirm Stakeholders" + : "Next"} + + ) : null + } + onBackClick={previousStep ? goToPreviousStep : undefined} + /> + )} +
+ ); +} + +export default function CreateFlowLayoutClient({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} diff --git a/app/create/CreateFlowLayoutGate.tsx b/app/create/CreateFlowLayoutGate.tsx new file mode 100644 index 0000000..8441c27 --- /dev/null +++ b/app/create/CreateFlowLayoutGate.tsx @@ -0,0 +1,26 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { ReactNode } from "react"; + +const CreateFlowLayoutClient = dynamic( + () => import("./CreateFlowLayoutClient"), + { + ssr: false, + loading: () => ( +
+ ), + }, +); + +export default function CreateFlowLayoutGate({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} diff --git a/app/create/PostLoginDraftTransfer.tsx b/app/create/PostLoginDraftTransfer.tsx index e140715..154346a 100644 --- a/app/create/PostLoginDraftTransfer.tsx +++ b/app/create/PostLoginDraftTransfer.tsx @@ -16,7 +16,8 @@ const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; /** * After magic-link verify, redirects to `/create/...?syncDraft=1` with session cookie. - * Uploads anonymous localStorage draft to `RuleDraft` once, then hydrates context. + * With backend sync: PUT draft once then hydrates context. Without sync: hydrates from + * `create-flow-anonymous` localStorage only (no server write). */ export function PostLoginDraftTransfer({ sessionUser, @@ -38,19 +39,46 @@ export function PostLoginDraftTransfer({ if (attemptedRef.current) return; if (!SYNC_ENABLED) { - if (attemptedRef.current) return; attemptedRef.current = true; - // eslint-disable-next-line react-hooks/set-state-in-effect -- sync-off path: show one-shot error then strip query - setTransferError( - "Saving to your account is not available (server sync is disabled). Your progress stays on this device.", - ); - if (pathname) { - const params = new URLSearchParams(searchParams.toString()); - params.delete("syncDraft"); - const q = params.toString(); - router.replace(q ? `${pathname}?${q}` : pathname); - } - return; + let cancelled = false; + void (async () => { + const local = readAnonymousCreateFlowState(); + const pending = hasTransferPendingFlag(); + + if (Object.keys(local).length === 0 && !pending) { + const params = new URLSearchParams(searchParams.toString()); + params.delete("syncDraft"); + const q = params.toString(); + if (pathname) { + router.replace(q ? `${pathname}?${q}` : pathname); + } + attemptedRef.current = false; + return; + } + + const segment = pathname?.split("/").pop() ?? ""; + const step = isValidStep(segment) ? segment : undefined; + const payload = { + ...local, + ...(step ? { currentStep: step } : {}), + }; + + if (cancelled) return; + clearAnonymousCreateFlowStorage(); + replaceState(payload); + + if (cancelled) return; + if (pathname) { + const params = new URLSearchParams(searchParams.toString()); + params.delete("syncDraft"); + const q = params.toString(); + router.replace(q ? `${pathname}?${q}` : pathname); + } + })(); + + return () => { + cancelled = true; + }; } attemptedRef.current = true; diff --git a/app/create/completed/page.tsx b/app/create/completed/page.tsx index 80cc5ba..5b86299 100644 --- a/app/create/completed/page.tsx +++ b/app/create/completed/page.tsx @@ -6,9 +6,12 @@ import HeaderLockup from "../../components/type/HeaderLockup"; import CommunityRuleDocument from "../../components/sections/CommunityRuleDocument"; import type { CommunityRuleDocumentSection } from "../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types"; import Alert from "../../components/modals/Alert"; +import { parseDocumentSectionsForDisplay } from "../../../lib/create/buildPublishPayload"; +import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule"; -const TITLE = "Mutual Aid Mondays"; -const DESCRIPTION = +/** Demo copy when `/create/completed` is opened without a prior publish in this tab. */ +const FALLBACK_TITLE = "Mutual Aid Mondays"; +const FALLBACK_DESCRIPTION = "Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness."; const TOAST_TITLE = "This is what folks see when you share your CommunityRule"; @@ -91,6 +94,12 @@ const COMPLETED_RULE_SECTIONS: CommunityRuleDocumentSection[] = [ export default function CompletedPage() { const [isMounted, setIsMounted] = useState(false); const [toastDismissed, setToastDismissed] = useState(false); + const [headerTitle, setHeaderTitle] = useState(FALLBACK_TITLE); + const [headerDescription, setHeaderDescription] = useState< + string | undefined + >(FALLBACK_DESCRIPTION); + const [documentSections, setDocumentSections] = + useState(COMPLETED_RULE_SECTIONS); const isMdOrLarger = useMediaQuery("(min-width: 640px)"); useEffect(() => { @@ -98,6 +107,18 @@ export default function CompletedPage() { setIsMounted(true); }, []); + useEffect(() => { + const stored = readLastPublishedRule(); + if (!stored) return; + const parsed = parseDocumentSectionsForDisplay(stored.document); + if (parsed.length === 0) return; + setDocumentSections(parsed); + setHeaderTitle(stored.title); + const sum = + typeof stored.summary === "string" ? stored.summary.trim() : ""; + setHeaderDescription(sum.length > 0 ? sum : undefined); + }, []); + const showDesktopLayout = !isMounted || isMdOrLarger; if (showDesktopLayout) { @@ -108,8 +129,8 @@ export default function CompletedPage() { {/* Left column: community title + header, centered, does not scroll */}
@@ -159,14 +180,14 @@ export default function CompletedPage() {
diff --git a/app/create/final-review/page.tsx b/app/create/final-review/page.tsx index 26d5ddb..f00fe84 100644 --- a/app/create/final-review/page.tsx +++ b/app/create/final-review/page.tsx @@ -1,18 +1,19 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import HeaderLockup from "../../components/type/HeaderLockup"; import RuleCard from "../../components/cards/RuleCard"; import type { Category } from "../../components/cards/RuleCard/RuleCard.types"; +import { useCreateFlow } from "../context/CreateFlowContext"; const TITLE = "Review your CommunityRule"; const DESCRIPTION = "Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again."; -const RULE_CARD_TITLE = "Mutual Aid Mondays"; -const RULE_CARD_DESCRIPTION = - "Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness."; +const RULE_CARD_TITLE_FALLBACK = "Your community"; +const RULE_CARD_DESCRIPTION_FALLBACK = + "Add a short description of your community on earlier steps when that field is available. For now, this card shows your community name."; /** Static categories for final review (read-only display). */ const FINAL_REVIEW_CATEGORIES: Category[] = [ @@ -55,9 +56,20 @@ const FINAL_REVIEW_CATEGORIES: Category[] = [ * Figma: 20907-212767 (full-size), 20976-220705 (small breakpoint). */ export default function FinalReviewPage() { + const { state } = useCreateFlow(); const [isMounted, setIsMounted] = useState(false); const isMdOrLarger = useMediaQuery("(min-width: 640px)"); + const ruleCardTitle = useMemo(() => { + const t = typeof state.title === "string" ? state.title.trim() : ""; + return t.length > 0 ? t : RULE_CARD_TITLE_FALLBACK; + }, [state.title]); + + const ruleCardDescription = useMemo(() => { + const s = typeof state.summary === "string" ? state.summary.trim() : ""; + return s.length > 0 ? s : RULE_CARD_DESCRIPTION_FALLBACK; + }, [state.summary]); + // Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop). useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash @@ -80,13 +92,13 @@ export default function FinalReviewPage() {
{}} @@ -107,13 +119,13 @@ export default function FinalReviewPage() { size="M" /> {}} diff --git a/app/create/layout.tsx b/app/create/layout.tsx index 7069cb2..bca407c 100644 --- a/app/create/layout.tsx +++ b/app/create/layout.tsx @@ -1,193 +1,6 @@ -"use client"; +import type { ReactNode } from "react"; +import CreateFlowLayoutGate from "./CreateFlowLayoutGate"; -import { Suspense, useEffect, useState, type ReactNode } from "react"; -import { usePathname, useRouter } from "next/navigation"; -import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext"; -import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; -import { useCreateFlowExit } from "./hooks/useCreateFlowExit"; -import CreateFlowTopNav from "../components/utility/CreateFlowTopNav"; -import { getStepIndex } from "./utils/flowSteps"; -import CreateFlowFooter from "../components/utility/CreateFlowFooter"; -import Button from "../components/buttons/Button"; -import { fetchAuthSession } from "../../lib/create/api"; -import messages from "../../messages/en/index"; -import { useAuthModal } from "../contexts/AuthModalContext"; -import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; -import { SignedInDraftHydration } from "./SignedInDraftHydration"; -import Alert from "../components/modals/Alert"; -import { - CreateFlowDraftSaveBannerProvider, - useCreateFlowDraftSaveBanner, -} from "./context/CreateFlowDraftSaveBannerContext"; - -/** First step where Save & Exit is offered (after informational + name / `text`). */ -const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select"); - -function CreateFlowSessionShell({ children }: { children: ReactNode }) { - const [sessionUser, setSessionUser] = useState< - { id: string; email: string } | null | undefined - >(undefined); - - useEffect(() => { - let cancelled = false; - void fetchAuthSession().then(({ user }) => { - if (!cancelled) setSessionUser(user); - }); - return () => { - cancelled = true; - }; - }, []); - - const sessionResolved = sessionUser !== undefined; - const enableAnonymousPersistence = sessionResolved && sessionUser === null; - - return ( - - - - {children} - - - - ); -} - -function CreateFlowLayoutContent({ - children, - sessionUser, - sessionResolved, -}: { - children: ReactNode; - sessionUser: { id: string; email: string } | null | undefined; - sessionResolved: boolean; -}) { - const router = useRouter(); - const pathname = usePathname(); - const { openLogin } = useAuthModal(); - const { - currentStep, - nextStep, - previousStep, - goToNextStep, - goToPreviousStep, - } = useCreateFlowNavigation(); - const { state, clearState } = useCreateFlow(); - const { draftSaveBannerMessage, setDraftSaveBannerMessage } = - useCreateFlowDraftSaveBanner(); - - const runAuthenticatedExit = useCreateFlowExit({ - state, - currentStep, - clearState, - router, - user: sessionUser ?? null, - setDraftSaveBannerMessage, - }); - - const handleExit = async (opts?: { saveDraft?: boolean }) => { - const saveDraft = opts?.saveDraft ?? false; - if (!sessionResolved) return; - - if (sessionUser === null) { - if (saveDraft) return; - openLogin({ - variant: "saveProgress", - nextPath: `${pathname ?? "/create/informational"}?syncDraft=1`, - backdropVariant: "blurredYellow", - }); - return; - } - - if (!sessionUser) return; - await runAuthenticatedExit(opts); - }; - - const isCompletedStep = currentStep === "completed"; - const isRightRailStep = currentStep === "right-rail"; - const useFullHeightMain = isCompletedStep || isRightRailStep; - const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1; - const saveDraftOnExit = - Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX; - - return ( -
- {draftSaveBannerMessage ? ( -
- setDraftSaveBannerMessage(null)} - className="w-full max-w-[960px] mx-auto" - /> -
- ) : null} - - - - - - - router.push("/create/final-review") - : undefined - } - onExit={(opts) => void handleExit(opts)} - buttonPalette={isCompletedStep ? "inverse" : undefined} - className={ - isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : undefined - } - /> -
- {children} -
- {!isCompletedStep && ( - - {currentStep === "final-review" - ? "Finalize CommunityRule" - : currentStep === "confirm-stakeholders" - ? "Confirm Stakeholders" - : "Next"} - - ) : null - } - onBackClick={previousStep ? goToPreviousStep : undefined} - /> - )} -
- ); -} - -export default function CreateFlowLayout({ - children, -}: { - children: ReactNode; -}) { - return {children}; +export default function CreateFlowLayout({ children }: { children: ReactNode }) { + return {children}; } diff --git a/lib/create/api.ts b/lib/create/api.ts index f674ae0..ca2acc6 100644 --- a/lib/create/api.ts +++ b/lib/create/api.ts @@ -83,6 +83,21 @@ export async function fetchDraftFromServer(): Promise { const DRAFT_SAVE_NETWORK_ERROR = "Something went wrong. Check your connection and try again."; +const PUBLISH_FAILED_FALLBACK = + "Something went wrong. Check your connection or try again."; + +/** Parse JSON body; empty or invalid bodies return `null` (avoids `response.json()` throws). */ +async function safeParseJsonResponse(res: Response): Promise { + const text = await res.text(); + const trimmed = text.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed) as unknown; + } catch { + return null; + } +} + export type SaveDraftResult = | { ok: true } | { ok: false; message: string; status?: number }; @@ -131,23 +146,44 @@ export async function publishRule(input: { title: string; summary?: string; document: Record; -}): Promise<{ ok: true; id: string; title: string } | { error: string }> { - const res = await fetch("/api/rules", { - method: "POST", - credentials: "include", - headers: jsonHeaders, - body: JSON.stringify({ - title: input.title, - summary: input.summary, - document: input.document, - }), - }); - const data = await parseJson<{ - error?: string; - rule?: { id: string; title: string }; - }>(res); - if (!res.ok || !data.rule) { - return { error: readApiErrorMessage(data) }; +}): Promise< + | { ok: true; id: string; title: string } + | { ok: false; error: string; status?: number } +> { + try { + const res = await fetch("/api/rules", { + method: "POST", + credentials: "include", + headers: jsonHeaders, + body: JSON.stringify({ + title: input.title, + summary: input.summary, + document: input.document, + }), + }); + const data = (await safeParseJsonResponse(res)) as { + error?: string | { message?: string }; + rule?: { id: string; title: string }; + } | null; + const rule = data && typeof data === "object" ? data.rule : undefined; + if (!res.ok || !rule) { + const fromBody = + data && typeof data === "object" ? readApiErrorMessage(data) : null; + const msg = + fromBody && fromBody !== "Request failed" + ? fromBody + : res.statusText?.trim() || PUBLISH_FAILED_FALLBACK; + return { + ok: false as const, + error: msg, + status: res.status, + }; + } + return { ok: true, id: rule.id, title: rule.title }; + } catch { + return { + ok: false as const, + error: DRAFT_SAVE_NETWORK_ERROR, + }; } - return { ok: true, id: data.rule.id, title: data.rule.title }; } diff --git a/lib/create/buildPublishPayload.ts b/lib/create/buildPublishPayload.ts new file mode 100644 index 0000000..f4a7868 --- /dev/null +++ b/lib/create/buildPublishPayload.ts @@ -0,0 +1,84 @@ +import type { CreateFlowState } from "../../app/create/types"; +import type { CommunityRuleDocumentSection } from "../../app/components/sections/CommunityRuleDocument/CommunityRuleDocument.types"; + +function isDocumentEntry(x: unknown): x is { title: string; body: string } { + if (!x || typeof x !== "object") return false; + const o = x as Record; + return typeof o.title === "string" && typeof o.body === "string"; +} + +function isDocumentSection(x: unknown): x is CommunityRuleDocumentSection { + if (!x || typeof x !== "object") return false; + const o = x as Record; + if (typeof o.categoryName !== "string") return false; + if (!Array.isArray(o.entries)) return false; + return o.entries.every(isDocumentEntry); +} + +/** Narrow `CreateFlowState.sections` into Community Rule document sections. */ +export function parseSectionsFromCreateFlowState( + state: CreateFlowState, +): CommunityRuleDocumentSection[] { + const raw = state.sections; + if (!Array.isArray(raw)) return []; + const out: CommunityRuleDocumentSection[] = []; + for (const x of raw) { + if (isDocumentSection(x)) out.push(x); + } + return out; +} + +export type BuildPublishPayloadResult = + | { + ok: true; + title: string; + summary?: string; + document: Record; + } + | { ok: false; error: string }; + +const FALLBACK_CATEGORY = "Overview"; + +const DEFAULT_FALLBACK_BODY = + "This CommunityRule was created in the create flow. Add more detail in a future edit."; + +export function buildPublishPayload( + state: CreateFlowState, +): BuildPublishPayloadResult { + const title = typeof state.title === "string" ? state.title.trim() : ""; + if (!title) { + return { ok: false, error: "missingCommunityName" }; + } + + let summary: string | undefined; + if (typeof state.summary === "string") { + const t = state.summary.trim(); + if (t.length > 0) summary = t; + } + + let sections = parseSectionsFromCreateFlowState(state); + if (sections.length === 0) { + const body = summary ?? DEFAULT_FALLBACK_BODY; + sections = [ + { + categoryName: FALLBACK_CATEGORY, + entries: [{ title: "Community", body }], + }, + ]; + } + + if (summary !== undefined) { + return { ok: true, title, summary, document: { sections } }; + } + return { ok: true, title, document: { sections } }; +} + +/** Read `document.sections` from a stored published payload for display. */ +export function parseDocumentSectionsForDisplay( + document: unknown, +): CommunityRuleDocumentSection[] { + if (!document || typeof document !== "object") return []; + const sections = (document as Record).sections; + if (!Array.isArray(sections)) return []; + return sections.filter(isDocumentSection); +} diff --git a/lib/create/lastPublishedRule.ts b/lib/create/lastPublishedRule.ts new file mode 100644 index 0000000..5093116 --- /dev/null +++ b/lib/create/lastPublishedRule.ts @@ -0,0 +1,50 @@ +/** + * Bridges final-review → completed without query strings. + * Replace with GET /api/rules/[id] (CR-81) when public rule fetch exists. + */ +export const CREATE_FLOW_LAST_PUBLISHED_KEY = "createFlow.lastPublished"; + +export type StoredLastPublishedRule = { + id: string; + title: string; + summary?: string | null; + document: Record; +}; + +export function writeLastPublishedRule(data: StoredLastPublishedRule): void { + if (typeof sessionStorage === "undefined") return; + sessionStorage.setItem(CREATE_FLOW_LAST_PUBLISHED_KEY, JSON.stringify(data)); +} + +export function readLastPublishedRule(): StoredLastPublishedRule | null { + if (typeof sessionStorage === "undefined") return null; + const raw = sessionStorage.getItem(CREATE_FLOW_LAST_PUBLISHED_KEY); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return null; + const o = parsed as Record; + if (typeof o.id !== "string" || typeof o.title !== "string") return null; + const doc = o.document; + if (doc === null || typeof doc !== "object" || Array.isArray(doc)) { + return null; + } + const summaryVal = o.summary; + let summary: string | null | undefined; + if (typeof summaryVal === "string") { + summary = summaryVal; + } else if (summaryVal === null) { + summary = null; + } else { + summary = undefined; + } + return { + id: o.id, + title: o.title, + summary, + document: doc as Record, + }; + } catch { + return null; + } +} diff --git a/messages/en/create/publish.json b/messages/en/create/publish.json new file mode 100644 index 0000000..f428743 --- /dev/null +++ b/messages/en/create/publish.json @@ -0,0 +1,6 @@ +{ + "finalizeBannerTitle": "Couldn't publish", + "missingCommunityName": "Add a community name before finalizing.", + "finalizeButtonPublishing": "Publishing…", + "genericPublishFailed": "Something went wrong. Try again." +} diff --git a/messages/en/index.ts b/messages/en/index.ts index 367413d..b4dca81 100644 --- a/messages/en/index.ts +++ b/messages/en/index.ts @@ -20,6 +20,7 @@ import metadata from "./metadata.json"; import communication from "./create/communication.json"; import createTopNav from "./create/topNav.json"; import createDraftHydration from "./create/draftHydration.json"; +import createPublish from "./create/publish.json"; export default { common, @@ -45,6 +46,7 @@ export default { communication, topNav: createTopNav, draftHydration: createDraftHydration, + publish: createPublish, }, navigation, metadata, diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index 52ace8d..4906f9c 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -1,7 +1,31 @@ +import { useLayoutEffect } from "react"; import { describe, it, expect } from "vitest"; -import { renderWithProviders as render, screen } from "../utils/test-utils"; +import { + renderWithProviders as render, + screen, + waitFor, +} from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; import FinalReviewPage from "../../app/create/final-review/page"; +import { useCreateFlow } from "../../app/create/context/CreateFlowContext"; + +const FALLBACK_CARD_TITLE = "Your community"; +const FALLBACK_CARD_DESCRIPTION_SNIPPET = + "Add a short description of your community"; + +function FinalReviewWithFlowState({ + title, + summary, +}: { + title: string; + summary?: string; +}) { + const { replaceState } = useCreateFlow(); + useLayoutEffect(() => { + replaceState({ title, ...(summary !== undefined ? { summary } : {}) }); + }, [replaceState, title, summary]); + return ; +} describe("FinalReviewPage", () => { it("renders without crashing", () => { @@ -27,17 +51,27 @@ describe("FinalReviewPage", () => { ).toBeInTheDocument(); }); - it("renders RuleCard with title", () => { + it("renders RuleCard with fallback title when context has no name", () => { render(); - expect(screen.getByText("Mutual Aid Mondays")).toBeInTheDocument(); + expect(screen.getByText(FALLBACK_CARD_TITLE)).toBeInTheDocument(); }); - it("renders RuleCard with description", () => { + it("renders RuleCard with fallback description when context has no summary", () => { render(); expect( - screen.getByText( - /Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness./i, - ), + screen.getByText(new RegExp(FALLBACK_CARD_DESCRIPTION_SNIPPET, "i")), + ).toBeInTheDocument(); + }); + + it("renders RuleCard title from create flow state", async () => { + render( + , + ); + await waitFor(() => { + expect(screen.getByText("Oak Park Commons")).toBeInTheDocument(); + }); + expect( + screen.getByText(/Local mutual aid\./i), ).toBeInTheDocument(); }); @@ -46,7 +80,7 @@ describe("FinalReviewPage", () => { const buttons = screen.getAllByRole("button"); expect(buttons.length).toBeGreaterThanOrEqual(1); expect( - buttons.some((el) => el.textContent?.includes("Mutual Aid Mondays")), + buttons.some((el) => el.textContent?.includes(FALLBACK_CARD_TITLE)), ).toBe(true); }); diff --git a/tests/unit/buildPublishPayload.test.ts b/tests/unit/buildPublishPayload.test.ts new file mode 100644 index 0000000..34d349b --- /dev/null +++ b/tests/unit/buildPublishPayload.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { + buildPublishPayload, + parseDocumentSectionsForDisplay, + parseSectionsFromCreateFlowState, +} from "../../lib/create/buildPublishPayload"; +import type { CreateFlowState } from "../../app/create/types"; + +describe("buildPublishPayload", () => { + it("returns error when title missing", () => { + expect(buildPublishPayload({})).toEqual({ + ok: false, + error: "missingCommunityName", + }); + }); + + it("returns error when title is whitespace only", () => { + expect(buildPublishPayload({ title: " \n\t " })).toEqual({ + ok: false, + error: "missingCommunityName", + }); + }); + + it("returns title and fallback Overview section when no sections", () => { + const r = buildPublishPayload({ title: "Oak Park Commons" }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.title).toBe("Oak Park Commons"); + expect(r.summary).toBeUndefined(); + expect(r.document).toEqual({ + sections: [ + { + categoryName: "Overview", + entries: [ + { + title: "Community", + body: "This CommunityRule was created in the create flow. Add more detail in a future edit.", + }, + ], + }, + ], + }); + }); + + it("includes trimmed summary in payload and uses it as fallback section body", () => { + const r = buildPublishPayload({ + title: " My Group ", + summary: " We organize locally. ", + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.title).toBe("My Group"); + expect(r.summary).toBe("We organize locally."); + expect(r.document).toEqual({ + sections: [ + { + categoryName: "Overview", + entries: [{ title: "Community", body: "We organize locally." }], + }, + ], + }); + }); + + it("uses valid state.sections when present", () => { + const sections: CreateFlowState["sections"] = [ + { + categoryName: "Values", + entries: [{ title: "A", body: "B" }], + }, + ]; + const r = buildPublishPayload({ title: "T", sections }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.document).toEqual({ sections }); + }); + + it("filters invalid section entries from state.sections", () => { + const r = buildPublishPayload({ + title: "T", + sections: [ + { categoryName: "Values", entries: [{ title: "A", body: "B" }] }, + { bad: true } as unknown as Record, + ], + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.document).toEqual({ + sections: [{ categoryName: "Values", entries: [{ title: "A", body: "B" }] }], + }); + }); +}); + +describe("parseDocumentSectionsForDisplay", () => { + it("returns empty for non-object", () => { + expect(parseDocumentSectionsForDisplay(null)).toEqual([]); + }); + + it("parses valid sections array", () => { + const doc = { + sections: [ + { categoryName: "X", entries: [{ title: "t", body: "b" }] }, + ], + }; + expect(parseDocumentSectionsForDisplay(doc)).toEqual(doc.sections); + }); +}); + +describe("parseSectionsFromCreateFlowState", () => { + it("returns empty when sections missing", () => { + expect(parseSectionsFromCreateFlowState({})).toEqual([]); + }); +}); diff --git a/tests/unit/publishRule.test.ts b/tests/unit/publishRule.test.ts new file mode 100644 index 0000000..1663b41 --- /dev/null +++ b/tests/unit/publishRule.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { publishRule } from "../../lib/create/api"; + +const input = { + title: "T", + document: { sections: [] as unknown[] }, +}; + +describe("publishRule", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns ok on 200 with rule", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ rule: { id: "r1", title: "T" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const result = await publishRule(input); + expect(result).toEqual({ ok: true, id: "r1", title: "T" }); + }); + + it("does not throw when body is empty (e.g. connection reset)", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response("", { + status: 503, + statusText: "Service Unavailable", + }), + ); + const result = await publishRule(input); + expect(result.ok).toBe(false); + if (result.ok === false) { + expect(result.status).toBe(503); + expect(result.error).toBe("Service Unavailable"); + } + }); + + it("parses validation error when JSON present", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { code: "validation_error", message: "title required" }, + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ), + ); + const result = await publishRule(input); + expect(result).toEqual({ + ok: false, + error: "title required", + status: 400, + }); + }); + + it("returns network message when fetch rejects", async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error("offline")); + const result = await publishRule(input); + expect(result).toEqual({ + ok: false, + error: "Something went wrong. Check your connection and try again.", + }); + }); +});