From a37a72c71dbde16e70333b41768f268888b5aadb Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:27:46 -0600 Subject: [PATCH] Implement share and export components --- app/(app)/create/CreateFlowLayoutClient.tsx | 119 +++++- .../hooks/useCompletedRuleShareExport.ts | 374 ++++++++++++++++++ .../create/hooks/useCreateFlowFinalize.ts | 22 +- .../create/hooks/useTemplateReviewActions.ts | 18 +- .../screens/completed/CompletedScreen.tsx | 46 ++- .../screens/review/CommunityReviewScreen.tsx | 23 +- .../create/utils/createFlowScreenRegistry.ts | 2 +- app/(app)/create/utils/flowSteps.ts | 4 + app/components/asset/icon/Icon.tsx | 9 + app/components/asset/icon/csv.svg | 13 + app/components/asset/icon/markdown_copy.svg | 13 + app/components/asset/icon/picture_as_pdf.svg | 19 + app/components/buttons/Button/Button.tsx | 16 +- .../layout/ListItem/ListItem.container.tsx | 17 + .../layout/ListItem/ListItem.types.ts | 10 + .../layout/ListItem/ListItem.view.tsx | 35 ++ app/components/layout/ListItem/index.tsx | 2 + .../modals/ModalHeader/ModalHeader.types.ts | 4 + .../modals/ModalHeader/ModalHeader.view.tsx | 6 +- .../modals/Popover/Popover.container.tsx | 17 + .../modals/Popover/Popover.types.ts | 8 + .../modals/Popover/Popover.view.tsx | 25 ++ app/components/modals/Popover/index.tsx | 2 + .../modals/Share/Share.container.tsx | 43 ++ app/components/modals/Share/Share.types.ts | 37 ++ app/components/modals/Share/Share.view.tsx | 165 ++++++++ app/components/modals/Share/index.tsx | 2 + .../CreateFlowTopNav.container.tsx | 11 +- .../CreateFlowTopNav.types.ts | 12 +- .../CreateFlowTopNav.view.tsx | 133 +++++-- docs/figma-component-registry.md | 3 +- .../publishedDocumentToCreateFlowState.ts | 71 +++- lib/create/ruleExport.ts | 328 +++++++++++++++ lib/create/shareChannels.ts | 105 +++++ .../create/reviewAndComplete/completed.json | 16 +- messages/en/index.ts | 6 + messages/en/modals/popoverExport.json | 6 + messages/en/modals/share.json | 12 + package-lock.json | 204 +++++++++- package.json | 1 + public/assets/Share/Discord.svg | 3 + public/assets/Share/Link.svg | 4 + public/assets/Share/Mail.svg | 8 + public/assets/Share/Signal.svg | 3 + public/assets/Share/Slack.svg | 6 + stories/modals/Share.stories.js | 41 ++ .../navigation/CreateFlowTopNav.stories.js | 2 +- tests/components/CompletedPage.test.tsx | 40 +- tests/components/CreateFlowTopNav.test.tsx | 49 ++- tests/components/layout/ListItem.test.tsx | 38 ++ tests/components/modals/Popover.test.tsx | 40 ++ tests/components/modals/Share.test.tsx | 80 ++++ tests/unit/applyTemplatePrefill.test.ts | 45 +++ .../useCompletedRuleShareExport.test.tsx | 351 ++++++++++++++++ .../unit/hooks/useCreateFlowFinalize.test.tsx | 123 ++++++ tests/unit/lib/ruleExport.test.ts | 159 ++++++++ tests/unit/lib/shareChannels.test.ts | 137 +++++++ ...publishedDocumentToCreateFlowState.test.ts | 182 ++++++++- 58 files changed, 3153 insertions(+), 117 deletions(-) create mode 100644 app/(app)/create/hooks/useCompletedRuleShareExport.ts create mode 100644 app/components/asset/icon/csv.svg create mode 100644 app/components/asset/icon/markdown_copy.svg create mode 100644 app/components/asset/icon/picture_as_pdf.svg create mode 100644 app/components/layout/ListItem/ListItem.container.tsx create mode 100644 app/components/layout/ListItem/ListItem.types.ts create mode 100644 app/components/layout/ListItem/ListItem.view.tsx create mode 100644 app/components/layout/ListItem/index.tsx create mode 100644 app/components/modals/Popover/Popover.container.tsx create mode 100644 app/components/modals/Popover/Popover.types.ts create mode 100644 app/components/modals/Popover/Popover.view.tsx create mode 100644 app/components/modals/Popover/index.tsx create mode 100644 app/components/modals/Share/Share.container.tsx create mode 100644 app/components/modals/Share/Share.types.ts create mode 100644 app/components/modals/Share/Share.view.tsx create mode 100644 app/components/modals/Share/index.tsx create mode 100644 lib/create/ruleExport.ts create mode 100644 lib/create/shareChannels.ts create mode 100644 messages/en/modals/popoverExport.json create mode 100644 messages/en/modals/share.json create mode 100644 public/assets/Share/Discord.svg create mode 100644 public/assets/Share/Link.svg create mode 100644 public/assets/Share/Mail.svg create mode 100644 public/assets/Share/Signal.svg create mode 100644 public/assets/Share/Slack.svg create mode 100644 stories/modals/Share.stories.js create mode 100644 tests/components/layout/ListItem.test.tsx create mode 100644 tests/components/modals/Popover.test.tsx create mode 100644 tests/components/modals/Share.test.tsx create mode 100644 tests/unit/hooks/useCompletedRuleShareExport.test.tsx create mode 100644 tests/unit/hooks/useCreateFlowFinalize.test.tsx create mode 100644 tests/unit/lib/ruleExport.test.ts create mode 100644 tests/unit/lib/shareChannels.test.ts diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index a23835a..e59ff21 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -13,6 +13,7 @@ import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation"; import { useCreateFlowExit } from "./hooks/useCreateFlowExit"; import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize"; import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions"; +import { useCompletedRuleShareExport } from "./hooks/useCompletedRuleShareExport"; import CreateFlowFooter from "../../components/navigation/CreateFlowFooter"; import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav"; import { @@ -41,7 +42,12 @@ import { clearAnonymousCreateFlowStorage, setTransferPendingFlag, } from "./utils/anonymousDraftStorage"; -import { createFlowStateFromPublishedRule } from "../../../lib/create/publishedDocumentToCreateFlowState"; +import type { CreateFlowMethodCardFacetSection } from "./types"; +import { + createFlowStateFromPublishedRule, + isPublishedRuleSelectionMissing, + methodSectionsPinsFromPublishedHydratePatch, +} from "../../../lib/create/publishedDocumentToCreateFlowState"; import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule"; import { deleteServerDraft } from "../../../lib/create/api"; import messages from "../../../messages/en/index"; @@ -60,6 +66,7 @@ import { useMessages, useTranslation } from "../../contexts/MessagesContext"; import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; import { SignedInDraftHydration } from "./SignedInDraftHydration"; import Alert from "../../components/modals/Alert"; +import Share from "../../components/modals/Share"; import { CreateFlowDraftSaveBannerProvider, useCreateFlowDraftSaveBanner, @@ -152,6 +159,37 @@ function CreateFlowLayoutContent({ >(null); const [communitySaveMagicLinkSuccess, setCommunitySaveMagicLinkSuccess] = useState(false); + const [completedFlowBanner, setCompletedFlowBanner] = useState<{ + key: string; + status: "positive" | "danger"; + title: string; + description?: string; + } | null>(null); + const [shareModalOpen, setShareModalOpen] = useState(false); + + const { + copyPublishedRuleLink, + mailtoPublishedRule, + sharePublishedRuleViaSignal, + sharePublishedRuleViaSlack, + sharePublishedRuleViaDiscord, + onSelectExportFormat: onCompletedExportFormat, + } = useCompletedRuleShareExport({ + setActionBanner: setCompletedFlowBanner, + }); + + const handleOpenCompletedShareModal = () => { + if (!readLastPublishedRule()) { + setCompletedFlowBanner({ + key: "completedShareNoRule", + status: "danger", + title: create.reviewAndComplete.completed.shareNoRuleTitle, + description: create.reviewAndComplete.completed.shareNoRuleDescription, + }); + return; + } + setShareModalOpen(true); + }; const loginReturnPath = currentStep === "edit-rule" @@ -267,14 +305,47 @@ function CreateFlowLayoutContent({ 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) { + const patch = createFlowStateFromPublishedRule(last); + const pinPatch = methodSectionsPinsFromPublishedHydratePatch(patch); + const METHOD_CARD_PIN_FACETS: readonly CreateFlowMethodCardFacetSection[] = + ["communication", "membership", "decisionApproaches", "conflictManagement"]; + const needsPinMerge = METHOD_CARD_PIN_FACETS.some( + (key) => + pinPatch[key] === true && + state.methodSectionsPinCommitted?.[key] !== true, + ); + /** + * Skip repeat merges once template `sections` are cleared **and** published + * facet selections are present. Without the selection check, TopNav **Edit** + * (`sections: []` before navigate) matched only `sectionsClear` and skipped + * the merge — method-card steps saw empty `selected*Ids` until a confirm. + * + * Still merge {@link methodSectionsPinsFromPublishedHydratePatch}: selections + * may already match draft state while compact CardStack pins stayed false + * (pins are normally set only on facet **Confirm**). + */ + if ( + titleOk && + editingId === last.id && + sectionsClear && + !isPublishedRuleSelectionMissing(state, patch) + ) { + if (needsPinMerge) { + updateState({ + methodSectionsPinCommitted: { + ...state.methodSectionsPinCommitted, + ...pinPatch, + }, + }); + } return; } updateState({ - ...createFlowStateFromPublishedRule(last), - /** Keep UI-only facet pin flags across published re-hydration (wizard draft field; not stored on publish). */ - methodSectionsPinCommitted: state.methodSectionsPinCommitted, + ...patch, + methodSectionsPinCommitted: { + ...state.methodSectionsPinCommitted, + ...pinPatch, + }, }); }, [ currentStep, @@ -286,6 +357,12 @@ function CreateFlowLayoutContent({ state.sections?.length, ]); + useEffect(() => { + if (currentStep !== "completed") { + setCompletedFlowBanner(null); + } + }, [currentStep]); + const handleCommunitySaveMagicLinkSubmit = useCallback(async () => { setCommunitySaveMagicLinkError(null); setCommunitySaveMagicLinkSuccess(false); @@ -381,11 +458,7 @@ function CreateFlowLayoutContent({ undefined)); /** - * Top banner stack rendered above the main column when any of the - * shell-level statuses are active. Each entry maps to one ``; - * we filter out empty messages so the wrapper only mounts when at - * least one banner is actually showing. Order here is the visual - * stacking order (top → bottom). + * Top banner stack above the main column; order is top → bottom. */ const topBanners: Array<{ key: string; @@ -440,6 +513,15 @@ function CreateFlowLayoutContent({ onClose: () => setCommunitySaveMagicLinkSuccess(false), } : null, + completedFlowBanner + ? { + key: `completedFlow-${completedFlowBanner.key}`, + status: completedFlowBanner.status, + title: completedFlowBanner.title, + description: completedFlowBanner.description, + onClose: () => setCompletedFlowBanner(null), + } + : null, ].filter((b): b is NonNullable => b !== null); return ( @@ -475,11 +557,26 @@ function CreateFlowLayoutContent({ + setShareModalOpen(false)} + onCopyLink={() => void copyPublishedRuleLink()} + onEmailShare={mailtoPublishedRule} + onSignalShare={() => void sharePublishedRuleViaSignal()} + onSlackShare={() => void sharePublishedRuleViaSlack()} + onDiscordShare={() => void sharePublishedRuleViaDiscord()} + /> void handleOpenCompletedShareModal() : undefined + } + onSelectExportFormat={ + isCompletedStep ? onCompletedExportFormat : undefined + } onEdit={ isCompletedStep ? () => { diff --git a/app/(app)/create/hooks/useCompletedRuleShareExport.ts b/app/(app)/create/hooks/useCompletedRuleShareExport.ts new file mode 100644 index 0000000..b3120b0 --- /dev/null +++ b/app/(app)/create/hooks/useCompletedRuleShareExport.ts @@ -0,0 +1,374 @@ +"use client"; + +import { useCallback } from "react"; +import { useTranslation } from "../../../contexts/MessagesContext"; +import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; +import { + buildMailtoShareHref, + buildSlackWebShareUrl, + DISCORD_NATIVE_DM_HUB_URL, + DISCORD_WEB_DM_HUB_URL, + scheduleNativeSchemeThenFallback, + SLACK_NATIVE_OPEN_URL, + type NativeFallbackTimers, + type NativeNavigateDeps, +} from "../../../../lib/create/shareChannels"; +import { + buildPublicRuleUrl, + downloadStoredRuleAsPdf, + downloadTextFile, + exportFilenameBase, + exportStoredRuleAsCsv, + exportStoredRuleAsMarkdown, +} from "../../../../lib/create/ruleExport"; + +export type CompletedFlowActionBanner = { + key: string; + status: "positive" | "danger"; + title: string; + description?: string; +}; + +function browserNativeShareNavigateDeps(win: Window): NativeNavigateDeps { + return { + assignLocationHref: (url: string): void => { + // Transient : same-tab custom-protocol handshake as location.href without replacing the SPA. + const anchor = win.document.createElement("a"); + anchor.href = url; + anchor.rel = "noreferrer noopener"; + anchor.style.position = "absolute"; + anchor.style.left = "-9999px"; + win.document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + }, + getVisibilityState: (): Document["visibilityState"] => + win.document.visibilityState, + onVisibilityChange: (listener: () => void): void => { + win.document.addEventListener("visibilitychange", listener); + }, + offVisibilityChange: (listener: () => void): void => { + win.document.removeEventListener("visibilitychange", listener); + }, + }; +} + +function browserNativeTimers(win: Window): NativeFallbackTimers { + return { + setTimeout: (cb: () => void, ms: number): unknown => win.setTimeout(cb, ms), + clearTimeout: (handle: unknown): void => + win.clearTimeout( + handle as ReturnType, + ), + }; +} + +/** + * After native app handoff, the page can stay `visibilityState === "visible"` while + * focus moves to the other app. Skip clipboard fallbacks in that case to avoid + * `NotAllowedError` noise when Slack/compose already succeeded. + */ +function shouldSkipShareClipboardFallback(win: Window): boolean { + return ( + win.document.visibilityState === "hidden" || !win.document.hasFocus() + ); +} + +function resolvePublishedRuleShareContext(windowObj: Window): { + url: string; + title: string; + text: string; +} | null { + const rule = readLastPublishedRule(); + if (!rule) return null; + const url = buildPublicRuleUrl(windowObj.location.origin, rule.id); + const summary = + typeof rule.summary === "string" ? rule.summary.trim() : ""; + const text = summary.length > 0 ? summary : rule.title; + return { url, title: rule.title, text }; +} + +/** + * Share / export handlers for the completed step (`readLastPublishedRule`). + */ +export function useCompletedRuleShareExport({ + setActionBanner, +}: { + setActionBanner: (_: CompletedFlowActionBanner | null) => void; +}): { + copyPublishedRuleLink: () => Promise; + mailtoPublishedRule: () => void; + sharePublishedRuleViaSignal: () => Promise; + sharePublishedRuleViaSlack: () => Promise; + sharePublishedRuleViaDiscord: () => Promise; + onSelectExportFormat: (_format: "pdf" | "csv" | "markdown") => void; +} { + const t = useTranslation("create.reviewAndComplete.completed"); + + const bannerNoRule = useCallback(() => { + setActionBanner({ + key: "completedShareNoRule", + status: "danger", + title: t("shareNoRuleTitle"), + description: t("shareNoRuleDescription"), + }); + }, [setActionBanner, t]); + + const bannerCopied = useCallback(() => { + setActionBanner({ + key: "completedShareCopied", + status: "positive", + title: t("shareLinkCopiedTitle"), + description: t("shareLinkCopiedDescription"), + }); + }, [setActionBanner, t]); + + const bannerCopyFailed = useCallback(() => { + setActionBanner({ + key: "completedShareCopyFailed", + status: "danger", + title: t("shareCopyFailedTitle"), + description: t("shareCopyFailedDescription"), + }); + }, [setActionBanner, t]); + + const copyUrlToClipboard = useCallback( + async ( + url: string, + banner?: () => void, + options?: { suppressFailureWhenDocumentNotFocused?: boolean }, + ) => { + try { + await navigator.clipboard.writeText(url); + (banner ?? bannerCopied)(); + } catch { + if ( + options?.suppressFailureWhenDocumentNotFocused === true && + typeof window !== "undefined" && + shouldSkipShareClipboardFallback(window) + ) { + return; + } + bannerCopyFailed(); + } + }, + [bannerCopied, bannerCopyFailed], + ); + + const copyPublishedRuleLink = useCallback(async () => { + if (typeof window === "undefined") return; + const ctx = resolvePublishedRuleShareContext(window); + if (!ctx) { + bannerNoRule(); + return; + } + await copyUrlToClipboard(ctx.url); + }, [bannerNoRule, copyUrlToClipboard]); + + const mailtoPublishedRule = useCallback(() => { + if (typeof window === "undefined") return; + const ctx = resolvePublishedRuleShareContext(window); + if (!ctx) { + bannerNoRule(); + return; + } + + const body = `${ctx.text}\n\n${ctx.url}`; + window.location.href = buildMailtoShareHref({ + subject: ctx.title, + body, + }); + }, [bannerNoRule]); + + const tryNavigatorShareAbortOk = useCallback( + async (data: ShareData): Promise => { + if (typeof navigator.share !== "function") return false; + const can = + typeof navigator.canShare !== "function" || navigator.canShare(data); + if (!can) return false; + try { + await navigator.share(data); + return true; + } catch (e) { + const err = e as { name?: string }; + if (err?.name === "AbortError") return true; + return false; + } + }, + [], + ); + + /** Prefer URL-only share data when the platform allows it (common on mobile). */ + const shareViaWebShareApiOrFalse = useCallback( + async (ctx: { url: string; title: string; text: string }) => { + const urlOnly: ShareData = { url: ctx.url }; + if (await tryNavigatorShareAbortOk(urlOnly)) return true; + const full: ShareData = { + title: ctx.title, + text: ctx.text, + url: ctx.url, + }; + return tryNavigatorShareAbortOk(full); + }, + [tryNavigatorShareAbortOk], + ); + + const sharePublishedRuleViaSignal = useCallback(async () => { + if (typeof window === "undefined") return; + const ctx = resolvePublishedRuleShareContext(window); + if (!ctx) { + bannerNoRule(); + return; + } + if (await shareViaWebShareApiOrFalse(ctx)) return; + await copyUrlToClipboard(ctx.url); + }, [bannerNoRule, copyUrlToClipboard, shareViaWebShareApiOrFalse]); + + const sharePublishedRuleViaSlack = useCallback(async () => { + if (typeof window === "undefined") return; + const ctx = resolvePublishedRuleShareContext(window); + if (!ctx) { + bannerNoRule(); + return; + } + + const runSlackWebComposeFallback = async (): Promise => { + const slackUrl = buildSlackWebShareUrl(ctx.url); + const popup = window.open( + slackUrl, + "_blank", + "noopener,noreferrer", + ); + if (popup) return; + + if (shouldSkipShareClipboardFallback(window)) return; + + if (await shareViaWebShareApiOrFalse(ctx)) return; + + if (shouldSkipShareClipboardFallback(window)) return; + + await copyUrlToClipboard( + ctx.url, + () => + setActionBanner({ + key: "completedShareSlackFallback", + status: "positive", + title: t("shareSlackFallbackTitle"), + description: t("shareSlackFallbackDescription"), + }), + { suppressFailureWhenDocumentNotFocused: true }, + ); + }; + + scheduleNativeSchemeThenFallback( + SLACK_NATIVE_OPEN_URL, + () => void runSlackWebComposeFallback(), + browserNativeShareNavigateDeps(window), + browserNativeTimers(window), + ); + }, [ + bannerNoRule, + copyUrlToClipboard, + shareViaWebShareApiOrFalse, + setActionBanner, + t, + ]); + + const sharePublishedRuleViaDiscord = useCallback(async () => { + if (typeof window === "undefined") return; + const ctx = resolvePublishedRuleShareContext(window); + if (!ctx) { + bannerNoRule(); + return; + } + + if (await shareViaWebShareApiOrFalse(ctx)) return; + + try { + await navigator.clipboard.writeText(ctx.url); + setActionBanner({ + key: "completedShareDiscordPaste", + status: "positive", + title: t("shareDiscordPasteTitle"), + description: t("shareDiscordPasteDescription"), + }); + } catch { + bannerCopyFailed(); + } + + scheduleNativeSchemeThenFallback( + DISCORD_NATIVE_DM_HUB_URL, + () => + void window.open( + DISCORD_WEB_DM_HUB_URL, + "_blank", + "noopener,noreferrer", + ), + browserNativeShareNavigateDeps(window), + browserNativeTimers(window), + ); + }, [ + bannerCopyFailed, + bannerNoRule, + shareViaWebShareApiOrFalse, + setActionBanner, + t, + ]); + + const onSelectExportFormat = useCallback( + (format: "pdf" | "csv" | "markdown") => { + if (typeof window === "undefined") return; + const rule = readLastPublishedRule(); + if (!rule) { + setActionBanner({ + key: "completedExportNoRule", + status: "danger", + title: t("shareNoRuleTitle"), + description: t("shareNoRuleDescription"), + }); + return; + } + + const base = exportFilenameBase(rule); + try { + if (format === "pdf") { + downloadStoredRuleAsPdf(rule); + } else if (format === "csv") { + const csv = exportStoredRuleAsCsv(rule); + downloadTextFile( + `${base}-community-rule.csv`, + csv, + "text/csv;charset=utf-8", + ); + } else { + const md = exportStoredRuleAsMarkdown(rule); + downloadTextFile( + `${base}-community-rule.md`, + md, + "text/markdown;charset=utf-8", + ); + } + } catch (e) { + const msg = e instanceof Error && e.message === "exportEmptyDocument"; + setActionBanner({ + key: "completedExportFailed", + status: "danger", + title: msg ? t("exportEmptyDocumentTitle") : t("exportFailedTitle"), + description: msg + ? t("exportEmptyDocumentDescription") + : t("exportFailedDescription"), + }); + } + }, + [setActionBanner, t], + ); + + return { + copyPublishedRuleLink, + mailtoPublishedRule, + sharePublishedRuleViaSignal, + sharePublishedRuleViaSlack, + sharePublishedRuleViaDiscord, + onSelectExportFormat, + }; +} diff --git a/app/(app)/create/hooks/useCreateFlowFinalize.ts b/app/(app)/create/hooks/useCreateFlowFinalize.ts index 97d0384..9415997 100644 --- a/app/(app)/create/hooks/useCreateFlowFinalize.ts +++ b/app/(app)/create/hooks/useCreateFlowFinalize.ts @@ -6,6 +6,10 @@ import { publishRule, updatePublishedRule } from "../../../../lib/create/api"; import { writeLastPublishedRule } from "../../../../lib/create/lastPublishedRule"; import messages from "../../../../messages/en/index"; import type { CreateFlowState } from "../types"; +import { + CREATE_FLOW_COMPLETED_CELEBRATE_QUERY, + CREATE_FLOW_COMPLETED_CELEBRATE_VALUE, +} from "../utils/flowSteps"; type AppRouterLike = { push: (_href: string) => void }; @@ -16,25 +20,13 @@ type OpenLogin = (args: { }) => void; export type UseCreateFlowFinalizeResult = { - /** Set when publish fails (validation, server error, or empty server message). Reset on each `finalize()` invocation. */ publishBannerMessage: string | null; setPublishBannerMessage: (_message: string | null) => void; - /** True from the moment the publish request fires until the response resolves. */ isPublishing: boolean; - /** - * Build a publish payload from the current `CreateFlowState`, post it to - * `publishRule` (or PATCH when editing a published rule), and route to - * `/create/completed` on success. - */ finalize: () => Promise; }; -/** - * Encapsulates the Final Review → publish flow that previously lived inline - * in `CreateFlowLayoutClient`. Keeps publish state (banner + in-flight flag) - * co-located with the publish handler so the layout shell only has to wire - * the resulting message into its banner stack. - */ +/** Final Review → publish: banner + `isPublishing`, consumed by `CreateFlowLayoutClient`. */ export function useCreateFlowFinalize({ state, router, @@ -120,7 +112,9 @@ export function useCreateFlowFinalize({ summary: summary ?? null, document: ruleDocument, }); - router.push("/create/completed"); + router.push( + `/create/completed?${CREATE_FLOW_COMPLETED_CELEBRATE_QUERY}=${CREATE_FLOW_COMPLETED_CELEBRATE_VALUE}`, + ); return; } if (publishResult.status === 401) { diff --git a/app/(app)/create/hooks/useTemplateReviewActions.ts b/app/(app)/create/hooks/useTemplateReviewActions.ts index a19b686..fb6ce8d 100644 --- a/app/(app)/create/hooks/useTemplateReviewActions.ts +++ b/app/(app)/create/hooks/useTemplateReviewActions.ts @@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from "react"; import { buildTemplateCustomizePrefill } from "../../../../lib/create/applyTemplatePrefill"; import { loadTemplateReviewBySlug } from "../../../../lib/create/loadTemplateReviewBySlug"; +import { methodSectionsPinsForHydratedSelections } from "../../../../lib/create/publishedDocumentToCreateFlowState"; import { stripCustomRuleSelectionFields } from "../../../../lib/create/stripCustomRuleSelectionFields"; import messages from "../../../../messages/en/index"; import type { @@ -98,10 +99,15 @@ export function useTemplateReviewActions({ return; } const prefill = buildTemplateCustomizePrefill(loaded.template.body); + const pinPatch = methodSectionsPinsForHydratedSelections(prefill); const hasCommunityName = typeof state.title === "string" && state.title.trim().length > 0; updateState({ ...prefill, + methodSectionsPinCommitted: { + ...state.methodSectionsPinCommitted, + ...pinPatch, + }, templateReviewBackSlug: undefined, ...(hasCommunityName ? { pendingTemplateAction: undefined } @@ -115,7 +121,13 @@ export function useTemplateReviewActions({ router.push( hasCommunityName ? "/create/core-values" : "/create/informational", ); - }, [router, state.title, templateReviewSlug, updateState]); + }, [ + router, + state.methodSectionsPinCommitted, + state.title, + templateReviewSlug, + updateState, + ]); const handleUseWithoutChanges = useCallback(async () => { if (!templateReviewSlug) return; @@ -170,6 +182,9 @@ export function useTemplateReviewActions({ const hasCommunityName = typeof prev.title === "string" && prev.title.trim().length > 0; + const pinPatch = + methodSectionsPinsForHydratedSelections(customizePrefill); + return { ...base, ...(hasValuesSeed @@ -204,6 +219,7 @@ export function useTemplateReviewActions({ } : {}), sections: sectionsWithoutValues, + methodSectionsPinCommitted: pinPatch, templateReviewBackSlug: templateReviewSlug, ...(hasCommunityName ? { pendingTemplateAction: undefined } diff --git a/app/(app)/create/screens/completed/CompletedScreen.tsx b/app/(app)/create/screens/completed/CompletedScreen.tsx index b0d06e7..74bc05f 100644 --- a/app/(app)/create/screens/completed/CompletedScreen.tsx +++ b/app/(app)/create/screens/completed/CompletedScreen.tsx @@ -18,6 +18,22 @@ import { CREATE_FLOW_MD_UP_GRID_CELL_CLASS, CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS, } from "../../components/createFlowLayoutTokens"; +import { + CREATE_FLOW_COMPLETED_CELEBRATE_QUERY, + CREATE_FLOW_COMPLETED_CELEBRATE_VALUE, +} from "../../utils/flowSteps"; + +function emptyCompletedDocumentState(): { + headerTitle: string; + headerDescription: string | undefined; + documentSections: CommunityRuleSection[]; +} { + return { + headerTitle: "", + headerDescription: undefined, + documentSections: [], + }; +} function initialCompletedUi( ruleIdFromUrl: string | null, @@ -27,26 +43,14 @@ function initialCompletedUi( documentSections: CommunityRuleSection[]; } { if (ruleIdFromUrl) { - return { - headerTitle: "", - headerDescription: undefined, - documentSections: [], - }; + return emptyCompletedDocumentState(); } if (typeof sessionStorage === "undefined") { - return { - headerTitle: "", - headerDescription: undefined, - documentSections: [], - }; + return emptyCompletedDocumentState(); } const stored = readLastPublishedRule(); if (!stored) { - return { - headerTitle: "", - headerDescription: undefined, - documentSections: [], - }; + return emptyCompletedDocumentState(); } const parsed = parsePublishedDocumentForCommunityRuleDisplay(stored.document); if (parsed.length === 0) { @@ -69,12 +73,17 @@ export function CompletedScreen() { const router = useRouter(); const searchParams = useSearchParams(); const ruleIdParam = searchParams.get("ruleId"); + const celebrateInUrl = + searchParams.get(CREATE_FLOW_COMPLETED_CELEBRATE_QUERY) === + CREATE_FLOW_COMPLETED_CELEBRATE_VALUE; const mdUp = useCreateFlowMdUp(); const m = useMessages(); const completed = m.create.reviewAndComplete.completed; const initial = initialCompletedUi(ruleIdParam); const [toastDismissed, setToastDismissed] = useState(false); + /** Latch: toast copy should survive `router.replace` after we strip `?celebrate=1`. */ + const [showCelebrateToast] = useState(celebrateInUrl); const [headerTitle, setHeaderTitle] = useState(initial.headerTitle); const [headerDescription, setHeaderDescription] = useState< string | undefined @@ -126,7 +135,12 @@ export function CompletedScreen() { }; }, [ruleIdParam, router]); - const toast = !toastDismissed ? ( + useEffect(() => { + if (!celebrateInUrl) return; + router.replace("/create/completed"); + }, [celebrateInUrl, router]); + + const toast = showCelebrateToast && !toastDismissed ? (
0 diff --git a/app/(app)/create/utils/createFlowScreenRegistry.ts b/app/(app)/create/utils/createFlowScreenRegistry.ts index 588afd3..f43e24a 100644 --- a/app/(app)/create/utils/createFlowScreenRegistry.ts +++ b/app/(app)/create/utils/createFlowScreenRegistry.ts @@ -137,7 +137,7 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record< }, completed: { layoutKind: "completed", - figmaNodeId: "20907-213286", + figmaNodeId: "20907-213288", messageNamespace: "create.reviewAndComplete.completed", centeredBodyBelowMd: false, }, diff --git a/app/(app)/create/utils/flowSteps.ts b/app/(app)/create/utils/flowSteps.ts index f68fa6d..e5f7875 100644 --- a/app/(app)/create/utils/flowSteps.ts +++ b/app/(app)/create/utils/flowSteps.ts @@ -161,6 +161,10 @@ export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const; export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const; export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const; +/** `/create/completed?celebrate=1` — post-finalize toast; set only after **initial** POST publish, not PATCH updates. */ +export const CREATE_FLOW_COMPLETED_CELEBRATE_QUERY = "celebrate" as const; +export const CREATE_FLOW_COMPLETED_CELEBRATE_VALUE = "1" as const; + /** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */ export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const; diff --git a/app/components/asset/icon/Icon.tsx b/app/components/asset/icon/Icon.tsx index 0b91225..1d5bad1 100644 --- a/app/components/asset/icon/Icon.tsx +++ b/app/components/asset/icon/Icon.tsx @@ -7,18 +7,24 @@ import ContentCopyIcon from "./content_copy.svg"; import EditIcon from "./edit.svg"; import ExclamationIcon from "./exclamation.svg"; import ChevronRightIcon from "./chevron_right.svg"; +import CsvIcon from "./csv.svg"; import LogOutIcon from "./log_out.svg"; import MailIcon from "./mail.svg"; +import MarkdownCopyIcon from "./markdown_copy.svg"; +import PictureAsPdfIcon from "./picture_as_pdf.svg"; import WarningIcon from "./warning.svg"; export const ICON_NAME_OPTIONS = [ "arrow_back", "chevron_right", "content_copy", + "csv", "edit", "exclamation", "log_out", "mail", + "markdown_copy", + "picture_as_pdf", "warning", ] as const; @@ -33,10 +39,13 @@ const iconMap: Record = { arrow_back: ArrowBackIcon, chevron_right: ChevronRightIcon, content_copy: ContentCopyIcon, + csv: CsvIcon, edit: EditIcon, exclamation: ExclamationIcon, log_out: LogOutIcon, mail: MailIcon, + markdown_copy: MarkdownCopyIcon, + picture_as_pdf: PictureAsPdfIcon, warning: WarningIcon, }; diff --git a/app/components/asset/icon/csv.svg b/app/components/asset/icon/csv.svg new file mode 100644 index 0000000..c838ed0 --- /dev/null +++ b/app/components/asset/icon/csv.svg @@ -0,0 +1,13 @@ + + + diff --git a/app/components/asset/icon/markdown_copy.svg b/app/components/asset/icon/markdown_copy.svg new file mode 100644 index 0000000..902a96a --- /dev/null +++ b/app/components/asset/icon/markdown_copy.svg @@ -0,0 +1,13 @@ + + + diff --git a/app/components/asset/icon/picture_as_pdf.svg b/app/components/asset/icon/picture_as_pdf.svg new file mode 100644 index 0000000..3e9c3e0 --- /dev/null +++ b/app/components/asset/icon/picture_as_pdf.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/app/components/buttons/Button/Button.tsx b/app/components/buttons/Button/Button.tsx index 20193a5..accf38a 100644 --- a/app/components/buttons/Button/Button.tsx +++ b/app/components/buttons/Button/Button.tsx @@ -115,21 +115,21 @@ const Button = memo( const variantStyles: Record = { filled: - "bg-[var(--color-surface-inverse-primary)] text-[var(--color-content-inverse-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-inverse-primary)] hover:text-[var(--color-content-invert-brand-primary)] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus:bg-[var(--color-surface-inverse-primary)] focus:text-[var(--color-content-invert-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-invert-brand-primary)] active:text-[var(--color-content-invert-primary)] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", + "bg-[var(--color-surface-inverse-primary)] text-[var(--color-content-inverse-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-inverse-primary)] hover:text-[var(--color-content-invert-brand-primary)] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus-visible:bg-[var(--color-surface-inverse-primary)] focus-visible:text-[var(--color-content-invert-brand-primary)] focus-visible:outline-none focus-visible:border-transparent focus-visible:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-invert-brand-primary)] active:text-[var(--color-content-invert-primary)] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", "filled-inverse": - "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-default-primary)] hover:text-[var(--color-content-default-brand-primary)] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus:bg-[var(--color-surface-default-primary)] focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", + "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-[1.5px] border-transparent hover:bg-[var(--color-surface-default-primary)] hover:text-[var(--color-content-default-brand-primary)] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus-visible:bg-[var(--color-surface-default-primary)] focus-visible:text-[var(--color-content-default-brand-primary)] focus-visible:outline-none focus-visible:border-transparent focus-visible:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-default-brand-primary)] active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", outline: - "bg-transparent text-[var(--color-content-default-primary)] border-[1.5px] border-[var(--color-border-invert-primary)] hover:bg-transparent hover:text-[var(--color-content-default-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-border-invert-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", + "bg-transparent text-[var(--color-content-default-primary)] border-[1.5px] border-[var(--color-border-invert-primary)] hover:bg-transparent hover:text-[var(--color-content-default-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-default-brand-primary)] hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-default-primary)] focus-visible:outline-none focus-visible:border-[1.5px] focus-visible:border-[var(--color-border-invert-primary)] focus-visible:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus-visible:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", "outline-inverse": - "bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-[var(--color-border-default-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-invert-primary)] focus:outline-none focus:border-[1.5px] focus:border-[var(--color-border-default-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-invert-primary)] active:border-[1.5px] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", + "bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-[var(--color-border-default-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-brand-primary)] hover:border-[1.5px] hover:border-[var(--color-border-invert-brand-primary)] hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-invert-primary)] focus-visible:outline-none focus-visible:border-[1.5px] focus-visible:border-[var(--color-border-default-primary)] focus-visible:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus-visible:scale-[1.02] active:bg-transparent active:text-[var(--color-content-invert-primary)] active:border-[1.5px] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-[1.5px] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", ghost: - "bg-transparent text-[var(--color-content-default-brand-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-default-primary)] hover:border-transparent hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-default-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", + "bg-transparent text-[var(--color-content-default-brand-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-default-primary)] hover:border-transparent hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-default-brand-primary)] focus-visible:outline-none focus-visible:border-transparent focus-visible:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus-visible:scale-[1.02] active:bg-transparent active:text-[var(--color-content-default-primary)] active:border-[var(--color-border-default-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-invert-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", "ghost-inverse": - "bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-invert-primary)] hover:border-transparent hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-invert-brand-primary)] focus:outline-none focus:border-transparent focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-invert-brand-primary)] active:text-[var(--color-content-invert-primary)] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", + "bg-transparent text-[var(--color-content-invert-primary)] border-[1.5px] border-transparent hover:bg-transparent hover:text-[var(--color-content-invert-primary)] hover:border-transparent hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-invert-brand-primary)] focus-visible:outline-none focus-visible:border-transparent focus-visible:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-invert-brand-primary)] active:text-[var(--color-content-invert-primary)] active:border-[var(--color-border-invert-brand-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-invert-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", danger: - "bg-transparent text-[var(--color-border-default-negative-primary)] border border-[var(--color-border-default-negative-primary)] hover:bg-[var(--color-surface-invert-negative-secondary)] hover:text-[var(--color-border-default-negative-primary)] hover:border-[var(--color-border-default-negative-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-border-default-negative-primary)] focus:outline-none focus:border-[var(--color-border-default-negative-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-invert-negative-primary)] active:text-[var(--color-content-invert-negative-primary)] active:border-[1.5px] active:border-[var(--color-border-default-negative-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", + "bg-transparent text-[var(--color-border-default-negative-primary)] border border-[var(--color-border-default-negative-primary)] hover:bg-[var(--color-surface-invert-negative-secondary)] hover:text-[var(--color-border-default-negative-primary)] hover:border-[var(--color-border-default-negative-primary)] hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-border-default-negative-primary)] focus-visible:outline-none focus-visible:border-[var(--color-border-default-negative-primary)] focus-visible:shadow-[0_0_0px_2px_var(--color-border-default-primary),0_0_0px_4px_var(--color-border-invert-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-invert-negative-primary)] active:text-[var(--color-content-invert-negative-primary)] active:border-[1.5px] active:border-[var(--color-border-default-negative-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-default-secondary)] disabled:text-[var(--color-content-inverse-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", "danger-inverse": - "bg-transparent text-[var(--color-content-invert-negative-primary)] border border-[var(--color-border-invert-negative-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-negative-primary)] hover:border-[var(--color-border-invert-negative-primary)] hover:scale-[1.02] focus:bg-transparent focus:text-[var(--color-content-invert-negative-primary)] focus:outline-none focus:border-[var(--color-border-invert-negative-primary)] focus:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus:scale-[1.02] active:bg-[var(--color-surface-default-negative-primary)] active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-negative-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", + "bg-transparent text-[var(--color-content-invert-negative-primary)] border border-[var(--color-border-invert-negative-primary)] hover:bg-transparent hover:text-[var(--color-content-invert-negative-primary)] hover:border-[var(--color-border-invert-negative-primary)] hover:scale-[1.02] focus-visible:bg-transparent focus-visible:text-[var(--color-content-invert-negative-primary)] focus-visible:outline-none focus-visible:border-[var(--color-border-invert-negative-primary)] focus-visible:shadow-[0_0_0px_2px_var(--color-border-invert-primary),0_0_0px_4px_var(--color-border-default-primary)] focus-visible:scale-[1.02] active:bg-[var(--color-surface-default-negative-primary)] active:text-[var(--color-content-default-primary)] active:border-[1.5px] active:border-[var(--color-border-default-negative-primary)] active:shadow-none active:scale-[0.98] disabled:bg-[var(--color-surface-inverse-secondary)] disabled:text-[var(--color-content-default-tertiary)] disabled:border-transparent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100", }; const hoverOutlineStyles: Record = { diff --git a/app/components/layout/ListItem/ListItem.container.tsx b/app/components/layout/ListItem/ListItem.container.tsx new file mode 100644 index 0000000..081b66d --- /dev/null +++ b/app/components/layout/ListItem/ListItem.container.tsx @@ -0,0 +1,17 @@ +"use client"; + +/** + * Figma: Community Rule System — "Add Custom Field/Popover" (List-item/lockup) + * https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20887-175710 + */ +import { memo } from "react"; +import { ListItemView } from "./ListItem.view"; +import type { ListItemProps } from "./ListItem.types"; + +const ListItem = memo((props) => { + return ; +}); + +ListItem.displayName = "ListItem"; + +export default ListItem; diff --git a/app/components/layout/ListItem/ListItem.types.ts b/app/components/layout/ListItem/ListItem.types.ts new file mode 100644 index 0000000..eaef610 --- /dev/null +++ b/app/components/layout/ListItem/ListItem.types.ts @@ -0,0 +1,10 @@ +import type { IconName } from "../../asset/icon"; + +export type ListItemProps = { + label: string; + leadingIcon: IconName; + onClick: () => void; + /** Bottom divider between rows — false on the final row per Figma. */ + showDivider: boolean; + className?: string; +}; diff --git a/app/components/layout/ListItem/ListItem.view.tsx b/app/components/layout/ListItem/ListItem.view.tsx new file mode 100644 index 0000000..dd7d3a0 --- /dev/null +++ b/app/components/layout/ListItem/ListItem.view.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { memo } from "react"; +import Icon from "../../asset/icon"; +import type { ListItemProps } from "./ListItem.types"; + +export const ListItemView = memo(function ListItemView({ + label, + leadingIcon, + onClick, + showDivider, + className = "", +}: ListItemProps) { + const dividerClass = showDivider + ? "border-b border-solid border-[var(--color-border-default-tertiary)]" + : ""; + + return ( + + ); +}); + +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("