Skip to content

Commit ea65525

Browse files
Copilothotlong
andcommitted
feat(plugin-form, fields): Phase 14 L1 - drag-and-drop upload, embeddable forms, form analytics
- Enhance FileUploadField with drag-and-drop zone, visual feedback, image thumbnails - Add EmbeddableForm component for standalone public forms (no auth required) - Add FormAnalytics component with submission metrics, fill rate, field drop-off - Register embeddable-form and form-analytics in ComponentRegistry - Export new components from plugin-form index - All 230 field tests and 36 form tests pass Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent c9a5cad commit ea65525

4 files changed

Lines changed: 582 additions & 18 deletions

File tree

packages/fields/src/widgets/FileField.tsx

Lines changed: 93 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import React, { useRef } from 'react';
1+
import React, { useRef, useState, useCallback } from 'react';
22
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';
44
import { FieldWidgetProps } from './types';
55

66
/**
@@ -12,6 +12,7 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
1212
const fileField = (field || (props as any).schema) as any;
1313
const multiple = fileField?.multiple || false;
1414
const accept = fileField?.accept ? fileField.accept.join(',') : undefined;
15+
const [isDragOver, setIsDragOver] = useState(false);
1516

1617
if (readonly) {
1718
if (!value) return <span className="text-sm">-</span>;
@@ -30,8 +31,7 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
3031

3132
const files = value ? (Array.isArray(value) ? value : [value]) : [];
3233

33-
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
34-
const selectedFiles = Array.from(e.target.files || []);
34+
const processFiles = useCallback((selectedFiles: File[]) => {
3535
if (selectedFiles.length === 0) return;
3636

3737
const fileObjects = selectedFiles.map(file => ({
@@ -48,8 +48,44 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
4848
} else {
4949
onChange(fileObjects[0]);
5050
}
51+
}, [files, multiple, onChange]);
52+
53+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
54+
processFiles(Array.from(e.target.files || []));
5155
};
5256

57+
const handleDragOver = useCallback((e: React.DragEvent) => {
58+
e.preventDefault();
59+
e.stopPropagation();
60+
setIsDragOver(true);
61+
}, []);
62+
63+
const handleDragLeave = useCallback((e: React.DragEvent) => {
64+
e.preventDefault();
65+
e.stopPropagation();
66+
setIsDragOver(false);
67+
}, []);
68+
69+
const handleDrop = useCallback((e: React.DragEvent) => {
70+
e.preventDefault();
71+
e.stopPropagation();
72+
setIsDragOver(false);
73+
74+
const droppedFiles = Array.from(e.dataTransfer.files);
75+
if (accept) {
76+
const acceptedTypes = accept.split(',').map(t => t.trim().toLowerCase());
77+
const filtered = droppedFiles.filter(file => {
78+
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
79+
return acceptedTypes.some(t =>
80+
t === file.type || t === ext || (t.endsWith('/*') && file.type.startsWith(t.replace('/*', '/')))
81+
);
82+
});
83+
processFiles(filtered);
84+
} else {
85+
processFiles(droppedFiles);
86+
}
87+
}, [accept, processFiles]);
88+
5389
const handleRemove = (index: number) => {
5490
if (multiple) {
5591
const newFiles = files.filter((_: any, i: number) => i !== index);
@@ -59,6 +95,11 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
5995
}
6096
};
6197

98+
const isImage = (file: any) => {
99+
const mime = file.mime_type || '';
100+
return mime.startsWith('image/');
101+
};
102+
62103
return (
63104
<div className={props.className}>
64105
<input
@@ -71,20 +112,61 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
71112
/>
72113

73114
<div className="space-y-2">
115+
{/* Drag-and-drop zone */}
116+
<div
117+
onDragOver={handleDragOver}
118+
onDragLeave={handleDragLeave}
119+
onDrop={handleDrop}
120+
onClick={() => inputRef.current?.click()}
121+
className={`
122+
flex flex-col items-center justify-center gap-2 p-6
123+
border-2 border-dashed rounded-lg cursor-pointer
124+
transition-colors duration-200
125+
${isDragOver
126+
? 'border-primary bg-primary/5 text-primary'
127+
: 'border-muted-foreground/25 hover:border-primary/50 text-muted-foreground hover:text-foreground'}
128+
`}
129+
role="button"
130+
tabIndex={0}
131+
onKeyDown={(e) => {
132+
if (e.key === 'Enter' || e.key === ' ') {
133+
e.preventDefault();
134+
inputRef.current?.click();
135+
}
136+
}}
137+
>
138+
<Upload className={`size-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`} />
139+
<div className="text-center">
140+
<p className="text-sm font-medium">
141+
{isDragOver ? 'Drop files here' : 'Drag & drop files here'}
142+
</p>
143+
<p className="text-xs text-muted-foreground mt-1">
144+
or click to browse
145+
</p>
146+
</div>
147+
</div>
148+
149+
{/* File list */}
74150
{files.length > 0 && (
75151
<div className="space-y-1">
76152
{files.map((file: any, idx: number) => (
77153
<div
78154
key={idx}
79-
className="flex items-center justify-between gap-2 p-2 bg-gray-50 rounded-md border border-gray-200"
155+
className="flex items-center justify-between gap-2 p-2 bg-muted/50 rounded-md border"
80156
>
81157
<div className="flex items-center gap-2 flex-1 min-w-0">
82-
<FileIcon className="size-4 text-gray-500 flex-shrink-0" />
158+
{isImage(file) && file.url ? (
159+
<img src={file.url} alt={file.name} className="size-8 object-cover rounded flex-shrink-0" />
160+
) : isImage(file) ? (
161+
<ImageIcon className="size-4 text-muted-foreground flex-shrink-0" />
162+
) : (
163+
<FileIcon className="size-4 text-muted-foreground flex-shrink-0" />
164+
)}
83165
<span className="text-sm truncate">
84166
{file.name || file.original_name || 'File'}
85167
</span>
86168
{file.size && (
87-
<span className="text-xs text-gray-500">
169+
<span className="text-xs text-muted-foreground">
88170
({(file.size / 1024).toFixed(1)} KB)
89171
</span>
90172
)}
@@ -93,7 +175,10 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
93175
type="button"
94176
variant="ghost"
95177
size="sm"
96-
onClick={() => handleRemove(idx)}
178+
onClick={(e) => {
179+
e.stopPropagation();
180+
handleRemove(idx);
181+
}}
97182
className="h-6 w-6 p-0"
98183
>
99184
<X className="size-3" />
@@ -102,16 +187,6 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
102187
))}
103188
</div>
104189
)}
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>
115190
</div>
116191
</div>
117192
);

0 commit comments

Comments
 (0)