diff --git a/ui/src/components/ApprovalPayload.test.tsx b/ui/src/components/ApprovalPayload.test.tsx index c11405e92d..9533947473 100644 --- a/ui/src/components/ApprovalPayload.test.tsx +++ b/ui/src/components/ApprovalPayload.test.tsx @@ -4,6 +4,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ApprovalPayloadRenderer, approvalLabel } from "./ApprovalPayload"; +import { ThemeProvider } from "../context/ThemeContext"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; @@ -35,17 +36,19 @@ describe("ApprovalPayloadRenderer", () => { act(() => { root.render( - , + + + , ); }); @@ -62,19 +65,140 @@ describe("ApprovalPayloadRenderer", () => { }); }); + it("renders markdown in board approval prose fields", () => { + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const bodies = container.querySelectorAll(".paperclip-markdown"); + expect(bodies.length).toBe(4); + + const summary = bodies[0]; + expect(summary.querySelector("strong")?.textContent).toBe("Bold"); + expect(summary.querySelector("code")?.textContent).toBe("code"); + const link = summary.querySelector("a"); + expect(link?.getAttribute("href")).toBe("https://example.com"); + expect(link?.textContent).toBe("a link"); + + // The raw markdown characters must not survive into the rendered text. + expect(container.textContent).not.toContain("**Bold**"); + expect(container.textContent).not.toContain("[a link](https://example.com)"); + + expect(bodies[1].querySelector("strong")?.textContent).toBe("frog"); + expect(bodies[2].querySelector("code")?.textContent).toBe("frog"); + expect(bodies[3].querySelector("strong")?.textContent).toBe("frog"); + + act(() => { + root.unmount(); + }); + }); + + it("does not nest a second bullet when a risk is authored as a markdown list item", () => { + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const bodies = container.querySelectorAll(".paperclip-markdown"); + expect(bodies.length).toBe(5); + for (const body of bodies) { + expect(body.querySelector("ul")).toBeNull(); + expect(body.querySelector("ol")).toBeNull(); + expect(body.querySelector("li")).toBeNull(); + } + + expect(bodies[0].querySelector("strong")?.textContent).toBe("Leading dash"); + expect(container.textContent).toContain("Leading star risk."); + expect(container.textContent).toContain("Leading dot risk."); + expect(container.textContent).toContain("Leading number risk."); + expect(container.textContent).toContain("Leading paren risk."); + expect(container.textContent).not.toContain("- **Leading dash**"); + + act(() => { + root.unmount(); + }); + }); + + it("renders every risk when two entries collapse to the same text after marker stripping", () => { + const root = createRoot(container); + const errors: unknown[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { + errors.push(args); + }; + + try { + act(() => { + root.render( + + + , + ); + }); + + expect(container.querySelectorAll(".paperclip-markdown").length).toBe(2); + expect(errors).toEqual([]); + } finally { + console.error = originalError; + act(() => { + root.unmount(); + }); + } + }); + it("can hide the repeated title when the card header already shows it", () => { const root = createRoot(container); act(() => { root.render( - , + + + , ); }); diff --git a/ui/src/components/ApprovalPayload.tsx b/ui/src/components/ApprovalPayload.tsx index 165ee8a223..6f8f9752d8 100644 --- a/ui/src/components/ApprovalPayload.tsx +++ b/ui/src/components/ApprovalPayload.tsx @@ -1,4 +1,5 @@ import { UserPlus, Lightbulb, ShieldAlert, ShieldCheck } from "lucide-react"; +import { MarkdownBody } from "./MarkdownBody"; import { formatCents } from "../lib/utils"; export const typeLabel: Record = { @@ -161,11 +162,19 @@ export function BoardApprovalPayload({ ); } +/** + * Risks render inside a custom bullet row, so a leading markdown list marker + * would nest a second bullet inside the first. Strip one leading marker. + */ +function stripLeadingListMarker(value: string): string { + return value.replace(/^(?:[-*•]|\d+[.)])\s+/, ""); +} + function BoardApprovalPayloadContent({ payload }: { payload: Record }) { const risks = Array.isArray(payload.risks) ? payload.risks .filter((value): value is string => typeof value === "string") - .map((value) => value.trim()) + .map((value) => stripLeadingListMarker(value.trim())) .filter(Boolean) : []; const title = firstNonEmptyString(payload.title); @@ -185,7 +194,7 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record

Summary

-

{summary}

+ {summary} )} {recommendedAction && ( @@ -193,23 +202,23 @@ function BoardApprovalPayloadContent({ payload }: { payload: Record Recommended action

-

{recommendedAction}

+ {recommendedAction} )} {nextActionOnApproval && (

On approval

-

{nextActionOnApproval}

+ {nextActionOnApproval}
)} {risks.length > 0 && (

Risks

    - {risks.map((risk) => ( -
  • - - {risk} + {risks.map((risk, index) => ( +
  • + + {risk}
  • ))}