feat: add bulk file actions (#7827)

* fixed styling

* Enabled header checkbox selection

* Changed styling of selection

* Implemented bulk downloading and deleting

* Added delete bulk hook

* Added download bulk hook

* Fix backend to send extension in download single file

* Fix hook to download single file directly

* Added header and selection handling

* Added delete confirmation

* [autofix.ci] apply automated fixes

* Fixed selection with shift

* Show disabled files

* Show disabled files as not clickable

* Changed color of icon when disabled

* Implemented pressed shift handling

* Fixed shift selection and disabled text selection when holding shift

* Created test for bulk selection on files modal

* add test of disabled components in file component

* Fixed files page test to include bulk editing test

* removed ring on focus visible

* Changed delete files having the right select

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Lucas Oliveira 2025-05-13 18:59:10 -03:00 committed by GitHub
commit a72995c408
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1244 additions and 235 deletions

View file

@ -1,9 +1,13 @@
import io
import re
import uuid
import zipfile
from collections.abc import AsyncGenerator
from datetime import datetime
from http import HTTPStatus
from pathlib import Path
from typing import Annotated
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
@ -159,6 +163,92 @@ async def list_files(
raise HTTPException(status_code=500, detail=f"Error listing files: {e}") from e
@router.delete("/batch/", status_code=HTTPStatus.OK)
async def delete_files_batch(
file_ids: list[uuid.UUID],
current_user: CurrentActiveUser,
session: DbSession,
storage_service: Annotated[StorageService, Depends(get_storage_service)],
):
"""Delete multiple files by their IDs."""
try:
# Fetch all files from the DB
stmt = select(UserFile).where(UserFile.id in file_ids, UserFile.user_id == current_user.id)
results = await session.exec(stmt)
files = results.all()
if not files:
raise HTTPException(status_code=404, detail="No files found")
# Delete all files from the storage service
for file in files:
await storage_service.delete_file(flow_id=str(current_user.id), file_name=file.path)
await session.delete(file)
# Delete all files from the database
await session.flush() # Ensures delete is staged
await session.commit() # Commit deletion
except Exception as e:
await session.rollback() # Rollback on failure
raise HTTPException(status_code=500, detail=f"Error deleting files: {e}") from e
return {"message": f"{len(files)} files deleted successfully"}
@router.post("/batch/", status_code=HTTPStatus.OK)
async def download_files_batch(
file_ids: list[uuid.UUID],
current_user: CurrentActiveUser,
session: DbSession,
storage_service: Annotated[StorageService, Depends(get_storage_service)],
):
"""Download multiple files as a zip file by their IDs."""
try:
# Fetch all files from the DB
stmt = select(UserFile).where(UserFile.id in file_ids, UserFile.user_id == current_user.id)
results = await session.exec(stmt)
files = results.all()
if not files:
raise HTTPException(status_code=404, detail="No files found")
# Create a byte stream to hold the ZIP file
zip_stream = io.BytesIO()
# Create a ZIP file
with zipfile.ZipFile(zip_stream, "w") as zip_file:
for file in files:
# Get the file content from storage
file_content = await storage_service.get_file(
flow_id=str(current_user.id), file_name=file.path.split("/")[-1]
)
# Get the file extension from the original filename
file_extension = Path(file.path).suffix
# Create the filename with extension
filename_with_extension = f"{file.name}{file_extension}"
# Write the file to the ZIP with the proper extension
zip_file.writestr(filename_with_extension, file_content)
# Seek to the beginning of the byte stream
zip_stream.seek(0)
# Generate the filename with the current datetime
current_time = datetime.now(tz=ZoneInfo("UTC")).astimezone().strftime("%Y%m%d_%H%M%S")
filename = f"{current_time}_langflow_files.zip"
return StreamingResponse(
zip_stream,
media_type="application/x-zip-compressed",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error downloading files: {e}") from e
@router.get("/{file_id}")
async def download_file(
file_id: uuid.UUID,
@ -177,6 +267,10 @@ async def download_file(
# Get file stream
file_stream = await storage_service.get_file(flow_id=str(current_user.id), file_name=file_name)
file_extension = Path(file.path).suffix
# Create the filename with extension
filename_with_extension = f"{file.name}{file_extension}"
# Ensure file_stream is an async iterator returning bytes
byte_stream = byte_stream_generator(file_stream)
except Exception as e:
@ -186,7 +280,7 @@ async def download_file(
return StreamingResponse(
byte_stream,
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{file.name}"'},
headers={"Content-Disposition": f'attachment; filename="{filename_with_extension}"'},
)

View file

@ -0,0 +1,43 @@
import { useMutationFunctionType } from "@/types/api";
import { UseMutationResult } from "@tanstack/react-query";
import { api } from "../../api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
interface IDeleteFiles {
ids: string[];
}
export const useDeleteFilesV2: useMutationFunctionType<
undefined,
IDeleteFiles
> = (options?) => {
const { mutate, queryClient } = UseRequestProcessor();
const deleteFileFn = async (params): Promise<any> => {
const response = await api.delete<any>(
`${getURL("FILE_MANAGEMENT", { mode: "batch/" }, true)}`,
{
data: params.ids,
},
);
return response.data;
};
const mutation: UseMutationResult<any, any, IDeleteFiles> = mutate(
["useDeleteFilesV2"],
deleteFileFn,
{
onSettled: (data, error, variables, context) => {
queryClient.invalidateQueries({
queryKey: ["useGetFilesV2"],
});
options?.onSettled?.(data, error, variables, context);
},
...options,
},
);
return mutation;
};

View file

@ -0,0 +1,76 @@
import { useMutationFunctionType } from "../../../../types/api";
import { getURL } from "../../helpers/constants";
import { UseRequestProcessor } from "../../services/request-processor";
interface DownloadFilesQueryParams {
ids: string[];
}
export const useGetDownloadFilesV2: useMutationFunctionType<
undefined,
DownloadFilesQueryParams
> = (options) => {
const { mutate } = UseRequestProcessor();
const getDownloadFilesFn = async (params) => {
if (!params) return;
// need to use fetch because axios convert blob data to string, and this convertion can corrupt the file
let response;
if (params.ids.length === 1) {
response = await fetch(
`${getURL("FILE_MANAGEMENT", { id: params.ids[0] }, true)}`,
{
headers: {
Accept: "*/*",
},
},
);
} else {
response = await fetch(
`${getURL("FILE_MANAGEMENT", { mode: "batch/" }, true)}`,
{
method: "POST",
body: JSON.stringify(params.ids),
headers: {
"Content-Type": "application/json",
Accept: "application/x-zip-compressed",
},
},
);
}
if (!response.ok) {
throw new Error(`Failed to download files: ${response.statusText}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
// Get the filename from the Content-Disposition header
const contentDisposition = response.headers.get("Content-Disposition");
let filename = "files.zip";
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename=(.+)/);
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1].replace(/["']/g, "");
}
}
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return {};
};
const queryResult = mutate(
["useGetDownloadFilesV2"],
getDownloadFilesFn,
options,
);
return queryResult;
};

View file

@ -18,6 +18,7 @@ export default function FileRendererComponent({
handleRemove,
handleRename,
index,
isShiftPressed,
}: {
file: FileType;
handleFileSelect?: (path: string) => void;
@ -25,6 +26,7 @@ export default function FileRendererComponent({
handleRemove?: (path: string) => void;
handleRename?: (id: string, name: string) => void;
index: number;
isShiftPressed?: boolean;
}) {
const type = file.path.split(".").pop() ?? "";
@ -41,158 +43,181 @@ export default function FileRendererComponent({
setNewName(file.name);
}, [openRename]);
return (
<div
key={index}
className={cn(
"flex w-full items-center justify-between gap-2 overflow-hidden rounded-lg py-2",
handleFileSelect ? "cursor-pointer px-3 hover:bg-accent" : "",
)}
onClick={() => {
if (!file.progress) handleFileSelect?.(file.path);
}}
>
<div className="flex w-full items-center gap-4 overflow-hidden">
{handleFileSelect && (
<div
className={cn(
"flex shrink-0",
file.progress !== undefined &&
"pointer-events-none cursor-not-allowed",
)}
onClick={(e) => e.stopPropagation()}
>
<Checkbox
data-testid={`checkbox-${file.name}`}
checked={selectedFiles?.includes(file.path)}
onCheckedChange={() => handleFileSelect?.(file.path)}
/>
</div>
)}
<div className="flex w-full items-center gap-2 overflow-hidden">
{file.progress !== undefined && file.progress !== -1 ? (
<div className="flex h-6 items-center justify-center text-xs font-semibold text-muted-foreground">
{Math.round(file.progress * 100)}%
</div>
) : (
<ForwardedIconComponent
name={FILE_ICONS[type]?.icon ?? "File"}
className={cn(
"h-6 w-6 shrink-0",
file.progress !== undefined
? "text-placeholder-foreground"
: (FILE_ICONS[type]?.color ?? undefined),
)}
/>
)}
const handleItemClick = () => {
if (!file.progress && handleFileSelect) {
handleFileSelect(file.path);
}
};
{openRename ? (
<div className="w-full">
<Input
value={newName}
autoFocus
onChange={(e) => setNewName(e.target.value)}
onBlur={() => {
setOpenRename(false);
handleRename?.(file.id, newName);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
setOpenRename(false);
handleRename?.(file.id, newName);
}
}}
return (
<ShadTooltip
content={file.disabled ? "Type not supported by component" : ""}
side="bottom"
align="start"
>
<div
className={cn(
file.disabled ? "cursor-not-allowed" : "",
isShiftPressed && "select-none",
)}
>
<div
key={index}
className={cn(
"flex w-full shrink-0 items-center justify-between gap-2 overflow-hidden rounded-lg py-2",
handleFileSelect ? "cursor-pointer px-3 hover:bg-accent" : "",
file.disabled
? "pointer-events-none cursor-not-allowed opacity-50"
: "",
)}
onClick={handleItemClick}
data-testid={`file-item-${file.name}`}
>
<div className="flex w-full items-center gap-4 overflow-hidden">
{handleFileSelect && (
<div
className={cn(
"flex shrink-0",
file.progress !== undefined &&
"pointer-events-none cursor-not-allowed",
)}
onClick={(e) => e.stopPropagation()}
className="h-6 py-1"
data-testid={`rename-input-${file.name}`}
/>
</div>
) : (
<span
className={cn(
"flex flex-1 items-center gap-2 overflow-hidden text-sm font-medium",
file.progress !== undefined &&
file.progress === -1 &&
"pointer-events-none text-placeholder-foreground",
>
<Checkbox
data-testid={`checkbox-${file.name}`}
checked={selectedFiles?.includes(file.path)}
onCheckedChange={handleItemClick}
disabled={file.disabled}
className="focus-visible:ring-0"
/>
</div>
)}
<div className="flex w-full items-center gap-2 overflow-hidden">
{file.progress !== undefined && file.progress !== -1 ? (
<div className="flex h-6 items-center justify-center text-xs font-semibold text-muted-foreground">
{Math.round(file.progress * 100)}%
</div>
) : (
<ForwardedIconComponent
name={FILE_ICONS[type]?.icon ?? "File"}
className={cn(
"h-6 w-6 shrink-0",
file.progress !== undefined || file.disabled
? "text-placeholder-foreground"
: (FILE_ICONS[type]?.color ?? undefined),
)}
/>
)}
>
<ShadTooltip content={`${file.name}.${type}`} side="bottom">
{openRename ? (
<div className="w-full">
<Input
value={newName}
autoFocus
onChange={(e) => setNewName(e.target.value)}
onBlur={() => {
setOpenRename(false);
handleRename?.(file.id, newName);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
setOpenRename(false);
handleRename?.(file.id, newName);
}
}}
onClick={(e) => e.stopPropagation()}
className="h-6 py-1"
data-testid={`rename-input-${file.name}`}
/>
</div>
) : (
<span
className={cn(
"w-full cursor-pointer overflow-hidden truncate",
handleRemove && "cursor-default",
"flex items-center gap-2 overflow-hidden text-sm font-medium",
file.progress !== undefined &&
file.progress === -1 &&
"pointer-events-none text-placeholder-foreground",
)}
>
{file.name}.{type}
<ShadTooltip content={`${file.name}.${type}`} side="bottom">
<span
className={cn(
"w-full cursor-pointer overflow-hidden truncate",
handleRemove && "cursor-default",
)}
>
{file.name}.{type}
</span>
</ShadTooltip>
<span className="shrink-0 text-xs font-normal text-muted-foreground">
{formatFileSize(file.size)}
</span>
</span>
</ShadTooltip>
<span className="shrink-0 text-xs font-normal text-muted-foreground">
{formatFileSize(file.size)}
</span>
</span>
)}
{file.progress !== undefined && file.progress === -1 ? (
<span className="text-mmd text-primary">
Upload failed,{" "}
<span
className="cursor-pointer text-accent-pink-foreground underline"
)}
{file.progress !== undefined && file.progress === -1 ? (
<span className="text-mmd text-primary">
Upload failed,{" "}
<span
className="cursor-pointer text-accent-pink-foreground underline"
onClick={(e) => {
e.stopPropagation();
if (file.file) {
uploadFile({ file: file.file });
}
}}
>
try again?
</span>
</span>
) : (
<></>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{handleRemove ? (
<Button
size="iconMd"
variant="ghost"
className="hover:bg-accent"
data-testid={`remove-file-button-${file.name}`}
onClick={(e) => {
e.stopPropagation();
if (file.file) {
uploadFile({ file: file.file });
}
handleRemove?.(file.path);
}}
>
try again?
</span>
</span>
) : (
<></>
)}
<ForwardedIconComponent
name="X"
className="h-5 w-5 shrink-0 text-muted-foreground"
/>
</Button>
) : file.progress === undefined ? (
<FilesContextMenuComponent
handleRename={handleOpenRename}
file={file}
simplified
>
<Button
size="iconMd"
data-testid={`context-menu-button-${file.name}`}
variant="ghost"
className="hover:bg-secondary-foreground/5"
onClick={(e) => {
e.stopPropagation();
}}
>
<ForwardedIconComponent
name="EllipsisVertical"
className="h-5 w-5 shrink-0"
/>
</Button>
</FilesContextMenuComponent>
) : (
<></>
)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{handleRemove ? (
<Button
size="iconMd"
variant="ghost"
className="hover:bg-accent"
data-testid={`remove-file-button-${file.name}`}
onClick={(e) => {
e.stopPropagation();
handleRemove?.(file.path);
}}
>
<ForwardedIconComponent
name="X"
className="h-5 w-5 shrink-0 text-muted-foreground"
/>
</Button>
) : file.progress === undefined ? (
<FilesContextMenuComponent
handleRename={handleOpenRename}
file={file}
simplified
>
<Button
size="iconMd"
data-testid={`context-menu-button-${file.name}`}
variant="ghost"
className="hover:bg-secondary-foreground/5"
onClick={(e) => {
e.stopPropagation();
}}
>
<ForwardedIconComponent
name="EllipsisVertical"
className="h-5 w-5 shrink-0"
/>
</Button>
</FilesContextMenuComponent>
) : (
<></>
)}
</div>
</div>
</ShadTooltip>
);
}

View file

@ -7,22 +7,27 @@ export default function FilesRendererComponent({
selectedFiles,
handleRemove,
handleRename,
isShiftPressed,
}: {
files: FileType[];
isSearch?: boolean;
handleFileSelect?: (name: string) => void;
handleFileSelect?: (name: string, index: number) => void;
selectedFiles?: string[];
handleRemove?: (name: string) => void;
handleRename?: (id: string, name: string) => void;
isShiftPressed?: boolean;
}) {
return files.map((file, index) => (
<FileRendererComponent
key={index}
file={file}
handleFileSelect={handleFileSelect}
handleFileSelect={
handleFileSelect ? (name) => handleFileSelect(name, index) : undefined
}
selectedFiles={selectedFiles}
handleRemove={handleRemove}
handleRename={handleRename}
isShiftPressed={isShiftPressed}
index={index}
/>
));

View file

@ -1,10 +1,11 @@
import { Input } from "@/components/ui/input";
import { IS_MAC } from "@/constants/constants";
import { usePostRenameFileV2 } from "@/controllers/API/queries/file-management/use-put-rename-file";
import { CustomLink } from "@/customization/components/custom-link";
import { sortByBoolean, sortByDate } from "@/pages/MainPage/utils/sort-flows";
import { FileType } from "@/types/file_management";
import Fuse from "fuse.js";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import FilesRendererComponent from "../filesRendererComponent";
export default function RecentFilesComponent({
@ -20,49 +21,111 @@ export default function RecentFilesComponent({
types: string[];
isList: boolean;
}) {
const filesWithType = useMemo(
const filesWithDisabled = useMemo(
() =>
files.map((file) => ({
...file,
type: file.path.split(".").pop()?.toLowerCase(),
})),
[files],
files.map((file) => {
const fileExtension = file.path.split(".").pop()?.toLowerCase();
return {
...file,
type: fileExtension,
disabled: !fileExtension || !types.includes(fileExtension),
};
}),
[files, types],
);
const fuse = useMemo(
() =>
new Fuse(filesWithType, {
new Fuse(filesWithDisabled, {
keys: ["name", "type"],
threshold: 0.3,
}),
[filesWithType],
[filesWithDisabled],
);
const [searchQuery, setSearchQuery] = useState("");
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const [isShiftPressed, setIsShiftPressed] = useState(false);
const { mutate: renameFile } = usePostRenameFileV2();
const searchResults = useMemo(() => {
const filteredFiles = (
searchQuery
? fuse.search(searchQuery).map(({ item }) => item)
: (filesWithType ?? [])
).filter((file) => {
const fileExtension = file.path.split(".").pop()?.toLowerCase();
return fileExtension && (!types || types.includes(fileExtension));
});
const filteredFiles = searchQuery
? fuse.search(searchQuery).map(({ item }) => item)
: (filesWithDisabled ?? []);
return filteredFiles;
}, [searchQuery, filesWithType, types]);
}, [searchQuery, filesWithDisabled, types]);
const handleFileSelect = (filePath: string) => {
setSelectedFiles(
selectedFiles.includes(filePath)
? selectedFiles.filter((path) => path !== filePath)
: isList
? [...selectedFiles, filePath]
: [filePath],
);
};
const sortedSearchResults = useMemo(() => {
return searchResults.toSorted((a, b) => {
const selectedOrder = sortByBoolean(
a.progress !== undefined,
b.progress !== undefined,
);
return selectedOrder === 0
? sortByDate(a.updated_at ?? a.created_at, b.updated_at ?? b.created_at)
: selectedOrder;
});
}, [searchResults]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Shift") {
setIsShiftPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Shift") {
setIsShiftPressed(false);
}
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keyup", handleKeyUp);
};
}, []);
const handleFileSelect = useCallback(
(filePath: string, index: number) => {
// Standard file selection behavior:
// 1. Click: Select only this file
// 2. Ctrl/Cmd + Click: Toggle selection for this file, keeping other selections
// 3. Shift + Click: Select range from last clicked to current file
if (isShiftPressed && lastClickedIndex !== null) {
// Select range - keep existing selection and add the range
const start = Math.min(lastClickedIndex, index);
const end = Math.max(lastClickedIndex, index);
// Get all file paths in the range
const rangeFilePaths = sortedSearchResults
.slice(start, end + 1)
.filter((file) => !file.disabled)
.map((file) => file.path);
return setSelectedFiles(rangeFilePaths);
} else {
// Ctrl/Cmd + Click: Toggle selection for this item while keeping others
setLastClickedIndex(index);
if (selectedFiles.includes(filePath)) {
setSelectedFiles(selectedFiles.filter((path) => path !== filePath));
} else {
setSelectedFiles([...selectedFiles, filePath]);
}
}
},
[
selectedFiles,
lastClickedIndex,
sortedSearchResults,
isShiftPressed,
setSelectedFiles,
],
);
const handleRename = (id: string, name: string) => {
renameFile({ id, name });
@ -90,23 +153,11 @@ export default function RecentFilesComponent({
>
{searchResults.length > 0 ? (
<FilesRendererComponent
files={searchResults
.toSorted((a, b) => {
const selectedOrder = sortByBoolean(
a.progress !== undefined,
b.progress !== undefined,
);
return selectedOrder === 0
? sortByDate(
a.updated_at ?? a.created_at,
b.updated_at ?? b.created_at,
)
: selectedOrder;
})
.slice(0, 10)}
files={sortedSearchResults}
handleFileSelect={handleFileSelect}
selectedFiles={selectedFiles}
handleRename={handleRename}
isShiftPressed={isShiftPressed}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm">

View file

@ -7,19 +7,27 @@ import { Input } from "@/components/ui/input";
import Loading from "@/components/ui/loading";
import { SidebarTrigger } from "@/components/ui/sidebar";
import {
useGetDownloadFileV2,
useGetFilesV2,
usePostUploadFileV2,
} from "@/controllers/API/queries/file-management";
import { useDeleteFilesV2 } from "@/controllers/API/queries/file-management/use-delete-files";
import { useGetDownloadFilesV2 } from "@/controllers/API/queries/file-management/use-get-download-files";
import { usePostRenameFileV2 } from "@/controllers/API/queries/file-management/use-put-rename-file";
import useUploadFile from "@/hooks/files/use-upload-file";
import DeleteConfirmationModal from "@/modals/deleteConfirmationModal";
import FilesContextMenuComponent from "@/modals/fileManagerModal/components/filesContextMenuComponent";
import useAlertStore from "@/stores/alertStore";
import { formatFileSize } from "@/utils/stringManipulation";
import { FILE_ICONS } from "@/utils/styleUtils";
import { cn } from "@/utils/utils";
import { ColDef, NewValueParams } from "ag-grid-community";
import {
ColDef,
NewValueParams,
SelectionChangedEvent,
} from "ag-grid-community";
import { AgGridReact } from "ag-grid-react";
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { sortByDate } from "../../utils/sort-flows";
import DragWrapComponent from "./components/dragWrapComponent";
@ -29,8 +37,50 @@ export const FilesPage = () => {
const setErrorData = useAlertStore((state) => state.setErrorData);
const setSuccessData = useAlertStore((state) => state.setSuccessData);
const [selectedFiles, setSelectedFiles] = useState<any[]>([]);
const [quantitySelected, setQuantitySelected] = useState(0);
const [isShiftPressed, setIsShiftPressed] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Shift") {
setIsShiftPressed(true);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Shift") {
setIsShiftPressed(false);
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, []);
const handleSelectionChanged = (event: SelectionChangedEvent) => {
const selectedRows = event.api.getSelectedRows();
setSelectedFiles(selectedRows);
if (selectedRows.length > 0) {
setQuantitySelected(selectedRows.length);
} else {
setTimeout(() => {
setQuantitySelected(0);
}, 300);
}
};
const { mutate: rename } = usePostRenameFileV2();
const { mutate: deleteFiles, isPending: isDeleting } = useDeleteFilesV2();
const { mutate: downloadFiles, isPending: isDownloading } =
useGetDownloadFilesV2();
const handleRename = (params: NewValueParams<any, any>) => {
rename({
id: params.data.id,
@ -72,32 +122,37 @@ export const FilesPage = () => {
headerName: "Name",
field: "name",
flex: 2,
headerCheckboxSelection: true,
checkboxSelection: true,
editable: true,
filter: "agTextColumnFilter",
cellClass: "cursor-text select-text",
cellClass:
"cursor-text select-text group-[.no-select-cells]:cursor-default group-[.no-select-cells]:select-none",
cellRenderer: (params) => {
const type = params.data.path.split(".")[1]?.toLowerCase();
return (
<div className="flex items-center gap-2 font-medium">
<div className="flex items-center gap-4 font-medium">
{params.data.progress !== undefined &&
params.data.progress !== -1 ? (
<div className="flex h-6 items-center justify-center text-xs font-semibold text-muted-foreground">
{Math.round(params.data.progress * 100)}%
</div>
) : (
<ForwardedIconComponent
name={FILE_ICONS[type]?.icon ?? "File"}
className={cn(
"h-6 w-6 shrink-0",
params.data.progress !== undefined
? "text-placeholder-foreground"
: (FILE_ICONS[type]?.color ?? undefined),
)}
/>
<div className="file-icon pointer-events-none relative">
<ForwardedIconComponent
name={FILE_ICONS[type]?.icon ?? "File"}
className={cn(
"-mx-[3px] h-6 w-6 shrink-0",
params.data.progress !== undefined
? "text-placeholder-foreground"
: (FILE_ICONS[type]?.color ?? undefined),
)}
/>
</div>
)}
<div
className={cn(
"flex cursor-text items-center gap-2 text-sm font-medium",
"flex items-center gap-2 text-sm font-medium",
params.data.progress !== undefined &&
params.data.progress === -1 &&
"pointer-events-none text-placeholder-foreground",
@ -137,7 +192,8 @@ export const FilesPage = () => {
valueFormatter: (params) => {
return params.value.split(".")[1]?.toUpperCase();
},
cellClass: "text-muted-foreground cursor-text select-text",
cellClass:
"text-muted-foreground cursor-text select-text group-[.no-select-cells]:cursor-default group-[.no-select-cells]:select-none",
},
{
headerName: "Size",
@ -147,7 +203,8 @@ export const FilesPage = () => {
return formatFileSize(params.value);
},
editable: false,
cellClass: "text-muted-foreground cursor-text select-text",
cellClass:
"text-muted-foreground cursor-text select-text group-[.no-select-cells]:cursor-default group-[.no-select-cells]:select-none",
},
{
headerName: "Modified",
@ -160,7 +217,8 @@ export const FilesPage = () => {
editable: false,
flex: 1,
resizable: false,
cellClass: "text-muted-foreground cursor-text select-text",
cellClass:
"text-muted-foreground cursor-text select-text group-[.no-select-cells]:cursor-default group-[.no-select-cells]:select-none",
},
{
maxWidth: 60,
@ -195,6 +253,48 @@ export const FilesPage = () => {
}
};
const handleDownload = () => {
console.log(selectedFiles);
downloadFiles(
{
ids: selectedFiles.map((file) => file.id),
},
{
onSuccess: (data) => {
setSuccessData({ title: data.message });
},
onError: (error) => {
setErrorData({
title: "Error downloading files",
list: [
error.message || "An error occurred while downloading the files",
],
});
},
},
);
};
const handleDelete = () => {
deleteFiles(
{
ids: selectedFiles.map((file) => file.id),
},
{
onSuccess: (data) => {
setSuccessData({ title: data.message });
},
onError: (error) => {
setErrorData({
title: "Error deleting files",
list: [
error.message || "An error occurred while deleting the files",
],
});
},
},
);
};
const UploadButtonComponent = useMemo(() => {
return (
<ShadTooltip content="Upload File" side="bottom">
@ -220,6 +320,7 @@ export const FilesPage = () => {
}, [uploadFile]);
const [quickFilterText, setQuickFilterText] = useState("");
return (
<div
className="flex h-full w-full flex-col overflow-y-auto"
@ -276,39 +377,89 @@ export const FilesPage = () => {
</div>
) : files.length > 0 ? (
<DragWrapComponent onFileDrop={onFileDrop}>
<TableComponent
rowHeight={45}
headerHeight={45}
cellSelection={false}
tableOptions={{
hide_options: true,
}}
editable={[
{
field: "name",
onUpdate: handleRename,
editableCell: true,
},
]}
enableCellTextSelection={false}
columnDefs={colDefs}
rowData={files.sort((a, b) => {
return sortByDate(
a.updated_at ?? a.created_at,
b.updated_at ?? b.created_at,
);
})}
className="ag-no-border w-full"
pagination
ref={tableRef}
quickFilterText={quickFilterText}
gridOptions={{
enableCellTextSelection: true,
stopEditingWhenCellsLoseFocus: true,
ensureDomOrder: true,
colResizeDefault: "shift",
}}
/>
<div className="relative h-full">
<TableComponent
rowHeight={45}
headerHeight={45}
cellSelection={false}
tableOptions={{
hide_options: true,
}}
suppressRowClickSelection={!isShiftPressed}
editable={[
{
field: "name",
onUpdate: handleRename,
editableCell: true,
},
]}
rowSelection="multiple"
onSelectionChanged={handleSelectionChanged}
columnDefs={colDefs}
rowData={files.sort((a, b) => {
return sortByDate(
a.updated_at ?? a.created_at,
b.updated_at ?? b.created_at,
);
})}
className={cn(
"ag-no-border group w-full",
isShiftPressed &&
quantitySelected > 0 &&
"no-select-cells",
)}
pagination
ref={tableRef}
quickFilterText={quickFilterText}
gridOptions={{
stopEditingWhenCellsLoseFocus: true,
ensureDomOrder: true,
colResizeDefault: "shift",
}}
/>
<div
className={cn(
"pointer-events-none absolute top-1.5 z-50 flex h-8 w-full transition-opacity",
selectedFiles.length > 0 ? "opacity-100" : "opacity-0",
)}
>
<div className="pointer-events-auto ml-12 flex h-full flex-1 items-center justify-between bg-background">
<span className="text-xs text-muted-foreground">
{quantitySelected} selected
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="iconMd"
onClick={handleDownload}
loading={isDownloading}
data-testid="bulk-download-btn"
>
<ForwardedIconComponent name="Download" />
</Button>
<DeleteConfirmationModal
onConfirm={handleDelete}
description={
"file" + (quantitySelected > 1 ? "s" : "")
}
>
<Button
variant="destructive"
size="iconMd"
className="px-2.5 !text-mmd"
loading={isDeleting}
data-testid="bulk-delete-btn"
>
<ForwardedIconComponent name="Trash2" />
Delete
</Button>
</DeleteConfirmationModal>
</div>
</div>
</div>
</div>
</DragWrapComponent>
) : (
<CardsWrapComponent

View file

@ -8,6 +8,7 @@
--ag-header-background-color: hsl(var(--background)) !important;
--ag-tooltip-background-color: hsl(var(--muted)) !important;
--ag-disabled-foreground-color: hsl(var(--muted-foreground)) !important;
--ag-active-color: hsl(var(--foreground)) !important;
--ag-border-color: hsl(var(--border)) !important;
--ag-selected-row-background-color: hsl(var(--accent)) !important;
--ag-menu-background-color: hsl(var(--accent)) !important;
@ -99,6 +100,36 @@
box-shadow: none !important;
outline: none !important;
}
.ag-no-border .ag-selection-checkbox {
margin-right: 0px !important;
}
.ag-no-border .ag-selection-checkbox .ag-checkbox {
width: 0px !important;
margin-right: 0px !important;
transition: width 0.1s ease-in-out;
opacity: 0;
}
.ag-no-border .ag-selection-checkbox:hover .ag-checkbox {
opacity: 1;
}
.ag-no-border .ag-selection-checkbox .ag-checkbox:has(.ag-checked) {
width: 32px !important;
opacity: 1 !important;
}
.ag-cell-wrapper:hover:has(.ag-checked) .file-icon {
transition: opacity 0.1s ease-in-out;
}
.ag-checkbox-input-wrapper:focus-within,
.ag-checkbox-input-wrapper:active {
box-shadow: none !important;
}
.ag-cell-wrapper:has(.ag-selection-checkbox:hover):not(:has(.ag-checked))
.file-icon {
opacity: 0;
}
.ag-tool-mode .ag-root-wrapper {
border: none !important;

View file

@ -10,4 +10,5 @@ export type FileType = {
progress?: number;
file?: File;
type?: string;
disabled?: boolean;
};

View file

@ -391,3 +391,390 @@ test(
}
},
);
test(
"should be able to select multiple files with shift-click",
{
tag: ["@release", "@workspace"],
},
async ({ page }) => {
// Generate unique filenames for this test run
const file1 = generateRandomFilename();
const file2 = generateRandomFilename();
const file3 = generateRandomFilename();
const file4 = generateRandomFilename();
const file5 = generateRandomFilename();
// Read the test file content
const testFilePath = path.join(__dirname, "../../assets/test_file.txt");
const fileContent = fs.readFileSync(testFilePath);
await awaitBootstrapTest(page);
await page.waitForSelector('[data-testid="blank-flow"]', {
timeout: 30000,
});
await page.getByTestId("blank-flow").click();
await addLegacyComponents(page);
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("file");
await page.waitForSelector('[data-testid="dataFile"]', {
timeout: 3000,
});
await page
.getByTestId("dataFile")
.first()
.dragTo(page.locator('//*[@id="react-flow-id"]'));
await page.mouse.up();
await page.mouse.down();
await adjustScreenView(page);
// Check if file management button is visible
const fileManagement = await page
.getByTestId("button_open_file_management")
?.isVisible();
if (fileManagement) {
// Open file management modal
await page.getByTestId("button_open_file_management").click();
// Upload 5 files for testing shift-click selection
// Upload file 1
const createFileTransfer = async (
filename: string,
content: string,
type: string,
) => {
return page.evaluateHandle(
(params) => {
const data = new DataTransfer();
const file = new File(
[params.content],
`${params.filename}.${params.type}`,
{ type: params.mimeType },
);
data.items.add(file);
return data;
},
{
filename,
content,
type,
mimeType: type === "txt" ? "text/plain" : "application/json",
},
);
};
// Upload five files
const files = [
{ name: file1, content: "file content 1", type: "txt" },
{ name: file2, content: "file content 2", type: "txt" },
{ name: file3, content: "file content 3", type: "txt" },
{ name: file4, content: "file content 4", type: "txt" },
{ name: file5, content: "file content 5", type: "txt" },
];
for (const file of files) {
const dataTransfer = await createFileTransfer(
file.name,
file.content,
file.type,
);
// Trigger drag events
await page.dispatchEvent(
'[data-testid="drag-files-component"]',
"dragover",
{ dataTransfer },
);
await page.dispatchEvent(
'[data-testid="drag-files-component"]',
"drop",
{ dataTransfer },
);
// Verify file was uploaded
await expect(
page.getByText(`${file.name}.${file.type}`).last(),
).toBeVisible({
timeout: 1000,
});
}
// Unselect all files first
for (const file of files) {
if (
(await page
.getByTestId(`checkbox-${file.name}`)
.last()
.getAttribute("data-state")) === "checked"
) {
await page.getByTestId(`checkbox-${file.name}`).last().click();
}
}
// Test 1: Select first file, then shift-click the third file
// First file
await page.getByTestId(`checkbox-${file1}`).last().click();
// Hold shift and click third file
await page.keyboard.down("Shift");
await page.getByTestId(`checkbox-${file3}`).last().click();
await page.keyboard.up("Shift");
// Verify files 1, 2, and 3 are selected
await expect(
page.getByTestId(`checkbox-${file1}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file2}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file3}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file4}`).last(),
).toHaveAttribute("data-state", "unchecked");
await expect(
page.getByTestId(`checkbox-${file5}`).last(),
).toHaveAttribute("data-state", "unchecked");
// Test 2: Shift-click to extend selection to file 5
await page.keyboard.down("Shift");
await page.getByTestId(`checkbox-${file5}`).last().click();
await page.keyboard.up("Shift");
// Verify all files are selected
await expect(
page.getByTestId(`checkbox-${file1}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file2}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file3}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file4}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file5}`).last(),
).toHaveAttribute("data-state", "checked");
// Test 3: Unselect a range with shift-click
// First select only file 2
for (const file of files) {
if (
(await page
.getByTestId(`checkbox-${file.name}`)
.last()
.getAttribute("data-state")) === "checked"
) {
await page.getByTestId(`checkbox-${file.name}`).last().click();
}
}
await page.getByTestId(`checkbox-${file2}`).last().click();
// Select file 2 through 4
await page.keyboard.down("Shift");
await page.getByTestId(`checkbox-${file4}`).last().click();
await page.keyboard.up("Shift");
// Verify files 2, 3, and 4 are selected
await expect(
page.getByTestId(`checkbox-${file1}`).last(),
).toHaveAttribute("data-state", "unchecked");
await expect(
page.getByTestId(`checkbox-${file2}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file3}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file4}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file5}`).last(),
).toHaveAttribute("data-state", "unchecked");
// Now use shift-click on an already selected range to deselect
await page.keyboard.down("Shift");
await page.getByTestId(`checkbox-${file2}`).last().click();
await page.keyboard.up("Shift");
// Verify the range is now deselected
await expect(
page.getByTestId(`checkbox-${file1}`).last(),
).toHaveAttribute("data-state", "unchecked");
await expect(
page.getByTestId(`checkbox-${file2}`).last(),
).toHaveAttribute("data-state", "checked");
await expect(
page.getByTestId(`checkbox-${file3}`).last(),
).toHaveAttribute("data-state", "unchecked");
await expect(
page.getByTestId(`checkbox-${file4}`).last(),
).toHaveAttribute("data-state", "unchecked");
await expect(
page.getByTestId(`checkbox-${file5}`).last(),
).toHaveAttribute("data-state", "unchecked");
// Close the modal
await page.getByTestId("select-files-modal-button").click();
}
},
);
test(
"should show PNG file as disabled in file component",
{
tag: ["@release", "@workspace"],
},
async ({ page }) => {
// Generate unique filenames for this test run
const pngFileName = generateRandomFilename();
const txtFileName = generateRandomFilename();
// Create PNG content (a simple 1x1 transparent PNG)
const pngFileContent = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
);
// Read the test file content for text file
const testFilePath = path.join(__dirname, "../../assets/test_file.txt");
const txtFileContent = fs.readFileSync(testFilePath);
// Step 1: First navigate to files page and upload both files
await awaitBootstrapTest(page, { skipModal: true });
// Navigate to My Files page
await page.getByText("My Files").first().click();
// Check if we're on the files page
await page.waitForSelector('[data-testid="mainpage_title"]');
const title = await page.getByTestId("mainpage_title");
expect(await title.textContent()).toContain("My Files");
// Upload the PNG file
const fileChooserPromisePng = page.waitForEvent("filechooser");
await page.getByTestId("upload-file-btn").click();
const fileChooserPng = await fileChooserPromisePng;
await fileChooserPng.setFiles([
{
name: `${pngFileName}.png`,
mimeType: "image/png",
buffer: pngFileContent,
},
]);
// Wait for upload success message
await expect(page.getByText("File uploaded successfully")).toBeVisible();
// Verify PNG file appears in the list
await expect(page.getByText(`${pngFileName}.png`)).toBeVisible();
// Upload the TXT file
const fileChooserPromiseTxt = page.waitForEvent("filechooser");
await page.getByTestId("upload-file-btn").click();
const fileChooserTxt = await fileChooserPromiseTxt;
await fileChooserTxt.setFiles([
{
name: `${txtFileName}.txt`,
mimeType: "text/plain",
buffer: txtFileContent,
},
]);
// Wait for upload success message
await expect(page.getByText("File uploaded successfully")).toBeVisible();
// Verify TXT file appears in the list
await expect(page.getByText(`${txtFileName}.txt`)).toBeVisible();
// Step 2: Create a flow with File component and check if PNG file is disabled
// Navigate to workspace page
await page.getByText("Starter Project").first().click();
await awaitBootstrapTest(page, { skipGoto: true });
// Create a new flow
await page.waitForSelector('[data-testid="blank-flow"]', {
timeout: 30000,
});
await page.getByTestId("blank-flow").click();
await addLegacyComponents(page);
// Add a file component to the flow
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("file");
await page.waitForSelector('[data-testid="dataFile"]', {
timeout: 3000,
});
await page
.getByTestId("dataFile")
.first()
.dragTo(page.locator('//*[@id="react-flow-id"]'));
await page.mouse.up();
await page.mouse.down();
await adjustScreenView(page);
// Open the file management modal
await page.getByTestId("button_open_file_management").click();
console.log(pngFileName);
// Check if the PNG file has the disabled class (greyed out)
await expect(page.getByTestId(`file-item-${pngFileName}`)).toHaveClass(
/pointer-events-none cursor-not-allowed opacity-50/,
);
// Check that the TXT file is not disabled
await expect(page.getByTestId(`file-item-${txtFileName}`)).not.toHaveClass(
/pointer-events-none cursor-not-allowed opacity-50/,
);
// Verify the tooltip for PNG file states it's not supported
await page
.locator(`[data-testid="file-item-${pngFileName}"]`)
.locator("..")
.hover();
await expect(
page.getByText("Type not supported by component"),
).toBeVisible();
// Try to select the PNG file (should not change its state)
await expect(page.getByTestId(`checkbox-${pngFileName}`)).toBeDisabled();
// Verify the PNG file checkbox remains unchecked
await expect(page.getByTestId(`checkbox-${pngFileName}`)).toHaveAttribute(
"data-state",
"unchecked",
);
// Select the TXT file (should work normally)
await page.getByTestId(`checkbox-${txtFileName}`).click();
// Verify the TXT file checkbox becomes checked
await expect(page.getByTestId(`checkbox-${txtFileName}`)).toHaveAttribute(
"data-state",
"checked",
);
// Submit the file selection
await page.getByTestId("select-files-modal-button").click();
// Verify that only the TXT file was selected in the component
await expect(page.getByText(`${txtFileName}.txt`)).toBeVisible();
await expect(page.getByText(`${pngFileName}.png`)).not.toBeVisible();
},
);

View file

@ -318,3 +318,148 @@ test(
}
},
);
test(
"should handle bulk actions for multiple files",
{ tag: ["@release", "@files"] },
async ({ page }) => {
const fileNames = {
txt: generateRandomFilename(),
json: generateRandomFilename(),
py: generateRandomFilename(),
};
const testFiles = [
path.join(__dirname, "../../assets/test-file.txt"),
path.join(__dirname, "../../assets/test-file.json"),
path.join(__dirname, "../../assets/test-file.py"),
];
const fileContents = testFiles.map((file) => fs.readFileSync(file));
await awaitBootstrapTest(page, { skipModal: true });
const firstRunLangflow = await page
.getByTestId("empty-project-description")
.count();
if (firstRunLangflow > 0) {
await addFlowToTestOnEmptyLangflow(page);
}
await page.waitForSelector('[data-testid="mainpage_title"]', {
timeout: 30000,
});
await page.getByText("My Files").first().click();
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByTestId("upload-file-btn").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles([
{
name: `${fileNames.txt}.txt`,
mimeType: "text/plain",
buffer: fileContents[0],
},
{
name: `${fileNames.json}.json`,
mimeType: "application/json",
buffer: fileContents[1],
},
{
name: `${fileNames.py}.py`,
mimeType: "text/x-python",
buffer: fileContents[2],
},
]);
// Wait for upload success message
const successMessage = await page.getByText("Files uploaded successfully");
expect(successMessage).toBeTruthy();
// Verify all files appear in the list
for (const name of Object.values(fileNames)) {
const file = await page.getByText(name).last();
await expect(file).toBeVisible({
timeout: 1000,
});
}
// Select files with shift (checkbox on the grid)
await page.keyboard.down("Shift");
await page.locator('input[data-ref="eInput"]').nth(5).click();
await page.locator('input[data-ref="eInput"]').nth(7).click();
await page.keyboard.up("Shift");
expect(
await page.locator('input[data-ref="eInput"]').nth(5).isChecked(),
).toBe(true);
expect(
await page.locator('input[data-ref="eInput"]').nth(6).isChecked(),
).toBe(true);
expect(
await page.locator('input[data-ref="eInput"]').nth(7).isChecked(),
).toBe(true);
// Check if the bulk actions toolbar appears
const selectedCountText = await page.getByText("3 selected");
await expect(selectedCountText).toBeVisible();
// Check if download button is visible
const downloadButton = await page.getByTestId("bulk-download-btn");
await expect(downloadButton).toBeVisible();
// Set up download listener
const downloadPromise = page.waitForEvent("download");
// Click download button
await downloadButton.click();
// Wait for download to start
const download = await downloadPromise;
// Verify the download was initiated
expect(download).toBeTruthy();
// Check for success message
const downloadSuccessMessage = await page.getByText(
/Files? downloaded successfully/,
);
expect(downloadSuccessMessage).toBeTruthy();
// Select both files (checkbox on the grid)
await page.locator('input[data-ref="eInput"]').nth(7).click();
// Check if the bulk actions toolbar appears
const selectedCountTextDelete = await page.getByText("2 selected");
await expect(selectedCountTextDelete).toBeVisible();
const deleteButton = await page.getByTestId("bulk-delete-btn");
await expect(deleteButton).toBeVisible();
// Test delete functionality
await deleteButton.click();
// Confirm the delete in the modal
const confirmDeleteButton = await page.getByRole("button", {
name: "Delete",
});
await confirmDeleteButton.click();
// Check for success message
const deleteSuccessMessage = await page.getByText(
"Files deleted successfully",
);
expect(deleteSuccessMessage).toBeTruthy();
// Verify the deleted files are no longer visible
const remainingFileCount =
(await page.getByText(fileNames.py + ".py").count()) +
(await page.getByText(fileNames.txt + ".txt").count()) +
(await page.getByText(fileNames.json + ".json").count());
expect(remainingFileCount).toBe(1);
},
);