Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions public/r/data-grid.json

Large diffs are not rendered by default.

220 changes: 141 additions & 79 deletions src/components/data-grid/data-grid-cell-variants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1365,11 +1365,9 @@ export function FileCell<TData>({
[accept],
);

// Sync files when cellValue changes (same pattern as other cells)
const prevCellValueRef = React.useRef(cellValue);
if (cellValue !== prevCellValueRef.current) {
prevCellValueRef.current = cellValue;
// Revoke old object URLs to prevent memory leaks
for (const file of files) {
if (file.url) {
URL.revokeObjectURL(file.url);
Expand All @@ -1393,15 +1391,12 @@ export function FileCell<TData>({
const fileExtension = `.${file.name.split(".").pop()}`;
const isAccepted = acceptedTypes.some((type) => {
if (type.endsWith("/*")) {
// Handle wildcard types like "image/*"
const baseType = type.slice(0, -2);
return file.type.startsWith(`${baseType}/`);
}
if (type.startsWith(".")) {
// Handle file extensions like ".pdf"
return fileExtension.toLowerCase() === type.toLowerCase();
}
// Exact match for specific MIME types
return file.type === type;
});
if (!isAccepted) {
Expand All @@ -1418,7 +1413,6 @@ export function FileCell<TData>({
if (readOnly) return;
setError(null);

// Check max files limit
if (maxFiles && files.length + newFiles.length > maxFiles) {
const errorMessage = `Maximum ${maxFiles} files allowed`;
setError(errorMessage);
Expand All @@ -1429,25 +1423,16 @@ export function FileCell<TData>({
return;
}

const validFiles: FileCellData[] = [];
const rejectedFiles: Array<{ name: string; reason: string }> = [];
const filesToValidate: File[] = [];

for (const file of newFiles) {
const validationError = validateFile(file);
if (validationError) {
rejectedFiles.push({ name: file.name, reason: validationError });
continue;
}

// Create file data object with temporary ID
const fileData: FileCellData = {
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
url: URL.createObjectURL(file),
};
validFiles.push(fileData);
filesToValidate.push(file);
}

if (rejectedFiles.length > 0) {
Expand Down Expand Up @@ -1476,75 +1461,159 @@ export function FileCell<TData>({
}
}

if (validFiles.length > 0) {
// If not skipping upload (dropped on cell), show skeletons first
if (filesToValidate.length > 0) {
if (!skipUpload) {
// Add temp files immediately (will show as skeletons)
const tempFiles = validFiles.map((f) => ({ ...f, url: undefined }));
const tempFiles = filesToValidate.map((f) => ({
id: crypto.randomUUID(),
name: f.name,
size: f.size,
type: f.type,
url: undefined,
}));
const filesWithTemp = [...files, ...tempFiles];
setFiles(filesWithTemp);

// Mark as uploading
const uploadingIds = new Set(validFiles.map((f) => f.id));
const uploadingIds = new Set(tempFiles.map((f) => f.id));
setUploadingFiles(uploadingIds);

// Simulate upload delay (in real app, this would be actual upload)
await new Promise((resolve) => setTimeout(resolve, 800));
let uploadedFiles: FileCellData[] = [];
const rowData = table.options.data[rowIndex];

if (meta?.onFilesUpload && rowData) {
try {
uploadedFiles = await meta.onFilesUpload({
files: filesToValidate,
rowIndex,
columnId,
row: rowData,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: `Failed to upload ${filesToValidate.length} file${filesToValidate.length !== 1 ? "s" : ""}`,
);
setFiles((prev) => prev.filter((f) => !uploadingIds.has(f.id)));
setUploadingFiles(new Set());
return;
}
} else {
await new Promise((resolve) => setTimeout(resolve, 800));
uploadedFiles = filesToValidate.map((f, i) => ({
id: tempFiles[i]?.id ?? crypto.randomUUID(),
name: f.name,
size: f.size,
type: f.type,
url: URL.createObjectURL(f),
}));
}

const finalFiles = filesWithTemp
.map((f) => {
if (uploadingIds.has(f.id)) {
return uploadedFiles.find((uf) => uf.name === f.name) ?? f;
}
return f;
})
.filter((f) => f.url !== undefined);

// Replace temp files with real ones
const finalFiles = filesWithTemp.map(
(f) => validFiles.find((vf) => vf.id === f.id) || f,
);
setFiles(finalFiles);
setUploadingFiles(new Set());
meta?.onDataUpdate?.({ rowIndex, columnId, value: finalFiles });
} else {
// If from editor, add immediately without skeleton
const updatedFiles = [...files, ...validFiles];
const newFilesData: FileCellData[] = filesToValidate.map((f) => ({
id: crypto.randomUUID(),
name: f.name,
size: f.size,
type: f.type,
url: URL.createObjectURL(f),
}));
const updatedFiles = [...files, ...newFilesData];
setFiles(updatedFiles);
meta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles });
}
}
},
[files, maxFiles, validateFile, meta, rowIndex, columnId, readOnly],
[files, maxFiles, validateFile, meta, rowIndex, columnId, readOnly, table],
);

const removeFile = React.useCallback(
(fileId: string) => {
async (fileId: string) => {
if (readOnly) return;
setError(null);
// Revoke object URL to prevent memory leak

const fileToRemove = files.find((f) => f.id === fileId);
if (fileToRemove?.url) {
if (!fileToRemove) return;

const rowData = table.options.data[rowIndex];
if (meta?.onFilesDelete && rowData) {
try {
await meta.onFilesDelete({
fileIds: [fileId],
rowIndex,
columnId,
row: rowData,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: `Failed to delete ${fileToRemove.name}`,
);
return;
}
}

if (fileToRemove.url?.startsWith("blob:")) {
URL.revokeObjectURL(fileToRemove.url);
}

const updatedFiles = files.filter((f) => f.id !== fileId);
setFiles(updatedFiles);
meta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles });
},
[files, meta, rowIndex, columnId, readOnly],
[files, meta, rowIndex, columnId, readOnly, table],
);

const clearAll = React.useCallback(() => {
const clearAll = React.useCallback(async () => {
if (readOnly) return;
// Revoke all object URLs to prevent memory leak
setError(null);

const rowData = table.options.data[rowIndex];
if (meta?.onFilesDelete && rowData && files.length > 0) {
try {
await meta.onFilesDelete({
fileIds: files.map((f) => f.id),
rowIndex,
columnId,
row: rowData,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to delete files",
);
return;
}
}

for (const file of files) {
if (file.url) {
if (file.url?.startsWith("blob:")) {
URL.revokeObjectURL(file.url);
}
}
setFiles([]);
setError(null);
meta?.onDataUpdate?.({ rowIndex, columnId, value: [] });
}, [files, meta, rowIndex, columnId, readOnly]);
}, [files, meta, rowIndex, columnId, readOnly, table]);

const onDragEnter = React.useCallback((event: React.DragEvent) => {
const onCellDragEnter = React.useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
if (event.dataTransfer.types.includes("Files")) {
setIsDraggingOver(true);
}
}, []);

const onDragLeave = React.useCallback((event: React.DragEvent) => {
const onCellDragLeave = React.useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
Expand All @@ -1557,48 +1626,36 @@ export function FileCell<TData>({
y <= rect.top ||
y >= rect.bottom
) {
setIsDragging(false);
setIsDraggingOver(false);
}
}, []);

const onDragOver = React.useCallback((event: React.DragEvent) => {
const onCellDragOver = React.useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
}, []);

const onDrop = React.useCallback(
const onCellDrop = React.useCallback(
(event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
setIsDraggingOver(false);

const droppedFiles = Array.from(event.dataTransfer.files);
addFiles(droppedFiles, true); // Skip upload skeleton in editor
},
[addFiles],
);

const onFileInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(event.target.files ?? []);
addFiles(selectedFiles, true); // Skip upload skeleton for manual selection
// Reset input so the same file can be selected again
event.target.value = "";
if (droppedFiles.length > 0) {
addFiles(droppedFiles, false);
}
},
[addFiles],
);

// Cell-level drag handlers (for dropping directly on cell)
const onCellDragEnter = React.useCallback((event: React.DragEvent) => {
const onDropzoneDragEnter = React.useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
// Only show drop indicator if dragging files
if (event.dataTransfer.types.includes("Files")) {
setIsDraggingOver(true);
}
setIsDragging(true);
}, []);

const onCellDragLeave = React.useCallback((event: React.DragEvent) => {
const onDropzoneDragLeave = React.useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
Expand All @@ -1611,25 +1668,23 @@ export function FileCell<TData>({
y <= rect.top ||
y >= rect.bottom
) {
setIsDraggingOver(false);
setIsDragging(false);
}
}, []);

const onCellDragOver = React.useCallback((event: React.DragEvent) => {
const onDropzoneDragOver = React.useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
}, []);

const onCellDrop = React.useCallback(
const onDropzoneDrop = React.useCallback(
(event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDraggingOver(false);
setIsDragging(false);

const droppedFiles = Array.from(event.dataTransfer.files);
if (droppedFiles.length > 0) {
addFiles(droppedFiles, false); // Show skeleton for dropped files
}
addFiles(droppedFiles, false);
},
[addFiles],
);
Expand All @@ -1648,6 +1703,15 @@ export function FileCell<TData>({
[onDropzoneClick],
);

const onFileInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(event.target.files ?? []);
addFiles(selectedFiles, false);
event.target.value = "";
},
[addFiles],
);

const onOpenChange = React.useCallback(
(isOpen: boolean) => {
if (isOpen && !readOnly) {
Expand All @@ -1673,7 +1737,6 @@ export function FileCell<TData>({
React.ComponentProps<typeof PopoverContent>["onOpenAutoFocus"]
> = React.useCallback((event) => {
event.preventDefault();
// Focus the dropzone for better keyboard UX - users can press Enter again to open file dialog
queueMicrotask(() => {
dropzoneRef.current?.focus();
});
Expand All @@ -1692,7 +1755,6 @@ export function FileCell<TData>({
onDropzoneClick();
}
} else if (isFocused && event.key === "Enter") {
// Handle Enter key to start editing when focused but not editing
event.preventDefault();
meta?.onCellEditingStart?.(rowIndex, columnId);
} else if (!isEditing && isFocused && event.key === "Tab") {
Expand Down Expand Up @@ -1784,10 +1846,10 @@ export function FileCell<TData>({
className="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-6 outline-none transition-colors hover:bg-accent/30 focus-visible:border-ring/50 data-dragging:border-primary/30 data-invalid:border-destructive data-dragging:bg-accent/30 data-invalid:ring-destructive/20"
ref={dropzoneRef}
onClick={onDropzoneClick}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
onDragEnter={onDropzoneDragEnter}
onDragLeave={onDropzoneDragLeave}
onDragOver={onDropzoneDragOver}
onDrop={onDropzoneDrop}
onKeyDown={onDropzoneKeyDown}
>
<Upload className="size-8 text-muted-foreground" />
Expand Down
Loading