From 815de2fdfd6f7b4f1524cad5fdcc41e545e072e8 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:34:40 -0600 Subject: [PATCH 01/14] Signed in create rule clear --- app/(app)/create/SignedInDraftHydration.tsx | 6 +++-- .../utils/clearCreateFlowPersistedDrafts.ts | 16 +++++-------- .../utils/prepareFreshCreateFlowEntry.ts | 23 ++++++++++++++++++ app/(app)/profile/ProfilePageClient.tsx | 12 ++++++++++ .../profile/_components/ProfilePage.view.tsx | 7 ++++-- .../templates/TemplatesPageClient.tsx | 14 ++++++----- .../navigation/Top/Top.container.tsx | 19 +++++++-------- .../RuleStack/RuleStack.container.tsx | 14 +++++------ docs/create-flow.md | 24 +++++++++++++------ tests/components/Top.test.tsx | 6 +++-- 10 files changed, 95 insertions(+), 46 deletions(-) create mode 100644 app/(app)/create/utils/prepareFreshCreateFlowEntry.ts diff --git a/app/(app)/create/SignedInDraftHydration.tsx b/app/(app)/create/SignedInDraftHydration.tsx index b53364f..e54605b 100644 --- a/app/(app)/create/SignedInDraftHydration.tsx +++ b/app/(app)/create/SignedInDraftHydration.tsx @@ -27,8 +27,10 @@ const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; * server draft on top would clobber unsaved keystrokes with a stale snapshot. * * Server draft becomes authoritative only when localStorage is empty — i.e. - * fresh device, after explicit Save & Exit (which clears localStorage), or - * after Exit-from-completed clears local state. + * fresh device, after explicit Save & Exit (which clears localStorage), + * after Exit-from-completed clears local state, or after + * {@link prepareFreshCreateFlowEntry} (Create rule / new template entry) clears + * local + deletes the server draft when sync is on. * * Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer} * owns that path. diff --git a/app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts b/app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts index 868bf46..4dbe23f 100644 --- a/app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts +++ b/app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts @@ -4,17 +4,13 @@ import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorag /** * Wipe the anonymous in-progress create-flow draft from `localStorage` (both * the main `create-flow-anonymous` blob and the separate core-value details - * key). Intended for call sites that navigate **into** the create flow from - * outside and want a fresh slate — today that's the marketing "Popular - * templates" click handler on the home page and the `/templates` index page - * (when not in-flow). `CreateFlowProvider` reads `localStorage` during its - * `useState` initializer, so clearing *before* pushing the next route means - * the provider mounts empty and the Create Community stage starts clean. + * key). Clearing *before* `router.push` means `CreateFlowProvider` can read + * empty storage on mount. * - * Note: this only touches localStorage. It does **not** delete the - * authenticated user's server draft (`/api/drafts/me`). Server drafts are - * loaded deliberately from the profile page, not re-hydrated into the flow - * on every entry, so there's nothing to wipe here for signed-in users. + * For marketing/profile “new rule” entry that should also remove the signed-in + * server draft when backend sync is on, use {@link prepareFreshCreateFlowEntry}. + * + * This helper only touches `localStorage`; it does **not** `DELETE /api/drafts/me`. */ export function clearCreateFlowPersistedDrafts(): void { clearAnonymousCreateFlowStorage(); diff --git a/app/(app)/create/utils/prepareFreshCreateFlowEntry.ts b/app/(app)/create/utils/prepareFreshCreateFlowEntry.ts new file mode 100644 index 0000000..9eaa332 --- /dev/null +++ b/app/(app)/create/utils/prepareFreshCreateFlowEntry.ts @@ -0,0 +1,23 @@ +import { deleteServerDraft } from "../../../../lib/create/api"; +import { clearAnonymousCreateFlowStorage } from "./anonymousDraftStorage"; +import { clearCoreValueDetailsLocalStorage } from "./coreValueDetailsLocalStorage"; + +const SYNC_ENABLED = + process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true"; + +/** + * Call **before** navigating into `/create` from marketing or profile “new rule” + * entry points so signed-in + sync matches an anonymous fresh start: wipe + * `localStorage` draft keys and, when sync is on, `DELETE /api/drafts/me`. + * Anonymous `DELETE` is harmless (401). Await ensures the server draft is gone + * before mount so {@link SignedInDraftHydration} does not rehydrate stale work. + * + * Do **not** use for “Continue draft” — that path should load the server draft. + */ +export async function prepareFreshCreateFlowEntry(): Promise { + clearAnonymousCreateFlowStorage(); + clearCoreValueDetailsLocalStorage(); + if (SYNC_ENABLED) { + await deleteServerDraft(); + } +} diff --git a/app/(app)/profile/ProfilePageClient.tsx b/app/(app)/profile/ProfilePageClient.tsx index aeb6446..1631a69 100644 --- a/app/(app)/profile/ProfilePageClient.tsx +++ b/app/(app)/profile/ProfilePageClient.tsx @@ -22,6 +22,8 @@ import { } from "../create/utils/flowSteps"; import type { CreateFlowStep } from "../create/types"; import { clearAnonymousCreateFlowStorage } from "../create/utils/anonymousDraftStorage"; +import { clearCoreValueDetailsLocalStorage } from "../create/utils/coreValueDetailsLocalStorage"; +import { prepareFreshCreateFlowEntry } from "../create/utils/prepareFreshCreateFlowEntry"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { ProfilePageSignedOutView, @@ -245,9 +247,18 @@ export default function ProfilePageClient() { const handleContinueDraft = useCallback(() => { if (draft == null || !draft.hasDraft) return; const step = resolveContinueStepState(draft.state); + clearAnonymousCreateFlowStorage(); + clearCoreValueDetailsLocalStorage(); router.push(`/create/${step}`); }, [draft, router]); + const handleStartNewCustomRule = useCallback(() => { + void (async () => { + await prepareFreshCreateFlowEntry(); + router.push("/create"); + })(); + }, [router]); + const handleRequestDeleteDraft = useCallback(() => { setActionError(null); setDraftDeleteOpen(true); @@ -360,6 +371,7 @@ export default function ProfilePageClient() { }} onCloseDeleteAccount={() => setAccountDeleteOpen(false)} onConfirmDeleteAccount={handleConfirmDeleteAccount} + onStartNewCustomRule={handleStartNewCustomRule} /> ); } diff --git a/app/(app)/profile/_components/ProfilePage.view.tsx b/app/(app)/profile/_components/ProfilePage.view.tsx index fd9ba6d..ad03d80 100644 --- a/app/(app)/profile/_components/ProfilePage.view.tsx +++ b/app/(app)/profile/_components/ProfilePage.view.tsx @@ -72,6 +72,8 @@ export type ProfilePageViewProps = { onDismissProfileSuccess: () => void; onDismissActionError: () => void; onDismissRulesError: () => void; + /** Clears local + server draft (when sync) then routes to `/create` — same fresh start as marketing “Create rule”. */ + onStartNewCustomRule: () => void; }; /** @@ -199,6 +201,7 @@ export function ProfilePageView({ onDismissProfileSuccess, onDismissActionError, onDismissRulesError, + onStartNewCustomRule, }: ProfilePageViewProps) { const t = useTranslation("pages.profile"); const tLogin = useTranslation("pages.login"); @@ -213,7 +216,7 @@ export function ProfilePageView({ id: "create-custom", title: t("optionCreateCustom"), description: "", - href: "/create", + onClick: onStartNewCustomRule, leadingIcon: "edit", showDescription: false, }, @@ -251,7 +254,7 @@ export function ProfilePageView({ showDescription: false, }, ]; - }, [t, onSignOut, onOpenDeleteAccount, onOpenEmailChange]); + }, [t, onSignOut, onOpenDeleteAccount, onOpenEmailChange, onStartNewCustomRule]); const ruleCardShellClass = "w-full !max-w-full cursor-default !gap-3 !rounded-[12px] shadow-[0_0_48px_rgba(0,0,0,0.1)] lg:!rounded-[24px] lg:shadow-[0_0_24px_rgba(0,0,0,0.1)]"; diff --git a/app/(marketing)/templates/TemplatesPageClient.tsx b/app/(marketing)/templates/TemplatesPageClient.tsx index f3cbe19..0d292d0 100644 --- a/app/(marketing)/templates/TemplatesPageClient.tsx +++ b/app/(marketing)/templates/TemplatesPageClient.tsx @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import HeaderLockup from "../../components/type/HeaderLockup"; import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid"; import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation"; -import { clearCreateFlowPersistedDrafts } from "../../(app)/create/utils/clearCreateFlowPersistedDrafts"; +import { prepareFreshCreateFlowEntry } from "../../(app)/create/utils/prepareFreshCreateFlowEntry"; import { buildTemplateReviewHref } from "../../(app)/create/utils/flowSteps"; import { useTranslation } from "../../contexts/MessagesContext"; @@ -83,11 +83,13 @@ function TemplatesGrid({ entries={entries} onTemplateClick={(slug) => { if (!fromFlow) { - // Direct entry to `/templates`: treat template click as a fresh - // create-flow start and wipe any stale anonymous draft before - // navigating. In-flow entry (`?fromFlow=1`) skips the clear so - // the user's community stage survives the detour through here. - clearCreateFlowPersistedDrafts(); + void (async () => { + await prepareFreshCreateFlowEntry(); + router.push( + buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }), + ); + })(); + return; } router.push( buildTemplateReviewHref(slug, { fromCreateWizard: fromFlow }), diff --git a/app/components/navigation/Top/Top.container.tsx b/app/components/navigation/Top/Top.container.tsx index b32a525..13344b9 100644 --- a/app/components/navigation/Top/Top.container.tsx +++ b/app/components/navigation/Top/Top.container.tsx @@ -9,8 +9,7 @@ import Button from "../../buttons/Button"; import AvatarContainer from "../../asset/AvatarContainer"; import Avatar from "../../asset/Avatar"; import { getAssetPath, ASSETS } from "../../../../lib/assetUtils"; -import { clearAnonymousCreateFlowStorage } from "../../../(app)/create/utils/anonymousDraftStorage"; -import { clearCoreValueDetailsLocalStorage } from "../../../(app)/create/utils/coreValueDetailsLocalStorage"; +import { prepareFreshCreateFlowEntry } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry"; import { TopView } from "./Top.view"; import type { TopProps, NavSize } from "./Top.types"; @@ -45,16 +44,16 @@ const TopContainer = memo( /** * `Top` is hidden on `/create` routes by ConditionalNavigationClient, so - * this button is always clicked from outside the wizard — there is no - * mounted CreateFlowProvider to reset. Wiping the anonymous draft keys - * here guarantees a fresh start; the provider that mounts on `/create` - * will read empty storage. Server drafts (signed-in Save & Exit) are - * left alone — they're intentional persistence the user opted into. + * this button is always clicked from outside the wizard. Clears anonymous + * `localStorage` and, when backend sync is on, deletes the server draft + * so signed-in users get the same fresh start as guests (see + * {@link prepareFreshCreateFlowEntry}). */ const handleCreateRuleClick = useCallback(() => { - clearAnonymousCreateFlowStorage(); - clearCoreValueDetailsLocalStorage(); - router.push("/create"); + void (async () => { + await prepareFreshCreateFlowEntry(); + router.push("/create"); + })(); }, [router]); // Schema markup for site navigation diff --git a/app/components/sections/RuleStack/RuleStack.container.tsx b/app/components/sections/RuleStack/RuleStack.container.tsx index 5bf2faf..3b5685b 100644 --- a/app/components/sections/RuleStack/RuleStack.container.tsx +++ b/app/components/sections/RuleStack/RuleStack.container.tsx @@ -3,7 +3,7 @@ import { memo, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { logger } from "../../../../lib/logger"; -import { clearCreateFlowPersistedDrafts } from "../../../(app)/create/utils/clearCreateFlowPersistedDrafts"; +import { prepareFreshCreateFlowEntry } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry"; import { fetchTemplates, isTemplatesFetchAborted, @@ -90,12 +90,12 @@ const RuleStackContainer = memo( } } logger.debug(`${slug} template clicked`); - // Marketing entry is always a *fresh* create-flow start: wipe any - // in-progress anonymous draft so a stale community name/structure from - // an earlier abandoned session can't short-circuit the `state.title` - // check in `handleCustomizeTemplate` / `handleUseTemplateWithoutChanges`. - clearCreateFlowPersistedDrafts(); - router.push(`/create/review-template/${encodeURIComponent(slug)}`); + // Marketing home “Popular templates”: same fresh start as Top “Create rule” + // (local + server draft when sync) so stale state cannot break template apply. + void (async () => { + await prepareFreshCreateFlowEntry(); + router.push(`/create/review-template/${encodeURIComponent(slug)}`); + })(); }; return ( diff --git a/docs/create-flow.md b/docs/create-flow.md index a03b913..232c7b9 100644 --- a/docs/create-flow.md +++ b/docs/create-flow.md @@ -43,10 +43,20 @@ Order is defined in code by [`FLOW_STEP_ORDER`](../app/(app)/create/utils/flowSt | 15 | Review and complete | `final-review` | `/create/final-review` | | 16 | Review and complete | `completed` | `/create/completed` | -**Primary entry:** marketing header “Create rule” navigates to **`/create`**, which redirects to **`/create/informational`** (see [`Top.container.tsx`](../app/components/navigation/Top/Top.container.tsx)). +**Primary entry:** marketing header **Create rule** and profile **Create new custom Rule** both run **`prepareFreshCreateFlowEntry`** then navigate to **`/create`**, which redirects to **`/create/informational`** (see [`Top.container.tsx`](../app/components/navigation/Top/Top.container.tsx) and [`ProfilePageClient.tsx`](../app/(app)/profile/ProfilePageClient.tsx)). Active step for chrome and navigation is resolved from the pathname via [`parseCreateFlowScreenFromPathname`](../app/(app)/create/utils/flowSteps.ts) inside [`useCreateFlowNavigation`](../app/(app)/create/hooks/useCreateFlowNavigation.ts). +### Fresh start vs continue draft (signed-in + sync) + +**Established pattern:** anonymous and signed-in users should see the **same** wizard when starting a **new** rule from marketing or profile: empty state at the first step, with no surprise reload of old work. Signed-in users additionally get **Save & Exit** and **publish**; their in-progress payload may also live on **`/api/drafts/me`** when `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true`. + +- **New rule entry** (always a clean slate): call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** `router.push` into `/create` or `/create/review-template/...`. It clears **`create-flow-anonymous`** and the core-value-details `localStorage` key; when sync is on, it **`DELETE`s `/api/drafts/me`** so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) does not rehydrate a stale server draft after local storage was wiped. +- **Continue saved draft** (profile): do **not** call `prepareFreshCreateFlowEntry`. Clear the same `localStorage` keys **only** (see [`ProfilePageClient`](../app/(app)/profile/ProfilePageClient.tsx) `handleContinueDraft`) so the client mirror is empty, then navigate to **`/create/{savedStep}`**. Hydration loads the server draft; the URL may be corrected to `currentStep` when it differs from the path. +- **Local-only wipe** without touching the server: [`clearCreateFlowPersistedDrafts`](../app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts) (same two `localStorage` keys). Prefer **`prepareFreshCreateFlowEntry`** for any user-facing “start new rule” navigation so signed-in + sync stays aligned with anonymous. + +Call sites for **`prepareFreshCreateFlowEntry`**: [`Top.container.tsx`](../app/components/navigation/Top/Top.container.tsx) (Create rule), profile **Create new custom Rule** ([`ProfilePageClient.tsx`](../app/(app)/profile/ProfilePageClient.tsx)), home **Popular templates** ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)), and **direct** `/templates` template picks ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) when **`fromFlow` is absent**. + --- ## Auxiliary route (not a wizard step or Figma stage) @@ -61,17 +71,17 @@ From that page, **Customize** pre-fills the custom-rule selections on the curren **Entering a template before community stage is done.** When `state.title` is empty, both handlers apply their side effects eagerly (prefill for Customize; `sections` + `summary` for Use without changes) *and* pin a `pendingTemplateAction: { slug, mode }` on `CreateFlowState` before routing to `/create/informational`. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s past itself — to `/create/core-values` for `customize`, `/create/confirm-stakeholders` for `useWithoutChanges`. The user never sees the community-review page in that flow because their intent was already expressed at the template-review step. `replace` (not `push`) keeps `community-save` as the Back-button target from the destination. The action is cleared on the first fire so later direct visits to `/create/review` render normally. -**Direct entry vs in-flow template pick.** The same `/create/review-template/[slug]` URL is reached from two different origins. We disambiguate at the *click site*, not on the review-template page, using [`clearCreateFlowPersistedDrafts`](../app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts) — a tiny helper that wipes the anonymous draft from `localStorage` (both `create-flow-anonymous` and the core-value-details key) **before** the navigation fires. Because `CreateFlowProvider` reads `localStorage` in its `useState` initializer, the provider mounts empty and `handleCustomizeTemplate` / `handleUseTemplateWithoutChanges` naturally take the no-community branch — no per-handler marker plumbing needed. +**Direct entry vs in-flow template pick.** The same `/create/review-template/[slug]` URL is reached from two different origins. We disambiguate at the *click site*, not on the review-template page. **Direct** picks call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** navigation (local + server draft when sync is on — see **Fresh start vs continue draft** above). **In-flow** picks skip that call so the user’s community-stage state survives the detour. Because `CreateFlowProvider` reads `localStorage` in its `useState` initializer, clearing **before** `push` means a direct entry mounts without stale anonymous keys; signed-in users also avoid a stale server draft overwriting the empty mirror. | Origin | Click-site behavior | URL the user lands on | | --- | --- | --- | -| Home marketing "Popular templates" ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)) | always calls `clearCreateFlowPersistedDrafts()` | `/create/review-template/[slug]` | -| `/templates` index ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) visited directly / via pasted URL | `fromFlow` absent → calls `clearCreateFlowPersistedDrafts()` | `/create/review-template/[slug]` | -| In-flow: `/create/review` footer "Create from template" → `/templates?fromFlow=1` → template click | `fromFlow=1` → skips the clear | `/create/review-template/[slug]` | +| Home marketing "Popular templates" ([`RuleStack.container.tsx`](../app/components/sections/RuleStack/RuleStack.container.tsx)) | always `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` | +| `/templates` index ([`TemplatesPageClient.tsx`](../app/(marketing)/templates/TemplatesPageClient.tsx)) visited directly / via pasted URL | `fromFlow` absent → `await prepareFreshCreateFlowEntry()` then navigate | `/create/review-template/[slug]` | +| In-flow: `/create/review` footer "Create from template" → `/templates?fromFlow=1` → template click | `fromFlow=1` → no fresh-entry prep | `/create/review-template/[slug]` | Only one `?fromFlow=1` marker exists, on one hop (`/create/review` → `/templates`). It is not forwarded onto the review-template URL. The review-template handlers branch solely on `state.title` — they don't need to know the origin. -Server drafts (`/api/drafts/me`) are **not** touched here. Per product plan they are not auto-hydrated into the create flow; users select and load a specific saved draft from the profile page. So wiping `localStorage` is sufficient for the "fresh slate" invariant. +**Resume from profile** remains explicit-only: **Continue** clears local mirrors then opens `/create/{step}` so [`SignedInDraftHydration`](../app/(app)/create/SignedInDraftHydration.tsx) can load `/api/drafts/me` when the client buffer is empty. There is no automatic “pick template from marketing → silently merge server draft” path. **Final-review Rule category chips** are derived from `CreateFlowState` via [`buildFinalReviewCategoriesFromState`](../lib/create/buildFinalReviewCategories.ts): for the Customize / plain custom-rule path it resolves `selected{Communication,Membership,DecisionApproach,ConflictManagement}MethodIds` against the curated method presets in `messages/en/create/customRule/*.json`, and `buildCoreValuesForDocument` supplies the `Values` row from `coreValuesChipsSnapshot` + `selectedCoreValueIds`. For the Use-without-changes path the template body lives in `state.sections`; the helper renders `categoryName` + entry titles directly. The demo chips shipped in `finalReview.json` remain the fallback only when nothing in state resolves to any chip (e.g. direct navigation for development). @@ -92,7 +102,7 @@ Details and edge cases (conflict confirm, banners, `?syncDraft=1`) match **Ticke ## Known implementation gaps -- **Profile + drafts (CR-86):** The profile page lists the server draft, **Continue** deep-links to `/create/{currentStep}`, and **Start new rule** clears local + server draft before opening the wizard. `SignedInDraftHydration` calls `router.replace` to the saved step when it applies a server draft so the URL matches hydrated state. Remaining edge cases (e.g. template review routes) are handled when they surface in QA. +- **Profile + drafts (CR-86):** The profile page lists the server draft. **Continue** clears anonymous `localStorage` (and core-value details) then deep-links to `/create/{currentStep}` so hydration loads the server draft. **Create new custom Rule** and marketing **Create rule** use **`prepareFreshCreateFlowEntry`** (local + `DELETE /api/drafts/me` when sync is on) before opening the wizard so signed-in behavior matches a fresh anonymous start. `SignedInDraftHydration` may `router.replace` to the saved step when it applies a server draft so the URL matches hydrated state. Remaining edge cases (e.g. template review routes) are handled when they surface in QA. - **Inner “text/select shells”:** deferred until Create Community is stable; screens use **`CreateFlowStepShell`** only for Stage 1. --- diff --git a/tests/components/Top.test.tsx b/tests/components/Top.test.tsx index 39b656d..dd3f75b 100644 --- a/tests/components/Top.test.tsx +++ b/tests/components/Top.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom/vitest"; import Top from "../../app/components/navigation/Top"; @@ -92,10 +92,12 @@ describe('Top "Create rule" button', () => { }); await userEvent.click(btn); + await waitFor(() => { + expect(pushMock).toHaveBeenCalledWith("/create"); + }); expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull(); expect( window.localStorage.getItem(CORE_VALUE_DETAILS_STORAGE_KEY), ).toBeNull(); - expect(pushMock).toHaveBeenCalledWith("/create"); }); }); From ac1157a1724d2c49821e60297a851f7f51d6d84f Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:02:47 -0600 Subject: [PATCH 02/14] Persist choices through to completed page --- .../create/hooks/useTemplateReviewActions.ts | 5 - .../screens/completed/CompletedScreen.tsx | 6 +- .../screens/review/FinalReviewScreen.tsx | 3 +- app/(marketing)/rules/[id]/page.tsx | 4 +- docs/create-flow.md | 4 +- lib/create/buildFinalReviewCategories.ts | 12 + lib/create/buildPublishPayload.ts | 104 ++++++- lib/create/finalReviewChipPresets.ts | 44 +++ .../publishedDocumentToDisplaySections.ts | 282 ++++++++++++++++++ .../ruleSectionsFromMethodSelections.ts | 194 ++++++++++++ prisma/seed.ts | 7 +- tests/unit/buildPublishPayload.test.ts | 50 +++- ...publishedDocumentToDisplaySections.test.ts | 119 ++++++++ 13 files changed, 804 insertions(+), 30 deletions(-) create mode 100644 lib/create/publishedDocumentToDisplaySections.ts create mode 100644 lib/create/ruleSectionsFromMethodSelections.ts create mode 100644 tests/unit/publishedDocumentToDisplaySections.test.ts diff --git a/app/(app)/create/hooks/useTemplateReviewActions.ts b/app/(app)/create/hooks/useTemplateReviewActions.ts index 970f35c..2bc3bbc 100644 --- a/app/(app)/create/hooks/useTemplateReviewActions.ts +++ b/app/(app)/create/hooks/useTemplateReviewActions.ts @@ -163,16 +163,11 @@ export function useTemplateReviewActions({ }) : sections; - const summaryRaw = - typeof template.description === "string" - ? template.description.trim() - : ""; const hasCommunityName = typeof state.title === "string" && state.title.trim().length > 0; updateState({ ...coreValuesPrefill, sections: sectionsWithoutValues, - ...(summaryRaw.length > 0 ? { summary: summaryRaw } : {}), templateReviewBackSlug: templateReviewSlug, ...(hasCommunityName ? { pendingTemplateAction: undefined } diff --git a/app/(app)/create/screens/completed/CompletedScreen.tsx b/app/(app)/create/screens/completed/CompletedScreen.tsx index 7b615ac..b0d06e7 100644 --- a/app/(app)/create/screens/completed/CompletedScreen.tsx +++ b/app/(app)/create/screens/completed/CompletedScreen.tsx @@ -7,7 +7,7 @@ import type { CommunityRuleSection } from "../../../../components/type/Community import Alert from "../../../../components/modals/Alert"; import { useMessages } from "../../../../contexts/MessagesContext"; import { fetchPublishedRuleDetail } from "../../../../../lib/create/api"; -import { parseDocumentSectionsForDisplay } from "../../../../../lib/create/buildPublishPayload"; +import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../../lib/create/publishedDocumentToDisplaySections"; import { readLastPublishedRule, writeLastPublishedRule, @@ -48,7 +48,7 @@ function initialCompletedUi( documentSections: [], }; } - const parsed = parseDocumentSectionsForDisplay(stored.document); + const parsed = parsePublishedDocumentForCommunityRuleDisplay(stored.document); if (parsed.length === 0) { return { headerTitle: "", @@ -105,7 +105,7 @@ export function CompletedScreen() { summary: detail.rule.summary, document: doc, }); - const parsed = parseDocumentSectionsForDisplay(doc); + const parsed = parsePublishedDocumentForCommunityRuleDisplay(doc); if (parsed.length === 0) { router.replace(`/rules/${encodeURIComponent(ruleIdParam)}`); return; diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx index e104cc5..118be5b 100644 --- a/app/(app)/create/screens/review/FinalReviewScreen.tsx +++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx @@ -170,8 +170,7 @@ export function FinalReviewScreen() { /** * Match {@link CommunityReviewScreen}: the card body is the free-text - * `community-context` field only — not `summary` (template / one-line - * rule summary can carry template-review copy). + * `community-context` field only — not `summary`. */ const ruleCardDescription = useMemo(() => { const raw = diff --git a/app/(marketing)/rules/[id]/page.tsx b/app/(marketing)/rules/[id]/page.tsx index ee6fa8b..eef4bfa 100644 --- a/app/(marketing)/rules/[id]/page.tsx +++ b/app/(marketing)/rules/[id]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules"; -import { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPublishPayload"; +import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections"; import CommunityRule from "../../../components/type/CommunityRule"; import HeaderLockup from "../../../components/type/HeaderLockup"; @@ -49,7 +49,7 @@ export default async function PublicRuleDetailPage({ params }: PageProps) { notFound(); } - const sections = parseDocumentSectionsForDisplay(rule.document); + const sections = parsePublishedDocumentForCommunityRuleDisplay(rule.document); const description = typeof rule.summary === "string" && rule.summary.trim().length > 0 ? rule.summary diff --git a/docs/create-flow.md b/docs/create-flow.md index 232c7b9..744c94c 100644 --- a/docs/create-flow.md +++ b/docs/create-flow.md @@ -67,9 +67,9 @@ Call sites for **`prepareFreshCreateFlowEntry`**: [`Top.container.tsx`](../app/c From that page, **Customize** pre-fills the custom-rule selections on the current `CreateFlowState` (via [`buildTemplateCustomizePrefill`](../lib/create/applyTemplatePrefill.ts)) and routes to **`/create/core-values`** when the community name (`state.title`) is already set, otherwise to **`/create/informational`**. Name-only is the gate because other community-stage fields (e.g. `communityStructureChipSnapshots`) are sticky once the user lands on those screens; a non-empty title is also the minimum bar [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) enforces, so the two checks stay aligned. No query-param plumbing: state persists via the usual anonymous/server-draft mirrors. -**Use without changes** writes the template's `body.sections` into `state.sections` (and its `description` into `state.summary` when present), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and routes to **`/create/confirm-stakeholders`**. The user then exits via the normal **`final-review → handleFinalize → publishRule`** pipeline, which gates unauthenticated publishes with a **401 → `openLogin`** redirect back to `/create/final-review?syncDraft=1`. +**Use without changes** writes the template's `body.sections` into `state.sections` (chip titles only; bodies are empty in seeded templates), resets any prior Customize chip selections so they don't bleed into `document.coreValues`, and routes to **`/create/confirm-stakeholders`**. It does **not** copy the template catalog `description` into `state.summary` — the published rule summary comes from **`communityContext` first**, then `summary`, when the user publishes. At publish, [`buildPublishPayload`](../lib/create/buildPublishPayload.ts) derives `methodSelections` from those section titles, merges preset copy into `document.sections`, and emits structured `methodSelections`. The user then exits via the normal **`final-review → handleFinalize → publishRule`** pipeline, which gates unauthenticated publishes with a **401 → `openLogin`** redirect back to `/create/final-review?syncDraft=1`. -**Entering a template before community stage is done.** When `state.title` is empty, both handlers apply their side effects eagerly (prefill for Customize; `sections` + `summary` for Use without changes) *and* pin a `pendingTemplateAction: { slug, mode }` on `CreateFlowState` before routing to `/create/informational`. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s past itself — to `/create/core-values` for `customize`, `/create/confirm-stakeholders` for `useWithoutChanges`. The user never sees the community-review page in that flow because their intent was already expressed at the template-review step. `replace` (not `push`) keeps `community-save` as the Back-button target from the destination. The action is cleared on the first fire so later direct visits to `/create/review` render normally. +**Entering a template before community stage is done.** When `state.title` is empty, both handlers apply their side effects eagerly (prefill for Customize; `sections` for Use without changes) *and* pin a `pendingTemplateAction: { slug, mode }` on `CreateFlowState` before routing to `/create/informational`. Once the user reaches `/create/review`, [`CommunityReviewScreen`](../app/(app)/create/screens/review/CommunityReviewScreen.tsx) reads the action on mount, clears it via `updateState`, and `router.replace`s past itself — to `/create/core-values` for `customize`, `/create/confirm-stakeholders` for `useWithoutChanges`. The user never sees the community-review page in that flow because their intent was already expressed at the template-review step. `replace` (not `push`) keeps `community-save` as the Back-button target from the destination. The action is cleared on the first fire so later direct visits to `/create/review` render normally. **Direct entry vs in-flow template pick.** The same `/create/review-template/[slug]` URL is reached from two different origins. We disambiguate at the *click site*, not on the review-template page. **Direct** picks call [`prepareFreshCreateFlowEntry`](../app/(app)/create/utils/prepareFreshCreateFlowEntry.ts) **before** navigation (local + server draft when sync is on — see **Fresh start vs continue draft** above). **In-flow** picks skip that call so the user’s community-stage state survives the detour. Because `CreateFlowProvider` reads `localStorage` in its `useState` initializer, clearing **before** `push` means a direct entry mounts without stale anonymous keys; signed-in users also avoid a stale server draft overwriting the empty mirror. diff --git a/lib/create/buildFinalReviewCategories.ts b/lib/create/buildFinalReviewCategories.ts index 743f3b0..1ebd3df 100644 --- a/lib/create/buildFinalReviewCategories.ts +++ b/lib/create/buildFinalReviewCategories.ts @@ -129,6 +129,18 @@ function methodsForGroup( } } +/** + * Resolve a preset method id from a chip label (template sections / display + * enrichment where entries carry titles but not stable ids). + */ +export function resolveMethodPresetIdFromLabel( + label: string, + groupKey: TemplateFacetGroupKey, +): string | null { + if (groupKey === "coreValues") return null; + return overrideKeyForLabel(label, methodsForGroup(groupKey)); +} + /** * Detailed builder: same logic as {@link buildFinalReviewCategoriesFromState} * but each chip is returned with its `overrideKey` + `groupKey` so the diff --git a/lib/create/buildPublishPayload.ts b/lib/create/buildPublishPayload.ts index a12e834..301bde8 100644 --- a/lib/create/buildPublishPayload.ts +++ b/lib/create/buildPublishPayload.ts @@ -1,20 +1,23 @@ import type { CommunicationMethodDetailEntry, ConflictManagementDetailEntry, - CoreValueDetailEntry, CreateFlowState, DecisionApproachDetailEntry, MembershipMethodDetailEntry, } from "../../app/(app)/create/types"; import type { CommunityRuleSection } from "../../app/components/type/CommunityRule/CommunityRule.types"; +import { resolveMethodPresetIdFromLabel } from "./buildFinalReviewCategories"; import { communicationPresetFor, conflictManagementPresetFor, decisionApproachPresetFor, membershipPresetFor, + mergeCoreValueDetailWithPresets, methodLabelFor, } from "./finalReviewChipPresets"; import { isDocumentEntry } from "./documentEntryGuards"; +import { replaceMethodSectionsWithMethodSelections } from "./ruleSectionsFromMethodSelections"; +import { templateCategoryToGroupKey } from "./templateReviewMapping"; export { isDocumentEntry } from "./documentEntryGuards"; @@ -53,12 +56,12 @@ export function buildCoreValuesForDocument(state: CreateFlowState): Array<{ return snap .filter((r) => selected.has(r.id)) .map((r) => { - const d: CoreValueDetailEntry | undefined = details[r.id]; + const merged = mergeCoreValueDetailWithPresets(r.id, r.label, details[r.id]); return { chipId: r.id, label: r.label, - meaning: d?.meaning ?? "", - signals: d?.signals ?? "", + meaning: merged.meaning, + signals: merged.signals, }; }); } @@ -102,7 +105,9 @@ export type BuildPublishPayloadResult = } | { ok: false; error: string }; -const FALLBACK_CATEGORY = "Overview"; +export const PUBLISH_FALLBACK_OVERVIEW_CATEGORY = "Overview"; + +const FALLBACK_CATEGORY = PUBLISH_FALLBACK_OVERVIEW_CATEGORY; const DEFAULT_FALLBACK_BODY = "This CommunityRule was created in the create flow. Add more detail in a future edit."; @@ -124,7 +129,8 @@ export function buildPublishPayload( return undefined; }; - let summary = firstNonEmpty(state.summary, state.communityContext); + /** Community context wins over `summary` (template review no longer copies template description into `summary`). */ + let summary = firstNonEmpty(state.communityContext, state.summary); let sections = parseSectionsFromCreateFlowState(state); if (sections.length === 0) { @@ -138,8 +144,18 @@ export function buildPublishPayload( } const coreValues = buildCoreValuesForDocument(state); + if (coreValues.length > 0) { + sections = sections.filter( + (s) => templateCategoryToGroupKey(s.categoryName) !== "coreValues", + ); + } + const methodSelections = buildMethodSelectionsForDocument(state); + if (hasAnyMethodSelection(methodSelections)) { + sections = replaceMethodSectionsWithMethodSelections(sections, methodSelections); + } + const document: Record = { sections, coreValues }; if (hasAnyMethodSelection(methodSelections)) { document.methodSelections = methodSelections; @@ -160,6 +176,59 @@ function hasAnyMethodSelection(m: PublishedMethodSelections): boolean { ); } +function deriveMethodPresetIdsFromSections( + sections: CommunityRuleSection[], +): { + communication: string[]; + membership: string[]; + decisionApproaches: string[]; + conflictManagement: string[]; +} { + const out = { + communication: [] as string[], + membership: [] as string[], + decisionApproaches: [] as string[], + conflictManagement: [] as string[], + }; + for (const s of sections) { + const gk = templateCategoryToGroupKey(s.categoryName); + if (!gk || gk === "coreValues") continue; + const ids: string[] = []; + for (const e of s.entries) { + const title = typeof e.title === "string" ? e.title.trim() : ""; + if (title.length === 0) continue; + const id = resolveMethodPresetIdFromLabel(title, gk); + if (id) ids.push(id); + } + if (ids.length === 0) continue; + switch (gk) { + case "communication": + out.communication = ids; + break; + case "membership": + out.membership = ids; + break; + case "decisionApproaches": + out.decisionApproaches = ids; + break; + case "conflictManagement": + out.conflictManagement = ids; + break; + default: + break; + } + } + return out; +} + +function pickMethodIds( + fromState: string[] | undefined, + derived: string[], +): string[] { + if (fromState && fromState.length > 0) return fromState; + return derived; +} + /** * Merge `selected*MethodIds` with any saved `{group}MethodDetailsById` * overrides authored on the final-review screen. Preset defaults from the @@ -170,9 +239,15 @@ function hasAnyMethodSelection(m: PublishedMethodSelections): boolean { export function buildMethodSelectionsForDocument( state: CreateFlowState, ): PublishedMethodSelections { + const derived = deriveMethodPresetIdsFromSections( + parseSectionsFromCreateFlowState(state), + ); const out: PublishedMethodSelections = {}; - const commIds = state.selectedCommunicationMethodIds ?? []; + const commIds = pickMethodIds( + state.selectedCommunicationMethodIds, + derived.communication, + ); if (commIds.length > 0) { out.communication = commIds.map((id) => { const preset = communicationPresetFor(id); @@ -185,7 +260,10 @@ export function buildMethodSelectionsForDocument( }); } - const memIds = state.selectedMembershipMethodIds ?? []; + const memIds = pickMethodIds( + state.selectedMembershipMethodIds, + derived.membership, + ); if (memIds.length > 0) { out.membership = memIds.map((id) => { const preset = membershipPresetFor(id); @@ -198,7 +276,10 @@ export function buildMethodSelectionsForDocument( }); } - const daIds = state.selectedDecisionApproachIds ?? []; + const daIds = pickMethodIds( + state.selectedDecisionApproachIds, + derived.decisionApproaches, + ); if (daIds.length > 0) { out.decisionApproaches = daIds.map((id) => { const preset = decisionApproachPresetFor(id); @@ -211,7 +292,10 @@ export function buildMethodSelectionsForDocument( }); } - const cmIds = state.selectedConflictManagementIds ?? []; + const cmIds = pickMethodIds( + state.selectedConflictManagementIds, + derived.conflictManagement, + ); if (cmIds.length > 0) { out.conflictManagement = cmIds.map((id) => { const preset = conflictManagementPresetFor(id); diff --git a/lib/create/finalReviewChipPresets.ts b/lib/create/finalReviewChipPresets.ts index 6642d45..03a2f57 100644 --- a/lib/create/finalReviewChipPresets.ts +++ b/lib/create/finalReviewChipPresets.ts @@ -169,6 +169,50 @@ export function coreValuePresetFor(chipId: string): CoreValueDetailEntry { }; } +/** Match `coreValues.json` row by trimmed label (custom chip id / drift fallbacks). */ +export function coreValuePresetForLabel(label: string): CoreValueDetailEntry { + const t = label.trim(); + if (!t) return { meaning: "", signals: "" }; + const values = (coreValuesMessages as { values?: unknown }).values; + if (!Array.isArray(values)) return { meaning: "", signals: "" }; + for (const row of values) { + if (typeof row === "string") { + if (row.trim() === t) return { meaning: "", signals: "" }; + continue; + } + if (!row || typeof row !== "object") continue; + const o = row as Record; + if (typeof o.label === "string" && o.label.trim() === t) { + return { + meaning: asString(o.meaning), + signals: asString(o.signals), + }; + } + } + return { meaning: "", signals: "" }; +} + +/** + * Published / display copy: saved draft wins when non-empty; otherwise preset + * by chip id (numeric presets), then by label match in `coreValues.json`. + */ +export function mergeCoreValueDetailWithPresets( + chipId: string, + label: string, + saved: CoreValueDetailEntry | undefined, +): CoreValueDetailEntry { + const savedMeaning = + typeof saved?.meaning === "string" ? saved.meaning.trim() : ""; + const savedSignals = + typeof saved?.signals === "string" ? saved.signals.trim() : ""; + const fromId = coreValuePresetFor(chipId); + const fromLabel = coreValuePresetForLabel(label); + return { + meaning: savedMeaning || fromId.meaning || fromLabel.meaning, + signals: savedSignals || fromId.signals || fromLabel.signals, + }; +} + /** Resolve method preset label by id for a given group (localized display). */ export function methodLabelFor( groupKey: TemplateFacetGroupKey, diff --git a/lib/create/publishedDocumentToDisplaySections.ts b/lib/create/publishedDocumentToDisplaySections.ts new file mode 100644 index 0000000..82e59d7 --- /dev/null +++ b/lib/create/publishedDocumentToDisplaySections.ts @@ -0,0 +1,282 @@ +import type { + CommunityRuleEntry, + CommunityRuleSection, +} from "../../app/components/type/CommunityRule/CommunityRule.types"; +import type { PublishedMethodSelections } from "./buildPublishPayload"; +import { + PUBLISH_FALLBACK_OVERVIEW_CATEGORY, + parseDocumentSectionsForDisplay, +} from "./buildPublishPayload"; +import { resolveMethodPresetIdFromLabel } from "./buildFinalReviewCategories"; +import { + communicationPresetFor, + conflictManagementPresetFor, + decisionApproachPresetFor, + membershipPresetFor, + mergeCoreValueDetailWithPresets, +} from "./finalReviewChipPresets"; +import { + communityRuleEntryFromMethodChip, + formatScopePayload, + nonEmptyTrimmed, + RULE_SECTION_CATEGORY, + sectionFromCommunication, + sectionFromConflict, + sectionFromDecision, + sectionFromMembership, +} from "./ruleSectionsFromMethodSelections"; +import { + templateCategoryToGroupKey, + type TemplateFacetGroupKey, +} from "./templateReviewMapping"; + +/** Legacy seed placeholder (removed from `prisma/seed.ts`); still hydrate older published rows. */ +const TEMPLATE_COMPOSITION_SUGGESTED_BODY = + "Suggested focus for this governance area. Replace with your own language in the create flow."; + +const CAT_VALUES = RULE_SECTION_CATEGORY.values; +const CAT_COMMUNICATION = RULE_SECTION_CATEGORY.communication; +const CAT_MEMBERSHIP = RULE_SECTION_CATEGORY.membership; +const CAT_DECISION = RULE_SECTION_CATEGORY.decisionMaking; +const CAT_CONFLICT = RULE_SECTION_CATEGORY.conflictManagement; + +const CANONICAL_DISPLAY_SECTION_ORDER = [ + CAT_VALUES, + CAT_COMMUNICATION, + CAT_MEMBERSHIP, + CAT_DECISION, + CAT_CONFLICT, +] as const; + +const COMM_LABELS: Record = { + corePrinciple: "Core Principle & Scope", + logisticsAdmin: "Logistics, Admin & Norms", + codeOfConduct: "Code of Conduct", +}; + +const MEM_LABELS: Record = { + eligibility: "Eligibility & Philosophy", + joiningProcess: "Joining Process", + expectations: "Expectations & Removal", +}; + +const DEC_LABELS: Record = { + corePrinciple: "Core Principle", + applicableScope: "Applicable Scope", + stepByStepInstructions: "Step-by-Step Instructions", + consensusLevel: "Consensus Level", + objectionsDeadlocks: "Objections & Deadlocks", +}; + +const CM_LABELS: Record = { + corePrinciple: "Core Principle", + applicableScope: "Applicable Scope", + processProtocol: "Process Protocol", + restorationFallbacks: "Restoration & Fallbacks", +}; + +function needsPlaceholderPresetEnrichment(entry: CommunityRuleEntry): boolean { + if (entry.blocks && entry.blocks.length > 0) return false; + const b = (entry.body ?? "").trim(); + if (b.length === 0) return true; + return b === TEMPLATE_COMPOSITION_SUGGESTED_BODY; +} + +function presetRecordForMethodGroup( + groupKey: Exclude, + id: string, +): Record { + switch (groupKey) { + case "communication": + return { ...communicationPresetFor(id) } as Record; + case "membership": + return { ...membershipPresetFor(id) } as Record; + case "decisionApproaches": + return { ...decisionApproachPresetFor(id) } as Record; + case "conflictManagement": + return { ...conflictManagementPresetFor(id) } as Record; + } +} + +function enrichMethodEntryIfPlaceholder( + entry: CommunityRuleEntry, + categoryName: string, +): CommunityRuleEntry { + const groupKey = templateCategoryToGroupKey(categoryName); + if (!groupKey || groupKey === "coreValues") return entry; + if (!needsPlaceholderPresetEnrichment(entry)) return entry; + const id = resolveMethodPresetIdFromLabel(entry.title, groupKey); + if (!id) return entry; + const record = presetRecordForMethodGroup(groupKey, id); + const merged: Record = { ...record }; + if (groupKey === "decisionApproaches" || groupKey === "conflictManagement") { + const scope = + formatScopePayload(merged.selectedApplicableScope) ?? + formatScopePayload(merged.applicableScope); + if (scope) merged.applicableScope = scope; + delete merged.selectedApplicableScope; + } + const labelByKey = + groupKey === "communication" + ? COMM_LABELS + : groupKey === "membership" + ? MEM_LABELS + : groupKey === "decisionApproaches" + ? DEC_LABELS + : CM_LABELS; + const options = + groupKey === "decisionApproaches" + ? { consensusLevelKey: "consensusLevel" as const } + : undefined; + const enriched = communityRuleEntryFromMethodChip( + entry.title, + merged, + labelByKey, + options, + ); + return enriched ?? entry; +} + +function enrichCoreValueEntryIfPlaceholder( + entry: CommunityRuleEntry, +): CommunityRuleEntry { + if (!needsPlaceholderPresetEnrichment(entry)) return entry; + const merged = mergeCoreValueDetailWithPresets("", entry.title, { + meaning: "", + signals: "", + }); + const meaning = (merged.meaning ?? "").trim(); + const signals = (merged.signals ?? "").trim(); + const bodyParts: string[] = []; + if (meaning.length > 0) bodyParts.push(meaning); + if (signals.length > 0) bodyParts.push(signals); + const body = bodyParts.join("\n\n"); + if (body.length === 0) return entry; + return { ...entry, body }; +} + +function enrichDisplaySection(section: CommunityRuleSection): CommunityRuleSection { + const groupKey = templateCategoryToGroupKey(section.categoryName); + if (groupKey === "coreValues") { + return { + ...section, + entries: section.entries.map(enrichCoreValueEntryIfPlaceholder), + }; + } + return { + ...section, + entries: section.entries.map((e) => + enrichMethodEntryIfPlaceholder(e, section.categoryName), + ), + }; +} + +function sortSectionsCanonical( + sections: CommunityRuleSection[], +): CommunityRuleSection[] { + const order = CANONICAL_DISPLAY_SECTION_ORDER as readonly string[]; + const rank = (name: string): number => { + const i = order.indexOf(name); + return i === -1 ? order.length : i; + }; + return [...sections].sort((a, b) => { + const d = rank(a.categoryName) - rank(b.categoryName); + if (d !== 0) return d; + return a.categoryName.localeCompare(b.categoryName); + }); +} + +function sectionFromStoredCoreValues( + raw: unknown, +): CommunityRuleSection | null { + if (!Array.isArray(raw) || raw.length === 0) return null; + const entries: CommunityRuleEntry[] = []; + for (const row of raw) { + if (!row || typeof row !== "object") continue; + const o = row as Record; + const chipId = typeof o.chipId === "string" ? o.chipId : ""; + const label = nonEmptyTrimmed(o.label); + if (!label) continue; + const merged = mergeCoreValueDetailWithPresets(chipId, label, { + meaning: typeof o.meaning === "string" ? o.meaning : "", + signals: typeof o.signals === "string" ? o.signals : "", + }); + const meaning = (merged.meaning ?? "").trim(); + const signals = (merged.signals ?? "").trim(); + const bodyParts: string[] = []; + if (meaning.length > 0) bodyParts.push(meaning); + if (signals.length > 0) bodyParts.push(signals); + const body = bodyParts.join("\n\n"); + entries.push({ title: label, body }); + } + if (entries.length === 0) return null; + return { categoryName: CAT_VALUES, entries }; +} + +function parseMethodSelectionsLoose( + document: Record, +): PublishedMethodSelections | null { + const ms = document.methodSelections; + if (!ms || typeof ms !== "object" || Array.isArray(ms)) return null; + return ms as PublishedMethodSelections; +} + +/** + * Full `CommunityRule` sections for a published `document` JSON blob: validated + * `document.sections` plus synthesized categories from `document.coreValues` and + * `document.methodSelections` when those categories are not already present. + * **Overview** sections (see `PUBLISH_FALLBACK_OVERVIEW_CATEGORY` in `buildPublishPayload`) from the publish fallback are dropped so the lockup + * header is the only intro; core value copy is the combined meaning + signals **body** + * under each value **title** (chip label). + */ +export function parsePublishedDocumentForCommunityRuleDisplay( + document: unknown, +): CommunityRuleSection[] { + if (!document || typeof document !== "object") return []; + const doc = document as Record; + + const hasPublishedCoreValues = + Array.isArray(doc.coreValues) && doc.coreValues.length > 0; + + const base = parseDocumentSectionsForDisplay(doc).filter( + (s) => + s.categoryName !== PUBLISH_FALLBACK_OVERVIEW_CATEGORY && + !(hasPublishedCoreValues && s.categoryName === CAT_VALUES), + ); + const seen = new Set(base.map((s) => s.categoryName)); + + const extra: CommunityRuleSection[] = []; + + const valuesSection = sectionFromStoredCoreValues(doc.coreValues); + if (valuesSection && !seen.has(valuesSection.categoryName)) { + extra.push(valuesSection); + seen.add(valuesSection.categoryName); + } + + const methodSelections = parseMethodSelectionsLoose(doc); + if (methodSelections) { + const comm = sectionFromCommunication(methodSelections.communication ?? []); + if (comm && !seen.has(comm.categoryName)) { + extra.push(comm); + seen.add(comm.categoryName); + } + const mem = sectionFromMembership(methodSelections.membership ?? []); + if (mem && !seen.has(mem.categoryName)) { + extra.push(mem); + seen.add(mem.categoryName); + } + const dec = sectionFromDecision(methodSelections.decisionApproaches ?? []); + if (dec && !seen.has(dec.categoryName)) { + extra.push(dec); + seen.add(dec.categoryName); + } + const cm = sectionFromConflict(methodSelections.conflictManagement ?? []); + if (cm && !seen.has(cm.categoryName)) { + extra.push(cm); + seen.add(cm.categoryName); + } + } + + const combined = [...base, ...extra].map(enrichDisplaySection); + return sortSectionsCanonical(combined); +} diff --git a/lib/create/ruleSectionsFromMethodSelections.ts b/lib/create/ruleSectionsFromMethodSelections.ts new file mode 100644 index 0000000..b0d1a4a --- /dev/null +++ b/lib/create/ruleSectionsFromMethodSelections.ts @@ -0,0 +1,194 @@ +import type { + CommunityRuleEntry, + CommunityRuleLabeledBlock, + CommunityRuleSection, +} from "../../app/components/type/CommunityRule/CommunityRule.types"; +import type { PublishedMethodSelections } from "./buildPublishPayload"; +import { templateCategoryToGroupKey } from "./templateReviewMapping"; + +/** Canonical `categoryName` strings for method groups in published documents. */ +export const RULE_SECTION_CATEGORY = { + values: "Values", + communication: "Communication", + membership: "Membership", + decisionMaking: "Decision-making", + conflictManagement: "Conflict management", +} as const; + +const COMM_LABELS: Record = { + corePrinciple: "Core Principle & Scope", + logisticsAdmin: "Logistics, Admin & Norms", + codeOfConduct: "Code of Conduct", +}; + +const MEM_LABELS: Record = { + eligibility: "Eligibility & Philosophy", + joiningProcess: "Joining Process", + expectations: "Expectations & Removal", +}; + +const DEC_LABELS: Record = { + corePrinciple: "Core Principle", + applicableScope: "Applicable Scope", + stepByStepInstructions: "Step-by-Step Instructions", + consensusLevel: "Consensus Level", + objectionsDeadlocks: "Objections & Deadlocks", +}; + +const CM_LABELS: Record = { + corePrinciple: "Core Principle", + applicableScope: "Applicable Scope", + processProtocol: "Process Protocol", + restorationFallbacks: "Restoration & Fallbacks", +}; + +export function nonEmptyTrimmed(s: unknown): string | null { + if (typeof s !== "string") return null; + const t = s.trim(); + return t.length > 0 ? t : null; +} + +export function formatScopePayload(val: unknown): string | null { + if (typeof val === "string") return nonEmptyTrimmed(val); + if (!Array.isArray(val)) return null; + const lines = val.filter((x): x is string => typeof x === "string" && x.trim().length > 0); + if (lines.length === 0) return null; + return lines.join("\n"); +} + +export function blocksFromKeyedRecord( + sections: Record, + labelByKey: Record, + options?: { + consensusLevelKey?: string; + }, +): CommunityRuleLabeledBlock[] { + const blocks: CommunityRuleLabeledBlock[] = []; + for (const [key, label] of Object.entries(labelByKey)) { + if (options?.consensusLevelKey === key) { + const n = sections[key]; + if (typeof n === "number" && !Number.isNaN(n)) { + blocks.push({ label, body: `${n}%` }); + } + continue; + } + const raw = sections[key]; + const text = + key === "applicableScope" || key === "selectedApplicableScope" + ? formatScopePayload(raw) + : nonEmptyTrimmed(raw); + if (text) blocks.push({ label, body: text }); + } + return blocks; +} + +export function communityRuleEntryFromMethodChip( + title: string, + sections: Record, + labelByKey: Record, + options?: { consensusLevelKey?: string }, +): CommunityRuleEntry | null { + const blocks = blocksFromKeyedRecord(sections, labelByKey, options); + if (blocks.length === 0) return null; + return { title, body: "", blocks }; +} + +export function sectionFromCommunication( + ms: NonNullable, +): CommunityRuleSection | null { + if (ms.length === 0) return null; + const entries: CommunityRuleEntry[] = []; + for (const m of ms) { + const sec = m.sections as unknown as Record; + const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS); + if (e) entries.push(e); + } + return entries.length > 0 + ? { categoryName: RULE_SECTION_CATEGORY.communication, entries } + : null; +} + +export function sectionFromMembership( + ms: NonNullable, +): CommunityRuleSection | null { + if (ms.length === 0) return null; + const entries: CommunityRuleEntry[] = []; + for (const m of ms) { + const sec = m.sections as unknown as Record; + const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS); + if (e) entries.push(e); + } + return entries.length > 0 + ? { categoryName: RULE_SECTION_CATEGORY.membership, entries } + : null; +} + +export function sectionFromDecision( + ms: NonNullable, +): CommunityRuleSection | null { + if (ms.length === 0) return null; + const entries: CommunityRuleEntry[] = []; + for (const m of ms) { + const sec = m.sections as unknown as Record; + const merged: Record = { ...sec }; + const scope = + formatScopePayload(sec.selectedApplicableScope) ?? + formatScopePayload(sec.applicableScope); + if (scope) merged.applicableScope = scope; + delete merged.selectedApplicableScope; + const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, { + consensusLevelKey: "consensusLevel", + }); + if (e) entries.push(e); + } + return entries.length > 0 + ? { categoryName: RULE_SECTION_CATEGORY.decisionMaking, entries } + : null; +} + +export function sectionFromConflict( + ms: NonNullable, +): CommunityRuleSection | null { + if (ms.length === 0) return null; + const entries: CommunityRuleEntry[] = []; + for (const m of ms) { + const sec = m.sections as unknown as Record; + const merged: Record = { ...sec }; + const scope = + formatScopePayload(sec.selectedApplicableScope) ?? + formatScopePayload(sec.applicableScope); + if (scope) merged.applicableScope = scope; + delete merged.selectedApplicableScope; + const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS); + if (e) entries.push(e); + } + return entries.length > 0 + ? { categoryName: RULE_SECTION_CATEGORY.conflictManagement, entries } + : null; +} + +/** + * Swap template `sections` method rows for fully-resolved entries built from + * `methodSelections` (preset + overrides). + */ +export function replaceMethodSectionsWithMethodSelections( + sections: CommunityRuleSection[], + ms: PublishedMethodSelections, +): CommunityRuleSection[] { + return sections.map((s) => { + const gk = templateCategoryToGroupKey(s.categoryName); + if (gk === "communication" && ms.communication?.length) { + return sectionFromCommunication(ms.communication) ?? s; + } + if (gk === "membership" && ms.membership?.length) { + return sectionFromMembership(ms.membership) ?? s; + } + if (gk === "decisionApproaches" && ms.decisionApproaches?.length) { + return sectionFromDecision(ms.decisionApproaches) ?? s; + } + if (gk === "conflictManagement" && ms.conflictManagement?.length) { + return sectionFromConflict(ms.conflictManagement) ?? s; + } + return s; + }); +} diff --git a/prisma/seed.ts b/prisma/seed.ts index a906b33..06f7f00 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -61,10 +61,7 @@ function governancePatternBody(coreValues: string): Prisma.InputJsonValue { }; } -/** Chip copy from Template Composition.xlsx (Decision-making, Membership, Values, Communication, Conflict). */ -const COMPOSITION_CHIP_BODY = - "Suggested focus for this governance area. Replace with your own language in the create flow."; - +/** Chip titles from Template Composition.xlsx; bodies stay empty — presets hydrate at publish / display. */ function entriesFromCompositionCell(cell: string): { title: string; body: string }[] { const trimmed = cell.trim(); if (!trimmed) return []; @@ -72,7 +69,7 @@ function entriesFromCompositionCell(cell: string): { title: string; body: string .split(/,\s*/) .map((title) => title.trim()) .filter(Boolean) - .map((title) => ({ title, body: COMPOSITION_CHIP_BODY })); + .map((title) => ({ title, body: "" })); } function bodyFromXlsxComposition(row: { diff --git a/tests/unit/buildPublishPayload.test.ts b/tests/unit/buildPublishPayload.test.ts index 060d5c1..053c5a3 100644 --- a/tests/unit/buildPublishPayload.test.ts +++ b/tests/unit/buildPublishPayload.test.ts @@ -4,6 +4,7 @@ import { parseDocumentSectionsForDisplay, parseSectionsFromCreateFlowState, } from "../../lib/create/buildPublishPayload"; +import { mergeCoreValueDetailWithPresets } from "../../lib/create/finalReviewChipPresets"; import type { CreateFlowState } from "../../app/(app)/create/types"; describe("buildPublishPayload", () => { @@ -63,6 +64,17 @@ describe("buildPublishPayload", () => { }); }); + it("prefers communityContext over summary for the published summary field", () => { + const r = buildPublishPayload({ + title: "T", + summary: "One-liner or leftover", + communityContext: " Full community context. ", + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.summary).toBe("Full community context."); + }); + it("uses valid state.sections when present", () => { const sections: CreateFlowState["sections"] = [ { @@ -106,9 +118,15 @@ describe("buildPublishPayload", () => { }); expect(r.ok).toBe(true); if (!r.ok) return; + const preset2 = mergeCoreValueDetailWithPresets("2", "Beta", undefined); expect(r.document.coreValues).toEqual([ { chipId: "1", label: "Alpha", meaning: "m1", signals: "s1" }, - { chipId: "2", label: "Beta", meaning: "", signals: "" }, + { + chipId: "2", + label: "Beta", + meaning: preset2.meaning, + signals: preset2.signals, + }, ]); }); }); @@ -121,6 +139,36 @@ describe("buildPublishPayload — methodSelections", () => { expect(r.document.methodSelections).toBeUndefined(); }); + it("derives methodSelections from template sections when selected ids are empty", () => { + const r = buildPublishPayload({ + title: "T", + sections: [ + { + categoryName: "Communication", + entries: [{ title: "Slack", body: "" }], + }, + ], + }); + expect(r.ok).toBe(true); + if (!r.ok) return; + const ms = r.document.methodSelections as + | { + communication?: Array<{ + id: string; + sections: { corePrinciple: string }; + }>; + } + | undefined; + expect(ms?.communication?.length).toBe(1); + expect(ms?.communication?.[0]?.id).toBe("slack"); + const first = ms?.communication?.[0]; + expect(first?.sections.corePrinciple.length).toBeGreaterThan(10); + const entries = + (r.document.sections as Array<{ entries: Array<{ blocks?: unknown[] }> }>)[0] + ?.entries; + expect(entries?.[0]?.blocks?.length).toBeGreaterThanOrEqual(1); + }); + it("emits preset-only sections when a method is selected without an override", () => { const r = buildPublishPayload({ title: "T", diff --git a/tests/unit/publishedDocumentToDisplaySections.test.ts b/tests/unit/publishedDocumentToDisplaySections.test.ts new file mode 100644 index 0000000..adc8625 --- /dev/null +++ b/tests/unit/publishedDocumentToDisplaySections.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { parsePublishedDocumentForCommunityRuleDisplay } from "../../lib/create/publishedDocumentToDisplaySections"; + +describe("parsePublishedDocumentForCommunityRuleDisplay", () => { + it("returns [] for non-object document", () => { + expect(parsePublishedDocumentForCommunityRuleDisplay(null)).toEqual([]); + }); + + it("drops Overview and appends Values + methods with value body text", () => { + const doc = { + sections: [ + { + categoryName: "Overview", + entries: [{ title: "Community", body: "Our river cleanup org." }], + }, + ], + coreValues: [ + { chipId: "1", label: "Ecology", meaning: "We protect water.", signals: "Litter = violation." }, + ], + methodSelections: { + communication: [ + { + id: "signal", + label: "Signal", + sections: { + corePrinciple: "Privacy first.", + logisticsAdmin: "Admins rotate.", + codeOfConduct: "No doxxing.", + }, + }, + ], + }, + }; + const out = parsePublishedDocumentForCommunityRuleDisplay(doc); + expect(out.map((s) => s.categoryName)).toEqual(["Values", "Communication"]); + expect(out[0].entries[0].title).toBe("Ecology"); + expect(out[0].entries[0].body).toBe( + "We protect water.\n\nLitter = violation.", + ); + expect(out[0].entries[0].blocks).toBeUndefined(); + expect(out[1].entries[0].title).toBe("Signal"); + expect(out[1].entries[0].blocks?.length).toBeGreaterThanOrEqual(3); + }); + + it("strips Overview but keeps other categories when both exist", () => { + const doc = { + sections: [ + { categoryName: "Overview", entries: [{ title: "Community", body: "x" }] }, + { categoryName: "Membership", entries: [{ title: "Open", body: "y" }] }, + ], + }; + const out = parsePublishedDocumentForCommunityRuleDisplay(doc); + expect(out.map((s) => s.categoryName)).toEqual(["Membership"]); + }); + + it("prefers document.coreValues over a parallel Values section in document.sections", () => { + const doc = { + sections: [ + { + categoryName: "Values", + entries: [{ title: "From template", body: "Template body" }], + }, + ], + coreValues: [ + { label: "Should not duplicate", meaning: "x", signals: "y" }, + ], + }; + const out = parsePublishedDocumentForCommunityRuleDisplay(doc); + expect(out.length).toBe(1); + expect(out[0].categoryName).toBe("Values"); + expect(out[0].entries[0].title).toBe("Should not duplicate"); + expect(out[0].entries[0].body).toBe("x\n\ny"); + }); + + it("enriches empty core value copy from presets (label match)", () => { + const doc = { + sections: [], + coreValues: [ + { chipId: "", label: "Interdependence", meaning: "", signals: "" }, + ], + }; + const out = parsePublishedDocumentForCommunityRuleDisplay(doc); + expect(out[0].entries[0].body).toMatch(/survival and success/); + }); + + it("replaces template placeholder bodies with preset copy, Values first", () => { + const placeholder = + "Suggested focus for this governance area. Replace with your own language in the create flow."; + const doc = { + sections: [ + { + categoryName: "Communication", + entries: [{ title: "Slack", body: placeholder }], + }, + { + categoryName: "Values", + entries: [{ title: "Adaptability", body: placeholder }], + }, + ], + }; + const out = parsePublishedDocumentForCommunityRuleDisplay(doc); + expect(out.map((s) => s.categoryName)).toEqual(["Values", "Communication"]); + expect(out[0].entries[0].body).not.toContain("Suggested focus"); + expect(out[0].entries[0].body.length).toBeGreaterThan(20); + expect(out[1].entries[0].blocks?.length).toBeGreaterThanOrEqual(1); + expect(out[1].entries[0].blocks?.[0]?.body?.length).toBeGreaterThan(10); + }); + + it("matches parseDocumentSectionsForDisplay when no coreValues or methodSelections", () => { + const doc = { + sections: [ + { categoryName: "X", entries: [{ title: "t", body: "b" }] }, + ], + }; + expect(parsePublishedDocumentForCommunityRuleDisplay(doc)).toEqual( + doc.sections, + ); + }); +}); From 3a9727bceb51967b4abd30ab4eecb2264546b8c7 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:05:37 -0600 Subject: [PATCH 03/14] New edit-rule page created --- app/(app)/create/CreateFlowLayoutClient.tsx | 69 +++++++++-- .../FinalReviewCommunityContextEditModal.tsx | 111 ++++++++++++++++++ app/(app)/create/hooks/useCreateFlowExit.ts | 62 ++++++++-- .../create/hooks/useCreateFlowFinalize.ts | 59 ++++++++-- .../create/screens/CreateFlowScreenView.tsx | 2 + .../screens/review/FinalReviewScreen.tsx | 47 +++++++- app/(app)/create/types.ts | 7 ++ .../utils/createFlowProportionProgress.ts | 3 + .../create/utils/createFlowScreenRegistry.ts | 6 + app/(app)/create/utils/flowSteps.ts | 8 +- app/api/rules/[id]/route.ts | 56 ++++++++- .../InlineTextButton/InlineTextButton.tsx | 15 ++- app/components/cards/Rule/Rule.container.tsx | 6 + app/components/cards/Rule/Rule.types.ts | 15 +++ app/components/cards/Rule/Rule.view.tsx | 49 ++++++-- lib/create/api.ts | 42 +++++++ .../publishedDocumentToCreateFlowState.ts | 100 ++++++++++++++++ lib/server/validation/createFlowSchemas.ts | 1 + .../create/reviewAndComplete/finalReview.json | 8 ++ tests/components/FinalReviewPage.test.tsx | 64 ++++++++++ tests/unit/Rule.test.jsx | 17 +++ .../unit/createFlowProportionProgress.test.ts | 4 + tests/unit/flowSteps.test.ts | 13 +- ...publishedDocumentToCreateFlowState.test.ts | 52 ++++++++ tests/unit/rulesByIdPatchRoute.test.ts | 111 ++++++++++++++++++ 25 files changed, 875 insertions(+), 52 deletions(-) create mode 100644 app/(app)/create/components/FinalReviewCommunityContextEditModal.tsx create mode 100644 lib/create/publishedDocumentToCreateFlowState.ts create mode 100644 tests/unit/publishedDocumentToCreateFlowState.test.ts create mode 100644 tests/unit/rulesByIdPatchRoute.test.ts diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index 60a816b..ad67618 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -32,6 +32,8 @@ import { clearAnonymousCreateFlowStorage, setTransferPendingFlag, } from "./utils/anonymousDraftStorage"; +import { createFlowStateFromPublishedRule } from "../../../lib/create/publishedDocumentToCreateFlowState"; +import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule"; import { deleteServerDraft } from "../../../lib/create/api"; import messages from "../../../messages/en/index"; import { @@ -133,12 +135,23 @@ function CreateFlowLayoutContent({ const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] = useState(false); + const loginReturnPath = + currentStep === "edit-rule" + ? "/create/edit-rule?syncDraft=1" + : "/create/final-review?syncDraft=1"; + const { publishBannerMessage, setPublishBannerMessage, isPublishing, finalize: handleFinalize, - } = useCreateFlowFinalize({ state, router, openLogin }); + } = useCreateFlowFinalize({ + state, + router, + openLogin, + updateState, + loginReturnPath, + }); const { isTemplateReviewRoute, @@ -221,6 +234,34 @@ function CreateFlowLayoutContent({ } }, [currentStep]); + useEffect(() => { + if (currentStep !== "edit-rule") return; + const last = readLastPublishedRule(); + if (!last) { + router.replace("/create/completed"); + return; + } + const editingId = state.editingPublishedRuleId?.trim() ?? ""; + if (editingId.length > 0 && editingId !== last.id) { + router.replace("/create/completed"); + return; + } + const titleOk = + typeof state.title === "string" && state.title.trim().length > 0; + const sectionsClear = (state.sections?.length ?? 0) === 0; + /** Stale template `sections` (e.g. Values-only) makes final-review rows wrong; re-hydrate until cleared. */ + if (titleOk && editingId === last.id && sectionsClear) { + return; + } + updateState(createFlowStateFromPublishedRule(last)); + }, [ + currentStep, + router, + updateState, + state.editingPublishedRuleId, + state.title, + ]); + const handleCommunitySaveMagicLinkSubmit = useCallback(async () => { setCommunitySaveMagicLinkError(null); setCommunitySaveMagicLinkSuccess(false); @@ -260,7 +301,8 @@ function CreateFlowLayoutContent({ const isCompletedStep = currentStep === "completed"; const isRightRailStep = currentStep === "decision-approaches"; - const isFinalReviewStep = currentStep === "final-review"; + const isFinalReviewLike = + currentStep === "final-review" || currentStep === "edit-rule"; const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep); /** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */ const isSelectSplitScrollStep = @@ -275,7 +317,7 @@ function CreateFlowLayoutContent({ ? "items-stretch overflow-y-auto md:overflow-hidden" : isSelectSplitScrollStep ? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden" - : isFinalReviewStep || isCardLayoutStep || isTemplateReviewRoute + : isFinalReviewLike || isCardLayoutStep || isTemplateReviewRoute ? "items-start justify-center overflow-y-auto" : "items-start justify-center overflow-y-auto md:items-center"; @@ -289,7 +331,8 @@ function CreateFlowLayoutContent({ : "max-md:flex-col max-md:items-center"; const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`; const saveDraftOnExit = - Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX; + Boolean(sessionUser) && + (stepIdx >= SAVE_EXIT_FROM_STEP_INDEX || currentStep === "edit-rule"); const proportionBarProgress = getProportionBarProgressForCreateFlowStep( currentStep, @@ -408,7 +451,15 @@ function CreateFlowLayoutContent({ saveDraftOnExit={saveDraftOnExit} onEdit={ isCompletedStep - ? () => router.push("/create/final-review") + ? () => { + const last = readLastPublishedRule(); + if (!last) return; + updateState({ + editingPublishedRuleId: last.id, + sections: [], + }); + router.push("/create/edit-rule"); + } : undefined } onExit={(opts) => void handleExit(opts)} @@ -425,7 +476,7 @@ function CreateFlowLayoutContent({ {!isCompletedStep && ( {footer[customRuleConfirmFooter.footerMessageKey]} - ) : nextStep ? ( + ) : nextStep || isFinalReviewLike ? ( - ) : customRuleConfirmFooter && nextStep ? ( + ) : showCustomRuleFooterConfirm && + customRuleConfirmFooter ? ( + ); +}); + +ListItemView.displayName = "ListItemView"; diff --git a/app/components/layout/ListItem/index.tsx b/app/components/layout/ListItem/index.tsx new file mode 100644 index 0000000..eebb788 --- /dev/null +++ b/app/components/layout/ListItem/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./ListItem.container"; +export type { ListItemProps } from "./ListItem.types"; diff --git a/app/components/modals/ModalHeader/ModalHeader.types.ts b/app/components/modals/ModalHeader/ModalHeader.types.ts index 22b82d3..7c35733 100644 --- a/app/components/modals/ModalHeader/ModalHeader.types.ts +++ b/app/components/modals/ModalHeader/ModalHeader.types.ts @@ -3,5 +3,9 @@ export interface ModalHeaderProps { onMoreOptions?: () => void; showCloseButton?: boolean; showMoreOptionsButton?: boolean; + /** When set, used for the close control’s accessible name (e.g. localized). */ + closeButtonAriaLabel?: string; + /** When set, used for the more-options control’s accessible name (e.g. localized). */ + moreOptionsAriaLabel?: string; className?: string; } diff --git a/app/components/modals/ModalHeader/ModalHeader.view.tsx b/app/components/modals/ModalHeader/ModalHeader.view.tsx index 4ad3219..1bc0886 100644 --- a/app/components/modals/ModalHeader/ModalHeader.view.tsx +++ b/app/components/modals/ModalHeader/ModalHeader.view.tsx @@ -9,6 +9,8 @@ export function ModalHeaderView({ onMoreOptions, showCloseButton = true, showMoreOptionsButton = true, + closeButtonAriaLabel = "Close dialog", + moreOptionsAriaLabel = "More options", className = "", }: ModalHeaderProps) { return ( @@ -21,7 +23,7 @@ export function ModalHeaderView({ type="button" onClick={onClose} className={`${iconButtonClass} left-[24px] top-[12px]`} - aria-label="Close dialog" + aria-label={closeButtonAriaLabel} > {/* eslint-disable-next-line @next/next/no-img-element -- icon asset */} ((props) => { + return ; +}); + +Popover.displayName = "Popover"; + +export default Popover; diff --git a/app/components/modals/Popover/Popover.types.ts b/app/components/modals/Popover/Popover.types.ts new file mode 100644 index 0000000..0bc765b --- /dev/null +++ b/app/components/modals/Popover/Popover.types.ts @@ -0,0 +1,8 @@ +import type { ReactNode } from "react"; + +export type PopoverProps = { + id: string; + menuAriaLabel: string; + children: ReactNode; + className?: string; +}; diff --git a/app/components/modals/Popover/Popover.view.tsx b/app/components/modals/Popover/Popover.view.tsx new file mode 100644 index 0000000..ce6719e --- /dev/null +++ b/app/components/modals/Popover/Popover.view.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { memo } from "react"; +import type { PopoverProps } from "./Popover.types"; + +export const PopoverView = memo(function PopoverView({ + id, + menuAriaLabel, + children, + className = "", +}: PopoverProps) { + return ( + + ); +}); + +PopoverView.displayName = "PopoverView"; diff --git a/app/components/modals/Popover/index.tsx b/app/components/modals/Popover/index.tsx new file mode 100644 index 0000000..27f7840 --- /dev/null +++ b/app/components/modals/Popover/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Popover.container"; +export type { PopoverProps } from "./Popover.types"; diff --git a/app/components/modals/Share/Share.container.tsx b/app/components/modals/Share/Share.container.tsx new file mode 100644 index 0000000..f429384 --- /dev/null +++ b/app/components/modals/Share/Share.container.tsx @@ -0,0 +1,43 @@ +"use client"; + +/** + * Figma: Community Rule System — "Modal / Share" + * https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22073-30884 + */ +import { memo, useId, useRef } from "react"; +import { useTranslation } from "../../../contexts/MessagesContext"; +import { useCreateModalA11y } from "../Create/useCreateModalA11y"; +import { ShareView } from "./Share.view"; +import type { ShareProps } from "./Share.types"; + +const ShareContainer = memo((props) => { + const dialogRef = useRef(null); + const overlayRef = useRef(null); + const titleId = useId(); + const t = useTranslation("modals.share"); + + useCreateModalA11y(props.isOpen, props.onClose, dialogRef); + + return ( + + ); +}); + +ShareContainer.displayName = "Share"; + +export default ShareContainer; diff --git a/app/components/modals/Share/Share.types.ts b/app/components/modals/Share/Share.types.ts new file mode 100644 index 0000000..5618f9d --- /dev/null +++ b/app/components/modals/Share/Share.types.ts @@ -0,0 +1,37 @@ +import type { ReactNode, RefObject } from "react"; +import type { CreateModalBackdropVariant } from "../Create/CreateModalFrame.view"; + +export type ShareProps = { + isOpen: boolean; + onClose: () => void; + onCopyLink: () => void | Promise; + onEmailShare: () => void; + onSignalShare: () => void | Promise; + onSlackShare: () => void | Promise; + onDiscordShare: () => void | Promise; + className?: string; + backdropVariant?: CreateModalBackdropVariant; +}; + +export type ShareViewProps = ShareProps & { + dialogRef: RefObject; + overlayRef: RefObject; + titleId: string; + title: string; + description: string; + copyLinkLabel: string; + signalLabel: string; + slackLabel: string; + discordLabel: string; + emailLabel: string; + doneLabel: string; + closeDialogAriaLabel: string; + moreOptionsAriaLabel: string; +}; + +export type ShareChannelTileProps = { + label: string; + onClick: () => void | Promise; + circleClassName: string; + icon: ReactNode; +}; diff --git a/app/components/modals/Share/Share.view.tsx b/app/components/modals/Share/Share.view.tsx new file mode 100644 index 0000000..069a96d --- /dev/null +++ b/app/components/modals/Share/Share.view.tsx @@ -0,0 +1,165 @@ +"use client"; + +import Image from "next/image"; +import { memo } from "react"; +import ContentLockup from "../../type/ContentLockup"; +import Button from "../../buttons/Button"; +import ModalHeader from "../ModalHeader"; +import ModalFooter from "../ModalFooter"; +import { CreateModalFrameView } from "../Create/CreateModalFrame.view"; +import type { ShareChannelTileProps, ShareViewProps } from "./Share.types"; + +/** Decorative glyphs in `public/assets/Share/` — sizes match prior inline SVGs within the 60×60 circles. */ +function ShareAssetIcon(props: { + src: + | "/assets/Share/Discord.svg" + | "/assets/Share/Link.svg" + | "/assets/Share/Mail.svg" + | "/assets/Share/Signal.svg" + | "/assets/Share/Slack.svg"; + width: number; + height: number; +}) { + const { src, width, height } = props; + return ( + + ); +} + +function ShareChannelTile({ label, onClick, circleClassName, icon }: ShareChannelTileProps) { + return ( + + ); +} + +export const ShareView = memo(function ShareView({ + isOpen, + onClose, + onCopyLink, + onEmailShare, + onSignalShare, + onSlackShare, + onDiscordShare, + className = "", + backdropVariant = "default", + dialogRef, + overlayRef, + titleId, + title, + description, + copyLinkLabel, + signalLabel, + slackLabel, + discordLabel, + emailLabel, + doneLabel, + closeDialogAriaLabel, + moreOptionsAriaLabel, +}: ShareViewProps) { + return ( + + + +
+ +
+ +
+
+ } + /> + } + /> + } + /> + } + /> + } + /> +
+
+ + + + + } + /> +
+ ); +}); + +ShareView.displayName = "ShareView"; diff --git a/app/components/modals/Share/index.tsx b/app/components/modals/Share/index.tsx new file mode 100644 index 0000000..e47f308 --- /dev/null +++ b/app/components/modals/Share/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Share.container"; +export type { ShareProps } from "./Share.types"; diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx index 807ec41..f09e5d5 100644 --- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx +++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.container.tsx @@ -2,12 +2,14 @@ import { memo } from "react"; import { useRouter } from "next/navigation"; +import { useTranslation } from "../../../contexts/MessagesContext"; import { CreateFlowTopNavView } from "./CreateFlowTopNav.view"; import type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types"; /** * Figma: Utility / CreateFlowTopNav — wizard header (create-flow chrome). * Exit, optional share / export / edit; strings in `messages/en/create/topNav.json`. + * Export menu: Community Rule System — node 21998:22612 (`messages/en/modals/popoverExport.json`). */ const CreateFlowTopNavContainer = memo( ({ @@ -16,13 +18,14 @@ const CreateFlowTopNavContainer = memo( hasEdit = false, saveDraftOnExit = false, onShare, - onExport, + onSelectExportFormat, onEdit, onExit, buttonPalette, className = "", }) => { const router = useRouter(); + const tPopover = useTranslation("modals.popoverExport"); const handleExit = (options?: { saveDraft?: boolean }) => { if (onExit) { @@ -40,11 +43,15 @@ const CreateFlowTopNavContainer = memo( hasEdit={hasEdit} saveDraftOnExit={saveDraftOnExit} onShare={onShare} - onExport={onExport} + onSelectExportFormat={onSelectExportFormat} onEdit={onEdit} onExit={handleExit} buttonPalette={buttonPalette} className={className} + exportPopoverMenuAriaLabel={tPopover("menuAriaLabel")} + exportPopoverPdfLabel={tPopover("downloadPdf")} + exportPopoverCsvLabel={tPopover("downloadCsv")} + exportPopoverMarkdownLabel={tPopover("downloadMarkdown")} /> ); }, diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts index 7cb0f96..6589855 100644 --- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts +++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.types.ts @@ -32,9 +32,9 @@ export interface CreateFlowTopNavProps { */ onShare?: () => void; /** - * Callback when Export button is clicked + * Callback when user picks an export format from the Export menu. */ - onExport?: () => void; + onSelectExportFormat?: (_format: "pdf" | "csv" | "markdown") => void; /** * Callback when Edit button is clicked */ @@ -54,3 +54,11 @@ export interface CreateFlowTopNavProps { */ className?: string; } + +/** Resolved copy for the export popover; supplied by the container. */ +export type CreateFlowTopNavViewProps = CreateFlowTopNavProps & { + exportPopoverMenuAriaLabel: string; + exportPopoverPdfLabel: string; + exportPopoverCsvLabel: string; + exportPopoverMarkdownLabel: string; +}; diff --git a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx index b44f138..1498450 100644 --- a/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx +++ b/app/components/navigation/CreateFlowTopNav/CreateFlowTopNav.view.tsx @@ -1,9 +1,12 @@ "use client"; +import { useEffect, useId, useRef, useState } from "react"; import Logo from "../../asset/Logo"; import Button from "../../buttons/Button"; +import ListItem from "../../layout/ListItem"; +import Popover from "../../modals/Popover"; import { useTranslation } from "../../../contexts/MessagesContext"; -import type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types"; +import type { CreateFlowTopNavViewProps } from "./CreateFlowTopNav.types"; const exitButtonFigmaClass = "!rounded-[var(--radius-measures-radius-full,9999px)] !border-[1.25px] !px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!text-[12px] md:!leading-[14px]"; @@ -14,14 +17,44 @@ export function CreateFlowTopNavView({ hasEdit = false, saveDraftOnExit = false, onShare, - onExport, + onSelectExportFormat, onEdit, onExit, buttonPalette = "default", className = "", -}: CreateFlowTopNavProps) { + exportPopoverMenuAriaLabel, + exportPopoverPdfLabel, + exportPopoverCsvLabel, + exportPopoverMarkdownLabel, +}: CreateFlowTopNavViewProps) { const t = useTranslation("create.topNav"); const exitButtonText = saveDraftOnExit ? t("saveAndExit") : t("exit"); + const [exportMenuOpen, setExportMenuOpen] = useState(false); + const exportWrapRef = useRef(null); + const exportMenuId = useId(); + + useEffect(() => { + if (!exportMenuOpen) return; + const onDoc = (e: MouseEvent) => { + if ( + exportWrapRef.current && + !exportWrapRef.current.contains(e.target as Node) + ) { + setExportMenuOpen(false); + } + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [exportMenuOpen]); + + useEffect(() => { + if (!exportMenuOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setExportMenuOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [exportMenuOpen]); return (
)} - {hasExport && ( - - )} + {t("export")} + + + {exportMenuOpen ? ( +
+ + { + onSelectExportFormat("pdf"); + setExportMenuOpen(false); + }} + /> + { + onSelectExportFormat("csv"); + setExportMenuOpen(false); + }} + /> + { + onSelectExportFormat("markdown"); + setExportMenuOpen(false); + }} + /> + +
+ ) : null} + + ) : null} {hasEdit && ( + setOpen(false)} + onCopyLink={() => {}} + onEmailShare={() => {}} + onSignalShare={() => {}} + onSlackShare={() => {}} + onDiscordShare={() => {}} + /> + + + ); +} + +export const Default = { + name: "Modal / Share", + render: () => , +}; diff --git a/stories/navigation/CreateFlowTopNav.stories.js b/stories/navigation/CreateFlowTopNav.stories.js index 4b8c12b..ed4d1c4 100644 --- a/stories/navigation/CreateFlowTopNav.stories.js +++ b/stories/navigation/CreateFlowTopNav.stories.js @@ -31,7 +31,7 @@ export default { "After user input (or completed step), use Save & Exit and pass saveDraft: true to onExit", }, onShare: { action: "share clicked" }, - onExport: { action: "export clicked" }, + onSelectExportFormat: { action: "export format" }, onEdit: { action: "edit clicked" }, onExit: { action: "exit clicked" }, }, diff --git a/tests/components/CompletedPage.test.tsx b/tests/components/CompletedPage.test.tsx index 3e6178b..710ef06 100644 --- a/tests/components/CompletedPage.test.tsx +++ b/tests/components/CompletedPage.test.tsx @@ -1,8 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { useSearchParams } from "next/navigation"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders as render, screen } from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; import { CompletedScreen } from "../../app/(app)/create/screens/completed/CompletedScreen"; import { CREATE_FLOW_LAST_PUBLISHED_KEY } from "../../lib/create/lastPublishedRule"; +import { + CREATE_FLOW_COMPLETED_CELEBRATE_QUERY, + CREATE_FLOW_COMPLETED_CELEBRATE_VALUE, +} from "../../app/(app)/create/utils/flowSteps"; const storedRuleFixture = { id: "rule-fixture-1", @@ -32,9 +37,18 @@ const storedRuleFixture = { }, }; +function mockSearchParams(record?: Record) { + vi.mocked(useSearchParams).mockReturnValue( + new URLSearchParams(record ?? undefined) as NonNullable< + ReturnType + >, + ); +} + describe("CompletedScreen", () => { beforeEach(() => { sessionStorage.removeItem(CREATE_FLOW_LAST_PUBLISHED_KEY); + mockSearchParams(); }); afterEach(() => { @@ -70,7 +84,25 @@ describe("CompletedScreen", () => { expect(screen.getByText("Fixture value title")).toBeInTheDocument(); }); - it("renders toast alert when page loads", () => { + it("does not show post-finalize toast without celebrate query", () => { + render(); + expect( + screen.queryByText( + "This is what folks see when you share your CommunityRule", + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByText( + "Your group can use this document as an operating manual.", + ), + ).not.toBeInTheDocument(); + }); + + it("shows post-finalize toast in status region when celebrate query is set", () => { + mockSearchParams({ + [CREATE_FLOW_COMPLETED_CELEBRATE_QUERY]: + CREATE_FLOW_COMPLETED_CELEBRATE_VALUE, + }); render(); expect( screen.getByText( @@ -82,10 +114,6 @@ describe("CompletedScreen", () => { "Your group can use this document as an operating manual.", ), ).toBeInTheDocument(); - }); - - it("renders toast with role status", () => { - render(); const statusRegions = screen.getAllByRole("status"); expect(statusRegions.length).toBeGreaterThanOrEqual(1); expect( diff --git a/tests/components/CreateFlowTopNav.test.tsx b/tests/components/CreateFlowTopNav.test.tsx index 5a370c2..041c432 100644 --- a/tests/components/CreateFlowTopNav.test.tsx +++ b/tests/components/CreateFlowTopNav.test.tsx @@ -36,7 +36,7 @@ const config: ComponentTestSuiteConfig = { hasEdit: true, saveDraftOnExit: true, onShare: vi.fn(), - onExport: vi.fn(), + onSelectExportFormat: vi.fn(), onEdit: vi.fn(), onExit: vi.fn(), className: "test-class", @@ -66,11 +66,30 @@ describe("CreateFlowTopNav (behavioral tests)", () => { expect(exitButton).toBeInTheDocument(); }); - it("shows Exit when saveDraftOnExit is false", () => { - render(); - const exitButton = screen.getByRole("button", { name: "Exit" }); - expect(exitButton).toBeInTheDocument(); - }); + it.each([ + ["Download Markdown", "markdown"], + ["Download PDF", "pdf"], + ["Download CSV", "csv"], + ] as const)( + "opens export menu and calls onSelectExportFormat for %s", + async (menuLabel, expectedFormat) => { + const user = userEvent.setup(); + const handleExport = vi.fn(); + render( + , + ); + + const exportButton = screen.getByRole("button", { name: "Export" }); + await user.click(exportButton); + const item = screen.getByRole("menuitem", { name: menuLabel }); + await user.click(item); + + expect(handleExport).toHaveBeenCalledWith(expectedFormat); + }, + ); it("renders Share button when hasShare is true", () => { render(); @@ -86,7 +105,12 @@ describe("CreateFlowTopNav (behavioral tests)", () => { }); it("renders Export button when hasExport is true", () => { - render(); + render( + , + ); const exportButton = screen.getByRole("button", { name: "Export" }); expect(exportButton).toBeInTheDocument(); }); @@ -107,15 +131,4 @@ describe("CreateFlowTopNav (behavioral tests)", () => { expect(handleExit).toHaveBeenCalledTimes(1); }); - - it("calls onShare when Share button is clicked", async () => { - const user = userEvent.setup(); - const handleShare = vi.fn(); - render(); - - const shareButton = screen.getByRole("button", { name: "Share" }); - await user.click(shareButton); - - expect(handleShare).toHaveBeenCalledTimes(1); - }); }); diff --git a/tests/components/layout/ListItem.test.tsx b/tests/components/layout/ListItem.test.tsx new file mode 100644 index 0000000..f492148 --- /dev/null +++ b/tests/components/layout/ListItem.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders as render, screen } from "../../utils/test-utils"; +import "@testing-library/jest-dom/vitest"; +import ListItem from "../../../app/components/layout/ListItem"; + +describe("ListItem", () => { + it("renders as a menu item with label and icon", () => { + render( +
+ +
, + ); + expect(screen.getByRole("menuitem", { name: "Download Markdown" })).toBeTruthy(); + }); + + it("invokes onClick when activated", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( +
+ +
, + ); + await user.click(screen.getByRole("menuitem", { name: "Download CSV" })); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/components/modals/Popover.test.tsx b/tests/components/modals/Popover.test.tsx new file mode 100644 index 0000000..f461565 --- /dev/null +++ b/tests/components/modals/Popover.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders as render, screen } from "../../utils/test-utils"; +import "@testing-library/jest-dom/vitest"; +import ListItem from "../../../app/components/layout/ListItem"; +import Popover from "../../../app/components/modals/Popover"; + +describe("Popover (export menu)", () => { + it("exposes a menu landmark with localized label", () => { + render( + + + , + ); + expect(screen.getByRole("menu", { name: "Export format" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Download Markdown" })).toBeTruthy(); + }); + + it("invokes handler when list item clicked", async () => { + const user = userEvent.setup(); + const onCsv = vi.fn(); + render( + + + , + ); + await user.click(screen.getByRole("menuitem", { name: "Download CSV" })); + expect(onCsv).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/components/modals/Share.test.tsx b/tests/components/modals/Share.test.tsx new file mode 100644 index 0000000..6262ec9 --- /dev/null +++ b/tests/components/modals/Share.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders as render, screen } from "../../utils/test-utils"; +import "@testing-library/jest-dom/vitest"; +import Share from "../../../app/components/modals/Share"; + +const noopHandlers = { + onCopyLink: vi.fn(), + onEmailShare: vi.fn(), + onSignalShare: vi.fn(), + onSlackShare: vi.fn(), + onDiscordShare: vi.fn(), +}; + +describe("Share modal", () => { + it("does not render dialog when closed", () => { + render( + , + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("renders localized heading and copy link action when open", async () => { + const user = userEvent.setup(); + const onCopyLink = vi.fn(); + render( + , + ); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { level: 1, name: /Share this CommunityRule/ }), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Copy link" })); + expect(onCopyLink).toHaveBeenCalledTimes(1); + }); + + it("invokes channel handlers for Signal, Slack, and Discord", async () => { + const user = userEvent.setup(); + const onSignalShare = vi.fn(); + const onSlackShare = vi.fn(); + const onDiscordShare = vi.fn(); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Signal" })); + await user.click(screen.getByRole("button", { name: "Slack" })); + await user.click(screen.getByRole("button", { name: "Discord" })); + expect(onSignalShare).toHaveBeenCalledTimes(1); + expect(onSlackShare).toHaveBeenCalledTimes(1); + expect(onDiscordShare).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when Done is clicked", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "Done" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when header overflow (more) is activated, matching modal chrome parity", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "More share options" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/applyTemplatePrefill.test.ts b/tests/unit/applyTemplatePrefill.test.ts index e2222d1..bfbdfc5 100644 --- a/tests/unit/applyTemplatePrefill.test.ts +++ b/tests/unit/applyTemplatePrefill.test.ts @@ -3,6 +3,7 @@ import { buildCoreValuesPrefillFromTemplateBody, buildTemplateCustomizePrefill, } from "../../lib/create/applyTemplatePrefill"; +import { methodSectionsPinsForHydratedSelections } from "../../lib/create/publishedDocumentToCreateFlowState"; import coreValuesMessages from "../../messages/en/create/customRule/coreValues.json"; function coreValuePresetId(label: string): string { @@ -153,3 +154,47 @@ describe("buildCoreValuesPrefillFromTemplateBody", () => { expect(prefill.selectedCommunicationMethodIds).toBeUndefined(); }); }); + +describe("buildTemplateCustomizePrefill + method card pins", () => { + it("derives compact-deck pins for each non-empty method facet prefill", () => { + const body = { + sections: [ + { + categoryName: "Communication", + entries: [{ title: "In-Person Meetings", body: "x" }], + }, + { + categoryName: "Membership", + entries: [{ title: "Peer Sponsorship", body: "m" }], + }, + { + categoryName: "Decision-making", + entries: [{ title: "Consensus Decision-Making", body: "d" }], + }, + { + categoryName: "Conflict management", + entries: [{ title: "Restorative Justice", body: "c" }], + }, + ], + }; + const prefill = buildTemplateCustomizePrefill(body); + expect(methodSectionsPinsForHydratedSelections(prefill)).toEqual({ + communication: true, + membership: true, + decisionApproaches: true, + conflictManagement: true, + }); + }); + + it("does not set pins when template supplies values only", () => { + const prefill = buildTemplateCustomizePrefill({ + sections: [ + { + categoryName: "Values", + entries: [{ title: "Consensus", body: "" }], + }, + ], + }); + expect(methodSectionsPinsForHydratedSelections(prefill)).toEqual({}); + }); +}); diff --git a/tests/unit/hooks/useCompletedRuleShareExport.test.tsx b/tests/unit/hooks/useCompletedRuleShareExport.test.tsx new file mode 100644 index 0000000..f488388 --- /dev/null +++ b/tests/unit/hooks/useCompletedRuleShareExport.test.tsx @@ -0,0 +1,351 @@ +import React from "react"; +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { MessagesProvider } from "../../../app/contexts/MessagesContext"; +import messages from "../../../messages/en/index"; +import { useCompletedRuleShareExport } from "../../../app/(app)/create/hooks/useCompletedRuleShareExport"; +import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule"; +import { + DISCORD_WEB_DM_HUB_URL, + DISCORD_NATIVE_DM_HUB_URL, + NATIVE_SHARE_FALLBACK_DELAY_MS, + SLACK_NATIVE_OPEN_URL, +} from "../../../lib/create/shareChannels"; + +vi.mock("../../../lib/create/lastPublishedRule", () => ({ + readLastPublishedRule: vi.fn(), +})); + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("useCompletedRuleShareExport", () => { + const mockRule = { + id: "rule-1", + title: "Garden norms", + summary: "Be kind.", + document: {}, + }; + + beforeEach(() => { + vi.mocked(readLastPublishedRule).mockReturnValue(mockRule); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("shareViaSlack opens Slack web share URL when window.open succeeds", async () => { + vi.useFakeTimers(); + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); + const openSpy = vi.spyOn(window, "open").mockReturnValue({} as Window); + const setBanner = vi.fn(); + + const { result } = renderHook( + () => + useCompletedRuleShareExport({ + setActionBanner: setBanner, + }), + { wrapper }, + ); + + await act(async () => { + await result.current.sharePublishedRuleViaSlack(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25); + }); + + expect(clickSpy).toHaveBeenCalled(); + const anchorUnknown = clickSpy.mock.instances.at(-1) as unknown; + expect(anchorUnknown).toBeInstanceOf(HTMLAnchorElement); + const anchorEl = anchorUnknown as HTMLAnchorElement; + expect(anchorEl.getAttribute("href")).toBe(SLACK_NATIVE_OPEN_URL); + + const expectedUrl = `https://slack.com/share?url=${encodeURIComponent(`${window.location.origin}/rules/rule-1`)}`; + expect(openSpy).toHaveBeenCalledWith( + expectedUrl, + "_blank", + "noopener,noreferrer", + ); + expect(setBanner).not.toHaveBeenCalledWith( + expect.objectContaining({ + key: "completedShareCopyFailed", + status: "danger", + }), + ); + clickSpy.mockRestore(); + openSpy.mockRestore(); + }); + + it("shareViaSlack does not show copy-failed banner when fallback is skipped after handoff (no focus)", async () => { + vi.useFakeTimers(); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + vi.spyOn(window, "open").mockReturnValue(null); + const hasFocusSpy = vi.spyOn(document, "hasFocus").mockReturnValue(false); + const writeText = vi.fn().mockRejectedValue(new Error("NotAllowedError")); + vi.stubGlobal("navigator", { + ...navigator, + share: undefined, + canShare: undefined, + clipboard: { writeText }, + }); + + const setBanner = vi.fn(); + const { result } = renderHook( + () => + useCompletedRuleShareExport({ + setActionBanner: setBanner, + }), + { wrapper }, + ); + + await act(async () => { + await result.current.sharePublishedRuleViaSlack(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25); + }); + + expect(writeText).not.toHaveBeenCalled(); + expect(setBanner).not.toHaveBeenCalledWith( + expect.objectContaining({ key: "completedShareCopyFailed" }), + ); + hasFocusSpy.mockRestore(); + }); + + it("shareViaSlack suppresses copy-failed banner when clipboard denies after focus loss", async () => { + vi.useFakeTimers(); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + vi.spyOn(window, "open").mockReturnValue(null); + let hasFocusCalls = 0; + const hasFocusSpy = vi.spyOn(document, "hasFocus").mockImplementation(() => { + hasFocusCalls += 1; + return hasFocusCalls <= 2; + }); + const writeText = vi.fn().mockImplementation(async () => { + throw new Error("NotAllowedError"); + }); + vi.stubGlobal("navigator", { + ...navigator, + share: undefined, + canShare: undefined, + clipboard: { writeText }, + }); + + const setBanner = vi.fn(); + const { result } = renderHook( + () => + useCompletedRuleShareExport({ + setActionBanner: setBanner, + }), + { wrapper }, + ); + + await act(async () => { + await result.current.sharePublishedRuleViaSlack(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25); + }); + + expect(writeText).toHaveBeenCalled(); + expect(setBanner).not.toHaveBeenCalledWith( + expect.objectContaining({ key: "completedShareCopyFailed" }), + ); + hasFocusSpy.mockRestore(); + }); + + it("shareViaSlack falls back to clipboard when popup blocked and Web Share cannot run", async () => { + vi.useFakeTimers(); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + vi.spyOn(window, "open").mockReturnValue(null); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + const share = vi.fn(); + vi.stubGlobal("navigator", { + ...navigator, + share: share, + canShare: vi.fn().mockReturnValue(false), + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + + const setBanner = vi.fn(); + const { result } = renderHook( + () => + useCompletedRuleShareExport({ + setActionBanner: setBanner, + }), + { wrapper }, + ); + + await act(async () => { + await result.current.sharePublishedRuleViaSlack(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25); + }); + + expect(share).not.toHaveBeenCalled(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + `${window.location.origin}/rules/rule-1`, + ); + expect(setBanner).toHaveBeenCalledWith( + expect.objectContaining({ + key: "completedShareSlackFallback", + status: "positive", + }), + ); + }); + + it("shareViaSignal uses navigator.share when canShare allows URL-only data", async () => { + const share = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { + ...navigator, + share: share, + canShare: vi.fn().mockImplementation((data: ShareData) => data.url != null), + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + + const setBanner = vi.fn(); + const { result } = renderHook( + () => + useCompletedRuleShareExport({ + setActionBanner: setBanner, + }), + { wrapper }, + ); + + await act(async () => { + await result.current.sharePublishedRuleViaSignal(); + }); + + expect(share).toHaveBeenCalledWith({ + url: `${window.location.origin}/rules/rule-1`, + }); + expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); + expect(setBanner).not.toHaveBeenCalled(); + }); + + it("shareViaDiscord opens Discord hub and copies link when share unavailable", async () => { + vi.useFakeTimers(); + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); + const openSpy = vi.spyOn(window, "open").mockReturnValue(null); + vi.stubGlobal("navigator", { + ...navigator, + share: undefined, + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + + const setBanner = vi.fn(); + const { result } = renderHook( + () => + useCompletedRuleShareExport({ + setActionBanner: setBanner, + }), + { wrapper }, + ); + + await act(async () => { + await result.current.sharePublishedRuleViaDiscord(); + }); + + expect(clickSpy).toHaveBeenCalled(); + const anchorUnknown = clickSpy.mock.instances.at(-1) as unknown; + expect(anchorUnknown).toBeInstanceOf(HTMLAnchorElement); + expect((anchorUnknown as HTMLAnchorElement).getAttribute("href")).toBe( + DISCORD_NATIVE_DM_HUB_URL, + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(NATIVE_SHARE_FALLBACK_DELAY_MS + 25); + }); + + expect(openSpy).toHaveBeenCalledWith( + DISCORD_WEB_DM_HUB_URL, + "_blank", + "noopener,noreferrer", + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + `${window.location.origin}/rules/rule-1`, + ); + expect(setBanner).toHaveBeenCalledWith( + expect.objectContaining({ + key: "completedShareDiscordPaste", + status: "positive", + }), + ); + clickSpy.mockRestore(); + openSpy.mockRestore(); + }); + + it("onSelectExportFormat pdf triggers download with community-rule pdf filename", () => { + vi.mocked(readLastPublishedRule).mockReturnValue({ + ...mockRule, + document: { + sections: [ + { categoryName: "Values", entries: [{ title: "Norm", body: "Text." }] }, + ], + }, + }); + + const createObjectURL = vi.fn().mockReturnValue("blob:unit-test"); + const revokeObjectURL = vi.fn(); + Object.defineProperty(URL, "createObjectURL", { + value: createObjectURL, + writable: true, + configurable: true, + }); + Object.defineProperty(URL, "revokeObjectURL", { + value: revokeObjectURL, + writable: true, + configurable: true, + }); + + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); + const setBanner = vi.fn(); + + try { + const { result } = renderHook( + () => + useCompletedRuleShareExport({ + setActionBanner: setBanner, + }), + { wrapper }, + ); + + act(() => { + result.current.onSelectExportFormat("pdf"); + }); + + expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob)); + const blob = createObjectURL.mock.calls[0][0] as Blob; + expect(blob.type).toBe("application/pdf"); + + const anchorUnknown = clickSpy.mock.instances.at(-1) as unknown; + expect(anchorUnknown).toBeInstanceOf(HTMLAnchorElement); + const anchorEl = anchorUnknown as HTMLAnchorElement; + expect(anchorEl.getAttribute("download")).toBe( + "garden-norms-community-rule.pdf", + ); + expect(anchorEl.getAttribute("href")).toBe("blob:unit-test"); + expect(setBanner).not.toHaveBeenCalled(); + } finally { + Reflect.deleteProperty(URL, "createObjectURL"); + Reflect.deleteProperty(URL, "revokeObjectURL"); + clickSpy.mockRestore(); + } + }); +}); diff --git a/tests/unit/hooks/useCreateFlowFinalize.test.tsx b/tests/unit/hooks/useCreateFlowFinalize.test.tsx new file mode 100644 index 0000000..9a317a7 --- /dev/null +++ b/tests/unit/hooks/useCreateFlowFinalize.test.tsx @@ -0,0 +1,123 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { CreateFlowState } from "../../../app/(app)/create/types"; +import { useCreateFlowFinalize } from "../../../app/(app)/create/hooks/useCreateFlowFinalize"; +import { publishRule, updatePublishedRule } from "../../../lib/create/api"; +import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule"; +import { + CREATE_FLOW_COMPLETED_CELEBRATE_QUERY, + CREATE_FLOW_COMPLETED_CELEBRATE_VALUE, +} from "../../../app/(app)/create/utils/flowSteps"; + +vi.mock("../../../lib/create/buildPublishPayload", () => ({ + buildPublishPayload: vi.fn(() => ({ + ok: true as const, + title: "Published title", + summary: "Published summary", + document: {}, + })), +})); + +vi.mock("../../../lib/create/api", () => ({ + publishRule: vi.fn(), + updatePublishedRule: vi.fn(), +})); + +vi.mock("../../../lib/create/lastPublishedRule", () => ({ + writeLastPublishedRule: vi.fn(), +})); + +const emptyState = {} as CreateFlowState; + +describe("useCreateFlowFinalize", () => { + const router = { push: vi.fn() }; + const updateState = vi.fn(); + const openLogin = vi.fn(); + + beforeEach(() => { + vi.mocked(publishRule).mockReset(); + vi.mocked(updatePublishedRule).mockReset(); + vi.mocked(writeLastPublishedRule).mockReset(); + router.push.mockReset(); + updateState.mockReset(); + openLogin.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("routes with celebrate query after initial POST publish", async () => { + vi.mocked(publishRule).mockResolvedValue({ + ok: true, + id: "new-rule-id", + title: "Published title", + }); + + const { result } = renderHook(() => + useCreateFlowFinalize({ + state: emptyState, + router, + openLogin, + updateState, + loginReturnPath: "/create/final-review", + }), + ); + + await act(async () => { + await result.current.finalize(); + }); + + expect(router.push).toHaveBeenCalledWith( + `/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`, + ); + expect(updatePublishedRule).not.toHaveBeenCalled(); + expect(writeLastPublishedRule).toHaveBeenCalledWith({ + id: "new-rule-id", + title: "Published title", + summary: "Published summary", + document: {}, + }); + }); + + it("routes to /create/completed without celebrate after PATCH update", async () => { + vi.mocked(updatePublishedRule).mockResolvedValue({ ok: true }); + + const { result } = renderHook(() => + useCreateFlowFinalize({ + state: { + ...emptyState, + editingPublishedRuleId: " existing-id ", + }, + router, + openLogin, + updateState, + loginReturnPath: "/create/edit-rule", + }), + ); + + await act(async () => { + await result.current.finalize(); + }); + + expect(router.push).toHaveBeenCalledWith("/create/completed"); + expect(publishRule).not.toHaveBeenCalled(); + expect(updatePublishedRule).toHaveBeenCalledWith( + "existing-id", + expect.objectContaining({ + title: "Published title", + summary: "Published summary", + document: {}, + }), + ); + expect(writeLastPublishedRule).toHaveBeenCalledWith({ + id: "existing-id", + title: "Published title", + summary: "Published summary", + document: {}, + }); + expect(updateState).toHaveBeenCalledWith({ + editingPublishedRuleId: undefined, + }); + }); +}); diff --git a/tests/unit/lib/ruleExport.test.ts b/tests/unit/lib/ruleExport.test.ts new file mode 100644 index 0000000..51aa338 --- /dev/null +++ b/tests/unit/lib/ruleExport.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from "vitest"; +import { + buildPrintableRuleHtmlDocument, + buildPublicRuleUrl, + buildStoredRulePdfBlob, + exportFilenameBase, + sectionsToCsv, + sectionsToMarkdown, +} from "../../../lib/create/ruleExport"; +import type { CommunityRuleSection } from "../../../app/components/type/CommunityRule/CommunityRule.types"; + +async function readBlobAsArrayBuffer(blob: Blob): Promise { + if (typeof blob.arrayBuffer === "function") { + return blob.arrayBuffer(); + } + return new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = (): void => resolve(r.result as ArrayBuffer); + r.onerror = (): void => reject(new Error("FileReader failed")); + r.readAsArrayBuffer(blob); + }); +} + +describe("ruleExport", () => { + it("buildPublicRuleUrl encodes id and trims origin slash", () => { + expect(buildPublicRuleUrl("https://example.com/", "abc/xyz")).toBe( + "https://example.com/rules/abc%2Fxyz", + ); + expect(buildPublicRuleUrl("https://example.com", "r1")).toBe( + "https://example.com/rules/r1", + ); + }); + + it("exportFilenameBase slugifies title", () => { + expect( + exportFilenameBase({ + id: "id-1", + title: "Mutual Aid Mondays!", + document: {}, + }), + ).toBe("mutual-aid-mondays"); + }); + + it("exportFilenameBase falls back to id fragment", () => { + expect( + exportFilenameBase({ + id: "full-uuid-here", + title: " ", + document: {}, + }), + ).toBe("rule-full-uui"); + }); + + it("sectionsToMarkdown renders title, summary, and sections", () => { + const sections: CommunityRuleSection[] = [ + { + categoryName: "Values", + entries: [ + { + title: "Solidarity", + body: "First paragraph.\n\nSecond paragraph.", + }, + ], + }, + ]; + const md = sectionsToMarkdown( + "My Rule", + "Short summary.", + sections, + ); + expect(md).toContain("# My Rule"); + expect(md).toContain("Short summary."); + expect(md).toContain("## Values"); + expect(md).toContain("### Solidarity"); + expect(md).toContain("First paragraph."); + expect(md).toContain("Second paragraph."); + }); + + it("sectionsToCsv includes header row, title metadata, sections, and quotes commas", () => { + const sections: CommunityRuleSection[] = [ + { + categoryName: "Values", + entries: [ + { + title: "Solidarity", + body: "One, two", + }, + ], + }, + ]; + const csv = sectionsToCsv("My Rule", "Sum, mary", sections); + expect(csv).toContain("Section,Entry,Block label,Content"); + expect(csv).toContain('"Sum, mary"'); + expect(csv).toContain('"One, two"'); + expect(csv).toContain(",Title,,My Rule"); + }); + + it("buildPrintableRuleHtmlDocument escapes HTML in user content", () => { + const sections: CommunityRuleSection[] = [ + { + categoryName: 'Values ', + entries: [{ title: "Entry", body: "" }], + }, + ]; + const html = buildPrintableRuleHtmlDocument( + 'Title ', + null, + sections, + ); + expect(html).toContain("<script>"); + expect(html).not.toContain("