@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 +2 -0
- package/dist/generator/index.d.mts +1 -1
- package/dist/generator/index.mjs +2 -1
- package/docs/API.md +8 -14
- package/docs/README.md +1 -0
- package/docs/custom-renderers.md +61 -0
- package/docs/fetcher.md +14 -0
- package/docs/file-type.md +249 -0
- package/package.json +1 -1
- package/src/component/built-in-renderers.test.tsx +232 -0
- package/src/component/built-in-renderers.tsx +124 -1
- package/src/component/column-selector.test.tsx +70 -10
- package/src/component/column-selector.tsx +74 -12
- package/src/component/contexts/data-viewer-context.test.tsx +77 -0
- package/src/component/contexts/data-viewer-context.tsx +23 -2
- package/src/component/hooks/table-data-store.ts +1 -0
- package/src/component/hooks/use-relation-data.ts +31 -3
- package/src/component/hooks/use-single-record-data.ts +7 -1
- package/src/component/index.ts +5 -1
- package/src/component/lib/format-file-size.test.ts +44 -0
- package/src/component/lib/format-file-size.ts +23 -0
- package/src/component/search-filter.test.tsx +8 -8
- package/src/component/search-filter.tsx +16 -8
- package/src/component/single-record-tab-content.test.tsx +3 -0
- package/src/component/types.ts +34 -0
- package/src/generator/metadata-generator.ts +3 -1
- package/src/graphql/fetcher.ts +30 -7
- package/src/graphql/query-builder.test.ts +108 -0
- package/src/graphql/query-builder.ts +106 -9
- package/src/graphql/single-record-fetcher.ts +8 -1
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
|
*/
|
package/dist/generator/index.mjs
CHANGED
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
package/docs/custom-renderers.md
CHANGED
|
@@ -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
|
@@ -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
|
+
});
|