import { renderContentfulRichText, renderContentfulRichTextTable, useContentfulRichText, } from "./use-contentful-rich-text"; import { BLOCKS, INLINES, MARKS, type Document, } from "@contentful/rich-text-types"; import { render, renderHook, screen } from "@testing-library/react"; // Mock dependencies jest.mock("@shared/components/text", () => ({ Text: ({ as: Tag = "span", children, className }: any) => ( {children} ), })); jest.mock("@shared/components/link", () => ({ Link: ({ href, children, target, rel, className }: any) => ( {children} ), })); jest.mock("@shared/components/material-icon", () => ({ // eslint-disable-next-line @typescript-eslint/no-unused-vars MaterialIcon: ({ name, color, fill }: any) => ( {name} ), })); jest.mock("@shared/components/checklist", () => ({ Checklist: ({ items }: any) => ( ), })); // Helper to build a Contentful Document function makeDoc(...content: any[]): Document { return { nodeType: BLOCKS.DOCUMENT, data: {}, content, }; } function makeParagraph(text: string): any { return { nodeType: BLOCKS.PARAGRAPH, data: {}, content: [{ nodeType: "text", value: text, marks: [], data: {} }], }; } function makeHeading(level: number, text: string): any { const nodeType = `heading-${level}` as any; return { nodeType, data: {}, content: [{ nodeType: "text", value: text, marks: [], data: {} }], }; } function makeMarkedText(text: string, markType: string): any { return { nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: "text", value: text, marks: [{ type: markType }], data: {}, }, ], }; } function makeHyperlink(url: string, text: string): any { return { nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.HYPERLINK, data: { uri: url }, content: [{ nodeType: "text", value: text, marks: [], data: {} }], }, ], }; } function makeList(ordered: boolean, items: string[]): any { return { nodeType: ordered ? BLOCKS.OL_LIST : BLOCKS.UL_LIST, data: {}, content: items.map(item => ({ nodeType: BLOCKS.LIST_ITEM, data: {}, content: [makeParagraph(item)], })), }; } function makeBlockquote(text: string): any { return { nodeType: BLOCKS.QUOTE, data: {}, content: [makeParagraph(text)], }; } function makeEmbeddedAsset(url: string | undefined, title?: string): any { return { nodeType: BLOCKS.EMBEDDED_ASSET, data: { target: url ? { fields: { file: { url }, title: title ?? "Asset", }, } : { fields: {} }, }, content: [], }; } function makeEmbeddedEntry(contentTypeId: string, fields: any): any { return { nodeType: BLOCKS.EMBEDDED_ENTRY, data: { target: { sys: { contentType: { sys: { id: contentTypeId } } }, fields, }, }, content: [], }; } function makeInlineEntry(contentTypeId: string, fields: any): any { return { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: { contentType: { sys: { id: contentTypeId } } }, fields, }, }, content: [], }; } // ────────────────────────────────────────────── // renderContentfulRichText // ────────────────────────────────────────────── describe("renderContentfulRichText", () => { describe("Null / invalid input", () => { it("returns null for null doc", () => { expect(renderContentfulRichText(null)).toBeNull(); }); it("returns null for undefined doc", () => { expect(renderContentfulRichText(undefined)).toBeNull(); }); it("returns null for doc without content array", () => { const bad = { nodeType: BLOCKS.DOCUMENT, data: {} } as any; expect(renderContentfulRichText(bad)).toBeNull(); }); }); describe("Paragraph rendering", () => { it("renders paragraph with default className 'body1'", () => { const doc = makeDoc(makeParagraph("Hello world")); const { container } = render(<>{renderContentfulRichText(doc)}); const div = container.querySelector("div.body1"); expect(div).toBeInTheDocument(); expect(div).toHaveTextContent("Hello world"); }); it("renders paragraph with custom className", () => { const doc = makeDoc(makeParagraph("Custom class")); const { container } = render( <>{renderContentfulRichText(doc, undefined, "custom-class")} ); expect(container.querySelector("div.custom-class")).toHaveTextContent( "Custom class" ); }); }); describe("Mark rendering", () => { it("renders bold text", () => { const doc = makeDoc(makeMarkedText("Bold", MARKS.BOLD)); const { container } = render(<>{renderContentfulRichText(doc)}); const strong = container.querySelector("strong"); expect(strong).toHaveTextContent("Bold"); }); it("renders italic text", () => { const doc = makeDoc(makeMarkedText("Italic", MARKS.ITALIC)); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container.querySelector("em")).toHaveTextContent("Italic"); }); it("renders underline text", () => { const doc = makeDoc(makeMarkedText("Underline", MARKS.UNDERLINE)); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container.querySelector("u")).toHaveTextContent("Underline"); }); it("renders code text", () => { const doc = makeDoc(makeMarkedText("Code", MARKS.CODE)); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container.querySelector("code")).toHaveTextContent("Code"); }); }); describe("Hyperlink rendering", () => { it("renders external link with target=_blank when target is not specified", () => { const doc = makeDoc(makeHyperlink("https://example.com", "Click")); const { container } = render(<>{renderContentfulRichText(doc)}); const a = container.querySelector("a")!; expect(a).toHaveAttribute("href", "https://example.com"); expect(a).toHaveAttribute("target", "_blank"); expect(a).toHaveAttribute("rel", "noopener noreferrer"); }); it("renders internal link with target=_self for relative URLs", () => { const doc = makeDoc(makeHyperlink("/about", "About")); const { container } = render(<>{renderContentfulRichText(doc)}); const a = container.querySelector("a")!; expect(a).toHaveAttribute("target", "_self"); expect(a).not.toHaveAttribute("rel"); }); it("respects target=true flag to force _blank", () => { const doc = makeDoc(makeHyperlink("/local", "Local")); const { container } = render(<>{renderContentfulRichText(doc, true)}); const a = container.querySelector("a")!; expect(a).toHaveAttribute("target", "_blank"); }); it("respects target=false flag to force _self", () => { const doc = makeDoc(makeHyperlink("https://example.com", "External")); const { container } = render(<>{renderContentfulRichText(doc, false)}); const a = container.querySelector("a")!; expect(a).toHaveAttribute("target", "_self"); }); it("applies custom linkClassName", () => { const doc = makeDoc(makeHyperlink("https://x.com", "Link")); const { container } = render( <>{renderContentfulRichText(doc, undefined, "body1", "custom-link")} ); const a = container.querySelector("a")!; expect(a).toHaveClass("custom-link"); }); }); describe("Custom options merge", () => { it("merges custom renderNode options", () => { const doc = makeDoc(makeParagraph("Custom")); const customOptions = { renderNode: { [BLOCKS.PARAGRAPH]: (_node: any, children: any) => (

{children}

), }, }; render( <> {renderContentfulRichText( doc, undefined, "body1", "body1 font-bold", customOptions )} ); // The merged PARAGRAPH comes from the function's own override, not from customOptions // because the function re-defines BLOCKS.PARAGRAPH after spreading options.renderNode // The function's PARAGRAPH override wins. expect(screen.queryByTestId("custom-p")).not.toBeInTheDocument(); }); }); describe("Inline entry rendering", () => { it("renders componentCheckList inline entry via defaultOptions", () => { const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ makeInlineEntry("componentCheckList", { title: "Check Item" }), ], }); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container).toHaveTextContent("Check Item"); }); it("renders unknown inline entry title via defaultOptions", () => { const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [makeInlineEntry("someOtherType", { title: "Fallback" })], }); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container).toHaveTextContent("Fallback"); }); it("renders unknown inline entry with missing title as empty", () => { const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [makeInlineEntry("someOtherType", {})], }); const { container } = render(<>{renderContentfulRichText(doc)}); const spans = container.querySelectorAll("span"); const emptySpan = Array.from(spans).find(s => s.textContent === ""); expect(emptySpan).toBeDefined(); }); }); describe("Embedded entry rendering via defaultOptions", () => { it("renders callout block entry", () => { const doc = makeDoc( makeEmbeddedEntry("callout", { title: "Note", body: "Important info", }) ); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container.querySelector("aside")).toBeInTheDocument(); expect(container).toHaveTextContent("Note"); }); it("returns null for unknown block entry type", () => { const doc = makeDoc(makeEmbeddedEntry("unknownType", { title: "X" })); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container.querySelector("aside")).not.toBeInTheDocument(); }); }); describe("Embedded asset via defaultOptions", () => { it("renders image with protocol-relative URL", () => { const doc = makeDoc( makeEmbeddedAsset("//images.ctfl.net/photo.jpg", "Photo") ); const { container } = render(<>{renderContentfulRichText(doc)}); const img = container.querySelector("img")!; expect(img).toHaveAttribute("src", "https://images.ctfl.net/photo.jpg"); }); it("uses description as alt when title is missing", () => { const node: any = { nodeType: BLOCKS.EMBEDDED_ASSET, data: { target: { fields: { file: { url: "https://cdn.com/i.png" }, description: "Desc text", }, }, }, content: [], }; const doc = makeDoc(node); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container.querySelector("img")).toHaveAttribute( "alt", "Desc text" ); }); it("uses default alt when no title or description", () => { const node: any = { nodeType: BLOCKS.EMBEDDED_ASSET, data: { target: { fields: { file: { url: "https://cdn.com/i.png" }, }, }, }, content: [], }; const doc = makeDoc(node); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container.querySelector("img")).toHaveAttribute( "alt", "Embedded asset" ); }); it("uses target.url fallback when file.url is missing", () => { const node: any = { nodeType: BLOCKS.EMBEDDED_ASSET, data: { target: { url: "https://direct.com/img.png", fields: {}, }, }, content: [], }; const doc = makeDoc(node); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container.querySelector("img")).toHaveAttribute( "src", "https://direct.com/img.png" ); }); it("returns null when no url available", () => { const doc = makeDoc(makeEmbeddedAsset(undefined)); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container.querySelector("img")).not.toBeInTheDocument(); }); }); describe("HYPERLINK via defaultOptions in renderContentfulRichText", () => { it("renders external link with _blank via default HYPERLINK", () => { // When target param is not set, renderContentfulRichText uses its own HYPERLINK handler // But defaultOptions.HYPERLINK is still in the spread, though overridden // This tests renderContentfulRichText's own HYPERLINK handler const doc = makeDoc(makeHyperlink("https://ext.com", "Ext")); const { container } = render( <>{renderContentfulRichText(doc, undefined)} ); const a = container.querySelector("a")!; expect(a).toHaveAttribute("target", "_blank"); }); it("renders internal link with _self when target is undefined", () => { const doc = makeDoc(makeHyperlink("/page", "Page")); const { container } = render( <>{renderContentfulRichText(doc, undefined)} ); const a = container.querySelector("a")!; expect(a).toHaveAttribute("target", "_self"); }); it("handles hyperlink with missing uri data", () => { const node: any = { nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.HYPERLINK, data: {}, content: [ { nodeType: "text", value: "No URI", marks: [], data: {} }, ], }, ], }; const doc = makeDoc(node); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container).toHaveTextContent("No URI"); }); it("handles hyperlink with null data", () => { const node: any = { nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.HYPERLINK, data: null, content: [ { nodeType: "text", value: "Null data", marks: [], data: {} }, ], }, ], }; const doc = makeDoc(node); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container).toHaveTextContent("Null data"); }); }); describe("Superscript and subscript marks", () => { it("renders superscript text", () => { const doc = makeDoc(makeMarkedText("Sup", MARKS.SUPERSCRIPT)); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container.querySelector("sup")).toHaveTextContent("Sup"); }); it("renders subscript text", () => { const doc = makeDoc(makeMarkedText("Sub", MARKS.SUBSCRIPT)); const { container } = render(<>{renderContentfulRichText(doc)}); expect(container.querySelector("sub")).toHaveTextContent("Sub"); }); }); }); // ────────────────────────────────────────────── // useContentfulRichText // ────────────────────────────────────────────── describe("useContentfulRichText", () => { it("returns null for null doc", () => { const { result } = renderHook(() => useContentfulRichText(null)); expect(result.current).toBeNull(); }); it("returns null for undefined doc", () => { const { result } = renderHook(() => useContentfulRichText(undefined)); expect(result.current).toBeNull(); }); it("returns null for doc without content array", () => { const bad = { nodeType: BLOCKS.DOCUMENT, data: {} } as any; const { result } = renderHook(() => useContentfulRichText(bad)); expect(result.current).toBeNull(); }); it("renders paragraph content", () => { const doc = makeDoc(makeParagraph("Hook test")); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container).toHaveTextContent("Hook test"); }); it("renders headings h1-h6", () => { const doc = makeDoc( makeHeading(1, "H1"), makeHeading(2, "H2"), makeHeading(3, "H3"), makeHeading(4, "H4"), makeHeading(5, "H5"), makeHeading(6, "H6") ); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("h1")).toHaveTextContent("H1"); expect(container.querySelector("h2")).toHaveTextContent("H2"); expect(container.querySelector("h3")).toHaveTextContent("H3"); expect(container.querySelector("h4")).toHaveTextContent("H4"); expect(container.querySelector("h5")).toHaveTextContent("H5"); expect(container.querySelector("h6")).toHaveTextContent("H6"); }); it("renders unordered list", () => { const doc = makeDoc(makeList(false, ["Item A", "Item B"])); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("ul")).toBeInTheDocument(); expect(container.querySelectorAll("li")).toHaveLength(2); }); it("renders ordered list", () => { const doc = makeDoc(makeList(true, ["First", "Second"])); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("ol")).toBeInTheDocument(); }); it("renders blockquote", () => { const doc = makeDoc(makeBlockquote("Quote text")); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("blockquote")).toHaveTextContent( "Quote text" ); }); it("renders hyperlink with external target", () => { const doc = makeDoc(makeHyperlink("https://ext.com", "External")); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); const a = container.querySelector("a")!; expect(a).toHaveAttribute("target", "_blank"); }); it("renders hyperlink with internal target", () => { const doc = makeDoc(makeHyperlink("/page", "Internal")); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); const a = container.querySelector("a")!; expect(a).not.toHaveAttribute("target"); }); it("renders embedded asset with image", () => { const doc = makeDoc( makeEmbeddedAsset("//images.ctfl.net/photo.jpg", "Photo") ); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); const img = container.querySelector("img")!; expect(img).toHaveAttribute("src", "https://images.ctfl.net/photo.jpg"); expect(img).toHaveAttribute("alt", "Photo"); }); it("renders embedded asset with absolute URL", () => { const doc = makeDoc(makeEmbeddedAsset("https://cdn.example.com/img.png")); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); const img = container.querySelector("img")!; expect(img).toHaveAttribute("src", "https://cdn.example.com/img.png"); }); it("returns null for embedded asset without URL", () => { const doc = makeDoc(makeEmbeddedAsset(undefined)); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("img")).not.toBeInTheDocument(); }); it("renders embedded asset using target.url when fields.file.url is missing", () => { const node: any = { nodeType: BLOCKS.EMBEDDED_ASSET, data: { target: { url: "https://direct.com/image.png", fields: { title: "Direct" }, }, }, content: [], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("img")).toHaveAttribute( "src", "https://direct.com/image.png" ); }); it("renders embedded entry with callout type", () => { const doc = makeDoc( makeEmbeddedEntry("callout", { title: "Notice", body: "Important info" }) ); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("aside")).toBeInTheDocument(); expect(container).toHaveTextContent("Notice"); expect(container).toHaveTextContent("Important info"); }); it("returns null for embedded entry with unknown type", () => { const doc = makeDoc(makeEmbeddedEntry("unknown", { title: "X" })); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("aside")).not.toBeInTheDocument(); }); it("renders inline entry with componentCheckList type", () => { const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ makeInlineEntry("componentCheckList", { title: "Checklist Item" }), ], }); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container).toHaveTextContent("Checklist Item"); }); it("renders inline entry with unknown type showing title", () => { const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [makeInlineEntry("other", { title: "Other Entry" })], }); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container).toHaveTextContent("Other Entry"); }); it("renders inline entry with unknown type and missing title as empty", () => { const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [makeInlineEntry("other", {})], }); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); const spans = container.querySelectorAll("span"); const emptySpan = Array.from(spans).find(s => s.textContent === ""); expect(emptySpan).toBeDefined(); }); it("merges custom mark options", () => { const doc = makeDoc(makeMarkedText("Custom bold", MARKS.BOLD)); const customOpts = { renderMark: { [MARKS.BOLD]: (text: any) => {text}, }, }; const { result } = renderHook(() => useContentfulRichText(doc, customOpts)); render(<>{result.current}); expect(screen.getByTestId("custom-bold")).toHaveTextContent("Custom bold"); }); it("supports optional bold class name", () => { const doc = makeDoc(makeMarkedText("Styled bold", MARKS.BOLD)); const { result } = renderHook(() => useContentfulRichText(doc, undefined, { boldClassName: "label3" }) ); const { container } = render(<>{result.current}); const strong = container.querySelector("strong")!; expect(strong).toHaveClass("label3"); expect(strong).toHaveTextContent("Styled bold"); }); it("keeps custom renderMark bold precedence over boldClassName", () => { const doc = makeDoc(makeMarkedText("Priority", MARKS.BOLD)); const customOpts = { renderMark: { [MARKS.BOLD]: (text: any) => ( {text} ), }, }; const { result } = renderHook(() => useContentfulRichText(doc, customOpts, { boldClassName: "label3" }) ); render(<>{result.current}); expect(screen.getByTestId("custom-priority-bold")).toHaveTextContent( "Priority" ); }); it("memoizes result for same doc reference", () => { const doc = makeDoc(makeParagraph("Memoized")); const { result, rerender } = renderHook( ({ d }) => useContentfulRichText(d), { initialProps: { d: doc } } ); const first = result.current; rerender({ d: doc }); expect(result.current).toBe(first); }); it("recomputes when doc changes", () => { const doc1 = makeDoc(makeParagraph("First")); const doc2 = makeDoc(makeParagraph("Second")); const { result, rerender } = renderHook( ({ d }) => useContentfulRichText(d), { initialProps: { d: doc1 as Document | null } } ); const first = result.current; rerender({ d: doc2 }); expect(result.current).not.toBe(first); }); it("transitions from null doc to valid doc", () => { const { result, rerender } = renderHook( ({ d }) => useContentfulRichText(d), { initialProps: { d: null as Document | null } } ); expect(result.current).toBeNull(); rerender({ d: makeDoc(makeParagraph("Now visible")) }); const { container } = render(<>{result.current}); expect(container).toHaveTextContent("Now visible"); }); }); // ────────────────────────────────────────────── // renderContentfulRichTextTable // ────────────────────────────────────────────── describe("renderContentfulRichTextTable", () => { it("returns null for null doc", () => { expect(renderContentfulRichTextTable(null)).toBeNull(); }); it("returns null for undefined doc", () => { expect(renderContentfulRichTextTable(undefined)).toBeNull(); }); it("returns null for doc without content array", () => { const bad = { nodeType: BLOCKS.DOCUMENT, data: {} } as any; expect(renderContentfulRichTextTable(bad)).toBeNull(); }); it("renders table with rows", () => { const doc = makeDoc({ nodeType: BLOCKS.TABLE, data: {}, content: [ { nodeType: BLOCKS.TABLE_ROW, data: {}, content: [ { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("Header")], }, { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("Header 2")], }, ], }, { nodeType: BLOCKS.TABLE_ROW, data: {}, content: [ { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("Cell 1")], }, { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("Cell 2")], }, ], }, ], }); const { container } = render(<>{renderContentfulRichTextTable(doc)}); expect(container.querySelector("table")).toBeInTheDocument(); expect(container.querySelectorAll("th")).toHaveLength(2); expect(container.querySelectorAll("td")).toHaveLength(2); }); it("renders check_circle icon for 'yes' cell value", () => { const doc = makeDoc({ nodeType: BLOCKS.TABLE, data: {}, content: [ { nodeType: BLOCKS.TABLE_ROW, data: {}, content: [ { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("yes")], }, ], }, ], }); const { container } = render(<>{renderContentfulRichTextTable(doc)}); expect( container.querySelector('[data-testid="icon-check_circle"]') ).toBeInTheDocument(); }); it("renders cancel icon for 'no' cell value", () => { const doc = makeDoc({ nodeType: BLOCKS.TABLE, data: {}, content: [ { nodeType: BLOCKS.TABLE_ROW, data: {}, content: [ { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("no")], }, ], }, ], }); const { container } = render(<>{renderContentfulRichTextTable(doc)}); expect( container.querySelector('[data-testid="icon-cancel"]') ).toBeInTheDocument(); }); it("renders text content for non-yes/no cell values", () => { const doc = makeDoc({ nodeType: BLOCKS.TABLE, data: {}, content: [ { nodeType: BLOCKS.TABLE_ROW, data: {}, content: [ { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("Custom text")], }, ], }, ], }); const { container } = render(<>{renderContentfulRichTextTable(doc)}); expect(container).toHaveTextContent("Custom text"); expect( container.querySelector('[data-testid="icon-check_circle"]') ).not.toBeInTheDocument(); expect( container.querySelector('[data-testid="icon-cancel"]') ).not.toBeInTheDocument(); }); it("renders bold with table-specific class", () => { const doc = makeDoc(makeMarkedText("Table bold", MARKS.BOLD)); const { container } = render(<>{renderContentfulRichTextTable(doc)}); const strong = container.querySelector("strong")!; expect(strong).toHaveClass("label4"); expect(strong).toHaveClass("md:label2"); }); it("renders paragraph as fragment (no wrapper div)", () => { const doc = makeDoc(makeParagraph("Fragment text")); const { container } = render(<>{renderContentfulRichTextTable(doc)}); // Should NOT have a div.body3 wrapper like the default options expect(container.querySelector("div.body3")).not.toBeInTheDocument(); expect(container).toHaveTextContent("Fragment text"); }); it("renders inline embedded checklist entry from links", () => { const entryId = "entry-123"; const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: { id: entryId } } }, content: [], }, ], }); const links = { entries: { inline: [ { sys: { id: entryId }, __typename: "ComponentCheckList", list: { items: [{ checkListTitle: null }], }, }, ], }, }; const { container } = render( <>{renderContentfulRichTextTable(doc, links)} ); expect( container.querySelector('[data-testid="checklist"]') ).toBeInTheDocument(); }); it("renders inline entry title for non-checklist type", () => { const entryId = "entry-456"; const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: { id: entryId } } }, content: [], }, ], }); const links = { entries: { inline: [ { sys: { id: entryId }, __typename: "OtherType", title: "Other Title", }, ], }, }; const { container } = render( <>{renderContentfulRichTextTable(doc, links)} ); expect(container).toHaveTextContent("Other Title"); }); it("returns null for inline entry not found in links", () => { const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: { id: "missing" } } }, content: [], }, ], }); const { container } = render( <>{renderContentfulRichTextTable(doc, { entries: { inline: [] } })} ); // Should render the paragraph wrapper but no entry content expect(container.textContent).toBe(""); }); it("renders scrollable table with >2 columns", () => { const headerRow = { nodeType: BLOCKS.TABLE_ROW, data: {}, content: [ { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("A")], }, { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("B")], }, { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("C")], }, ], }; const doc = makeDoc({ nodeType: BLOCKS.TABLE, data: {}, content: [headerRow], }); const { container } = render(<>{renderContentfulRichTextTable(doc)}); expect(container.querySelector("table")?.className).toContain( "min-w-[100.1%]" ); }); it("renders non-scrollable table with <=2 columns", () => { const headerRow = { nodeType: BLOCKS.TABLE_ROW, data: {}, content: [ { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("A")], }, { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("B")], }, ], }; const doc = makeDoc({ nodeType: BLOCKS.TABLE, data: {}, content: [headerRow], }); const { container } = render(<>{renderContentfulRichTextTable(doc)}); expect(container.querySelector("table")?.className).toContain("min-w-full"); }); it("renders table cells with non-scrollable layout (node.parent not available)", () => { const doc = makeDoc({ nodeType: BLOCKS.TABLE, data: {}, content: [ { nodeType: BLOCKS.TABLE_ROW, data: {}, content: [ { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("H1")], }, { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("H2")], }, { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("H3")], }, ], }, { nodeType: BLOCKS.TABLE_ROW, data: {}, content: [ { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("C1")], }, { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("C2")], }, { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("C3")], }, ], }, ], }); const { container } = render(<>{renderContentfulRichTextTable(doc)}); const tds = container.querySelectorAll("td"); expect(tds.length).toBe(3); // node.parent is not set by documentToReactComponents, so isScrollable is always false tds.forEach(td => { expect(td.className).toContain("w-1/4"); }); }); it("renders non-scrollable table cells with w-1/4 for <=2 columns", () => { const doc = makeDoc({ nodeType: BLOCKS.TABLE, data: {}, content: [ { nodeType: BLOCKS.TABLE_ROW, data: {}, content: [ { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("H1")], }, { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("H2")], }, ], }, { nodeType: BLOCKS.TABLE_ROW, data: {}, content: [ { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("D1")], }, { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("D2")], }, ], }, ], }); const { container } = render(<>{renderContentfulRichTextTable(doc)}); const tds = container.querySelectorAll("td"); tds.forEach(td => { expect(td.className).toContain("w-1/4"); }); }); it("renders checklist with empty items when list has no items", () => { const entryId = "entry-empty"; const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: { id: entryId } } }, content: [], }, ], }); const links = { entries: { inline: [ { sys: { id: entryId }, __typename: "ComponentCheckList", list: undefined, }, ], }, }; const { container } = render( <>{renderContentfulRichTextTable(doc, links)} ); expect( container.querySelector('[data-testid="checklist"]') ).toBeInTheDocument(); }); it("renders non-checklist inline entry with empty title", () => { const entryId = "entry-no-title"; const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: { id: entryId } } }, content: [], }, ], }); const links = { entries: { inline: [ { sys: { id: entryId }, __typename: "OtherType", title: "", }, ], }, }; const { container } = render( <>{renderContentfulRichTextTable(doc, links)} ); const span = container.querySelector("span"); expect(span?.textContent).toBe(""); }); it("renders scrollable table header cells when node.parent has >2 children", () => { // Build row with 3 header cells and set parent on each cell const headerCells = [ { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("H1")], }, { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("H2")], }, { nodeType: BLOCKS.TABLE_HEADER_CELL, data: {}, content: [makeParagraph("H3")], }, ]; const row = { nodeType: BLOCKS.TABLE_ROW, data: {}, content: headerCells, }; // Set parent on each cell so isScrollable = true headerCells.forEach(cell => { (cell as any).parent = row; }); const doc = makeDoc({ nodeType: BLOCKS.TABLE, data: {}, content: [row], }); const { container } = render(<>{renderContentfulRichTextTable(doc)}); const ths = container.querySelectorAll("th"); expect(ths.length).toBe(3); ths.forEach(th => { expect(th.className).toContain("sticky"); }); }); it("renders scrollable table data cells when node.parent has >2 children", () => { const dataCells = [ { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("D1")], }, { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("D2")], }, { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("D3")], }, ]; const row = { nodeType: BLOCKS.TABLE_ROW, data: {}, content: dataCells, }; dataCells.forEach(cell => { (cell as any).parent = row; }); const doc = makeDoc({ nodeType: BLOCKS.TABLE, data: {}, content: [row], }); const { container } = render(<>{renderContentfulRichTextTable(doc)}); const tds = container.querySelectorAll("td"); expect(tds.length).toBe(3); tds.forEach(td => { expect(td.className).toContain("min-w-[50vw]"); }); }); it("renders scrollable table cell with 'yes' value and isScrollable true", () => { const dataCells = [ { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("yes")], }, { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("no")], }, { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [makeParagraph("other")], }, ]; const row = { nodeType: BLOCKS.TABLE_ROW, data: {}, content: dataCells, }; dataCells.forEach(cell => { (cell as any).parent = row; }); const doc = makeDoc({ nodeType: BLOCKS.TABLE, data: {}, content: [row], }); const { container } = render(<>{renderContentfulRichTextTable(doc)}); expect( container.querySelector('[data-testid="icon-check_circle"]') ).toBeInTheDocument(); expect( container.querySelector('[data-testid="icon-cancel"]') ).toBeInTheDocument(); const tds = container.querySelectorAll("td"); tds.forEach(td => { expect(td.className).toContain("min-w-[50vw]"); }); }); it("renders cell with empty content array gracefully", () => { const doc = makeDoc({ nodeType: BLOCKS.TABLE, data: {}, content: [ { nodeType: BLOCKS.TABLE_ROW, data: {}, content: [ { nodeType: BLOCKS.TABLE_CELL, data: {}, content: [], }, ], }, ], }); const { container } = render(<>{renderContentfulRichTextTable(doc)}); const td = container.querySelector("td"); expect(td).toBeInTheDocument(); }); it("renders superscript mark", () => { const doc = makeDoc(makeMarkedText("Sup", MARKS.SUPERSCRIPT)); const { container } = render(<>{renderContentfulRichTextTable(doc)}); expect(container.querySelector("sup")).toHaveTextContent("Sup"); }); it("renders subscript mark", () => { const doc = makeDoc(makeMarkedText("Sub", MARKS.SUBSCRIPT)); const { container } = render(<>{renderContentfulRichTextTable(doc)}); expect(container.querySelector("sub")).toHaveTextContent("Sub"); }); it("renders table when first row content is empty", () => { const doc = makeDoc({ nodeType: BLOCKS.TABLE, data: {}, content: [ { nodeType: BLOCKS.TABLE_ROW, data: {}, content: [], }, ], }); const { container } = render(<>{renderContentfulRichTextTable(doc)}); expect(container.querySelector("table")).toBeInTheDocument(); expect(container.querySelector("table")?.className).toContain("min-w-full"); }); it("renders inline entry when links is undefined", () => { const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: { id: "any" } } }, content: [], }, ], }); const { container } = render( <>{renderContentfulRichTextTable(doc, undefined)} ); expect(container).toBeInTheDocument(); }); it("renders inline entry when links has no entries", () => { const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: { id: "any" } } }, content: [], }, ], }); const { container } = render(<>{renderContentfulRichTextTable(doc, {})}); expect(container).toBeInTheDocument(); }); it("renders inline entry when links.entries has no inline", () => { const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: { id: "any" } } }, content: [], }, ], }); const { container } = render( <>{renderContentfulRichTextTable(doc, { entries: {} })} ); expect(container).toBeInTheDocument(); }); it("renders checklist entry with list but no items", () => { const entryId = "entry-no-items"; const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: { id: entryId } } }, content: [], }, ], }); const links = { entries: { inline: [ { sys: { id: entryId }, __typename: "ComponentCheckList", list: { items: null }, }, ], }, }; const { container } = render( <>{renderContentfulRichTextTable(doc, links)} ); expect( container.querySelector('[data-testid="checklist"]') ).toBeInTheDocument(); }); it("renders checklist entry with no list property", () => { const entryId = "entry-no-list"; const doc = makeDoc({ nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: { id: entryId } } }, content: [], }, ], }); const links = { entries: { inline: [ { sys: { id: entryId }, __typename: "ComponentCheckList", }, ], }, }; const { container } = render( <>{renderContentfulRichTextTable(doc, links)} ); expect( container.querySelector('[data-testid="checklist"]') ).toBeInTheDocument(); }); it("handles hyperlink with missing uri in table", () => { const node: any = { nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.HYPERLINK, data: {}, content: [{ nodeType: "text", value: "NoUri", marks: [], data: {} }], }, ], }; const doc = makeDoc(node); const { container } = render(<>{renderContentfulRichTextTable(doc)}); expect(container).toHaveTextContent("NoUri"); }); }); // ────────────────────────────────────────────── // Additional defaultOptions coverage // ────────────────────────────────────────────── describe("defaultOptions (via useContentfulRichText)", () => { it("renders embedded asset with description fallback alt", () => { const node: any = { nodeType: BLOCKS.EMBEDDED_ASSET, data: { target: { fields: { file: { url: "https://cdn.com/img.png" }, description: "Desc alt", }, }, }, content: [], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("img")).toHaveAttribute("alt", "Desc alt"); }); it("renders embedded asset with default alt when no title or description", () => { const node: any = { nodeType: BLOCKS.EMBEDDED_ASSET, data: { target: { fields: { file: { url: "https://cdn.com/img.png" }, }, }, }, content: [], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("img")).toHaveAttribute( "alt", "Embedded asset" ); }); it("renders inline entry with missing fields as empty span", () => { const node: any = { nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: { contentType: { sys: { id: "unknown" } } }, fields: {}, }, }, content: [], }, ], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); const spans = container.querySelectorAll("span"); const emptySpan = Array.from(spans).find(s => s.textContent === ""); expect(emptySpan).toBeDefined(); }); // Cover ?. null short-circuit branches in defaultOptions it("handles embedded asset with missing target fields gracefully", () => { const node: any = { nodeType: BLOCKS.EMBEDDED_ASSET, data: { target: null }, content: [], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("img")).not.toBeInTheDocument(); }); it("handles embedded asset with missing data gracefully", () => { const node: any = { nodeType: BLOCKS.EMBEDDED_ASSET, data: {}, content: [], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("img")).not.toBeInTheDocument(); }); it("handles embedded asset with fields but no file", () => { const node: any = { nodeType: BLOCKS.EMBEDDED_ASSET, data: { target: { fields: { title: "No file" }, }, }, content: [], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("img")).not.toBeInTheDocument(); }); it("handles embedded entry with missing data target", () => { const node: any = { nodeType: BLOCKS.EMBEDDED_ENTRY, data: { target: null }, content: [], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("aside")).not.toBeInTheDocument(); }); it("handles embedded entry with no sys contentType", () => { const node: any = { nodeType: BLOCKS.EMBEDDED_ENTRY, data: { target: { sys: {}, fields: { title: "No CT" }, }, }, content: [], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container.querySelector("aside")).not.toBeInTheDocument(); }); it("handles inline entry with missing data target", () => { const node: any = { nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: null }, content: [], }, ], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container).toBeInTheDocument(); }); it("handles inline entry with no sys on target", () => { const node: any = { nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { fields: { title: "No sys" } }, }, content: [], }, ], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container).toHaveTextContent("No sys"); }); it("handles inline entry with no contentType in sys", () => { const node: any = { nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: {}, fields: { title: "No CT" }, }, }, content: [], }, ], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container).toHaveTextContent("No CT"); }); it("handles inline entry with null fields", () => { const node: any = { nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.EMBEDDED_ENTRY, data: { target: { sys: { contentType: { sys: { id: "other" } } }, }, }, content: [], }, ], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); const spans = container.querySelectorAll("span"); const emptySpan = Array.from(spans).find(s => s.textContent === ""); expect(emptySpan).toBeDefined(); }); it("handles hyperlink with missing data", () => { const node: any = { nodeType: BLOCKS.PARAGRAPH, data: {}, content: [ { nodeType: INLINES.HYPERLINK, data: {}, content: [{ nodeType: "text", value: "Link", marks: [], data: {} }], }, ], }; const doc = makeDoc(node); const { result } = renderHook(() => useContentfulRichText(doc)); const { container } = render(<>{result.current}); expect(container).toHaveTextContent("Link"); }); });