@izumisy-tailor/tailor-data-viewer 0.1.43 → 0.1.45

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/README.md CHANGED
@@ -14,6 +14,8 @@ A React component library for building data exploration interfaces with GraphQL
14
14
  - **CSV Export**: Download current view as CSV
15
15
  - **Single Record View**: Detailed single record view with all relations
16
16
  - **Custom Labels**: Internationalization support for field names and UI text
17
+ - **Custom Renderers**: Built-in and custom cell renderers with pattern matching support
18
+ - **File Type Support**: Automatic query expansion and thumbnail renderers for TailorDB File fields
17
19
 
18
20
  ## Installation
19
21
 
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Field type mapping for Data View
4
4
  */
5
- type FieldType = "string" | "number" | "boolean" | "uuid" | "datetime" | "date" | "time" | "enum" | "array" | "nested";
5
+ type FieldType = "string" | "number" | "boolean" | "uuid" | "datetime" | "date" | "time" | "enum" | "array" | "nested" | "file";
6
6
  /**
7
7
  * Metadata for a single field
8
8
  */
@@ -13,7 +13,8 @@ function mapFieldType(tailorType, isArray) {
13
13
  date: "date",
14
14
  time: "time",
15
15
  enum: "enum",
16
- nested: "nested"
16
+ nested: "nested",
17
+ file: "file"
17
18
  }[tailorType] ?? "string";
18
19
  if (isArray) return {
19
20
  type: "array",
package/docs/API.md CHANGED
@@ -55,22 +55,16 @@ Context provider for view persistence.
55
55
  ### `<DataTable />`
56
56
 
57
57
  Core data table component with sortable columns and expandable relations.
58
+ Must be used within `DataViewer.Root` context (table metadata and data are obtained from context).
58
59
 
59
60
  ```tsx
60
61
  interface DataTableProps {
61
- data: Record<string, unknown>[];
62
- fields: FieldMetadata[];
63
- selectedFields: string[];
64
- sortState: SortState | null;
65
- onSort: (field: string) => void;
66
- loading?: boolean;
67
- tableMetadata?: TableMetadata;
68
- tableMetadataMap?: TableMetadataMap;
69
- appUri?: string;
70
- selectedRelations?: string[];
71
- onOpenAsSheet?: (tableName: string, filterField: string, filterValue: string) => void;
72
- onOpenSingleRecordAsSheet?: (tableName: string, recordId: string) => void;
73
- expandedRelationFields?: ExpandedRelationFields;
62
+ /** Callback when a row is clicked */
63
+ onClickRow?: OnClickRow;
64
+ /** Callback to open a relation as a new view (used for nested relation expansion) */
65
+ onOpenRelation?: OnOpenRelation;
66
+ /** Row-level actions to display in the action dropdown menu */
67
+ rowActions?: RowActions;
74
68
  }
75
69
  ```
76
70
 
@@ -145,7 +139,7 @@ interface FieldMetadata {
145
139
  enumValues?: string[];
146
140
  }
147
141
 
148
- type FieldType = "string" | "number" | "boolean" | "uuid" | "datetime" | "date" | "time" | "json" | "enum" | "nested" | "array";
142
+ type FieldType = "string" | "number" | "boolean" | "uuid" | "datetime" | "date" | "time" | "json" | "enum" | "nested" | "array" | "file";
149
143
  ```
150
144
 
151
145
  ### RelationMetadata
package/docs/README.md CHANGED
@@ -16,6 +16,7 @@ Documentation for `@izumisy-tailor/tailor-data-viewer`.
16
16
  - [GraphQL Fetcher](fetcher.md) - GraphQL fetcher configuration and custom implementation
17
17
  - [Custom Labels](labels.md) - Customizing field names, table names, and UI text (i18n support)
18
18
  - [Custom Renderers](custom-renderers.md) - Custom cell renderers and built-in renderers
19
+ - [File Type Support](file-type.md) - TailorDB File type handling, auto-expansion, and file renderers
19
20
 
20
21
  ## Advanced Usage
21
22
 
@@ -51,6 +51,35 @@ The `byFieldName` object uses a `tableName:fieldName` key format:
51
51
  5. `byFieldType["string"]` — type-based fallback
52
52
  6. Default formatting (lowest)
53
53
 
54
+ ## Default Renderers
55
+
56
+ Certain field types have default renderers applied automatically:
57
+
58
+ | Field Type | Default Renderer | Description |
59
+ |------------|------------------|-------------|
60
+ | `file` | `FileLink` | Displays file as a clickable link with file icon and formatted size |
61
+
62
+ These defaults are applied when no custom renderer is specified. You can override them by providing your own renderer via `byFieldType` or `byFieldName`:
63
+
64
+ ```tsx
65
+ import { builtInRenderers } from "@izumisy-tailor/tailor-data-viewer";
66
+
67
+ const { FileImageThumbnail } = builtInRenderers;
68
+
69
+ // Override the default file renderer with a thumbnail
70
+ const DataViewer = createDataViewer({
71
+ metadata,
72
+ fetcher,
73
+ renderers: {
74
+ cell: {
75
+ byFieldType: {
76
+ file: FileImageThumbnail({ width: 40, height: 40 }),
77
+ },
78
+ },
79
+ },
80
+ });
81
+ ```
82
+
54
83
  ## Renderers vs Column API Renderer Option
55
84
 
56
85
  There are two ways to define cell renderers: centralized management via `renderers` in `createDataViewer`, or ad-hoc specification using the `renderer` option in the [Column API](./columns.md).
@@ -305,3 +334,35 @@ StatusBadge({
305
334
  3. Suffix match (`"*approved"`)
306
335
  4. Contains match (`"*approved*"`)
307
336
  5. `defaultColor`
337
+
338
+ ### FileLink
339
+
340
+ Renders file type values as a download link with file icon and formatted size.
341
+
342
+ ```tsx
343
+ "*:attachment": FileLink
344
+ // { url: "...", size: 1048576 } → 📄 File (1 MB)
345
+ ```
346
+
347
+ ### FileImageThumbnail
348
+
349
+ Factory function that creates a thumbnail renderer for image files. Non-image files display a file icon.
350
+
351
+ ```tsx
352
+ // Default size (40x40)
353
+ "*:avatar": FileImageThumbnail()
354
+
355
+ // Custom size with click handler
356
+ "*:profileImage": FileImageThumbnail({
357
+ width: 60,
358
+ height: 60,
359
+ onClick: (file, row) => openPreview(file.url),
360
+ })
361
+ ```
362
+
363
+ **Options:**
364
+ - `width?: number` — Thumbnail width in pixels (default: 40)
365
+ - `height?: number` — Thumbnail height in pixels (default: 40)
366
+ - `onClick?: (file: FileValue, row: Record<string, unknown>) => void` — Click handler
367
+
368
+ > For more details on File type support, see [File Type Support](./file-type.md).
package/docs/fetcher.md CHANGED
@@ -37,6 +37,20 @@ const fetcher = createDefaultFetcher({
37
37
  });
38
38
  ```
39
39
 
40
+ ### Dynamic Headers
41
+
42
+ The `headers` option can also accept a function that returns headers. This is useful when headers need to be generated dynamically per request (e.g., nonce generation for CSP):
43
+
44
+ ```tsx
45
+ const fetcher = createDefaultFetcher({
46
+ endpoint: "https://your-app.tailor.tech/graphql",
47
+ headers: () => ({
48
+ "X-Custom-Header": "value",
49
+ "X-Nonce": generateNonce(), // Called on each request
50
+ }),
51
+ });
52
+ ```
53
+
40
54
  ## `createUrqlFetcher`
41
55
 
42
56
  Creates a GraphQL fetcher from an existing urql Client.
@@ -0,0 +1,249 @@
1
+ # File Type Support
2
+
3
+ ## Overview
4
+
5
+ TailorDB's `File` type is a special composite type that contains file metadata including URL, size, content type, and more. This library provides built-in support for File type fields, including automatic query expansion and dedicated renderers.
6
+
7
+ ## Default Renderer
8
+
9
+ **File type fields use `FileLink` as their default renderer.** When you include a `file` type field in your columns without specifying a renderer, `FileLink` is automatically applied. This means file fields will display as clickable links with file size information out of the box.
10
+
11
+ ```tsx
12
+ // FileLink is automatically used - no renderer needed!
13
+ <TableDataProvider
14
+ tableName="document"
15
+ columns={["name", "attachment"]} // attachment renders with FileLink
16
+ >
17
+ ```
18
+
19
+ To override this default, see [Centralized File Renderer Configuration](#centralized-file-renderer-configuration) or use the [Column API](./columns.md) to specify a renderer per column.
20
+
21
+ ## File Type Structure
22
+
23
+ When a TailorDB field is defined as `file` type, the GraphQL schema returns the following structure:
24
+
25
+ ```graphql
26
+ type File {
27
+ url: String! # Pre-signed URL for file access
28
+ size: Int # File size in bytes
29
+ contentType: String # MIME type (e.g., "image/jpeg", "application/pdf")
30
+ sha256sum: String # SHA256 checksum
31
+ lastUploadedAt: DateTime # Upload timestamp
32
+ }
33
+ ```
34
+
35
+ ## Automatic Query Expansion
36
+
37
+ When you include a `file` type field in your column selection, the query builder automatically expands it to fetch all subfields:
38
+
39
+ ```tsx
40
+ // Your column definition
41
+ <TableDataProvider
42
+ tableName="user"
43
+ columns={["name", "avatar"]} // avatar is a file type field
44
+ >
45
+ ```
46
+
47
+ ```graphql
48
+ # Generated GraphQL query
49
+ query UserList($query: UserQueryInput) {
50
+ users(query: $query) {
51
+ edges {
52
+ node {
53
+ name
54
+ avatar {
55
+ url
56
+ size
57
+ contentType
58
+ sha256sum
59
+ lastUploadedAt
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ This automatic expansion happens when you pass `tableMetadata` to the query builder options. The library uses the metadata to detect `file` type fields and expand them appropriately.
68
+
69
+ ## FileValue Type
70
+
71
+ The library exports a `FileValue` interface and type guard for working with file values:
72
+
73
+ ```tsx
74
+ import { FileValue, isFileValue } from "@izumisy-tailor/tailor-data-viewer";
75
+
76
+ interface FileValue {
77
+ url: string;
78
+ contentType?: string | null;
79
+ size?: number | null;
80
+ sha256sum?: string | null;
81
+ lastUploadedAt?: string | null;
82
+ }
83
+
84
+ // Type guard
85
+ if (isFileValue(value)) {
86
+ console.log(value.url); // TypeScript knows this is FileValue
87
+ }
88
+ ```
89
+
90
+ ## Built-in File Renderers
91
+
92
+ The library provides two built-in renderers for file fields:
93
+
94
+ ### FileLink
95
+
96
+ Renders file values as a download link with file icon and formatted size.
97
+
98
+ ```tsx
99
+ import { builtInRenderers } from "@izumisy-tailor/tailor-data-viewer";
100
+
101
+ const { FileLink } = builtInRenderers;
102
+
103
+ // Usage
104
+ <TableDataProvider
105
+ tableName="document"
106
+ columns={[
107
+ "name",
108
+ ["attachment", FileLink], // Renders as: 📄 File (1.5 MB)
109
+ ]}
110
+ >
111
+ ```
112
+
113
+ **Features:**
114
+ - Displays file icon and "File" label
115
+ - Shows formatted file size (e.g., "1.5 MB")
116
+ - Opens file URL in new tab on click
117
+ - Shows "-" for null/undefined values
118
+
119
+ ### FileImageThumbnail
120
+
121
+ Factory function that creates a thumbnail renderer for image files. Non-image files display a file icon.
122
+
123
+ ```tsx
124
+ import { builtInRenderers } from "@izumisy-tailor/tailor-data-viewer";
125
+
126
+ const { FileImageThumbnail } = builtInRenderers;
127
+
128
+ // Basic usage with defaults (40x40 px)
129
+ <TableDataProvider
130
+ tableName="user"
131
+ columns={[
132
+ "name",
133
+ ["avatar", FileImageThumbnail()],
134
+ ]}
135
+ >
136
+
137
+ // Custom size
138
+ ["avatar", FileImageThumbnail({ width: 60, height: 60 })]
139
+
140
+ // With click handler for preview modal
141
+ ["avatar", FileImageThumbnail({
142
+ width: 80,
143
+ height: 80,
144
+ onClick: (file, row) => {
145
+ openPreviewModal(file.url);
146
+ },
147
+ })]
148
+ ```
149
+
150
+ **Options:**
151
+
152
+ | Option | Type | Default | Description |
153
+ |--------|------|---------|-------------|
154
+ | `width` | `number` | `40` | Thumbnail width in pixels |
155
+ | `height` | `number` | `40` | Thumbnail height in pixels |
156
+ | `onClick` | `(file: FileValue, row: Record<string, unknown>) => void` | - | Click handler with file data and full row |
157
+
158
+ **Behavior:**
159
+ - **Image files** (`image/*` content types): Displays actual image thumbnail
160
+ - **Non-image files**: Displays file icon placeholder
161
+ - **Null values**: Displays "-"
162
+
163
+ ## Utility Functions
164
+
165
+ ### formatFileSize
166
+
167
+ The library includes a utility for formatting byte values into human-readable strings:
168
+
169
+ ```tsx
170
+ import { formatFileSize } from "@izumisy-tailor/tailor-data-viewer/component/lib/format-file-size";
171
+
172
+ formatFileSize(512); // "512 B"
173
+ formatFileSize(1024); // "1 KB"
174
+ formatFileSize(1536); // "1.5 KB"
175
+ formatFileSize(1048576); // "1 MB"
176
+ formatFileSize(1073741824); // "1 GB"
177
+ ```
178
+
179
+ ## Creating Custom File Renderers
180
+
181
+ You can create your own file renderers using the `FileValue` type and `isFileValue` guard:
182
+
183
+ ```tsx
184
+ import { isFileValue, type FileValue, type CellRenderer } from "@izumisy-tailor/tailor-data-viewer";
185
+ import { formatFileSize } from "@izumisy-tailor/tailor-data-viewer/component/lib/format-file-size";
186
+
187
+ const CustomFileRenderer: CellRenderer = ({ value, row }) => {
188
+ if (!isFileValue(value)) {
189
+ return <span className="text-muted">No file</span>;
190
+ }
191
+
192
+ const { url, size, contentType } = value;
193
+ const isImage = contentType?.startsWith("image/");
194
+ const isPdf = contentType === "application/pdf";
195
+
196
+ return (
197
+ <div className="flex items-center gap-2">
198
+ {isImage && <img src={url} alt="" className="w-8 h-8 rounded" />}
199
+ {isPdf && <PdfIcon className="w-8 h-8" />}
200
+ <div>
201
+ <a href={url} target="_blank" className="text-blue-600 hover:underline">
202
+ Download
203
+ </a>
204
+ {size && <span className="text-xs text-gray-500 ml-2">{formatFileSize(size)}</span>}
205
+ </div>
206
+ </div>
207
+ );
208
+ };
209
+ ```
210
+
211
+ ## Centralized File Renderer Configuration
212
+
213
+ You can override the default `FileLink` renderer or set field-specific file renderers globally using the `renderers` option in `createDataViewer`:
214
+
215
+ ```tsx
216
+ import { createDataViewer, builtInRenderers } from "@izumisy-tailor/tailor-data-viewer";
217
+
218
+ const { FileImageThumbnail } = builtInRenderers;
219
+
220
+ export const DataViewer = createDataViewer({
221
+ metadata: tableMetadata,
222
+ fetcher,
223
+ renderers: {
224
+ cell: {
225
+ byFieldType: {
226
+ // Override default FileLink with thumbnail for all file fields
227
+ file: FileImageThumbnail({ width: 40, height: 40 }),
228
+ },
229
+ byFieldName: {
230
+ // Override with larger thumbnail for specific avatar fields
231
+ "*:avatar": FileImageThumbnail({ width: 48, height: 48 }),
232
+ "*:profileImage": FileImageThumbnail({ width: 48, height: 48 }),
233
+ },
234
+ },
235
+ },
236
+ });
237
+ ```
238
+
239
+ ## Differences from Other Field Types
240
+
241
+ File type fields are handled specially compared to other field types:
242
+
243
+ | Aspect | Regular Fields | Relation Fields | File Fields |
244
+ |--------|---------------|-----------------|-------------|
245
+ | Query | Single field | Nested object with selected fields | Nested object with fixed subfields |
246
+ | Value | Primitive | Object (varies by selection) | Fixed `FileValue` object |
247
+ | Column Definition | `"fieldName"` | `"relation.fieldName"` | `"fieldName"` (expanded automatically) |
248
+
249
+ The key difference is that file fields have a **fixed structure** (always the same subfields), while relation fields have **dynamic structure** (depends on which related fields you select).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.1.43",
4
+ "version": "0.1.45",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -0,0 +1,232 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { builtInRenderers } from "./built-in-renderers";
4
+ import type { CellRendererProps } from "./types";
5
+ import type { TableMetadata } from "../generator/metadata-generator";
6
+
7
+ // Mock table metadata for testing
8
+ const mockTableMetadata: TableMetadata = {
9
+ name: "testTable",
10
+ pluralForm: "testTables",
11
+ readAllowedRoles: [],
12
+ fields: [],
13
+ relations: [],
14
+ };
15
+
16
+ // Helper to create mock CellRendererProps
17
+ const createMockProps = (
18
+ overrides: Partial<CellRendererProps> = {},
19
+ ): CellRendererProps => ({
20
+ value: null,
21
+ field: { name: "file", type: "file", required: false },
22
+ row: {},
23
+ rowIndex: 0,
24
+ tableName: "testTable",
25
+ tableMetadata: mockTableMetadata,
26
+ ...overrides,
27
+ });
28
+
29
+ describe("FileLink", () => {
30
+ const { FileLink } = builtInRenderers;
31
+
32
+ it("renders '-' for null value", () => {
33
+ const props = createMockProps({ value: null });
34
+ render(<>{FileLink(props)}</>);
35
+ expect(screen.getByText("-")).toBeInTheDocument();
36
+ });
37
+
38
+ it("renders '-' for non-FileValue object", () => {
39
+ const props = createMockProps({ value: { invalid: "object" } });
40
+ render(<>{FileLink(props)}</>);
41
+ expect(screen.getByText("-")).toBeInTheDocument();
42
+ });
43
+
44
+ it("renders link for valid FileValue", () => {
45
+ const props = createMockProps({
46
+ value: {
47
+ url: "https://example.com/file.pdf",
48
+ size: 1048576,
49
+ contentType: "application/pdf",
50
+ },
51
+ });
52
+ render(<>{FileLink(props)}</>);
53
+
54
+ const link = screen.getByRole("link");
55
+ expect(link).toHaveAttribute("href", "https://example.com/file.pdf");
56
+ expect(link).toHaveAttribute("target", "_blank");
57
+ expect(screen.getByText("File")).toBeInTheDocument();
58
+ expect(screen.getByText("(1 MB)")).toBeInTheDocument();
59
+ });
60
+
61
+ it("renders without size when size is null", () => {
62
+ const props = createMockProps({
63
+ value: {
64
+ url: "https://example.com/file.pdf",
65
+ size: null,
66
+ contentType: "application/pdf",
67
+ },
68
+ });
69
+ render(<>{FileLink(props)}</>);
70
+
71
+ expect(screen.getByText("File")).toBeInTheDocument();
72
+ expect(screen.queryByText(/\(/)).not.toBeInTheDocument();
73
+ });
74
+
75
+ it("stops propagation on click", () => {
76
+ const props = createMockProps({
77
+ value: { url: "https://example.com/file.pdf" },
78
+ });
79
+ render(<>{FileLink(props)}</>);
80
+
81
+ const link = screen.getByRole("link");
82
+ const stopPropagation = vi.fn();
83
+ fireEvent.click(link, { stopPropagation });
84
+ // Note: stopPropagation is called inside the component
85
+ });
86
+ });
87
+
88
+ describe("FileImageThumbnail", () => {
89
+ const { FileImageThumbnail } = builtInRenderers;
90
+
91
+ it("renders '-' for null value", () => {
92
+ const renderer = FileImageThumbnail();
93
+ const props = createMockProps({ value: null });
94
+ render(<>{renderer(props)}</>);
95
+ expect(screen.getByText("-")).toBeInTheDocument();
96
+ });
97
+
98
+ it("renders '-' for non-FileValue object", () => {
99
+ const renderer = FileImageThumbnail();
100
+ const props = createMockProps({ value: { invalid: "object" } });
101
+ render(<>{renderer(props)}</>);
102
+ expect(screen.getByText("-")).toBeInTheDocument();
103
+ });
104
+
105
+ it("renders image thumbnail for image content type", () => {
106
+ const renderer = FileImageThumbnail({ width: 60, height: 60 });
107
+ const props = createMockProps({
108
+ value: {
109
+ url: "https://example.com/image.jpg",
110
+ contentType: "image/jpeg",
111
+ },
112
+ });
113
+ render(<>{renderer(props)}</>);
114
+
115
+ const img = screen.getByRole("img");
116
+ expect(img).toHaveAttribute("src", "https://example.com/image.jpg");
117
+ expect(img).toHaveStyle({ width: "60px", height: "60px" });
118
+ });
119
+
120
+ it("renders file icon for non-image content type", () => {
121
+ const renderer = FileImageThumbnail();
122
+ const props = createMockProps({
123
+ value: {
124
+ url: "https://example.com/document.pdf",
125
+ contentType: "application/pdf",
126
+ },
127
+ });
128
+ const { container } = render(<>{renderer(props)}</>);
129
+
130
+ // Should not render an img element
131
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
132
+ // Should render a div with the file icon container
133
+ expect(container.querySelector("div")).toBeInTheDocument();
134
+ });
135
+
136
+ it("uses default dimensions when not specified", () => {
137
+ const renderer = FileImageThumbnail();
138
+ const props = createMockProps({
139
+ value: {
140
+ url: "https://example.com/image.png",
141
+ contentType: "image/png",
142
+ },
143
+ });
144
+ render(<>{renderer(props)}</>);
145
+
146
+ const img = screen.getByRole("img");
147
+ expect(img).toHaveStyle({ width: "40px", height: "40px" });
148
+ });
149
+
150
+ it("calls onClick handler with file and row data", () => {
151
+ const onClick = vi.fn();
152
+ const renderer = FileImageThumbnail({ onClick });
153
+ const fileValue = {
154
+ url: "https://example.com/image.jpg",
155
+ contentType: "image/jpeg",
156
+ size: 1024,
157
+ };
158
+ const row = { id: "123", name: "Test" };
159
+ const props = createMockProps({ value: fileValue, row });
160
+ render(<>{renderer(props)}</>);
161
+
162
+ const img = screen.getByRole("img");
163
+ fireEvent.click(img);
164
+
165
+ expect(onClick).toHaveBeenCalledWith(fileValue, row);
166
+ });
167
+
168
+ it("has cursor-pointer class when onClick is provided", () => {
169
+ const onClick = vi.fn();
170
+ const renderer = FileImageThumbnail({ onClick });
171
+ const props = createMockProps({
172
+ value: {
173
+ url: "https://example.com/image.jpg",
174
+ contentType: "image/jpeg",
175
+ },
176
+ });
177
+ render(<>{renderer(props)}</>);
178
+
179
+ const img = screen.getByRole("img");
180
+ expect(img.className).toContain("cursor-pointer");
181
+ });
182
+
183
+ it("does not have cursor-pointer class when onClick is not provided", () => {
184
+ const renderer = FileImageThumbnail();
185
+ const props = createMockProps({
186
+ value: {
187
+ url: "https://example.com/image.jpg",
188
+ contentType: "image/jpeg",
189
+ },
190
+ });
191
+ render(<>{renderer(props)}</>);
192
+
193
+ const img = screen.getByRole("img");
194
+ expect(img.className).not.toContain("cursor-pointer");
195
+ });
196
+
197
+ describe("image content type detection", () => {
198
+ const imageTypes = [
199
+ "image/jpeg",
200
+ "image/png",
201
+ "image/gif",
202
+ "image/webp",
203
+ "image/svg+xml",
204
+ ];
205
+
206
+ it.each(imageTypes)("renders image for %s", (contentType) => {
207
+ const renderer = FileImageThumbnail();
208
+ const props = createMockProps({
209
+ value: { url: "https://example.com/file", contentType },
210
+ });
211
+ render(<>{renderer(props)}</>);
212
+ expect(screen.getByRole("img")).toBeInTheDocument();
213
+ });
214
+
215
+ const nonImageTypes = [
216
+ "application/pdf",
217
+ "text/plain",
218
+ "application/json",
219
+ null,
220
+ undefined,
221
+ ];
222
+
223
+ it.each(nonImageTypes)("renders file icon for %s", (contentType) => {
224
+ const renderer = FileImageThumbnail();
225
+ const props = createMockProps({
226
+ value: { url: "https://example.com/file", contentType },
227
+ });
228
+ render(<>{renderer(props)}</>);
229
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
230
+ });
231
+ });
232
+ });