@tangle-network/ui 11.25.0 → 11.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +22 -0
- package/dist/editor/editor-peers.js +1 -21
- package/dist/files/file-card.d.ts +30 -0
- package/dist/files/file-card.js +162 -0
- package/dist/files/file-list.d.ts +25 -0
- package/dist/files/file-list.js +88 -0
- package/dist/files/file-preview.js +16 -62
- package/dist/files/pdf-loader.d.ts +22 -0
- package/dist/files/pdf-loader.js +82 -0
- package/dist/files/pdf-viewer.d.ts +32 -0
- package/dist/files/pdf-viewer.js +567 -0
- package/dist/files.d.ts +5 -1
- package/dist/files.js +5 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +5 -1
- package/dist/lib/optional-peer.js +29 -0
- package/dist/primitives.d.ts +2 -2
- package/package.json +6 -1
- package/src/editor/editor-peers.ts +3 -25
- package/src/files/file-artifact-pane.test.tsx +12 -2
- package/src/files/file-card.test.tsx +62 -0
- package/src/files/file-card.tsx +210 -0
- package/src/files/file-list.stories.tsx +121 -0
- package/src/files/file-list.test.tsx +81 -0
- package/src/files/file-list.tsx +167 -0
- package/src/files/file-preview.tsx +25 -94
- package/src/files/index.ts +9 -0
- package/src/files/pdf-loader.test.ts +130 -0
- package/src/files/pdf-loader.ts +169 -0
- package/src/files/pdf-viewer.stories.tsx +81 -0
- package/src/files/pdf-viewer.test.tsx +245 -0
- package/src/files/pdf-viewer.tsx +677 -0
- package/src/files/pdfjs-worker.d.ts +3 -0
- package/src/lib/optional-peer.ts +30 -0
package/dist/index.js
CHANGED
|
@@ -82,7 +82,11 @@ import { LiveDuration } from "./run/run-item-primitives.js";
|
|
|
82
82
|
import { ToolCallFeed, parseToolEvent } from "./run/tool-call-feed.js";
|
|
83
83
|
import { FileTree, filterFileTree } from "./files/file-tree.js";
|
|
84
84
|
import { RichFileTree } from "./files/rich-file-tree.js";
|
|
85
|
+
import { FileCard } from "./files/file-card.js";
|
|
86
|
+
import { configurePdfViewer } from "./files/pdf-loader.js";
|
|
87
|
+
import { PdfViewer } from "./files/pdf-viewer.js";
|
|
85
88
|
import { CSV_PREVIEW_ROW_LIMIT, FilePreview, parseCsv } from "./files/file-preview.js";
|
|
89
|
+
import { FileList } from "./files/file-list.js";
|
|
86
90
|
import { FileTabs } from "./files/file-tabs.js";
|
|
87
91
|
import { FileArtifactPane } from "./files/file-artifact-pane.js";
|
|
88
92
|
import { AuthHeader, GitHubLoginButton, UserMenu } from "./auth/auth.js";
|
|
@@ -99,4 +103,4 @@ import { addMessage, addParts, clearChat, isStreamingAtom, messagesAtom, partMap
|
|
|
99
103
|
import { copyText } from "./utils/copy-text.js";
|
|
100
104
|
import { timeAgo } from "./utils/time-ago.js";
|
|
101
105
|
import { RedactedDocument } from "./redaction/redacted-document.js";
|
|
102
|
-
export { AgentTimeline, Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, ArtifactPane, AuthHeader, Avatar, AvatarFallback, AvatarImage, Badge, Button, CATEGORY_TONES, CSV_PREVIEW_ROW_LIMIT, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, ChatContainer, ChatMessage, Checkbox, Chip, CodeBlock, Collapsible, CollapsibleContent, CollapsibleTrigger, CommandPreview, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, CopyButton, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DiffPreview, DropZone, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, ExpandedToolDetail, FileArtifactPane, FilePreview, FileTabs, FileTree, FilterField, GitHubLoginButton, GlobResultsPreview, GrepResultsPreview, Heading, IconTile, InlineCode, InlineThinkingItem, InlineToolItem, Input, Label, LiveDuration, LoginLayout, Logo, Markdown, MessageAuthor, MessageList, Metric, MetricStrip, OPENUI_NODE_JSON_SCHEMA, OPENUI_NODE_TYPES, OpenUIArtifactRenderer, PageHeader, PageShell, Pagination, Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, Progress, QuestionPreview, RealtimeSessionRegistry, RedactedDocument, RichFileTree, RunGroup, RunRowShell, RunRowStatusDot, SegmentedControl, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, SidebarDropZone, Skeleton, SkeletonCard, SkeletonTable, Slider, StatCard, StatusPill, Switch, TONE_CLASSES, TOOL_CATEGORY_ICONS, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, TangleKnot, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, TextShimmer, Textarea, ThemeToggle, ThinkingIndicator, ToastContainer, ToastProvider, ToolCallFeed, Toolbar, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, UploadProgress, UserMenu, UserMessage, WebSearchPreview, WriteFilePreview, activeSessionsAtom, addMessage, addParts, badgeVariants, bumpActiveSessionActivity, buttonVariants, clearChat, cn, copyText, createAuthFetcher, detectFileFormat, fileExtension, filterFileTree, findUnsupportedOpenUINode, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getActiveSession, getAllActiveSessions, getAllProjectActivity, getCodeLanguage, getFormatLabel, getSanitizedMarkdownHeadingIdFromRawFragment, getSessionsByActivity, getSessionsForNavbar, getSessionsForProject, getSyntaxLanguage, getToolCategory, getToolDisplayMetadata, getToolErrorText, getTotalRunningSessionCount, hasBackgroundRunningSessions, initialsOf, isStreamingAtom, isViewerMessage, messagesAtom, parseCsv, parseToolEvent, partMapAtom, registerActiveSession, resetActiveSessions, resolveFilePreviewKind, setActiveSessionAttention, setActiveSessionConnection, setActiveSessionError, setActiveSessionRunning, setForegroundActiveSession, timeAgo, toneFor, truncateText, unregisterActiveSession, updateActiveSessionMeta, updatePart, useActiveSession, useActiveSessions, useActiveSessionsState, useApiKey, useAuth, useAutoScroll, useDropdownMenu, useHasBackgroundRunningSessions, useLiveTime, useNavbarSessions, useProjectActivity, useProjectSessions, useRealtimeSession, useRunCollapseState, useRunGroups, useSSEStream, useSdkSession, useSessionsByActivity, useTheme, useToast, useToolCallStream, useTotalRunningSessions, validateOpenUIJsonArtifact, validateOpenUIJsonNode };
|
|
106
|
+
export { AgentTimeline, Alert, AlertDescription, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, AlertTitle, ArtifactPane, AuthHeader, Avatar, AvatarFallback, AvatarImage, Badge, Button, CATEGORY_TONES, CSV_PREVIEW_ROW_LIMIT, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, ChatContainer, ChatMessage, Checkbox, Chip, CodeBlock, Collapsible, CollapsibleContent, CollapsibleTrigger, CommandPreview, ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, CopyButton, Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, DiffPreview, DropZone, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, EmptyState, ExpandedToolDetail, FileArtifactPane, FileCard, FileList, FilePreview, FileTabs, FileTree, FilterField, GitHubLoginButton, GlobResultsPreview, GrepResultsPreview, Heading, IconTile, InlineCode, InlineThinkingItem, InlineToolItem, Input, Label, LiveDuration, LoginLayout, Logo, Markdown, MessageAuthor, MessageList, Metric, MetricStrip, OPENUI_NODE_JSON_SCHEMA, OPENUI_NODE_TYPES, OpenUIArtifactRenderer, PageHeader, PageShell, Pagination, PdfViewer, Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger, Progress, QuestionPreview, RealtimeSessionRegistry, RedactedDocument, RichFileTree, RunGroup, RunRowShell, RunRowStatusDot, SegmentedControl, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger, SidebarDropZone, Skeleton, SkeletonCard, SkeletonTable, Slider, StatCard, StatusPill, Switch, TONE_CLASSES, TOOL_CATEGORY_ICONS, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Tag, TangleKnot, TerminalCursor, TerminalDisplay, TerminalInput, TerminalLine, TextShimmer, Textarea, ThemeToggle, ThinkingIndicator, ToastContainer, ToastProvider, ToolCallFeed, Toolbar, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, UploadProgress, UserMenu, UserMessage, WebSearchPreview, WriteFilePreview, activeSessionsAtom, addMessage, addParts, badgeVariants, bumpActiveSessionActivity, buttonVariants, clearChat, cn, configurePdfViewer, copyText, createAuthFetcher, detectFileFormat, fileExtension, filterFileTree, findUnsupportedOpenUINode, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getActiveSession, getAllActiveSessions, getAllProjectActivity, getCodeLanguage, getFormatLabel, getSanitizedMarkdownHeadingIdFromRawFragment, getSessionsByActivity, getSessionsForNavbar, getSessionsForProject, getSyntaxLanguage, getToolCategory, getToolDisplayMetadata, getToolErrorText, getTotalRunningSessionCount, hasBackgroundRunningSessions, initialsOf, isStreamingAtom, isViewerMessage, messagesAtom, parseCsv, parseToolEvent, partMapAtom, registerActiveSession, resetActiveSessions, resolveFilePreviewKind, setActiveSessionAttention, setActiveSessionConnection, setActiveSessionError, setActiveSessionRunning, setForegroundActiveSession, timeAgo, toneFor, truncateText, unregisterActiveSession, updateActiveSessionMeta, updatePart, useActiveSession, useActiveSessions, useActiveSessionsState, useApiKey, useAuth, useAutoScroll, useDropdownMenu, useHasBackgroundRunningSessions, useLiveTime, useNavbarSessions, useProjectActivity, useProjectSessions, useRealtimeSession, useRunCollapseState, useRunGroups, useSSEStream, useSdkSession, useSessionsByActivity, useTheme, useToast, useToolCallStream, useTotalRunningSessions, validateOpenUIJsonArtifact, validateOpenUIJsonNode };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//#region src/lib/optional-peer.ts
|
|
2
|
+
/**
|
|
3
|
+
* Shared handling for optional peers that an entry reaches through a dynamic
|
|
4
|
+
* `import()`: `./editor` for tiptap, yjs and Hocuspocus, `./files` for
|
|
5
|
+
* pdfjs-dist. `scripts/validate-dist.mjs` holds the rules these helpers serve.
|
|
6
|
+
*/
|
|
7
|
+
/** The messages a bundler or a runtime gives for a module it cannot resolve. */
|
|
8
|
+
const RESOLUTION_FAILURE = /could not resolve|cannot find (?:module|package)|can't resolve|failed to resolve|module not found/i;
|
|
9
|
+
/**
|
|
10
|
+
* True when the rejection says the package is not installed. Only such an
|
|
11
|
+
* error gets the install list: a transient chunk-fetch failure that reads as
|
|
12
|
+
* "install the peers" sends the reader to the wrong fix. An error this
|
|
13
|
+
* predicate does not match keeps its own message, so it can only
|
|
14
|
+
* under-report.
|
|
15
|
+
*/
|
|
16
|
+
function isMissingPeerError(error) {
|
|
17
|
+
return error instanceof Error && RESOLUTION_FAILURE.test(error.message);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Hands an `import()` rejection on unchanged. esbuild reports an unresolvable
|
|
21
|
+
* literal `import()` as a build error and defers it to run time only when the
|
|
22
|
+
* call carries a `.catch()`, so every peer import attaches this handler. It
|
|
23
|
+
* changes nothing at run time.
|
|
24
|
+
*/
|
|
25
|
+
function rethrow(error) {
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
export { isMissingPeerError, rethrow };
|
package/dist/primitives.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Button, ButtonProps, buttonVariants } from "./primitives/button.js";
|
|
2
2
|
import { ArtifactPane, ArtifactPaneProps } from "./primitives/artifact-pane.js";
|
|
3
|
+
import { CATEGORY_TONES, CategoryTone, TONE_CLASSES, Tone, ToneClasses, toneFor } from "./primitives/tone.js";
|
|
4
|
+
import { IconTile, IconTileProps, initialsOf } from "./primitives/icon-tile.js";
|
|
3
5
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, CardTitleProps } from "./primitives/card.js";
|
|
4
6
|
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger } from "./primitives/dialog.js";
|
|
5
7
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger } from "./primitives/alert-dialog.js";
|
|
@@ -40,10 +42,8 @@ import { PageHeader, PageHeaderProps } from "./primitives/page-header.js";
|
|
|
40
42
|
import { StatusPill, StatusPillProps, StatusTone } from "./primitives/status-pill.js";
|
|
41
43
|
import { Alert, AlertDescription, AlertProps, AlertTitle, AlertTone } from "./primitives/alert.js";
|
|
42
44
|
import { Pagination, PaginationProps } from "./primitives/pagination.js";
|
|
43
|
-
import { CATEGORY_TONES, CategoryTone, TONE_CLASSES, Tone, ToneClasses, toneFor } from "./primitives/tone.js";
|
|
44
45
|
import { Tag, TagProps } from "./primitives/tag.js";
|
|
45
46
|
import { Chip, ChipProps } from "./primitives/chip.js";
|
|
46
|
-
import { IconTile, IconTileProps, initialsOf } from "./primitives/icon-tile.js";
|
|
47
47
|
import { Metric, MetricProps, MetricStrip, MetricStripProps } from "./primitives/metric-strip.js";
|
|
48
48
|
import { FilterField, FilterFieldProps, Toolbar, ToolbarProps } from "./primitives/toolbar.js";
|
|
49
49
|
import { Logo, LogoProps, TangleKnot } from "@tangle-network/brand";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/ui",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.26.0",
|
|
4
4
|
"description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -156,6 +156,7 @@
|
|
|
156
156
|
"@tiptap/react": ">=3.20.4 <4.0.0",
|
|
157
157
|
"@tiptap/starter-kit": ">=3.20.4 <4.0.0",
|
|
158
158
|
"nanostores": ">=0.11.3 <2.0.0",
|
|
159
|
+
"pdfjs-dist": ">=6.0.0 <7.0.0",
|
|
159
160
|
"react": "^18 || ^19",
|
|
160
161
|
"react-dom": "^18 || ^19",
|
|
161
162
|
"react-router": "^7 || ^8",
|
|
@@ -191,6 +192,9 @@
|
|
|
191
192
|
},
|
|
192
193
|
"react-router": {
|
|
193
194
|
"optional": true
|
|
195
|
+
},
|
|
196
|
+
"pdfjs-dist": {
|
|
197
|
+
"optional": true
|
|
194
198
|
}
|
|
195
199
|
},
|
|
196
200
|
"devDependencies": {
|
|
@@ -208,6 +212,7 @@
|
|
|
208
212
|
"@types/turndown": "^5.0.6",
|
|
209
213
|
"ajv": "8.17.1",
|
|
210
214
|
"nanostores": "^1.5.4",
|
|
215
|
+
"pdfjs-dist": "^6.4.299",
|
|
211
216
|
"react": "^19.3.0",
|
|
212
217
|
"react-dom": "^19.3.0",
|
|
213
218
|
"react-router": "^8.4.0",
|
|
@@ -32,6 +32,9 @@ import type * as TiptapCollaborationCaret from "@tiptap/extension-collaboration-
|
|
|
32
32
|
import type * as TiptapReact from "@tiptap/react";
|
|
33
33
|
import type * as TiptapStarterKit from "@tiptap/starter-kit";
|
|
34
34
|
import type * as Yjs from "yjs";
|
|
35
|
+
import { isMissingPeerError, rethrow } from "../lib/optional-peer";
|
|
36
|
+
|
|
37
|
+
export { isMissingPeerError };
|
|
35
38
|
|
|
36
39
|
/** Namespaces the local markdown editor needs. */
|
|
37
40
|
export interface DocumentEditorPeers {
|
|
@@ -89,31 +92,6 @@ export function isMissingEditorPeersError(error: unknown): boolean {
|
|
|
89
92
|
return error instanceof Error && error.name === "MissingEditorPeersError";
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
/** The messages a bundler or a runtime gives for a module it cannot resolve. */
|
|
93
|
-
const RESOLUTION_FAILURE =
|
|
94
|
-
/could not resolve|cannot find (?:module|package)|can't resolve|failed to resolve|module not found/i;
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* True when the rejection says the package is not installed. Only such an
|
|
98
|
-
* error gets the install list: a transient chunk-fetch failure that reads as
|
|
99
|
-
* "install the peers" sends the reader to the wrong fix. An error this
|
|
100
|
-
* predicate does not match keeps its own message, so it can only
|
|
101
|
-
* under-report.
|
|
102
|
-
*/
|
|
103
|
-
export function isMissingPeerError(error: unknown): boolean {
|
|
104
|
-
return error instanceof Error && RESOLUTION_FAILURE.test(error.message);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Hands an `import()` rejection on unchanged. esbuild reports an unresolvable
|
|
109
|
-
* literal `import()` as a build error and defers it to run time only when the
|
|
110
|
-
* call carries a `.catch()`, so every peer import attaches this handler. It
|
|
111
|
-
* changes nothing at run time.
|
|
112
|
-
*/
|
|
113
|
-
function rethrow(error: unknown): never {
|
|
114
|
-
throw error;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
95
|
/**
|
|
118
96
|
* Every optional peer the `./editor` entry needs at run time. The loaders
|
|
119
97
|
* import all but `@tiptap/core`, which the tiptap packages need in turn: a
|
|
@@ -2,6 +2,12 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
3
|
import { FileArtifactPane } from "./file-artifact-pane";
|
|
4
4
|
import { CSV_PREVIEW_ROW_LIMIT, parseCsv } from "./file-preview";
|
|
5
|
+
import { MissingPdfjsError, loadPdfjs } from "./pdf-loader";
|
|
6
|
+
|
|
7
|
+
vi.mock("./pdf-loader", async (importOriginal) => ({
|
|
8
|
+
...(await importOriginal<typeof import("./pdf-loader")>()),
|
|
9
|
+
loadPdfjs: vi.fn(),
|
|
10
|
+
}));
|
|
5
11
|
|
|
6
12
|
const BLOB_URL = "blob:https://app.tangle.tools/1f2e3d4c";
|
|
7
13
|
|
|
@@ -32,16 +38,20 @@ describe("FileArtifactPane previews", () => {
|
|
|
32
38
|
expect(container.querySelector("img")).toHaveClass("max-w-none");
|
|
33
39
|
});
|
|
34
40
|
|
|
35
|
-
it("renders a PDF
|
|
41
|
+
it("renders a PDF in PdfViewer, and in an <object> of the blobUrl without pdfjs-dist", async () => {
|
|
42
|
+
vi.mocked(loadPdfjs).mockRejectedValue(new MissingPdfjsError());
|
|
43
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
36
44
|
const { container } = render(
|
|
37
45
|
<FileArtifactPane filename="report.pdf" blobUrl={BLOB_URL} mimeType="application/pdf" />,
|
|
38
46
|
);
|
|
39
47
|
|
|
48
|
+
expect(screen.getByRole("status")).toHaveTextContent("Loading report.pdf");
|
|
49
|
+
expect(await screen.findByText(/does not include the page viewer/)).toBeInTheDocument();
|
|
40
50
|
const viewer = container.querySelector("object");
|
|
41
|
-
expect(viewer).not.toBeNull();
|
|
42
51
|
expect(viewer).toHaveAttribute("data", BLOB_URL);
|
|
43
52
|
expect(viewer).toHaveAttribute("type", "application/pdf");
|
|
44
53
|
expect(viewer).toHaveAttribute("title", "report.pdf");
|
|
54
|
+
consoleError.mockRestore();
|
|
45
55
|
});
|
|
46
56
|
|
|
47
57
|
it("renders video and audio with native controls", () => {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { FileCard } from "./file-card";
|
|
4
|
+
|
|
5
|
+
const UPLOADED = new Date("2026-10-06T15:30:00Z");
|
|
6
|
+
|
|
7
|
+
describe("FileCard", () => {
|
|
8
|
+
it("names the file with its size, date and uploader", () => {
|
|
9
|
+
const { container } = render(
|
|
10
|
+
<FileCard
|
|
11
|
+
filename="Engagement letter.pdf"
|
|
12
|
+
size={2_516_582}
|
|
13
|
+
date={UPLOADED}
|
|
14
|
+
uploader={{ name: "Dana Ruiz", role: "Client" }}
|
|
15
|
+
/>,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
expect(screen.getByText("Engagement letter.pdf")).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByText("2.4 MB")).toBeInTheDocument();
|
|
20
|
+
expect(container.querySelector("time")).toHaveAttribute("datetime", UPLOADED.toISOString());
|
|
21
|
+
expect(screen.getByText("Uploaded by Dana Ruiz (Client)")).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("leaves out what it does not know", () => {
|
|
25
|
+
const { container } = render(<FileCard filename="notes.txt" date="not a date" />);
|
|
26
|
+
expect(container.querySelector("time")).toBeNull();
|
|
27
|
+
expect(screen.queryByText(/Uploaded by/)).toBeNull();
|
|
28
|
+
expect(screen.queryByRole("button")).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("takes its icon tone from the file kind, with the MIME type outranking the name", () => {
|
|
32
|
+
const pdf = render(<FileCard filename="brief.pdf" />);
|
|
33
|
+
expect(pdf.container.querySelector("[aria-hidden='true']")?.className).toContain(
|
|
34
|
+
"--tone-orange-bg",
|
|
35
|
+
);
|
|
36
|
+
pdf.unmount();
|
|
37
|
+
|
|
38
|
+
const sheet = render(<FileCard filename="export" mimeType="text/csv" />);
|
|
39
|
+
expect(sheet.container.querySelector("[aria-hidden='true']")?.className).toContain(
|
|
40
|
+
"--tone-lime-bg",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("offers Download and the host's own actions", () => {
|
|
45
|
+
const onDownload = vi.fn();
|
|
46
|
+
render(
|
|
47
|
+
<FileCard
|
|
48
|
+
filename="book.xlsx"
|
|
49
|
+
description="Download to open this workbook in a spreadsheet app."
|
|
50
|
+
onDownload={onDownload}
|
|
51
|
+
actions={<button type="button">Open</button>}
|
|
52
|
+
/>,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(
|
|
56
|
+
screen.getByText("Download to open this workbook in a spreadsheet app."),
|
|
57
|
+
).toBeInTheDocument();
|
|
58
|
+
fireEvent.click(screen.getByRole("button", { name: "Download" }));
|
|
59
|
+
expect(onDownload).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileCard — one file, described: its kind, name, size, date and uploader,
|
|
3
|
+
* with Download and any actions the host adds. FilePreview shows it for a file
|
|
4
|
+
* it cannot render inline; a host can show it anywhere a file stands on its own.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
import {
|
|
9
|
+
Download,
|
|
10
|
+
File,
|
|
11
|
+
FileAudio,
|
|
12
|
+
FileCode,
|
|
13
|
+
FileImage,
|
|
14
|
+
FileJson,
|
|
15
|
+
FileSpreadsheet,
|
|
16
|
+
FileText,
|
|
17
|
+
FileVideo,
|
|
18
|
+
type LucideIcon,
|
|
19
|
+
} from "lucide-react";
|
|
20
|
+
import { cn } from "../lib/utils";
|
|
21
|
+
import { Button } from "../primitives/button";
|
|
22
|
+
import { IconTile, type IconTileProps } from "../primitives/icon-tile";
|
|
23
|
+
import type { CategoryTone } from "../primitives/tone";
|
|
24
|
+
import { formatBytes } from "../utils/format";
|
|
25
|
+
import { getFormatLabel, resolveFilePreviewKind, type FilePreviewKind } from "./file-format";
|
|
26
|
+
|
|
27
|
+
/** Who added a file, as a reader of the list should see them. */
|
|
28
|
+
export interface FileUploader {
|
|
29
|
+
name: string;
|
|
30
|
+
/** Their relationship to the work, e.g. "Client" or "Attorney". */
|
|
31
|
+
role?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What FileCard and FileList show about one file. */
|
|
35
|
+
export interface FileDetails {
|
|
36
|
+
filename: string;
|
|
37
|
+
/** Wins over the extension when choosing the kind, as in FilePreview. */
|
|
38
|
+
mimeType?: string;
|
|
39
|
+
/** Size in bytes. */
|
|
40
|
+
size?: number;
|
|
41
|
+
/** When the file was added or last changed. */
|
|
42
|
+
date?: Date | string | number;
|
|
43
|
+
uploader?: FileUploader;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// File kinds are categories, so each takes a categorical tone and never a
|
|
47
|
+
// status colour: a PDF is not an error. See "Color Roles" in the brand guide.
|
|
48
|
+
const KIND_ICON: Record<FilePreviewKind, { icon: LucideIcon; tone: CategoryTone }> = {
|
|
49
|
+
pdf: { icon: FileText, tone: "orange" },
|
|
50
|
+
image: { icon: FileImage, tone: "pink" },
|
|
51
|
+
video: { icon: FileVideo, tone: "violet" },
|
|
52
|
+
audio: { icon: FileAudio, tone: "cyan" },
|
|
53
|
+
csv: { icon: FileSpreadsheet, tone: "lime" },
|
|
54
|
+
spreadsheet: { icon: FileSpreadsheet, tone: "lime" },
|
|
55
|
+
code: { icon: FileCode, tone: "blue" },
|
|
56
|
+
json: { icon: FileJson, tone: "blue" },
|
|
57
|
+
yaml: { icon: FileCode, tone: "blue" },
|
|
58
|
+
markdown: { icon: FileText, tone: "teal" },
|
|
59
|
+
text: { icon: FileText, tone: "teal" },
|
|
60
|
+
binary: { icon: File, tone: "brown" },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The kind's icon on its category tone. Decorative: the filename names the file. */
|
|
64
|
+
export function FileKindTile({
|
|
65
|
+
filename,
|
|
66
|
+
mimeType,
|
|
67
|
+
size = "md",
|
|
68
|
+
className,
|
|
69
|
+
}: {
|
|
70
|
+
filename: string;
|
|
71
|
+
mimeType?: string;
|
|
72
|
+
size?: IconTileProps["size"];
|
|
73
|
+
className?: string;
|
|
74
|
+
}) {
|
|
75
|
+
const { icon: Icon, tone } = KIND_ICON[resolveFilePreviewKind(filename, mimeType)];
|
|
76
|
+
return <IconTile tone={tone} size={size} icon={<Icon />} className={className} />;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toDate(value: FileDetails["date"]): Date | undefined {
|
|
80
|
+
if (value === undefined) return undefined;
|
|
81
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
82
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A dot between facts for the eye, a comma for a screen reader, which would
|
|
89
|
+
* otherwise read the facts as one run-on word or announce "middle dot".
|
|
90
|
+
*/
|
|
91
|
+
export function FactSeparator() {
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
<span aria-hidden="true"> · </span>
|
|
95
|
+
<span className="sr-only">, </span>
|
|
96
|
+
</>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The kind, size and date as one muted line, each part only when known.
|
|
102
|
+
* The date follows the reader's locale, which a server render may not share,
|
|
103
|
+
* so the element tolerates a hydration difference.
|
|
104
|
+
*/
|
|
105
|
+
export function FileFacts({
|
|
106
|
+
filename,
|
|
107
|
+
mimeType,
|
|
108
|
+
size,
|
|
109
|
+
date,
|
|
110
|
+
showKind = false,
|
|
111
|
+
className,
|
|
112
|
+
}: Pick<FileDetails, "filename" | "mimeType" | "size" | "date"> & {
|
|
113
|
+
showKind?: boolean;
|
|
114
|
+
className?: string;
|
|
115
|
+
}) {
|
|
116
|
+
const parsed = toDate(date);
|
|
117
|
+
const parts: React.ReactNode[] = [];
|
|
118
|
+
if (showKind) parts.push(getFormatLabel(resolveFilePreviewKind(filename, mimeType)));
|
|
119
|
+
if (size !== undefined) parts.push(formatBytes(size));
|
|
120
|
+
if (parsed) {
|
|
121
|
+
parts.push(
|
|
122
|
+
<time key="date" dateTime={parsed.toISOString()} suppressHydrationWarning>
|
|
123
|
+
{DATE_FORMAT.format(parsed)}
|
|
124
|
+
</time>,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (parts.length === 0) return null;
|
|
128
|
+
return (
|
|
129
|
+
<span className={cn("text-xs text-muted-foreground", className)}>
|
|
130
|
+
{parts.map((part, index) => (
|
|
131
|
+
<React.Fragment key={index}>
|
|
132
|
+
{index > 0 && <FactSeparator />}
|
|
133
|
+
{part}
|
|
134
|
+
</React.Fragment>
|
|
135
|
+
))}
|
|
136
|
+
</span>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** "Dana Ruiz (Client)", or the name alone. */
|
|
141
|
+
export function uploaderLabel(uploader: FileUploader): string {
|
|
142
|
+
return uploader.role ? `${uploader.name} (${uploader.role})` : uploader.name;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface FileCardProps
|
|
146
|
+
extends FileDetails,
|
|
147
|
+
Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
148
|
+
/** One sentence under the details, such as why there is no preview. */
|
|
149
|
+
description?: React.ReactNode;
|
|
150
|
+
/** Adds a Download button. */
|
|
151
|
+
onDownload?: () => void;
|
|
152
|
+
/** Further buttons after Download, such as Open or Delete. */
|
|
153
|
+
actions?: React.ReactNode;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const FileCard = React.forwardRef<HTMLDivElement, FileCardProps>(
|
|
157
|
+
(
|
|
158
|
+
{
|
|
159
|
+
filename,
|
|
160
|
+
mimeType,
|
|
161
|
+
size,
|
|
162
|
+
date,
|
|
163
|
+
uploader,
|
|
164
|
+
description,
|
|
165
|
+
onDownload,
|
|
166
|
+
actions,
|
|
167
|
+
className,
|
|
168
|
+
...props
|
|
169
|
+
},
|
|
170
|
+
ref,
|
|
171
|
+
) => (
|
|
172
|
+
<div
|
|
173
|
+
ref={ref}
|
|
174
|
+
className={cn(
|
|
175
|
+
"flex min-w-0 flex-col items-center justify-center rounded-[var(--radius-md)] border border-border bg-card px-6 py-12 text-center",
|
|
176
|
+
className,
|
|
177
|
+
)}
|
|
178
|
+
{...props}
|
|
179
|
+
>
|
|
180
|
+
<FileKindTile filename={filename} mimeType={mimeType} size="lg" />
|
|
181
|
+
<p
|
|
182
|
+
className="mt-3 line-clamp-2 max-w-full text-sm font-medium text-foreground [overflow-wrap:anywhere]"
|
|
183
|
+
title={filename}
|
|
184
|
+
>
|
|
185
|
+
{filename}
|
|
186
|
+
</p>
|
|
187
|
+
<FileFacts filename={filename} size={size} date={date} className="mt-1" />
|
|
188
|
+
{uploader && (
|
|
189
|
+
<p className="mt-0.5 max-w-full truncate text-xs text-muted-foreground">
|
|
190
|
+
Uploaded by {uploaderLabel(uploader)}
|
|
191
|
+
</p>
|
|
192
|
+
)}
|
|
193
|
+
{description && (
|
|
194
|
+
<div className="mt-3 max-w-md text-sm text-muted-foreground">{description}</div>
|
|
195
|
+
)}
|
|
196
|
+
{(onDownload || actions) && (
|
|
197
|
+
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
|
|
198
|
+
{onDownload && (
|
|
199
|
+
<Button type="button" variant="outline" size="sm" onClick={onDownload}>
|
|
200
|
+
<Download />
|
|
201
|
+
Download
|
|
202
|
+
</Button>
|
|
203
|
+
)}
|
|
204
|
+
{actions}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
FileCard.displayName = "FileCard";
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Button } from "../primitives/button";
|
|
4
|
+
import { FileCard } from "./file-card";
|
|
5
|
+
import { FileList, type FileListItem } from "./file-list";
|
|
6
|
+
|
|
7
|
+
const CASE_FILES: FileListItem[] = [
|
|
8
|
+
{
|
|
9
|
+
id: "lease",
|
|
10
|
+
filename: "Commercial lease — 418 Harbor Street.pdf",
|
|
11
|
+
size: 2_516_582,
|
|
12
|
+
date: "2026-09-28T16:20:00Z",
|
|
13
|
+
uploader: { name: "Dana Ruiz", role: "Client" },
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "notice",
|
|
17
|
+
filename: "Notice of lease termination.pdf",
|
|
18
|
+
size: 75_955,
|
|
19
|
+
date: "2026-10-06T09:05:00Z",
|
|
20
|
+
uploader: { name: "Priya Adeyemi", role: "Attorney" },
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "photos",
|
|
24
|
+
filename: "Rear storage room, water damage.jpg",
|
|
25
|
+
mimeType: "image/jpeg",
|
|
26
|
+
size: 3_145_728,
|
|
27
|
+
date: "2026-08-12T18:42:00Z",
|
|
28
|
+
uploader: { name: "Dana Ruiz", role: "Client" },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "ledger",
|
|
32
|
+
filename: "Rent ledger 2023–2026.xlsx",
|
|
33
|
+
size: 48_640,
|
|
34
|
+
date: "2026-09-30T11:00:00Z",
|
|
35
|
+
uploader: { name: "Coastal Property Holdings" },
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "estimate",
|
|
39
|
+
filename: "Contractor estimate.docx",
|
|
40
|
+
size: 182_000,
|
|
41
|
+
date: "2026-08-20T14:10:00Z",
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function CaseDocuments() {
|
|
46
|
+
const [files, setFiles] = useState(CASE_FILES);
|
|
47
|
+
const [openId, setOpenId] = useState("notice");
|
|
48
|
+
return (
|
|
49
|
+
<FileList
|
|
50
|
+
aria-label="Case documents"
|
|
51
|
+
files={files}
|
|
52
|
+
activeId={openId}
|
|
53
|
+
onOpen={(file) => setOpenId(file.id)}
|
|
54
|
+
onDownload={() => {}}
|
|
55
|
+
onDelete={(file) => setFiles((current) => current.filter((item) => item.id !== file.id))}
|
|
56
|
+
empty="No documents yet. Upload the lease to get started."
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const meta: Meta<typeof FileList> = {
|
|
62
|
+
title: "Files/FileList",
|
|
63
|
+
component: FileList,
|
|
64
|
+
parameters: { layout: "fullscreen" },
|
|
65
|
+
decorators: [
|
|
66
|
+
(Story) => (
|
|
67
|
+
<div className="min-h-[100dvh] bg-background p-4 text-foreground">
|
|
68
|
+
<div className="mx-auto max-w-2xl">
|
|
69
|
+
<Story />
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
),
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export default meta;
|
|
77
|
+
type Story = StoryObj<typeof FileList>;
|
|
78
|
+
|
|
79
|
+
export const Documents: Story = {
|
|
80
|
+
name: "Case documents",
|
|
81
|
+
render: () => <CaseDocuments />,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const DocumentsLight: Story = {
|
|
85
|
+
name: "Case documents (light)",
|
|
86
|
+
globals: { theme: "light" },
|
|
87
|
+
render: () => <CaseDocuments />,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const ReadOnly: Story = {
|
|
91
|
+
name: "Read-only list",
|
|
92
|
+
render: () => <FileList aria-label="Shared with you" files={CASE_FILES.slice(0, 3)} />,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const Empty: Story = {
|
|
96
|
+
render: () => <FileList files={[]} empty="No documents yet. Upload the lease to get started." />,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const Card: Story = {
|
|
100
|
+
name: "FileCard",
|
|
101
|
+
render: () => (
|
|
102
|
+
<FileCard
|
|
103
|
+
filename="Commercial lease — 418 Harbor Street.pdf"
|
|
104
|
+
size={2_516_582}
|
|
105
|
+
date="2026-09-28T16:20:00Z"
|
|
106
|
+
uploader={{ name: "Dana Ruiz", role: "Client" }}
|
|
107
|
+
onDownload={() => {}}
|
|
108
|
+
actions={
|
|
109
|
+
<Button type="button" size="sm">
|
|
110
|
+
Open
|
|
111
|
+
</Button>
|
|
112
|
+
}
|
|
113
|
+
/>
|
|
114
|
+
),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const CardLight: Story = {
|
|
118
|
+
name: "FileCard (light)",
|
|
119
|
+
globals: { theme: "light" },
|
|
120
|
+
render: Card.render,
|
|
121
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { FileList, type FileListItem } from "./file-list";
|
|
4
|
+
|
|
5
|
+
interface CaseDocument extends FileListItem {
|
|
6
|
+
matterId: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const FILES: CaseDocument[] = [
|
|
10
|
+
{
|
|
11
|
+
id: "lease",
|
|
12
|
+
matterId: "m-1",
|
|
13
|
+
filename: "Commercial lease.pdf",
|
|
14
|
+
size: 482_000,
|
|
15
|
+
date: "2026-10-01T12:00:00Z",
|
|
16
|
+
uploader: { name: "Dana Ruiz", role: "Client" },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "photos",
|
|
20
|
+
matterId: "m-1",
|
|
21
|
+
filename: "Water damage.jpg",
|
|
22
|
+
mimeType: "image/jpeg",
|
|
23
|
+
size: 3_100_000,
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
describe("FileList", () => {
|
|
28
|
+
it("shows each file's kind, size and uploader", () => {
|
|
29
|
+
render(<FileList files={FILES} aria-label="Case documents" />);
|
|
30
|
+
const rows = within(screen.getByRole("list", { name: "Case documents" })).getAllByRole(
|
|
31
|
+
"listitem",
|
|
32
|
+
);
|
|
33
|
+
expect(rows).toHaveLength(2);
|
|
34
|
+
expect(rows[0]).toHaveTextContent("Commercial lease.pdf");
|
|
35
|
+
// A dot between facts on screen, a comma for a screen reader.
|
|
36
|
+
expect(rows[0]).toHaveTextContent(/PDF · , 471 KB · , .+ · , Dana Ruiz \(Client\)/);
|
|
37
|
+
expect(rows[1]).toHaveTextContent("Image · , 3.0 MB");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("opens a file from its row and hands back the host's own record", () => {
|
|
41
|
+
const onOpen = vi.fn();
|
|
42
|
+
render(<FileList files={FILES} onOpen={onOpen} activeId="photos" />);
|
|
43
|
+
|
|
44
|
+
fireEvent.click(screen.getByRole("button", { name: /Commercial lease\.pdf/ }));
|
|
45
|
+
expect(onOpen).toHaveBeenCalledWith(FILES[0]);
|
|
46
|
+
expect(onOpen.mock.calls[0][0].matterId).toBe("m-1");
|
|
47
|
+
|
|
48
|
+
expect(screen.getByRole("button", { name: /Water damage\.jpg/ })).toHaveAttribute(
|
|
49
|
+
"aria-current",
|
|
50
|
+
"true",
|
|
51
|
+
);
|
|
52
|
+
expect(screen.getByRole("button", { name: /Commercial lease\.pdf/ })).not.toHaveAttribute(
|
|
53
|
+
"aria-current",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("downloads and deletes through per-file buttons", () => {
|
|
58
|
+
const onDownload = vi.fn();
|
|
59
|
+
const onDelete = vi.fn();
|
|
60
|
+
render(<FileList files={FILES} onDownload={onDownload} onDelete={onDelete} />);
|
|
61
|
+
|
|
62
|
+
fireEvent.click(screen.getByRole("button", { name: "Download Water damage.jpg" }));
|
|
63
|
+
expect(onDownload).toHaveBeenCalledWith(FILES[1]);
|
|
64
|
+
fireEvent.click(screen.getByRole("button", { name: "Delete Commercial lease.pdf" }));
|
|
65
|
+
expect(onDelete).toHaveBeenCalledWith(FILES[0]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("renders no controls the host did not ask for", () => {
|
|
69
|
+
render(<FileList files={FILES} />);
|
|
70
|
+
expect(screen.queryByRole("button")).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("shows the empty message in place of the list", () => {
|
|
74
|
+
const { rerender } = render(<FileList files={[]} />);
|
|
75
|
+
expect(screen.queryByRole("list")).toBeNull();
|
|
76
|
+
expect(screen.getByText("No files yet.")).toBeInTheDocument();
|
|
77
|
+
|
|
78
|
+
rerender(<FileList files={[]} empty="Upload the lease to start." />);
|
|
79
|
+
expect(screen.getByText("Upload the lease to start.")).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
});
|