mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-24 07:59:30 +02:00
Compare commits
2 Commits
fix/header
...
agent/squi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
316d5f4c00 | ||
|
|
32c4b9b51d |
113
packages/views/editor/attachment-block.test.tsx
Normal file
113
packages/views/editor/attachment-block.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const { getAttachmentTextContentMock } = vi.hoisted(() => ({
|
||||
getAttachmentTextContentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: { getAttachmentTextContent: getAttachmentTextContentMock },
|
||||
PreviewTooLargeError: class extends Error {},
|
||||
PreviewUnsupportedError: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("../i18n", () => ({
|
||||
useT: () => ({
|
||||
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
|
||||
sel({
|
||||
image: { download: "Download" },
|
||||
attachment: {
|
||||
preview: "Preview",
|
||||
preview_loading: "Loading preview…",
|
||||
preview_failed: "Couldn't load preview",
|
||||
},
|
||||
code_block: { copy_code: "Copy code" },
|
||||
file_card: { uploading: "Uploading {{filename}}" },
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AttachmentBlock } from "./attachment-block";
|
||||
|
||||
function renderWithQuery(ui: ReactElement) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("AttachmentBlock — dispatcher", () => {
|
||||
it("routes html + attachmentId to HtmlAttachmentPreview (no file-card chrome)", () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>chart</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
renderWithQuery(
|
||||
<AttachmentBlock
|
||||
filename="report.html"
|
||||
contentType="text/html"
|
||||
attachmentId="att-1"
|
||||
href="https://cdn.example/report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
// HtmlAttachmentPreview never renders the filename row — that's the
|
||||
// file-card chrome it replaces.
|
||||
expect(screen.queryByText("report.html")).toBeNull();
|
||||
// Toolbar shows the Maximize-style preview button.
|
||||
expect(screen.getByTitle("Preview")).toBeTruthy();
|
||||
expect(screen.getByTitle("Copy code")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("routes html WITHOUT attachmentId to AttachmentCard (URL-only is chrome-only)", () => {
|
||||
renderWithQuery(
|
||||
<AttachmentBlock
|
||||
filename="report.html"
|
||||
contentType="text/html"
|
||||
href="https://cdn.example/report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("report.html")).toBeTruthy();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("routes html while uploading to AttachmentCard (no iframe before upload finishes)", () => {
|
||||
renderWithQuery(
|
||||
<AttachmentBlock
|
||||
filename="report.html"
|
||||
contentType="text/html"
|
||||
attachmentId="att-1"
|
||||
href="https://cdn.example/report.html"
|
||||
uploading
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Uploading state surfaces the chrome row with the upload template.
|
||||
expect(screen.getByText("Uploading {{filename}}")).toBeTruthy();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("routes non-html kinds (pdf, image, other) to AttachmentCard", () => {
|
||||
renderWithQuery(
|
||||
<AttachmentBlock
|
||||
filename="manual.pdf"
|
||||
contentType="application/pdf"
|
||||
attachmentId="att-1"
|
||||
href="https://cdn.example/manual.pdf"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("manual.pdf")).toBeTruthy();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
});
|
||||
42
packages/views/editor/attachment-block.tsx
Normal file
42
packages/views/editor/attachment-block.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AttachmentBlock — thin dispatcher choosing the renderer for an attachment.
|
||||
*
|
||||
* Centralizes the kind-aware routing that previously lived inside
|
||||
* AttachmentCard (and earlier still, was duplicated across three call sites).
|
||||
* Three entry points render attachments and all must agree on which kinds get
|
||||
* which visual treatment, otherwise feature work has to be repeated three
|
||||
* times — see MUL-2330, where the inline HTML preview was missed on the
|
||||
* standalone path.
|
||||
*
|
||||
* Current routing:
|
||||
* - kind === "html" + attachmentId + !uploading → HtmlAttachmentPreview
|
||||
* (independent iframe + hover toolbar, no file-card chrome)
|
||||
* - everything else → AttachmentCard (icon + filename + Eye/Download row)
|
||||
*
|
||||
* Props match AttachmentCardProps exactly so callers stay as-is — only the
|
||||
* import changes.
|
||||
*/
|
||||
|
||||
import { getPreviewKind } from "./utils/preview";
|
||||
import { AttachmentCard, type AttachmentCardProps } from "./attachment-card";
|
||||
import { HtmlAttachmentPreview } from "./html-attachment-preview";
|
||||
|
||||
export type AttachmentBlockProps = AttachmentCardProps;
|
||||
|
||||
export function AttachmentBlock(props: AttachmentBlockProps) {
|
||||
const { filename, contentType = "", attachmentId, uploading } = props;
|
||||
const kind = filename ? getPreviewKind(contentType, filename) : null;
|
||||
if (kind === "html" && attachmentId && !uploading) {
|
||||
return (
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId={attachmentId}
|
||||
filename={filename}
|
||||
onPreview={props.onPreview}
|
||||
onDownload={props.onDownload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <AttachmentCard {...props} />;
|
||||
}
|
||||
@@ -1,19 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// vi.hoisted lets us reference the mock from inside the vi.mock factory
|
||||
// even though the factory hoists above the file's top-level statements.
|
||||
const { getAttachmentTextContentMock } = vi.hoisted(() => ({
|
||||
getAttachmentTextContentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: { getAttachmentTextContent: getAttachmentTextContentMock },
|
||||
PreviewTooLargeError: class extends Error {},
|
||||
PreviewUnsupportedError: class extends Error {},
|
||||
}));
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("../i18n", () => ({
|
||||
useT: () => ({
|
||||
@@ -31,55 +17,29 @@ vi.mock("../i18n", () => ({
|
||||
|
||||
import { AttachmentCard } from "./attachment-card";
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
return rtlRender(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("AttachmentCard — kind dispatch", () => {
|
||||
it("renders chrome only for non-html kinds (image, video, other)", () => {
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="snapshot.png"
|
||||
contentType="image/png"
|
||||
attachmentId="att-1"
|
||||
href="https://cdn.example/snapshot.png"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("snapshot.png")).toBeTruthy();
|
||||
// No inline iframe for an image-kind attachment.
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders chrome only for an html URL-only source (no attachmentId)", () => {
|
||||
describe("AttachmentCard — chrome row", () => {
|
||||
it("renders chrome only and never an inline iframe (HTML rich preview lives in HtmlAttachmentPreview)", () => {
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="report.html"
|
||||
contentType="text/html"
|
||||
attachmentId="att-1"
|
||||
href="https://cdn.example/report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Without an attachment id we cannot hit the ID-keyed /content proxy,
|
||||
// so the card must fall back to chrome-only.
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
expect(screen.getByText("report.html")).toBeTruthy();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the Eye button for an html URL-only source (the modal's /content proxy is ID-keyed)", () => {
|
||||
// Regression: a cross-comment / copy-pasted `!file[report.html](url)`
|
||||
// used to surface a dead Eye button — the AttachmentCard allowed
|
||||
// preview when `previewableFromUrl` was true even without an
|
||||
// attachmentId, but the modal's tryOpen rejects URL-only text kinds
|
||||
// and the click became a silent no-op.
|
||||
// used to surface a dead Eye button — text kinds need an attachmentId,
|
||||
// otherwise tryOpen rejects and the click becomes a silent no-op.
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="report.html"
|
||||
@@ -94,11 +54,7 @@ describe("AttachmentCard — kind dispatch", () => {
|
||||
expect(screen.getByTitle("Download")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still shows the Eye button for an html source when an attachmentId is available", () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>ok</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
it("shows the Eye button for an html source when an attachmentId is available", () => {
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="report.html"
|
||||
@@ -127,29 +83,6 @@ describe("AttachmentCard — kind dispatch", () => {
|
||||
);
|
||||
expect(screen.getByTitle("Preview")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an inline iframe with sandbox='allow-scripts' for an HTML attachment", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>chart goes here</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="report.html"
|
||||
contentType="text/html"
|
||||
attachmentId="att-1"
|
||||
href="https://cdn.example/report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
const frame = document.querySelector("iframe") as HTMLIFrameElement | null;
|
||||
expect(frame).toBeTruthy();
|
||||
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
expect(frame?.getAttribute("srcdoc")).toBe("<p>chart goes here</p>");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AttachmentCard — Eye / Download buttons", () => {
|
||||
@@ -185,7 +118,7 @@ describe("AttachmentCard — Eye / Download buttons", () => {
|
||||
expect(onDownload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the Eye button while uploading and skips the inline HTML preview", () => {
|
||||
it("hides Eye and Download buttons while uploading", () => {
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="report.html"
|
||||
@@ -199,7 +132,6 @@ describe("AttachmentCard — Eye / Download buttons", () => {
|
||||
);
|
||||
expect(screen.queryByTitle("Preview")).toBeNull();
|
||||
expect(screen.queryByTitle("Download")).toBeNull();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
// The mock `t()` returns the i18n template as-is; the production t-fn
|
||||
// interpolates {{filename}} → "report.html". Asserting the template
|
||||
// proves the uploading branch was selected without depending on the
|
||||
|
||||
@@ -1,84 +1,24 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AttachmentCard — shared attachment row UI used by every entry point that
|
||||
* renders a non-image attachment in the editor surface.
|
||||
* AttachmentCard — shared file-card row UI (icon + filename + Eye + Download).
|
||||
*
|
||||
* Three call sites:
|
||||
* 1. `extensions/file-card.tsx` — Tiptap NodeView for `!file[name](url)`
|
||||
* inline in markdown.
|
||||
* 2. `readonly-content.tsx` — readonly file-card `<div data-type="fileCard">`
|
||||
* branch, rendered through preprocessMarkdown.
|
||||
* 3. `comment-card.tsx` `AttachmentList` — standalone attachments that were
|
||||
* not referenced by URL inside the markdown body.
|
||||
* Rendered for every attachment kind that does not have a richer inline
|
||||
* renderer. Three call sites reach it indirectly through AttachmentBlock,
|
||||
* which picks the right component per kind:
|
||||
*
|
||||
* Centralizing this avoids the third-instance trap: every previous attempt to
|
||||
* add a feature here had to be added in three places, and dropping one
|
||||
* silently re-introduced the bug — MUL-2330's HTML chart was a standalone
|
||||
* attachment, so the inline HTML preview only works if THIS path is covered.
|
||||
* 1. `extensions/file-card.tsx` — Tiptap NodeView for `!file[name](url)`.
|
||||
* 2. `readonly-content.tsx` — readonly `<div data-type="fileCard">`.
|
||||
* 3. `comment-card.tsx` AttachmentList — standalone attachments not inlined.
|
||||
*
|
||||
* HTML kind extension:
|
||||
* - When the attachment is HTML and the caller can provide an
|
||||
* `attachmentId` (i.e. the attachment record is known — required for the
|
||||
* ID-keyed `/api/attachments/{id}/content` proxy), the card mounts an
|
||||
* inline `CodeBlockIframe` underneath the row to render the HTML body
|
||||
* directly. Loading errors and 413/415 cases collapse back to the bare
|
||||
* row + Eye/Download buttons.
|
||||
* - For non-HTML kinds (or HTML where we only have a URL), the card looks
|
||||
* and behaves exactly like the previous handwritten rows.
|
||||
* Kind-aware routing (e.g. HTML → inline iframe preview) lives in
|
||||
* AttachmentBlock — keep that decision out of this file so this stays a
|
||||
* single-purpose row UI.
|
||||
*/
|
||||
|
||||
import { Download, Eye, FileText, Loader2 } from "lucide-react";
|
||||
import { useT } from "../i18n";
|
||||
import { getPreviewKind } from "./utils/preview";
|
||||
import { CodeBlockIframe } from "./code-block-iframe";
|
||||
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline HTML preview body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fixed height per the V2 plan; auto-resize via postMessage handshake is
|
||||
// explicitly out of scope for V1.
|
||||
const INLINE_HTML_HEIGHT = "h-[480px]";
|
||||
|
||||
function InlineHtmlIframe({
|
||||
attachmentId,
|
||||
filename,
|
||||
}: {
|
||||
attachmentId: string;
|
||||
filename: string;
|
||||
}) {
|
||||
const { t } = useT("editor");
|
||||
const query = useAttachmentHtmlText(attachmentId);
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="mt-1 flex h-[480px] items-center justify-center gap-2 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{t(($) => $.attachment.preview_loading)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Any error path (413 / 415 / transport) — fall back silently. The
|
||||
// surrounding card still offers Eye → modal (which surfaces the typed
|
||||
// error) and Download as escape hatches.
|
||||
if (query.error || !query.data) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<CodeBlockIframe
|
||||
html={query.data.text}
|
||||
title={filename}
|
||||
heightClassName={INLINE_HTML_HEIGHT}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card chrome — icon + filename + optional Eye + Download
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AttachmentCardChromeProps {
|
||||
filename: string;
|
||||
@@ -149,23 +89,18 @@ function AttachmentCardChrome({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AttachmentCard — public component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AttachmentCardProps {
|
||||
/** Filename used for icon label and previewable-kind detection. */
|
||||
filename: string;
|
||||
/** Content type used in addition to filename for previewable-kind detection. */
|
||||
contentType?: string;
|
||||
/**
|
||||
* Attachment id — required for HTML inline rendering (the `/content`
|
||||
* proxy is ID-keyed). Undefined means we only have a URL (e.g. a
|
||||
* cross-comment `!file[]()` reference) — the card still renders, the
|
||||
* HTML iframe just doesn't expand.
|
||||
* Attachment id — required when the preview proxy is ID-keyed (text kinds
|
||||
* like markdown / html / text). Media kinds (pdf/video/audio) preview from
|
||||
* the URL alone.
|
||||
*/
|
||||
attachmentId?: string;
|
||||
/** Download URL — used purely as a non-null sentinel for the download button. */
|
||||
/** Download URL — used as a non-null sentinel for the download button. */
|
||||
href?: string;
|
||||
/** True while a synchronous upload is in flight (file-card NodeView only). */
|
||||
uploading?: boolean;
|
||||
@@ -173,12 +108,6 @@ export interface AttachmentCardProps {
|
||||
onPreview: () => void;
|
||||
/** Pressed when the Download button is clicked. */
|
||||
onDownload: () => void;
|
||||
/**
|
||||
* Set to false to disable the HTML inline preview branch (and behave like
|
||||
* the legacy chrome-only card). Useful for editor NodeViews while a draft
|
||||
* upload is still in flight.
|
||||
*/
|
||||
inlineHtmlEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function AttachmentCard({
|
||||
@@ -189,7 +118,6 @@ export function AttachmentCard({
|
||||
uploading,
|
||||
onPreview,
|
||||
onDownload,
|
||||
inlineHtmlEnabled = true,
|
||||
}: AttachmentCardProps) {
|
||||
const kind = filename ? getPreviewKind(contentType, filename) : null;
|
||||
// Media kinds (pdf/video/audio) are previewable from a URL alone — the
|
||||
@@ -202,14 +130,6 @@ export function AttachmentCard({
|
||||
const canPreview =
|
||||
!!href && kind !== null && (!!attachmentId || isUrlPreviewableKind);
|
||||
|
||||
// Mount the inline iframe only when we can hit the /content proxy
|
||||
// (attachmentId present) AND kind is HTML AND no upload is in flight.
|
||||
const showInlineHtml =
|
||||
inlineHtmlEnabled &&
|
||||
!uploading &&
|
||||
kind === "html" &&
|
||||
!!attachmentId;
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
<AttachmentCardChrome
|
||||
@@ -220,9 +140,6 @@ export function AttachmentCard({
|
||||
onPreview={onPreview}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
{showInlineHtml && (
|
||||
<InlineHtmlIframe attachmentId={attachmentId!} filename={filename} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
105
packages/views/editor/extensions/file-card.test.tsx
Normal file
105
packages/views/editor/extensions/file-card.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// Tiptap NodeView primitives can't be instantiated without a full editor.
|
||||
// Stub the wrapper so FileCardView renders as a plain React component and
|
||||
// the DOM can be inspected directly.
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
NodeViewWrapper: ({ children, ...rest }: any) => <div {...rest}>{children}</div>,
|
||||
}));
|
||||
|
||||
const { getAttachmentTextContentMock, resolveAttachmentMock, openByUrlMock, tryOpenMock } =
|
||||
vi.hoisted(() => ({
|
||||
getAttachmentTextContentMock: vi.fn(),
|
||||
resolveAttachmentMock: vi.fn(),
|
||||
openByUrlMock: vi.fn(),
|
||||
tryOpenMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: { getAttachmentTextContent: getAttachmentTextContentMock },
|
||||
PreviewTooLargeError: class extends Error {},
|
||||
PreviewUnsupportedError: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("../attachment-download-context", () => ({
|
||||
useAttachmentDownloadResolver: () => ({
|
||||
openByUrl: openByUrlMock,
|
||||
resolveAttachment: resolveAttachmentMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../attachment-preview-modal", () => ({
|
||||
useAttachmentPreview: () => ({ tryOpen: tryOpenMock, open: vi.fn(), modal: null }),
|
||||
}));
|
||||
|
||||
vi.mock("../i18n", () => ({
|
||||
useT: () => ({
|
||||
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
|
||||
sel({
|
||||
image: { download: "Download" },
|
||||
attachment: {
|
||||
preview: "Preview",
|
||||
preview_loading: "Loading preview…",
|
||||
preview_failed: "Couldn't load preview",
|
||||
},
|
||||
code_block: { copy_code: "Copy code" },
|
||||
file_card: { uploading: "Uploading {{filename}}" },
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { FileCardView } from "./file-card";
|
||||
|
||||
function renderWithQuery(ui: ReactElement) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("FileCardView — HTML attachment routes through AttachmentBlock to iframe", () => {
|
||||
// Regression pin for file-card.tsx:59. The NodeView must render through
|
||||
// <AttachmentBlock>, not the older <AttachmentCard>. If someone reverts that
|
||||
// line, the dispatcher's html+attachmentId branch is bypassed and the user
|
||||
// is left with the file-card chrome — exactly the bug MUL-2330 surfaced.
|
||||
it("renders an iframe (no file-card chrome) when the node resolves to an HTML attachment", async () => {
|
||||
resolveAttachmentMock.mockReturnValue({
|
||||
id: "att-1",
|
||||
content_type: "text/html",
|
||||
url: "/uploads/report.html",
|
||||
filename: "report.html",
|
||||
});
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>chart</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
|
||||
const node = {
|
||||
attrs: {
|
||||
href: "/uploads/report.html",
|
||||
filename: "report.html",
|
||||
uploading: false,
|
||||
},
|
||||
} as any;
|
||||
|
||||
renderWithQuery(<FileCardView node={node} {...({} as any)} />);
|
||||
|
||||
const frame = await waitFor(() => {
|
||||
const f = document.querySelector("iframe") as HTMLIFrameElement | null;
|
||||
expect(f).toBeTruthy();
|
||||
return f!;
|
||||
});
|
||||
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<p>chart</p>");
|
||||
// The AttachmentCard chrome surfaces the filename as text inside its row.
|
||||
// HtmlAttachmentPreview replaces the chrome entirely, so the filename
|
||||
// must not appear as visible text.
|
||||
expect(screen.queryByText("report.html")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ import type { NodeViewProps } from "@tiptap/react";
|
||||
import { FILE_CARD_URL_PATTERN } from "@multica/ui/markdown";
|
||||
import { useAttachmentDownloadResolver } from "../attachment-download-context";
|
||||
import { useAttachmentPreview } from "../attachment-preview-modal";
|
||||
import { AttachmentCard } from "../attachment-card";
|
||||
import { AttachmentBlock } from "../attachment-block";
|
||||
|
||||
const FILE_CARD_MARKDOWN_RE = new RegExp(
|
||||
`^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)`,
|
||||
@@ -31,7 +31,7 @@ const FILE_CARD_MARKDOWN_RE = new RegExp(
|
||||
// React NodeView
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FileCardView({ node }: NodeViewProps) {
|
||||
export function FileCardView({ node }: NodeViewProps) {
|
||||
const href = (node.attrs.href as string) || "";
|
||||
const filename = (node.attrs.filename as string) || "";
|
||||
const uploading = node.attrs.uploading as boolean;
|
||||
@@ -56,7 +56,7 @@ function FileCardView({ node }: NodeViewProps) {
|
||||
return (
|
||||
<NodeViewWrapper as="div" className="file-card-node" data-type="fileCard">
|
||||
<div contentEditable={false}>
|
||||
<AttachmentCard
|
||||
<AttachmentBlock
|
||||
filename={filename}
|
||||
contentType={attachment?.content_type ?? ""}
|
||||
attachmentId={attachment?.id}
|
||||
|
||||
196
packages/views/editor/html-attachment-preview.test.tsx
Normal file
196
packages/views/editor/html-attachment-preview.test.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const { getAttachmentTextContentMock } = vi.hoisted(() => ({
|
||||
getAttachmentTextContentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: { getAttachmentTextContent: getAttachmentTextContentMock },
|
||||
PreviewTooLargeError: class extends Error {},
|
||||
PreviewUnsupportedError: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("../i18n", () => ({
|
||||
useT: () => ({
|
||||
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
|
||||
sel({
|
||||
image: { download: "Download" },
|
||||
attachment: {
|
||||
preview: "Preview",
|
||||
preview_loading: "Loading preview…",
|
||||
preview_failed: "Couldn't load preview",
|
||||
},
|
||||
code_block: { copy_code: "Copy code" },
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { HtmlAttachmentPreview } from "./html-attachment-preview";
|
||||
|
||||
function renderWithQuery(ui: ReactElement) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("HtmlAttachmentPreview — visual shell (does not use file-card chrome)", () => {
|
||||
it("does not render the filename row that AttachmentCard chrome would render", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>ok</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector("iframe")).toBeTruthy();
|
||||
});
|
||||
// The chrome row would surface the filename as text; we replace that
|
||||
// entirely with an iframe + floating toolbar.
|
||||
expect(screen.queryByText("report.html")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders iframe with sandbox='allow-scripts' and srcdoc when text loads", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>chart goes here</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
const frame = document.querySelector("iframe") as HTMLIFrameElement | null;
|
||||
expect(frame).toBeTruthy();
|
||||
// Critical: sandbox must not include allow-same-origin, otherwise the
|
||||
// sandbox is defeated per the HTML spec.
|
||||
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
expect(frame?.getAttribute("srcdoc")).toBe("<p>chart goes here</p>");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HtmlAttachmentPreview — toolbar actions", () => {
|
||||
it("invokes onPreview when Maximize is clicked", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>ok</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const onPreview = vi.fn();
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={onPreview}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTitle("Preview")).toBeTruthy());
|
||||
fireEvent.mouseDown(screen.getByTitle("Preview"));
|
||||
expect(onPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invokes onDownload when Download is clicked", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>ok</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const onDownload = vi.fn();
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={onDownload}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTitle("Download")).toBeTruthy());
|
||||
fireEvent.mouseDown(screen.getByTitle("Download"));
|
||||
expect(onDownload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes the loaded text to the clipboard when Copy code is clicked", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>chart source</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
// jsdom does not implement navigator.clipboard; install it directly on
|
||||
// the existing navigator instance so the component's `navigator.clipboard`
|
||||
// global lookup resolves to our mock.
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Wait until the query resolves and the iframe appears — the Copy button
|
||||
// is rendered in the loading state too (disabled), so we cannot just wait
|
||||
// for it to exist.
|
||||
await waitFor(() => expect(document.querySelector("iframe")).toBeTruthy());
|
||||
fireEvent.mouseDown(screen.getByTitle("Copy code"));
|
||||
await waitFor(() => {
|
||||
expect(writeText).toHaveBeenCalledWith("<p>chart source</p>");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HtmlAttachmentPreview — failure mode does not unmount the toolbar", () => {
|
||||
it("keeps Open and Download enabled and disables Copy code when fetch errors", async () => {
|
||||
getAttachmentTextContentMock.mockRejectedValueOnce(new Error("nope"));
|
||||
const onPreview = vi.fn();
|
||||
const onDownload = vi.fn();
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={onPreview}
|
||||
onDownload={onDownload}
|
||||
/>,
|
||||
);
|
||||
// Wait for the error placeholder — guarantees the query has settled.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("html-attachment-preview-error"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
// Critical: the figure does NOT collapse, and the chrome row is NOT
|
||||
// rendered as a fallback. Open and Download stay reachable.
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
expect(screen.queryByText("report.html")).toBeNull();
|
||||
|
||||
const previewBtn = screen.getByTitle("Preview") as HTMLButtonElement;
|
||||
const downloadBtn = screen.getByTitle("Download") as HTMLButtonElement;
|
||||
const copyBtn = screen.getByTitle("Copy code") as HTMLButtonElement;
|
||||
expect(previewBtn.disabled).toBe(false);
|
||||
expect(downloadBtn.disabled).toBe(false);
|
||||
expect(copyBtn.disabled).toBe(true);
|
||||
|
||||
fireEvent.mouseDown(previewBtn);
|
||||
expect(onPreview).toHaveBeenCalled();
|
||||
fireEvent.mouseDown(downloadBtn);
|
||||
expect(onDownload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
160
packages/views/editor/html-attachment-preview.tsx
Normal file
160
packages/views/editor/html-attachment-preview.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* HtmlAttachmentPreview — inline HTML attachment renderer.
|
||||
*
|
||||
* Visual model mirrors the image renderer: the iframe body is the card, and a
|
||||
* floating right-top toolbar reveals on hover with Open / Download / Copy code
|
||||
* actions. No file-card chrome (icon + filename row).
|
||||
*
|
||||
* Mounted by AttachmentBlock when the attachment is HTML and the caller can
|
||||
* supply an `attachmentId` (the /content proxy is ID-keyed). For other kinds,
|
||||
* AttachmentBlock falls back to the shared AttachmentCard.
|
||||
*
|
||||
* Failure mode (413 / 415 / transport): we do not unmount the figure or fall
|
||||
* back to AttachmentCard chrome — standalone attachment lists filter URLs
|
||||
* already inlined in the markdown body, so a silent unmount would remove the
|
||||
* user's only Open/Download entry point. Instead the body collapses to an
|
||||
* 80px placeholder, the toolbar pins itself open, Open and Download remain
|
||||
* enabled, and Copy code is disabled (no text payload available).
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, Download, Maximize2 } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../i18n";
|
||||
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
|
||||
|
||||
const PREVIEW_HEIGHT = "h-[480px]";
|
||||
const ERROR_PLACEHOLDER_HEIGHT = "h-20";
|
||||
|
||||
interface HtmlAttachmentPreviewProps {
|
||||
attachmentId: string;
|
||||
filename: string;
|
||||
onPreview: () => void;
|
||||
onDownload: () => void;
|
||||
}
|
||||
|
||||
export function HtmlAttachmentPreview({
|
||||
attachmentId,
|
||||
filename,
|
||||
onPreview,
|
||||
onDownload,
|
||||
}: HtmlAttachmentPreviewProps) {
|
||||
const { t } = useT("editor");
|
||||
const query = useAttachmentHtmlText(attachmentId);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const text = query.data?.text;
|
||||
const isLoading = query.isLoading;
|
||||
const isError = !isLoading && (!!query.error || !text);
|
||||
const canCopy = !!text;
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard failures are user-recoverable (try again, or open in modal
|
||||
// and use the text view). No toast — keep the toolbar quiet.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/html-preview relative my-1"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-md border border-border bg-muted/30 text-xs text-muted-foreground",
|
||||
PREVIEW_HEIGHT,
|
||||
)}
|
||||
>
|
||||
{t(($) => $.attachment.preview_loading)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-md border border-border bg-muted/30 px-3 text-xs text-muted-foreground",
|
||||
ERROR_PLACEHOLDER_HEIGHT,
|
||||
)}
|
||||
data-testid="html-attachment-preview-error"
|
||||
>
|
||||
<span className="truncate">{t(($) => $.attachment.preview_failed)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
srcDoc={text}
|
||||
sandbox="allow-scripts"
|
||||
title={filename}
|
||||
className={cn(
|
||||
"block w-full rounded-md border border-border bg-background",
|
||||
PREVIEW_HEIGHT,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-2 top-2 flex items-center gap-0.5 rounded-md border border-border bg-background/95 p-0.5 shadow-sm transition-opacity",
|
||||
// Error state pins the toolbar open — Open / Download are the only
|
||||
// user-reachable escape hatches when inline render fails.
|
||||
isError
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/html-preview:opacity-100",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={t(($) => $.attachment.preview)}
|
||||
aria-label={t(($) => $.attachment.preview)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onPreview();
|
||||
}}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={t(($) => $.image.download)}
|
||||
aria-label={t(($) => $.image.download)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDownload();
|
||||
}}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
|
||||
!canCopy && "cursor-not-allowed opacity-50 hover:bg-transparent hover:text-muted-foreground",
|
||||
)}
|
||||
disabled={!canCopy}
|
||||
title={t(($) => $.code_block.copy_code)}
|
||||
aria-label={t(($) => $.code_block.copy_code)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (canCopy) void handleCopy();
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,3 +22,5 @@ export {
|
||||
export type { AttachmentPreviewHandle } from "./attachment-preview-modal";
|
||||
export { AttachmentCard } from "./attachment-card";
|
||||
export type { AttachmentCardProps } from "./attachment-card";
|
||||
export { AttachmentBlock } from "./attachment-block";
|
||||
export type { AttachmentBlockProps } from "./attachment-block";
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const { getAttachmentTextContentMock } = vi.hoisted(() => ({
|
||||
getAttachmentTextContentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: { getAttachmentTextContent: getAttachmentTextContentMock },
|
||||
PreviewTooLargeError: class extends Error {},
|
||||
PreviewUnsupportedError: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
useWorkspacePaths: () => ({
|
||||
@@ -253,3 +265,47 @@ describe("ReadonlyContent HTML block rendering", () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReadonlyContent file-card → AttachmentBlock HTML routing", () => {
|
||||
// Regression pin for readonly-content.tsx:279. The `div data-type=fileCard`
|
||||
// branch must render through <AttachmentBlock>, not the older
|
||||
// <AttachmentCard>. Reverting that line would skip the html+attachmentId
|
||||
// dispatcher branch and surface the bare file-card chrome (filename row)
|
||||
// instead of the rendered iframe — the exact regression MUL-2330 fixed.
|
||||
function renderWithQuery(ui: ReactElement) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
it("renders the !file[](url) HTML attachment as an iframe (no file-card chrome)", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>chart</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const attachment = {
|
||||
id: "att-1",
|
||||
url: "/uploads/report.html",
|
||||
filename: "report.html",
|
||||
content_type: "text/html",
|
||||
size_bytes: 0,
|
||||
} as any;
|
||||
const { container, queryByText } = renderWithQuery(
|
||||
<ReadonlyContent
|
||||
content="!file[report.html](/uploads/report.html)"
|
||||
attachments={[attachment]}
|
||||
/>,
|
||||
);
|
||||
const frame = await waitFor(() => {
|
||||
const f = container.querySelector<HTMLIFrameElement>("iframe");
|
||||
expect(f).not.toBeNull();
|
||||
return f!;
|
||||
});
|
||||
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<p>chart</p>");
|
||||
// AttachmentCard chrome surfaces the filename as visible text in a
|
||||
// <p class="truncate"> row. HtmlAttachmentPreview replaces it entirely.
|
||||
expect(queryByText("report.html")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ import { MermaidDiagram } from "./mermaid-diagram";
|
||||
import { HtmlBlockPreview } from "./html-block-preview";
|
||||
import { useDownloadAttachment } from "./use-download-attachment";
|
||||
import { useAttachmentPreview, type PreviewSource } from "./attachment-preview-modal";
|
||||
import { AttachmentCard } from "./attachment-card";
|
||||
import { AttachmentBlock } from "./attachment-block";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "./content-editor.css";
|
||||
|
||||
@@ -276,7 +276,7 @@ function ReadonlyFileCard({
|
||||
}
|
||||
};
|
||||
return (
|
||||
<AttachmentCard
|
||||
<AttachmentBlock
|
||||
filename={filename}
|
||||
contentType={attachment?.content_type ?? ""}
|
||||
attachmentId={attachment?.id}
|
||||
|
||||
64
packages/views/issues/components/comment-card.test.tsx
Normal file
64
packages/views/issues/components/comment-card.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const { getAttachmentTextContentMock } = vi.hoisted(() => ({
|
||||
getAttachmentTextContentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
getAttachmentTextContent: getAttachmentTextContentMock,
|
||||
getAttachment: vi.fn(),
|
||||
},
|
||||
PreviewTooLargeError: class extends Error {},
|
||||
PreviewUnsupportedError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { AttachmentList } from "./comment-card";
|
||||
|
||||
function renderWithQuery(ui: ReactElement) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("AttachmentList — standalone HTML attachment routes through AttachmentBlock", () => {
|
||||
// Regression pin for comment-card.tsx:152. This is the entry point
|
||||
// MUL-2330 originally regressed on: standalone HTML attachments (not
|
||||
// referenced inline in the markdown body) MUST render through
|
||||
// <AttachmentBlock> so the html+attachmentId dispatch fires. Reverting to
|
||||
// <AttachmentCard> here re-introduces the "report.html shows as a bare
|
||||
// file card row instead of the rendered chart" bug.
|
||||
it("renders an iframe (no file-card chrome) for a standalone HTML attachment", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>chart</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const attachment = {
|
||||
id: "att-1",
|
||||
url: "/uploads/report.html",
|
||||
filename: "report.html",
|
||||
content_type: "text/html",
|
||||
size_bytes: 0,
|
||||
} as any;
|
||||
|
||||
renderWithQuery(<AttachmentList attachments={[attachment]} content="" />);
|
||||
|
||||
const frame = await waitFor(() => {
|
||||
const f = document.querySelector("iframe") as HTMLIFrameElement | null;
|
||||
expect(f).toBeTruthy();
|
||||
return f!;
|
||||
});
|
||||
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<p>chart</p>");
|
||||
// AttachmentCard chrome would render the filename as visible <p> text;
|
||||
// HtmlAttachmentPreview replaces the row entirely.
|
||||
expect(screen.queryByText("report.html")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ import { QuickEmojiPicker } from "@multica/ui/components/common/quick-emoji-pick
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { timeAgo } from "@multica/core/utils";
|
||||
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, useDownloadAttachment, useAttachmentPreview, AttachmentCard } from "../../editor";
|
||||
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, useDownloadAttachment, useAttachmentPreview, AttachmentBlock } from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
@@ -121,7 +121,7 @@ function DeleteCommentDialog({
|
||||
// Standalone attachment list — renders attachments not already in the markdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
|
||||
export function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
|
||||
const download = useDownloadAttachment();
|
||||
const preview = useAttachmentPreview();
|
||||
if (!attachments?.length) return null;
|
||||
@@ -149,7 +149,7 @@ function AttachmentList({ attachments, content, className }: { attachments?: Att
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
{standalone.map((a) => (
|
||||
<AttachmentCard
|
||||
<AttachmentBlock
|
||||
key={a.id}
|
||||
filename={a.filename}
|
||||
contentType={a.content_type}
|
||||
|
||||
Reference in New Issue
Block a user