diff --git a/app/components/type/CommunityRule/CommunityRule.types.ts b/app/components/type/CommunityRule/CommunityRule.types.ts index 0dc9e21..a1724c3 100644 --- a/app/components/type/CommunityRule/CommunityRule.types.ts +++ b/app/components/type/CommunityRule/CommunityRule.types.ts @@ -1,7 +1,12 @@ /** Labeled paragraph group (Figma “Text” stacks under Membership / Decision-making, etc.). */ export interface CommunityRuleLabeledBlock { label: string; + /** With {@link imageUrl}, optional caption paragraphs only (not the uploaded file name). */ body: string; + /** Image URL (e.g. custom method upload). Rendered as `` when set. */ + imageUrl?: string; + /** Non-image attachment URL. Rendered as a link when set and {@link imageUrl} is absent. */ + fileUrl?: string; } export interface CommunityRuleEntry { diff --git a/app/components/type/TextBlock/TextBlock.view.tsx b/app/components/type/TextBlock/TextBlock.view.tsx index dcc2a65..fb62b5e 100644 --- a/app/components/type/TextBlock/TextBlock.view.tsx +++ b/app/components/type/TextBlock/TextBlock.view.tsx @@ -54,12 +54,42 @@ function TextBlockView({

{title}

{hasRows - ? rows!.map((row, i) => ( -
-

{row.label}

- -
- )) + ? rows!.map((row, i) => { + const imageSrc = row.imageUrl?.trim(); + const fileHref = row.fileUrl?.trim(); + const caption = row.body.trim(); + return ( +
+

{row.label}

+ {imageSrc ? ( + <> + {/* eslint-disable-next-line @next/next/no-img-element -- same-origin or absolute upload URL */} + {caption.length 0 ? caption : row.label} + className="max-h-[240px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain" + /> + {caption.length > 0 ? ( + + ) : null} + + ) : fileHref ? ( +

+ + {caption.length > 0 ? caption : fileHref} + +

+ ) : ( + + )} +
+ ); + }) : body.trim().length > 0 && }
diff --git a/lib/create/documentEntryGuards.ts b/lib/create/documentEntryGuards.ts index b2bef26..38cb3a9 100644 --- a/lib/create/documentEntryGuards.ts +++ b/lib/create/documentEntryGuards.ts @@ -6,7 +6,10 @@ import type { function isLabeledBlock(x: unknown): x is CommunityRuleLabeledBlock { if (!x || typeof x !== "object") return false; const o = x as Record; - return typeof o.label === "string" && typeof o.body === "string"; + if (typeof o.label !== "string" || typeof o.body !== "string") return false; + if (o.imageUrl !== undefined && typeof o.imageUrl !== "string") return false; + if (o.fileUrl !== undefined && typeof o.fileUrl !== "string") return false; + return true; } /** Shared by publish payload parsing and template body parsing — keep in sync. */ diff --git a/lib/create/ruleExport.ts b/lib/create/ruleExport.ts index 7319c13..92d90dd 100644 --- a/lib/create/ruleExport.ts +++ b/lib/create/ruleExport.ts @@ -27,7 +27,19 @@ function entryToMarkdown(entry: CommunityRuleEntry): string { const lines: string[] = [`### ${entry.title}`, ""]; if (entry.blocks && entry.blocks.length > 0) { for (const b of entry.blocks) { - lines.push(`#### ${b.label}`, "", b.body, ""); + lines.push(`#### ${b.label}`, ""); + const img = b.imageUrl?.trim(); + const file = b.fileUrl?.trim(); + if (img) { + lines.push( + `![${b.body.trim() || b.label}](${img})`, + "", + ); + } else if (file) { + lines.push(`[${b.body.trim() || "file"}](${file})`, ""); + } else { + lines.push(b.body, ""); + } } } else { const body = (entry.body ?? "").trim(); @@ -86,7 +98,17 @@ export function sectionsToCsv( for (const ent of sec.entries) { if (ent.blocks && ent.blocks.length > 0) { for (const b of ent.blocks) { - rows.push([sec.categoryName, ent.title, b.label, b.body]); + const img = b.imageUrl?.trim(); + const file = b.fileUrl?.trim(); + const content = + img != null && img.length > 0 + ? b.body.trim().length > 0 + ? `${b.body}\n${img}` + : img + : file != null && file.length > 0 + ? `${b.body}\n${file}` + : b.body; + rows.push([sec.categoryName, ent.title, b.label, content]); } } else { rows.push([sec.categoryName, ent.title, "", ent.body ?? ""]); @@ -136,7 +158,18 @@ function entryToPrintHtml(entry: CommunityRuleEntry): string { if (entry.blocks && entry.blocks.length > 0) { for (const b of entry.blocks) { inner += `

${escapeHtml(b.label)}

`; - inner += paragraphsHtml(b.body); + const img = b.imageUrl?.trim(); + const file = b.fileUrl?.trim(); + if (img) { + inner += `

${escapeHtml(b.body.trim() || b.label)}

`; + if (b.body.trim().length > 0) { + inner += paragraphsHtml(b.body); + } + } else if (file) { + inner += `

${escapeHtml(b.body.trim() || file)}

`; + } else { + inner += paragraphsHtml(b.body); + } } } else { inner += paragraphsHtml(entry.body ?? ""); diff --git a/lib/create/ruleSectionsFromMethodSelections.ts b/lib/create/ruleSectionsFromMethodSelections.ts index ed05f4b..44600a2 100644 --- a/lib/create/ruleSectionsFromMethodSelections.ts +++ b/lib/create/ruleSectionsFromMethodSelections.ts @@ -7,6 +7,15 @@ import type { PublishedMethodSelections } from "./buildPublishPayload"; import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks"; import { templateCategoryToGroupKey } from "./templateReviewMapping"; +/** Uses filename extension and/or URL path so uploads render as `` vs file link on read-only surfaces. */ +export function wizardUploadDisplaysAsImage( + fileName: string | null, + assetUrl: string | null, +): boolean { + if (fileName && /\.(jpe?g|png|gif|webp)$/i.test(fileName)) return true; + if (assetUrl && /\.(jpe?g|png|gif|webp)(\?|#|$)/i.test(assetUrl)) return true; + return false; +} /** * Serialize wizard-authored field blocks into Community Rule labeled rows for * read-only surfaces (completed step, exported views). Matches how those blocks @@ -32,8 +41,23 @@ export function labeledBlocksFromCustomMethodCardFieldBlocks( case "upload": { const name = nonEmptyTrimmed(b.fileName); const url = nonEmptyTrimmed(b.assetUrl); - const body = name ?? url; - if (body) out.push({ label: b.blockTitle, body }); + if (url) { + if (wizardUploadDisplaysAsImage(name, url)) { + out.push({ + label: b.blockTitle, + body: "", + imageUrl: url, + }); + } else { + out.push({ + label: b.blockTitle, + body: name ?? url, + fileUrl: url, + }); + } + } else if (name) { + out.push({ label: b.blockTitle, body: name }); + } break; } case "proportion": diff --git a/tests/components/TextBlock.test.tsx b/tests/components/TextBlock.test.tsx index d1124a6..fa52913 100644 --- a/tests/components/TextBlock.test.tsx +++ b/tests/components/TextBlock.test.tsx @@ -1,9 +1,11 @@ -import { describe } from "vitest"; +import { describe, it, expect } from "vitest"; import { componentTestSuite, type ComponentTestSuiteConfig, } from "../utils/componentTestSuite"; import TextBlock from "../../app/components/type/TextBlock"; +import { screen } from "@testing-library/react"; +import { renderWithProviders as render } from "../utils/test-utils"; type Props = React.ComponentProps; @@ -23,4 +25,24 @@ const config: ComponentTestSuiteConfig = { describe("TextBlock", () => { componentTestSuite(config); + + it("renders labeled row imageUrl as img", () => { + render( + , + ); + const img = screen.getByRole("img", { name: "Photo" }); + expect(img).toHaveAttribute( + "src", + "/api/uploads/aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee", + ); + }); }); diff --git a/tests/unit/publishedDocumentToDisplaySections.test.ts b/tests/unit/publishedDocumentToDisplaySections.test.ts index e258581..dd9dc28 100644 --- a/tests/unit/publishedDocumentToDisplaySections.test.ts +++ b/tests/unit/publishedDocumentToDisplaySections.test.ts @@ -208,4 +208,44 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => { { label: "Expectations", body: "Answer stored only on field blocks." }, ]); }); + + it("exposes custom upload image blocks with imageUrl for CommunityRule display", () => { + const customId = "b7c0a9f3-0000-4000-8000-000000000002"; + const doc = { + sections: [], + methodSelections: { + communication: [ + { + id: customId, + label: "Policy with photo", + sections: { + corePrinciple: "", + logisticsAdmin: "", + codeOfConduct: "", + }, + }, + ], + }, + customMethodCardFieldBlocksById: { + [customId]: [ + { + kind: "upload" as const, + id: "u1", + blockTitle: "Site photo", + fileName: "garden.jpg", + assetUrl: "/api/uploads/11111111-1111-4111-8111-111111111111", + }, + ], + }, + }; + const out = parsePublishedDocumentForCommunityRuleDisplay(doc); + const comm = out.find((s) => s.categoryName === "Communication"); + expect(comm?.entries[0]?.blocks).toEqual([ + { + label: "Site photo", + body: "", + imageUrl: "/api/uploads/11111111-1111-4111-8111-111111111111", + }, + ]); + }); });