refactor(create): DRY rule export media + TextBlock row view
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
import { memo } from "react";
|
||||
import type { TextBlockProps } from "./TextBlock.types";
|
||||
|
||||
type TextRow = NonNullable<TextBlockProps["rows"]>[number];
|
||||
|
||||
/**
|
||||
* Figma: Utility / **Community Rule / Text Block** (22001:29793).
|
||||
* Title + body paragraphs and/or labeled rows (12px between stacks, 8px label→body).
|
||||
@@ -38,6 +40,45 @@ function ParagraphGroup({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LabeledRowView({ row }: { row: TextRow }) {
|
||||
const imageSrc = row.imageUrl?.trim();
|
||||
const fileHref = row.fileUrl?.trim();
|
||||
const caption = row.body.trim();
|
||||
const hasCaption = caption.length > 0;
|
||||
const alt = hasCaption ? caption : row.label;
|
||||
const linkText = hasCaption ? caption : fileHref;
|
||||
|
||||
return (
|
||||
<div 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={alt}
|
||||
className="max-h-[240px] max-w-full rounded-[var(--measures-radius-200,8px)] object-contain"
|
||||
/>
|
||||
{hasCaption ? <ParagraphGroup text={row.body} /> : null}
|
||||
</>
|
||||
) : fileHref ? (
|
||||
<p className={`${PARAGRAPH_CLASS} whitespace-pre-wrap`}>
|
||||
<a
|
||||
href={fileHref}
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{linkText}
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<ParagraphGroup text={row.body} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextBlockView({
|
||||
title,
|
||||
body = "",
|
||||
@@ -54,42 +95,7 @@ function TextBlockView({
|
||||
<p className={`${ENTRY_TITLE_CLASS} w-full min-w-0`}>{title}</p>
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{hasRows
|
||||
? rows!.map((row, i) => {
|
||||
const imageSrc = row.imageUrl?.trim();
|
||||
const fileHref = row.fileUrl?.trim();
|
||||
const caption = row.body.trim();
|
||||
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>
|
||||
);
|
||||
})
|
||||
? rows!.map((row, i) => <LabeledRowView key={i} row={row} />)
|
||||
: body.trim().length > 0 && <ParagraphGroup text={body} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+30
-22
@@ -2,6 +2,7 @@ import { jsPDF } from "jspdf";
|
||||
|
||||
import type {
|
||||
CommunityRuleEntry,
|
||||
CommunityRuleLabeledBlock,
|
||||
CommunityRuleSection,
|
||||
} from "../../app/components/type/CommunityRule/CommunityRule.types";
|
||||
import type { StoredLastPublishedRule } from "./lastPublishedRule";
|
||||
@@ -23,20 +24,30 @@ export function exportFilenameBase(rule: StoredLastPublishedRule): string {
|
||||
return fromTitle.length > 0 ? fromTitle : `rule-${rule.id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function labeledBlockMedia(b: CommunityRuleLabeledBlock): {
|
||||
img?: string;
|
||||
file?: string;
|
||||
bodyTrim: string;
|
||||
} {
|
||||
const imgRaw = b.imageUrl?.trim();
|
||||
const fileRaw = b.fileUrl?.trim();
|
||||
return {
|
||||
img: imgRaw && imgRaw.length > 0 ? imgRaw : undefined,
|
||||
file: fileRaw && fileRaw.length > 0 ? fileRaw : undefined,
|
||||
bodyTrim: b.body.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
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}`, "");
|
||||
const img = b.imageUrl?.trim();
|
||||
const file = b.fileUrl?.trim();
|
||||
const { img, file, bodyTrim } = labeledBlockMedia(b);
|
||||
if (img) {
|
||||
lines.push(
|
||||
``,
|
||||
"",
|
||||
);
|
||||
lines.push(``, "");
|
||||
} else if (file) {
|
||||
lines.push(`[${b.body.trim() || "file"}](${file})`, "");
|
||||
lines.push(`[${bodyTrim || "file"}](${file})`, "");
|
||||
} else {
|
||||
lines.push(b.body, "");
|
||||
}
|
||||
@@ -98,16 +109,14 @@ export function sectionsToCsv(
|
||||
for (const ent of sec.entries) {
|
||||
if (ent.blocks && ent.blocks.length > 0) {
|
||||
for (const b of ent.blocks) {
|
||||
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;
|
||||
const { img, file, bodyTrim } = labeledBlockMedia(b);
|
||||
const content = img
|
||||
? bodyTrim.length > 0
|
||||
? `${b.body}\n${img}`
|
||||
: img
|
||||
: file
|
||||
? `${b.body}\n${file}`
|
||||
: b.body;
|
||||
rows.push([sec.categoryName, ent.title, b.label, content]);
|
||||
}
|
||||
} else {
|
||||
@@ -158,15 +167,14 @@ function entryToPrintHtml(entry: CommunityRuleEntry): string {
|
||||
if (entry.blocks && entry.blocks.length > 0) {
|
||||
for (const b of entry.blocks) {
|
||||
inner += `<h4 class="block-label">${escapeHtml(b.label)}</h4>`;
|
||||
const img = b.imageUrl?.trim();
|
||||
const file = b.fileUrl?.trim();
|
||||
const { img, file, bodyTrim } = labeledBlockMedia(b);
|
||||
if (img) {
|
||||
inner += `<p><img src="${escapeHtml(img)}" alt="${escapeHtml(b.body.trim() || b.label)}" /></p>`;
|
||||
if (b.body.trim().length > 0) {
|
||||
inner += `<p><img src="${escapeHtml(img)}" alt="${escapeHtml(bodyTrim || b.label)}" /></p>`;
|
||||
if (bodyTrim.length > 0) {
|
||||
inner += paragraphsHtml(b.body);
|
||||
}
|
||||
} else if (file) {
|
||||
inner += `<p><a href="${escapeHtml(file)}">${escapeHtml(b.body.trim() || file)}</a></p>`;
|
||||
inner += `<p><a href="${escapeHtml(file)}">${escapeHtml(bodyTrim || file)}</a></p>`;
|
||||
} else {
|
||||
inner += paragraphsHtml(b.body);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export function wizardUploadDisplaysAsImage(
|
||||
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
|
||||
|
||||
@@ -95,6 +95,45 @@ describe("ruleExport", () => {
|
||||
expect(csv).toContain(",Title,,My Rule");
|
||||
});
|
||||
|
||||
it("sectionsToMarkdown, sectionsToCsv, and printable HTML include imageUrl and fileUrl blocks", () => {
|
||||
const sections: CommunityRuleSection[] = [
|
||||
{
|
||||
categoryName: "Values",
|
||||
entries: [
|
||||
{
|
||||
title: "Entry",
|
||||
body: "",
|
||||
blocks: [
|
||||
{
|
||||
label: "Photo",
|
||||
body: "Caption",
|
||||
imageUrl: "https://cdn.example.com/pic.jpg",
|
||||
},
|
||||
{
|
||||
label: "Handbook",
|
||||
body: "Download",
|
||||
fileUrl: "https://cdn.example.com/guidance.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const md = sectionsToMarkdown("Rule", null, sections);
|
||||
expect(md).toContain("");
|
||||
expect(md).toContain("[Download](https://cdn.example.com/guidance.pdf)");
|
||||
|
||||
const csv = sectionsToCsv("Rule", null, sections);
|
||||
expect(csv).toContain("Caption\nhttps://cdn.example.com/pic.jpg");
|
||||
expect(csv).toContain("Download\nhttps://cdn.example.com/guidance.pdf");
|
||||
|
||||
const html = buildPrintableRuleHtmlDocument("Rule", null, sections);
|
||||
expect(html).toContain('src="https://cdn.example.com/pic.jpg"');
|
||||
expect(html).toContain("Caption");
|
||||
expect(html).toContain('href="https://cdn.example.com/guidance.pdf"');
|
||||
expect(html).toContain("Download");
|
||||
});
|
||||
|
||||
it("buildPrintableRuleHtmlDocument escapes HTML in user content", () => {
|
||||
const sections: CommunityRuleSection[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user