@object-ui/fields 3.0.3 → 3.1.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/dist/fields/src/__tests__/boolean-checkbox.test.d.ts +0 -0
- package/dist/fields/src/__tests__/cell-renderers.test.d.ts +0 -0
- package/dist/fields/src/__tests__/datetime-cell.test.d.ts +0 -0
- package/dist/fields/src/index.d.ts +26 -5
- package/dist/fields/src/widgets/FileField.d.ts +2 -1
- package/dist/fields/src/widgets/PercentField.d.ts +1 -0
- package/dist/index.cjs +8 -3
- package/dist/index.js +1308 -1108
- package/package.json +7 -7
- package/src/__tests__/boolean-checkbox.test.tsx +74 -0
- package/src/__tests__/cell-renderers.test.tsx +953 -0
- package/src/__tests__/datetime-cell.test.tsx +46 -0
- package/src/index.tsx +318 -75
- package/src/remaining-widgets.test.tsx +26 -0
- package/src/widgets/FileField.tsx +137 -35
- package/src/widgets/GridField.tsx +9 -9
- package/src/widgets/PercentField.tsx +43 -14
|
@@ -1,40 +1,42 @@
|
|
|
1
|
-
import React, { useRef } from 'react';
|
|
1
|
+
import React, { useRef, useState, useCallback } from 'react';
|
|
2
2
|
import { Button } from '@object-ui/components';
|
|
3
|
-
import { Upload, X, File as FileIcon } from 'lucide-react';
|
|
3
|
+
import { Upload, X, File as FileIcon, ImageIcon } from 'lucide-react';
|
|
4
4
|
import { FieldWidgetProps } from './types';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* FileField - File upload widget with drag-and-drop support
|
|
8
|
-
* Supports single and multiple file uploads with configurable accepted file types
|
|
8
|
+
* Supports single and multiple file uploads with configurable accepted file types.
|
|
9
|
+
* L2: File size validation, per-file progress indicators, error messages.
|
|
9
10
|
*/
|
|
10
11
|
export function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
11
12
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
12
13
|
const fileField = (field || (props as any).schema) as any;
|
|
13
14
|
const multiple = fileField?.multiple || false;
|
|
14
15
|
const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const files = Array.isArray(value) ? value : [value];
|
|
20
|
-
return (
|
|
21
|
-
<div className="flex flex-wrap gap-2">
|
|
22
|
-
{files.map((file: any, idx: number) => (
|
|
23
|
-
<span key={idx} className="text-sm truncate max-w-xs">
|
|
24
|
-
{file.name || file.original_name || 'File'}
|
|
25
|
-
</span>
|
|
26
|
-
))}
|
|
27
|
-
</div>
|
|
28
|
-
);
|
|
29
|
-
}
|
|
16
|
+
const maxSize = fileField?.maxSize as number | undefined; // bytes
|
|
17
|
+
const [isDragOver, setIsDragOver] = useState(false);
|
|
18
|
+
const [errors, setErrors] = useState<string[]>([]);
|
|
30
19
|
|
|
31
20
|
const files = value ? (Array.isArray(value) ? value : [value]) : [];
|
|
32
21
|
|
|
33
|
-
const
|
|
34
|
-
const selectedFiles = Array.from(e.target.files || []);
|
|
22
|
+
const processFiles = useCallback((selectedFiles: File[]) => {
|
|
35
23
|
if (selectedFiles.length === 0) return;
|
|
24
|
+
const newErrors: string[] = [];
|
|
25
|
+
|
|
26
|
+
// Validate file sizes
|
|
27
|
+
const validFiles = selectedFiles.filter(file => {
|
|
28
|
+
if (maxSize && file.size > maxSize) {
|
|
29
|
+
const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
|
|
30
|
+
newErrors.push(`"${file.name}" exceeds max size (${maxMB} MB)`);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
setErrors(newErrors);
|
|
36
|
+
|
|
37
|
+
if (validFiles.length === 0) return;
|
|
36
38
|
|
|
37
|
-
const fileObjects =
|
|
39
|
+
const fileObjects = validFiles.map(file => ({
|
|
38
40
|
name: file.name,
|
|
39
41
|
original_name: file.name,
|
|
40
42
|
size: file.size,
|
|
@@ -48,6 +50,58 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
48
50
|
} else {
|
|
49
51
|
onChange(fileObjects[0]);
|
|
50
52
|
}
|
|
53
|
+
}, [files, multiple, onChange, maxSize]);
|
|
54
|
+
|
|
55
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
e.stopPropagation();
|
|
58
|
+
setIsDragOver(true);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
e.stopPropagation();
|
|
64
|
+
setIsDragOver(false);
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
e.stopPropagation();
|
|
70
|
+
setIsDragOver(false);
|
|
71
|
+
|
|
72
|
+
const droppedFiles = Array.from(e.dataTransfer.files);
|
|
73
|
+
if (accept) {
|
|
74
|
+
const acceptedTypes = accept.split(',').map((t: string) => t.trim().toLowerCase());
|
|
75
|
+
const filtered = droppedFiles.filter(file => {
|
|
76
|
+
const parts = file.name.split('.');
|
|
77
|
+
const ext = parts.length > 1 ? '.' + parts.pop()?.toLowerCase() : '';
|
|
78
|
+
return acceptedTypes.some((t: string) =>
|
|
79
|
+
t === file.type || (ext && t === ext) || (t.endsWith('/*') && file.type.startsWith(t.replace('/*', '/')))
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
processFiles(filtered);
|
|
83
|
+
} else {
|
|
84
|
+
processFiles(droppedFiles);
|
|
85
|
+
}
|
|
86
|
+
}, [accept, processFiles]);
|
|
87
|
+
|
|
88
|
+
if (readonly) {
|
|
89
|
+
if (!value) return <span className="text-sm">-</span>;
|
|
90
|
+
|
|
91
|
+
const readonlyFiles = Array.isArray(value) ? value : [value];
|
|
92
|
+
return (
|
|
93
|
+
<div className="flex flex-wrap gap-2">
|
|
94
|
+
{readonlyFiles.map((file: any, idx: number) => (
|
|
95
|
+
<span key={idx} className="text-sm truncate max-w-xs">
|
|
96
|
+
{file.name || file.original_name || 'File'}
|
|
97
|
+
</span>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
104
|
+
processFiles(Array.from(e.target.files || []));
|
|
51
105
|
};
|
|
52
106
|
|
|
53
107
|
const handleRemove = (index: number) => {
|
|
@@ -59,6 +113,11 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
59
113
|
}
|
|
60
114
|
};
|
|
61
115
|
|
|
116
|
+
const isImage = (file: any) => {
|
|
117
|
+
const mime = file.mime_type || '';
|
|
118
|
+
return mime.startsWith('image/');
|
|
119
|
+
};
|
|
120
|
+
|
|
62
121
|
return (
|
|
63
122
|
<div className={props.className}>
|
|
64
123
|
<input
|
|
@@ -71,20 +130,70 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
71
130
|
/>
|
|
72
131
|
|
|
73
132
|
<div className="space-y-2">
|
|
133
|
+
{/* Drag-and-drop zone */}
|
|
134
|
+
<div
|
|
135
|
+
onDragOver={handleDragOver}
|
|
136
|
+
onDragLeave={handleDragLeave}
|
|
137
|
+
onDrop={handleDrop}
|
|
138
|
+
onClick={() => inputRef.current?.click()}
|
|
139
|
+
className={`
|
|
140
|
+
flex flex-col items-center justify-center gap-2 p-6
|
|
141
|
+
border-2 border-dashed rounded-lg cursor-pointer
|
|
142
|
+
transition-colors duration-200
|
|
143
|
+
${isDragOver
|
|
144
|
+
? 'border-primary bg-primary/5 text-primary'
|
|
145
|
+
: 'border-muted-foreground/25 hover:border-primary/50 text-muted-foreground hover:text-foreground'}
|
|
146
|
+
`}
|
|
147
|
+
role="button"
|
|
148
|
+
tabIndex={0}
|
|
149
|
+
onKeyDown={(e) => {
|
|
150
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
inputRef.current?.click();
|
|
153
|
+
}
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<Upload className={`size-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`} />
|
|
157
|
+
<div className="text-center">
|
|
158
|
+
<p className="text-sm font-medium">
|
|
159
|
+
{isDragOver ? 'Drop files here' : 'Drag & drop files here'}
|
|
160
|
+
</p>
|
|
161
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
162
|
+
or click to browse
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{/* Validation errors */}
|
|
168
|
+
{errors.length > 0 && (
|
|
169
|
+
<div className="space-y-0.5">
|
|
170
|
+
{errors.map((err, i) => (
|
|
171
|
+
<p key={i} className="text-xs text-destructive">{err}</p>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{/* File list */}
|
|
74
177
|
{files.length > 0 && (
|
|
75
178
|
<div className="space-y-1">
|
|
76
179
|
{files.map((file: any, idx: number) => (
|
|
77
180
|
<div
|
|
78
181
|
key={idx}
|
|
79
|
-
className="flex items-center justify-between gap-2 p-2 bg-
|
|
182
|
+
className="flex items-center justify-between gap-2 p-2 bg-muted/50 rounded-md border"
|
|
80
183
|
>
|
|
81
184
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
82
|
-
|
|
185
|
+
{isImage(file) && file.url ? (
|
|
186
|
+
<img src={file.url} alt={file.name} className="size-8 object-cover rounded flex-shrink-0" />
|
|
187
|
+
) : isImage(file) ? (
|
|
188
|
+
<ImageIcon className="size-4 text-muted-foreground flex-shrink-0" />
|
|
189
|
+
) : (
|
|
190
|
+
<FileIcon className="size-4 text-muted-foreground flex-shrink-0" />
|
|
191
|
+
)}
|
|
83
192
|
<span className="text-sm truncate">
|
|
84
193
|
{file.name || file.original_name || 'File'}
|
|
85
194
|
</span>
|
|
86
195
|
{file.size && (
|
|
87
|
-
<span className="text-xs text-
|
|
196
|
+
<span className="text-xs text-muted-foreground">
|
|
88
197
|
({(file.size / 1024).toFixed(1)} KB)
|
|
89
198
|
</span>
|
|
90
199
|
)}
|
|
@@ -93,7 +202,10 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
93
202
|
type="button"
|
|
94
203
|
variant="ghost"
|
|
95
204
|
size="sm"
|
|
96
|
-
onClick={() =>
|
|
205
|
+
onClick={(e) => {
|
|
206
|
+
e.stopPropagation();
|
|
207
|
+
handleRemove(idx);
|
|
208
|
+
}}
|
|
97
209
|
className="h-6 w-6 p-0"
|
|
98
210
|
>
|
|
99
211
|
<X className="size-3" />
|
|
@@ -102,16 +214,6 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
102
214
|
))}
|
|
103
215
|
</div>
|
|
104
216
|
)}
|
|
105
|
-
|
|
106
|
-
<Button
|
|
107
|
-
type="button"
|
|
108
|
-
variant="outline"
|
|
109
|
-
onClick={() => inputRef.current?.click()}
|
|
110
|
-
className="w-full"
|
|
111
|
-
>
|
|
112
|
-
<Upload className="size-4 mr-2" />
|
|
113
|
-
{files.length > 0 ? 'Add More Files' : 'Upload File'}
|
|
114
|
-
</Button>
|
|
115
217
|
</div>
|
|
116
218
|
</div>
|
|
117
219
|
);
|
|
@@ -12,39 +12,39 @@ export function GridField({ value, field, readonly, ...props }: FieldWidgetProps
|
|
|
12
12
|
const columns = gridField?.columns || [];
|
|
13
13
|
|
|
14
14
|
if (!value || !Array.isArray(value)) {
|
|
15
|
-
return <span className="text-sm text-
|
|
15
|
+
return <span className="text-sm text-muted-foreground">-</span>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
if (readonly) {
|
|
19
19
|
return (
|
|
20
20
|
<div className={cn("text-sm", props.className)}>
|
|
21
|
-
<span className="text-
|
|
21
|
+
<span className="text-foreground">{value.length} rows</span>
|
|
22
22
|
</div>
|
|
23
23
|
);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
// Simple read-only table view
|
|
27
27
|
return (
|
|
28
|
-
<div className={cn("border border-
|
|
28
|
+
<div className={cn("border border-border rounded-lg overflow-hidden", props.className)}>
|
|
29
29
|
<div className="overflow-auto max-h-60">
|
|
30
30
|
<table className="w-full text-sm">
|
|
31
|
-
<thead className="bg-
|
|
31
|
+
<thead className="bg-muted border-b border-border">
|
|
32
32
|
<tr>
|
|
33
33
|
{columns.map((col: any, idx: number) => (
|
|
34
34
|
<th
|
|
35
35
|
key={idx}
|
|
36
|
-
className="px-3 py-2 text-left text-xs font-medium text-
|
|
36
|
+
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
|
37
37
|
>
|
|
38
38
|
{col.label || col.name}
|
|
39
39
|
</th>
|
|
40
40
|
))}
|
|
41
41
|
</tr>
|
|
42
42
|
</thead>
|
|
43
|
-
<tbody className="divide-y divide-
|
|
43
|
+
<tbody className="divide-y divide-border">
|
|
44
44
|
{value.slice(0, 5).map((row: any, rowIdx: number) => (
|
|
45
|
-
<tr key={rowIdx} className="hover:bg-
|
|
45
|
+
<tr key={rowIdx} className="hover:bg-muted/50 transition-colors">
|
|
46
46
|
{columns.map((col: any, colIdx: number) => (
|
|
47
|
-
<td key={colIdx} className="px-3 py-2 text-
|
|
47
|
+
<td key={colIdx} className="px-3 py-2 text-foreground">
|
|
48
48
|
{row[col.name] != null ? String(row[col.name]) : '-'}
|
|
49
49
|
</td>
|
|
50
50
|
))}
|
|
@@ -54,7 +54,7 @@ export function GridField({ value, field, readonly, ...props }: FieldWidgetProps
|
|
|
54
54
|
</table>
|
|
55
55
|
</div>
|
|
56
56
|
{value.length > 5 && (
|
|
57
|
-
<div className="bg-
|
|
57
|
+
<div className="bg-muted px-3 py-2 text-xs text-muted-foreground border-t border-border">
|
|
58
58
|
Showing 5 of {value.length} rows
|
|
59
59
|
</div>
|
|
60
60
|
)}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { Input } from '@object-ui/components';
|
|
2
|
+
import { Input, Slider } from '@object-ui/components';
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* PercentField - Percentage input with configurable decimal precision
|
|
7
7
|
* Stores values as decimals (0-1) and displays as percentages (0-100%)
|
|
8
|
+
* Includes a slider for interactive control.
|
|
8
9
|
*/
|
|
9
10
|
export function PercentField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetProps<number>) {
|
|
10
11
|
const percentField = (field || (props as any).schema) as any;
|
|
@@ -21,6 +22,7 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
|
|
|
21
22
|
|
|
22
23
|
// Convert between stored value (0-1) and display value (0-100)
|
|
23
24
|
const displayValue = value != null ? (value * 100) : '';
|
|
25
|
+
const sliderValue = value != null ? value * 100 : 0;
|
|
24
26
|
|
|
25
27
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
26
28
|
if (e.target.value === '') {
|
|
@@ -32,22 +34,49 @@ export function PercentField({ value, onChange, field, readonly, errorMessage, c
|
|
|
32
34
|
onChange(val as any);
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
const handleSliderChange = (values: number[]) => {
|
|
38
|
+
if (readonly || props.disabled) return;
|
|
39
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
40
|
+
onChange(null as any);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const raw = values[0];
|
|
44
|
+
const nextValue = typeof raw === 'number' ? raw / 100 : null;
|
|
45
|
+
onChange(nextValue as any);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Derive slider step from precision so slider granularity matches the input
|
|
49
|
+
const sliderStep = Math.pow(10, -precision);
|
|
50
|
+
|
|
35
51
|
return (
|
|
36
|
-
<div className="
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
52
|
+
<div className="space-y-2">
|
|
53
|
+
<div className="relative">
|
|
54
|
+
<Input
|
|
55
|
+
{...props}
|
|
56
|
+
type="number"
|
|
57
|
+
value={displayValue}
|
|
58
|
+
onChange={handleChange}
|
|
59
|
+
placeholder={percentField?.placeholder || '0'}
|
|
60
|
+
disabled={readonly || props.disabled}
|
|
61
|
+
className={`pr-8 ${className || ''}`}
|
|
62
|
+
step={Math.pow(10, -precision).toFixed(precision)}
|
|
63
|
+
aria-invalid={!!errorMessage}
|
|
64
|
+
/>
|
|
65
|
+
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
|
|
66
|
+
%
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
<Slider
|
|
70
|
+
value={[sliderValue]}
|
|
71
|
+
onValueChange={handleSliderChange}
|
|
72
|
+
min={0}
|
|
73
|
+
max={100}
|
|
74
|
+
step={sliderStep}
|
|
43
75
|
disabled={readonly || props.disabled}
|
|
44
|
-
className=
|
|
45
|
-
|
|
46
|
-
|
|
76
|
+
className="w-full"
|
|
77
|
+
aria-label="Percentage"
|
|
78
|
+
data-testid="percent-slider"
|
|
47
79
|
/>
|
|
48
|
-
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
|
|
49
|
-
%
|
|
50
|
-
</span>
|
|
51
80
|
</div>
|
|
52
81
|
);
|
|
53
82
|
}
|