feat(create): wizard uploads render as images in display and exports (imageUrl/fileUrl)
This commit is contained in:
@@ -1,7 +1,12 @@
|
|||||||
/** Labeled paragraph group (Figma “Text” stacks under Membership / Decision-making, etc.). */
|
/** Labeled paragraph group (Figma “Text” stacks under Membership / Decision-making, etc.). */
|
||||||
export interface CommunityRuleLabeledBlock {
|
export interface CommunityRuleLabeledBlock {
|
||||||
label: string;
|
label: string;
|
||||||
|
/** With {@link imageUrl}, optional caption paragraphs only (not the uploaded file name). */
|
||||||
body: string;
|
body: string;
|
||||||
|
/** Image URL (e.g. custom method upload). Rendered as `<img>` when set. */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Non-image attachment URL. Rendered as a link when set and {@link imageUrl} is absent. */
|
||||||
|
fileUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommunityRuleEntry {
|
export interface CommunityRuleEntry {
|
||||||
|
|||||||
@@ -54,12 +54,42 @@ function TextBlockView({
|
|||||||
<p className={`${ENTRY_TITLE_CLASS} w-full min-w-0`}>{title}</p>
|
<p className={`${ENTRY_TITLE_CLASS} w-full min-w-0`}>{title}</p>
|
||||||
<div className="flex min-w-0 flex-col gap-3">
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
{hasRows
|
{hasRows
|
||||||
? rows!.map((row, i) => (
|
? rows!.map((row, i) => {
|
||||||
<div key={i} className="flex min-w-0 flex-col gap-2">
|
const imageSrc = row.imageUrl?.trim();
|
||||||
<p className={ROW_LABEL_CLASS}>{row.label}</p>
|
const fileHref = row.fileUrl?.trim();
|
||||||
<ParagraphGroup text={row.body} />
|
const caption = row.body.trim();
|
||||||
</div>
|
return (
|
||||||
))
|
<div key={i} className="flex min-w-0 flex-col gap-2">
|
||||||
|
<p className={ROW_LABEL_CLASS}>{row.label}</p>
|
||||||
|
{imageSrc ? (
|
||||||
|
<>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin or absolute upload URL */}
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={caption.length > 0 ? caption : row.label}
|
||||||
|
className="max-h-[240px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||||
|
/>
|
||||||
|
{caption.length > 0 ? (
|
||||||
|
<ParagraphGroup text={row.body} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : fileHref ? (
|
||||||
|
<p className={`${PARAGRAPH_CLASS} whitespace-pre-wrap`}>
|
||||||
|
<a
|
||||||
|
href={fileHref}
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{caption.length > 0 ? caption : fileHref}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ParagraphGroup text={row.body} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
: body.trim().length > 0 && <ParagraphGroup text={body} />}
|
: body.trim().length > 0 && <ParagraphGroup text={body} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import type {
|
|||||||
function isLabeledBlock(x: unknown): x is CommunityRuleLabeledBlock {
|
function isLabeledBlock(x: unknown): x is CommunityRuleLabeledBlock {
|
||||||
if (!x || typeof x !== "object") return false;
|
if (!x || typeof x !== "object") return false;
|
||||||
const o = x as Record<string, unknown>;
|
const o = x as Record<string, unknown>;
|
||||||
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. */
|
/** Shared by publish payload parsing and template body parsing — keep in sync. */
|
||||||
|
|||||||
@@ -27,7 +27,19 @@ function entryToMarkdown(entry: CommunityRuleEntry): string {
|
|||||||
const lines: string[] = [`### ${entry.title}`, ""];
|
const lines: string[] = [`### ${entry.title}`, ""];
|
||||||
if (entry.blocks && entry.blocks.length > 0) {
|
if (entry.blocks && entry.blocks.length > 0) {
|
||||||
for (const b of entry.blocks) {
|
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(
|
||||||
|
``,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
} else if (file) {
|
||||||
|
lines.push(`[${b.body.trim() || "file"}](${file})`, "");
|
||||||
|
} else {
|
||||||
|
lines.push(b.body, "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const body = (entry.body ?? "").trim();
|
const body = (entry.body ?? "").trim();
|
||||||
@@ -86,7 +98,17 @@ export function sectionsToCsv(
|
|||||||
for (const ent of sec.entries) {
|
for (const ent of sec.entries) {
|
||||||
if (ent.blocks && ent.blocks.length > 0) {
|
if (ent.blocks && ent.blocks.length > 0) {
|
||||||
for (const b of ent.blocks) {
|
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 {
|
} else {
|
||||||
rows.push([sec.categoryName, ent.title, "", ent.body ?? ""]);
|
rows.push([sec.categoryName, ent.title, "", ent.body ?? ""]);
|
||||||
@@ -136,7 +158,18 @@ function entryToPrintHtml(entry: CommunityRuleEntry): string {
|
|||||||
if (entry.blocks && entry.blocks.length > 0) {
|
if (entry.blocks && entry.blocks.length > 0) {
|
||||||
for (const b of entry.blocks) {
|
for (const b of entry.blocks) {
|
||||||
inner += `<h4 class="block-label">${escapeHtml(b.label)}</h4>`;
|
inner += `<h4 class="block-label">${escapeHtml(b.label)}</h4>`;
|
||||||
inner += paragraphsHtml(b.body);
|
const img = b.imageUrl?.trim();
|
||||||
|
const file = b.fileUrl?.trim();
|
||||||
|
if (img) {
|
||||||
|
inner += `<p><img src="${escapeHtml(img)}" alt="${escapeHtml(b.body.trim() || b.label)}" /></p>`;
|
||||||
|
if (b.body.trim().length > 0) {
|
||||||
|
inner += paragraphsHtml(b.body);
|
||||||
|
}
|
||||||
|
} else if (file) {
|
||||||
|
inner += `<p><a href="${escapeHtml(file)}">${escapeHtml(b.body.trim() || file)}</a></p>`;
|
||||||
|
} else {
|
||||||
|
inner += paragraphsHtml(b.body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
inner += paragraphsHtml(entry.body ?? "");
|
inner += paragraphsHtml(entry.body ?? "");
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ import type { PublishedMethodSelections } from "./buildPublishPayload";
|
|||||||
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
|
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
|
||||||
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
import { templateCategoryToGroupKey } from "./templateReviewMapping";
|
||||||
|
|
||||||
|
/** Uses filename extension and/or URL path so uploads render as `<img>` 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
|
* Serialize wizard-authored field blocks into Community Rule labeled rows for
|
||||||
* read-only surfaces (completed step, exported views). Matches how those blocks
|
* read-only surfaces (completed step, exported views). Matches how those blocks
|
||||||
@@ -32,8 +41,23 @@ export function labeledBlocksFromCustomMethodCardFieldBlocks(
|
|||||||
case "upload": {
|
case "upload": {
|
||||||
const name = nonEmptyTrimmed(b.fileName);
|
const name = nonEmptyTrimmed(b.fileName);
|
||||||
const url = nonEmptyTrimmed(b.assetUrl);
|
const url = nonEmptyTrimmed(b.assetUrl);
|
||||||
const body = name ?? url;
|
if (url) {
|
||||||
if (body) out.push({ label: b.blockTitle, body });
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "proportion":
|
case "proportion":
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
componentTestSuite,
|
componentTestSuite,
|
||||||
type ComponentTestSuiteConfig,
|
type ComponentTestSuiteConfig,
|
||||||
} from "../utils/componentTestSuite";
|
} from "../utils/componentTestSuite";
|
||||||
import TextBlock from "../../app/components/type/TextBlock";
|
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<typeof TextBlock>;
|
type Props = React.ComponentProps<typeof TextBlock>;
|
||||||
|
|
||||||
@@ -23,4 +25,24 @@ const config: ComponentTestSuiteConfig<Props> = {
|
|||||||
|
|
||||||
describe("TextBlock", () => {
|
describe("TextBlock", () => {
|
||||||
componentTestSuite<Props>(config);
|
componentTestSuite<Props>(config);
|
||||||
|
|
||||||
|
it("renders labeled row imageUrl as img", () => {
|
||||||
|
render(
|
||||||
|
<TextBlock
|
||||||
|
title="Entry"
|
||||||
|
rows={[
|
||||||
|
{
|
||||||
|
label: "Photo",
|
||||||
|
body: "",
|
||||||
|
imageUrl: "/api/uploads/aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const img = screen.getByRole("img", { name: "Photo" });
|
||||||
|
expect(img).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
"/api/uploads/aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -208,4 +208,44 @@ describe("parsePublishedDocumentForCommunityRuleDisplay", () => {
|
|||||||
{ label: "Expectations", body: "Answer stored only on field blocks." },
|
{ 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",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user