mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
feat(ui): render CSV files in task tabs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b315580640
commit
b7da00cc00
10 files changed
+202
-20
No files matched your search
@@ -232,3 +232,12 @@ to Tasks. A Views menu on the task list covers the inbox views (Mine, Unread,
|
||||
Blocked, Recent, Everything) and the task-status views (All, Active, Backlog,
|
||||
Done). Bare `/issues` opens the last-used view, defaulting to Mine; links that
|
||||
carry a task filter open All. Old `/inbox` links redirect to the matching view.
|
||||
|
||||
### CSV file previews
|
||||
|
||||
Task attachment and workspace file tabs render CSV files as tables by default.
|
||||
The first record supplies column headers. Row numbers, row and column counts,
|
||||
and sticky headers help operators scan exports. Rendered and raw view icons
|
||||
remain next to download; raw view preserves the original source. The table
|
||||
shows up to 500 data rows and 100 columns, with a notice when the preview is
|
||||
limited. Download retains the complete file.
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMemo } from "react";
|
||||
import { parseCsv } from "@/lib/csv-preview";
|
||||
|
||||
const MAX_VISIBLE_ROWS = 500;
|
||||
const MAX_VISIBLE_COLUMNS = 100;
|
||||
|
||||
export function CsvPreview({ text, title }: { text: string; title: string }) {
|
||||
const parsed = useMemo(() => {
|
||||
try { return { rows: parseCsv(text), error: null }; }
|
||||
catch (error) { return { rows: [], error: (error as Error).message }; }
|
||||
}, [text]);
|
||||
if (parsed.error) return <p role="alert" className="p-4 text-sm text-muted-foreground">{parsed.error}</p>;
|
||||
if (!parsed.rows.length) return <p className="p-4 text-sm text-muted-foreground">File is empty.</p>;
|
||||
const [header, ...rows] = parsed.rows;
|
||||
const columnCount = parsed.rows.reduce((count, row) => Math.max(count, row.length), 0);
|
||||
const columns = Array.from({ length: Math.min(columnCount, MAX_VISIBLE_COLUMNS) }, (_, index) => index);
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-2 text-xs text-muted-foreground" role="status">
|
||||
<span className="font-medium text-foreground">CSV preview</span>
|
||||
<span>{rows.length.toLocaleString()} {rows.length === 1 ? "row" : "rows"}</span>
|
||||
<span>{columnCount.toLocaleString()} {columnCount === 1 ? "column" : "columns"}</span>
|
||||
{(rows.length > MAX_VISIBLE_ROWS || columnCount > MAX_VISIBLE_COLUMNS) && <span>Showing the first {Math.min(rows.length, MAX_VISIBLE_ROWS)} rows and {columns.length} columns. Download for the complete file.</span>}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset" role="region" aria-label={`${title} CSV table`} tabIndex={0}>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<caption className="sr-only">{title} — first record used as column headers</caption>
|
||||
<thead className="sticky top-0 z-10 bg-muted">
|
||||
<tr><th scope="col" className="w-10 border-b border-border px-3 py-3 text-right font-mono text-xs text-muted-foreground"><span className="sr-only">Row</span>#</th>
|
||||
{columns.map((column) => <th key={column} scope="col" className="whitespace-nowrap border-b border-border px-4 py-3 text-left font-medium">{header[column] || `Column ${column + 1}`}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows.slice(0, MAX_VISIBLE_ROWS).map((row, index) => <tr key={index} className="even:bg-muted/30 hover:bg-accent/50">
|
||||
<th scope="row" className="border-b border-border/50 px-3 py-2 text-right font-mono text-xs font-normal text-muted-foreground">{index + 1}</th>
|
||||
{columns.map((column) => <td key={column} className="whitespace-pre-wrap border-b border-border/50 px-4 py-2 align-top">{row[column] ?? ""}</td>)}
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -168,3 +168,20 @@ describe("FileViewerMetadataRow", () => {
|
||||
expect(markup).toContain("Loading file details");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("workspace CSV content", () => {
|
||||
const csv: WorkspaceFileContent = {
|
||||
resource: { kind: "file", provider: "git_worktree", title: "export.csv", displayPath: "export.csv", workspaceLabel: "Workspace", workspaceKind: "execution_workspace", workspaceId: "ws", contentType: "text/csv", byteSize: 20, previewKind: "text", capabilities: { preview: true, download: true, listChildren: false } },
|
||||
content: { encoding: "utf8", data: 'Name,Amount\n"Lee, Sam",12' },
|
||||
};
|
||||
it("defaults to a table and supports toolbar-controlled raw mode", () => {
|
||||
const rendered = renderToStaticMarkup(<FileContentViewer content={csv} highlightedLine={null} previewMode="rendered" />);
|
||||
expect(rendered).toContain("<table");
|
||||
expect(rendered).toContain("Lee, Sam");
|
||||
expect(rendered).not.toContain("CSV preview mode");
|
||||
const raw = renderToStaticMarkup(<FileContentViewer content={csv} highlightedLine={null} previewMode="raw" />);
|
||||
expect(raw).toContain("export.csv source");
|
||||
expect(raw).not.toContain("<table");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CsvPreview } from "./CsvPreview";
|
||||
import { isCsvFile } from "@/lib/csv-preview";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -234,14 +236,18 @@ interface FileContentViewerProps {
|
||||
content: WorkspaceFileContent;
|
||||
highlightedLine: number | null;
|
||||
onLoaded?: (summary: string) => void;
|
||||
previewMode?: "raw" | "rendered";
|
||||
}
|
||||
|
||||
type MarkdownPreviewMode = "raw" | "rendered";
|
||||
|
||||
export function FileContentViewer({ content, highlightedLine, onLoaded }: FileContentViewerProps) {
|
||||
export function FileContentViewer({ content, highlightedLine, onLoaded, previewMode }: FileContentViewerProps) {
|
||||
const { resource } = content;
|
||||
const isMarkdown = resource.previewKind === "text" && content.content.encoding === "utf8" && isMarkdownResource(resource);
|
||||
const [markdownMode, setMarkdownMode] = useState<MarkdownPreviewMode>("rendered");
|
||||
const isCsv = resource.previewKind === "text" && content.content.encoding === "utf8" && isCsvFile(resource.displayPath || resource.title, resource.contentType ?? "");
|
||||
const [localMode, setMarkdownMode] = useState<MarkdownPreviewMode>("rendered");
|
||||
const markdownMode = previewMode ?? localMode;
|
||||
const previewLabel = isCsv ? "CSV" : "Markdown";
|
||||
const lines = useMemo(() => {
|
||||
if (resource.previewKind === "text") {
|
||||
return splitContentIntoLines(content.content.data);
|
||||
@@ -253,8 +259,8 @@ export function FileContentViewer({ content, highlightedLine, onLoaded }: FileCo
|
||||
const highlightedLineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdownMode(isMarkdown ? "rendered" : "raw");
|
||||
}, [isMarkdown, resource.displayPath, resource.title, resource.contentType]);
|
||||
setMarkdownMode(isMarkdown || isCsv ? "rendered" : "raw");
|
||||
}, [isMarkdown, isCsv, resource.displayPath, resource.title, resource.contentType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lines) return;
|
||||
@@ -373,24 +379,24 @@ export function FileContentViewer({ content, highlightedLine, onLoaded }: FileCo
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isMarkdown) {
|
||||
if (!isMarkdown && !isCsv) {
|
||||
return rawSourceView;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div className="absolute right-3 top-3 z-20">
|
||||
{previewMode === undefined && <div className="absolute right-3 top-3 z-20">
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Markdown preview mode"
|
||||
aria-label={`${previewLabel} preview mode`}
|
||||
className="inline-flex rounded-md border border-border bg-background/95 p-0.5 shadow-sm backdrop-blur"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant={markdownMode === "rendered" ? "secondary" : "ghost"}
|
||||
size="icon-sm"
|
||||
aria-label="Show rendered Markdown"
|
||||
title="Rendered Markdown"
|
||||
aria-label={`Show rendered ${previewLabel}`}
|
||||
title={`Rendered ${previewLabel}`}
|
||||
aria-pressed={markdownMode === "rendered"}
|
||||
onClick={() => setMarkdownMode("rendered")}
|
||||
className={cn(
|
||||
@@ -404,8 +410,8 @@ export function FileContentViewer({ content, highlightedLine, onLoaded }: FileCo
|
||||
type="button"
|
||||
variant={markdownMode === "raw" ? "secondary" : "ghost"}
|
||||
size="icon-sm"
|
||||
aria-label="Show raw Markdown"
|
||||
title="Raw Markdown"
|
||||
aria-label={`Show raw ${previewLabel}`}
|
||||
title={`Raw ${previewLabel}`}
|
||||
aria-pressed={markdownMode === "raw"}
|
||||
onClick={() => setMarkdownMode("raw")}
|
||||
className={cn(
|
||||
@@ -417,9 +423,10 @@ export function FileContentViewer({ content, highlightedLine, onLoaded }: FileCo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{markdownMode === "raw" ? (
|
||||
rawSourceView
|
||||
) : (
|
||||
) : isCsv ? <CsvPreview text={content.content.data} title={resource.title} /> : (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={`${resource.title} rendered Markdown`}
|
||||
@@ -934,6 +941,7 @@ interface FileViewerBodyProps {
|
||||
onRetry: () => void;
|
||||
onSetAnnouncement: (message: string) => void;
|
||||
onFallbackToProject: null | (() => void);
|
||||
previewMode?: "raw" | "rendered";
|
||||
}
|
||||
|
||||
export function FileViewerBody({
|
||||
@@ -945,6 +953,7 @@ export function FileViewerBody({
|
||||
onRetry,
|
||||
onSetAnnouncement,
|
||||
onFallbackToProject,
|
||||
previewMode,
|
||||
}: FileViewerBodyProps) {
|
||||
if (resolveQuery.isFetching && !resolveQuery.data) {
|
||||
return <LoadingView elapsedMs={elapsedMs} />;
|
||||
@@ -1042,6 +1051,7 @@ export function FileViewerBody({
|
||||
<FileContentViewer
|
||||
content={contentQuery.data}
|
||||
highlightedLine={highlightedLine}
|
||||
previewMode={previewMode}
|
||||
onLoaded={onSetAnnouncement}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -54,6 +54,22 @@ describe("text attachment tabs", () => {
|
||||
expect(host.querySelector("[data-rendered]")).not.toBeNull();
|
||||
await act(async () => root.unmount()); host.remove();
|
||||
});
|
||||
it("renders CSV cells safely and switches to the exact raw source beside download", async () => {
|
||||
const host = document.createElement("div"); const root = createRoot(host);
|
||||
const text = 'Name,Notes,Amount\n"Lee, Sam","<script>hello</script>",12\nJo,"line 1\nline 2",';
|
||||
await act(async () => root.render(<TextAttachmentPreview title="export.csv" markdown={false} csv text={text} downloadUrl="/download" />));
|
||||
expect(host.querySelector("table")).not.toBeNull();
|
||||
expect(host.textContent).toContain("2 rows");
|
||||
expect(host.querySelector("td")?.textContent).toBe("Lee, Sam");
|
||||
expect(host.querySelector("script")).toBeNull();
|
||||
expect(host.querySelector("header")?.lastElementChild?.getAttribute("href")).toBe("/download");
|
||||
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Raw"]')!.click());
|
||||
expect(host.querySelector("pre")?.textContent).toBe(text);
|
||||
expect(host.querySelector("table")).toBeNull();
|
||||
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Rendered"]')!.click());
|
||||
expect(host.querySelector("table")).not.toBeNull();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
it("shows plain text literally without Markdown controls", async () => {
|
||||
const host = document.createElement("div"); const root = createRoot(host);
|
||||
await act(async () => root.render(<TextAttachmentPreview title="notes.txt" markdown={false} text="<script>alert(1)</script>" downloadUrl="/download" />));
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Code2, Download, Eye } from "lucide-react";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CsvPreview } from "@/components/CsvPreview";
|
||||
import { isCsvFile } from "@/lib/csv-preview";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { attachmentDownloadPath, isMarkdownAttachment, isTextAttachment } from "@/lib/issue-attachments";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
@@ -34,19 +36,21 @@ export async function readTextPreview(response: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
export function TextAttachmentPreview({ title, text, markdown, downloadUrl }: {
|
||||
export function TextAttachmentPreview({ title, text, markdown, downloadUrl, csv = false }: {
|
||||
title: string;
|
||||
text: string;
|
||||
markdown: boolean;
|
||||
csv?: boolean;
|
||||
downloadUrl: string;
|
||||
}) {
|
||||
const [raw, setRaw] = useState(false);
|
||||
useEffect(() => setRaw(false), [title]);
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-medium" title={title}>{title}</h2>
|
||||
{markdown ? (
|
||||
<div className="flex gap-1" role="group" aria-label="Markdown view">
|
||||
{markdown || csv ? (
|
||||
<div className="flex gap-1" role="group" aria-label={csv ? "CSV view" : "Markdown view"}>
|
||||
<Button size="icon-sm" variant={raw ? "ghost" : "secondary"} aria-label="Rendered" title="Rendered" aria-pressed={!raw} onClick={() => setRaw(false)}>
|
||||
<Eye aria-hidden />
|
||||
</Button>
|
||||
@@ -59,8 +63,9 @@ export function TextAttachmentPreview({ title, text, markdown, downloadUrl }: {
|
||||
<a href={downloadUrl} download aria-label={`Download ${title}`} title={`Download ${title}`}><Download aria-hidden /></a>
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
<div className={csv && !raw ? "flex min-h-0 flex-1 flex-col overflow-hidden" : "min-h-0 flex-1 overflow-auto p-4"}>
|
||||
{text.length === 0 ? <p className="text-sm text-muted-foreground">File is empty.</p>
|
||||
: csv && !raw ? <CsvPreview text={text} title={title} />
|
||||
: markdown && !raw ? <MarkdownBody mediaMode="reference">{text}</MarkdownBody>
|
||||
: <pre className="whitespace-pre-wrap break-words font-mono text-sm" aria-label={`${title} raw text`}>{text}</pre>}
|
||||
</div>
|
||||
@@ -99,5 +104,5 @@ export function TaskAttachmentPanel({ issueId, attachmentId }: { issueId: string
|
||||
);
|
||||
}
|
||||
if (content.data === undefined) return <p className="p-4 text-sm" role="status">Loading file…</p>;
|
||||
return <TextAttachmentPreview title={attachment.originalFilename ?? attachment.id} text={content.data} markdown={isMarkdownAttachment(attachment)} downloadUrl={downloadUrl} />;
|
||||
return <TextAttachmentPreview title={attachment.originalFilename ?? attachment.id} text={content.data} csv={isCsvFile(attachment.originalFilename ?? "", attachment.contentType)} markdown={isMarkdownAttachment(attachment)} downloadUrl={downloadUrl} />;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { isCsvFile } from "@/lib/csv-preview";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { Check, Copy, Download, Eye, Code2 } from "lucide-react";
|
||||
import { fileResourcesApi } from "@/api/file-resources";
|
||||
import { FileViewerBody, FileViewerMetadataRow } from "@/components/FileViewerSheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,6 +21,8 @@ export function TaskWorkspaceFilePanel({
|
||||
payload: WorkspaceFilePayload;
|
||||
onFallbackToProject?: () => void;
|
||||
}) {
|
||||
const [previewMode, setPreviewMode] = useState<"raw" | "rendered">("rendered");
|
||||
useEffect(() => setPreviewMode("rendered"), [payload.path]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const state: FileViewerUrlState = {
|
||||
@@ -46,6 +49,8 @@ export function TaskWorkspaceFilePanel({
|
||||
? fileResourcesApi.downloadUrl(issueId, state)
|
||||
: null;
|
||||
|
||||
const renderedPreview = resource && (isCsvFile(resource.displayPath || resource.title, resource.contentType ?? "") || /\.(md|markdown|mdown|mkdn|mkd)$/i.test(resource.displayPath || resource.title) || resource.contentType?.includes("markdown"));
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-muted/20">
|
||||
<header className="flex min-w-0 items-start gap-2 border-b border-border bg-card px-3 py-2">
|
||||
@@ -72,6 +77,10 @@ export function TaskWorkspaceFilePanel({
|
||||
{copied ? <Check aria-hidden /> : <Copy aria-hidden />}
|
||||
</Button>
|
||||
) : null}
|
||||
{renderedPreview && contentQuery.data ? <div role="group" aria-label="File preview mode" className="flex gap-1">
|
||||
<Button size="icon-sm" variant={previewMode === "rendered" ? "secondary" : "ghost"} aria-label="Rendered" title="Rendered" aria-pressed={previewMode === "rendered"} onClick={() => setPreviewMode("rendered")}><Eye aria-hidden /></Button>
|
||||
<Button size="icon-sm" variant={previewMode === "raw" ? "secondary" : "ghost"} aria-label="Raw" title="Raw" aria-pressed={previewMode === "raw"} onClick={() => setPreviewMode("raw")}><Code2 aria-hidden /></Button>
|
||||
</div> : null}
|
||||
{downloadUrl ? (
|
||||
<Button asChild variant="ghost" size="icon-sm">
|
||||
<a href={downloadUrl} download={resource?.title} aria-label="Download file" title="Download file">
|
||||
@@ -84,6 +93,7 @@ export function TaskWorkspaceFilePanel({
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="sr-only" aria-live="polite">{announcement}</div>
|
||||
<FileViewerBody
|
||||
previewMode={previewMode}
|
||||
resolveQuery={resourceQuery}
|
||||
contentQuery={contentQuery}
|
||||
elapsedMs={0}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isCsvFile, parseCsv } from "./csv-preview";
|
||||
|
||||
describe("CSV preview parsing", () => {
|
||||
it("handles BOM, CRLF, quoted commas, escaped quotes and multiline cells", () => {
|
||||
expect(parseCsv('\uFEFFName,Notes,Amount\r\n"Lee, Sam","Said ""hello""\nagain",12\r\n')).toEqual([
|
||||
["Name", "Notes", "Amount"], ["Lee, Sam", 'Said "hello"\nagain', "12"],
|
||||
]);
|
||||
});
|
||||
it("preserves trailing empty fields, blank records and uneven rows", () => {
|
||||
expect(parseCsv("a,b\n1,\n\n2,3,4")).toEqual([["a", "b"], ["1", ""], [""], ["2", "3", "4"]]);
|
||||
expect(parseCsv("")).toEqual([]);
|
||||
expect(parseCsv("a,b")).toEqual([["a", "b"]]);
|
||||
});
|
||||
it("reports incomplete quoted fields", () => {
|
||||
expect(() => parseCsv('a,b\n"unfinished')).toThrow("incomplete");
|
||||
});
|
||||
it("recognizes CSV filenames and media types", () => {
|
||||
expect(isCsvFile("EXPORT.CSV", "application/octet-stream")).toBe(true);
|
||||
expect(isCsvFile("download", "text/csv; charset=utf-8")).toBe(true);
|
||||
expect(isCsvFile("notes.txt", "text/plain")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export function isCsvFile(filename: string, contentType = "") {
|
||||
return /\.csv$/i.test(filename) || contentType.split(";")[0].trim().toLowerCase() === "text/csv";
|
||||
}
|
||||
|
||||
/** Parse comma-separated records, preserving quoted newlines and escaped quotes. */
|
||||
export function parseCsv(text: string): string[][] {
|
||||
const source = text.replace(/^\uFEFF/, "");
|
||||
if (!source) return [];
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let quoted = false;
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const char = source[i];
|
||||
if (char === '"') {
|
||||
if (quoted && source[i + 1] === '"') { field += '"'; i++; }
|
||||
else if (quoted || field === "") quoted = !quoted;
|
||||
else field += char;
|
||||
} else if (!quoted && char === ",") {
|
||||
row.push(field); field = "";
|
||||
} else if (!quoted && (char === "\n" || char === "\r")) {
|
||||
row.push(field); rows.push(row); row = []; field = "";
|
||||
if (char === "\r" && source[i + 1] === "\n") i++;
|
||||
} else field += char;
|
||||
}
|
||||
if (quoted) throw new Error("A quoted field is incomplete. Switch to raw view to inspect the file.");
|
||||
if (field !== "" || row.length || !/[\r\n]$/.test(source)) { row.push(field); rows.push(row); }
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { TextAttachmentPreview } from "@/components/task-side-panel/TaskAttachmentPanel";
|
||||
|
||||
const meta: Meta<typeof TextAttachmentPreview> = {
|
||||
title: "Components/CSV File Tab",
|
||||
component: TextAttachmentPreview,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [(Story) => <div className="h-screen bg-background text-foreground"><Story /></div>],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof TextAttachmentPreview>;
|
||||
export const RevenueExport: Story = {
|
||||
args: {
|
||||
title: "monthly-revenue.csv",
|
||||
markdown: false,
|
||||
csv: true,
|
||||
downloadUrl: "data:text/csv,Month%2CRevenue%0AOctober%2C12800",
|
||||
text: 'Month,Customer,Plan,Revenue,Status,Notes\nOctober,"Acme, Inc.",Enterprise,12800,Active,"Expanded to 40 seats"\nOctober,Northstar Studio,Pro,2400,Active,"Annual renewal"\nOctober,Atlas Labs,Enterprise,9600,Active,"Includes onboarding"\nOctober,Juniper Design,Starter,480,Trial,"Review on Friday"\nSeptember,"Acme, Inc.",Enterprise,11200,Active,"32 seats"\nSeptember,Northstar Studio,Pro,2400,Active,"Annual renewal"\nSeptember,Atlas Labs,Pro,4800,Active,"Upgrade scheduled"\nSeptember,Juniper Design,Starter,480,Trial,"First month"',
|
||||
},
|
||||
};
|
||||
export const Empty: Story = { args: { ...RevenueExport.args, text: "" } };
|
||||
export const Invalid: Story = { args: { ...RevenueExport.args, text: 'Name,Notes\nSam,"unfinished' } };
|
||||
Reference in new issue
Block a user