File upload with drag-and-drop#
Uploading files to a Palmyra-backed API using react-dropzone and the store’s post() method. The store handles the FormData encoding, error handling, and the refresh signal.
Install#
npm install react-dropzoneReusable dropzone component#
// src/components/fileUpload/FileDropZone.tsx
import { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { Button, Group, Paper, Text, Stack } from '@mantine/core';
import { IconUpload, IconFile, IconX } from '@tabler/icons-react';
import { toast } from 'react-toastify';
import AppStoreFactory from '../../wire/StoreFactory';
interface Props {
endPoint: string;
parentId?: string | number;
maxFiles?: number; // default 5
maxSizeMB?: number; // default 5
accept?: Record<string, string[]>;
onUploaded?: () => void;
}
const DEFAULT_ACCEPT = {
'application/pdf': ['.pdf'],
'image/png': ['.png'],
'image/jpeg': ['.jpg', '.jpeg'],
'application/msword': ['.doc', '.docx'],
};
export default function FileDropZone(props: Props) {
const { endPoint, parentId, maxFiles = 5, maxSizeMB = 5, onUploaded } = props;
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const onDrop = useCallback((accepted: File[]) => {
setFiles(prev => [...prev, ...accepted].slice(0, maxFiles));
}, [maxFiles]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: props.accept ?? DEFAULT_ACCEPT,
maxSize: maxSizeMB * 1024 * 1024,
maxFiles,
});
const removeFile = (index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
const upload = async () => {
if (files.length === 0) return;
setUploading(true);
try {
const store = AppStoreFactory.getFormStore({}, endPoint);
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
if (parentId) formData.append('parentId', String(parentId));
await store.post(formData as any);
}
toast.success(`${files.length} file(s) uploaded`);
setFiles([]);
onUploaded?.();
} catch {
toast.error('Upload failed');
} finally {
setUploading(false);
}
};
return (
<Stack>
<Paper
{...getRootProps()}
p="xl"
withBorder
style={{ borderStyle: 'dashed', cursor: 'pointer',
backgroundColor: isDragActive ? '#f0f9ff' : undefined }}
>
<input {...getInputProps()} />
<Stack align="center" gap="xs">
<IconUpload size={32} color="gray" />
<Text c="dimmed" size="sm">
{isDragActive
? 'Drop files here…'
: `Drag & drop up to ${maxFiles} files, or click to browse (max ${maxSizeMB} MB each)`}
</Text>
</Stack>
</Paper>
{files.map((f, i) => (
<Group key={i} justify="space-between">
<Group gap="xs">
<IconFile size={16} />
<Text size="sm">{f.name}</Text>
<Text size="xs" c="dimmed">({(f.size / 1024).toFixed(0)} KB)</Text>
</Group>
<IconX size={14} style={{ cursor: 'pointer' }} onClick={() => removeFile(i)} />
</Group>
))}
{files.length > 0 && (
<Button onClick={upload} loading={uploading}>Upload {files.length} file(s)</Button>
)}
</Stack>
);
}Using it on a detail page#
// Inside EmployeeViewPage or similar
<FileDropZone
endPoint="/employee/{id}/documents"
parentId={params.id}
maxFiles={3}
maxSizeMB={10}
accept={{ 'application/pdf': ['.pdf'] }}
onUploaded={() => documentGridRef.current?.refresh()}
/>Backend endpoint#
The backend receives multipart/form-data. A plain Spring controller alongside the Palmyra handler works well:
@PostMapping("/employee/{id}/documents")
public ResponseEntity<?> upload(@PathVariable Long id,
@RequestParam("file") MultipartFile file) {
documentService.store(id, file);
return ResponseEntity.ok().build();
}Or use Palmyra’s TusUploadHandler if you need resumable uploads for large files.
See also: TusUploadHandler, PalmyraStoreFactory.