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-dropzone

Reusable 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.