diff --git a/src/backend/base/langflow/api/v2/files.py b/src/backend/base/langflow/api/v2/files.py index 0672a3969..2d53b344a 100644 --- a/src/backend/base/langflow/api/v2/files.py +++ b/src/backend/base/langflow/api/v2/files.py @@ -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}"'}, ) diff --git a/src/frontend/src/controllers/API/queries/file-management/use-delete-files.ts b/src/frontend/src/controllers/API/queries/file-management/use-delete-files.ts new file mode 100644 index 000000000..d2128782c --- /dev/null +++ b/src/frontend/src/controllers/API/queries/file-management/use-delete-files.ts @@ -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 => { + const response = await api.delete( + `${getURL("FILE_MANAGEMENT", { mode: "batch/" }, true)}`, + { + data: params.ids, + }, + ); + + return response.data; + }; + + const mutation: UseMutationResult = mutate( + ["useDeleteFilesV2"], + deleteFileFn, + { + onSettled: (data, error, variables, context) => { + queryClient.invalidateQueries({ + queryKey: ["useGetFilesV2"], + }); + options?.onSettled?.(data, error, variables, context); + }, + ...options, + }, + ); + + return mutation; +}; diff --git a/src/frontend/src/controllers/API/queries/file-management/use-get-download-files.ts b/src/frontend/src/controllers/API/queries/file-management/use-get-download-files.ts new file mode 100644 index 000000000..468fa90a6 --- /dev/null +++ b/src/frontend/src/controllers/API/queries/file-management/use-get-download-files.ts @@ -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; +}; diff --git a/src/frontend/src/modals/fileManagerModal/components/filesRendererComponent/components/fileRendererComponent/index.tsx b/src/frontend/src/modals/fileManagerModal/components/filesRendererComponent/components/fileRendererComponent/index.tsx index b5535fdf4..cf14d1d98 100644 --- a/src/frontend/src/modals/fileManagerModal/components/filesRendererComponent/components/fileRendererComponent/index.tsx +++ b/src/frontend/src/modals/fileManagerModal/components/filesRendererComponent/components/fileRendererComponent/index.tsx @@ -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 ( -
{ - if (!file.progress) handleFileSelect?.(file.path); - }} - > -
- {handleFileSelect && ( -
e.stopPropagation()} - > - handleFileSelect?.(file.path)} - /> -
- )} -
- {file.progress !== undefined && file.progress !== -1 ? ( -
- {Math.round(file.progress * 100)}% -
- ) : ( - - )} + const handleItemClick = () => { + if (!file.progress && handleFileSelect) { + handleFileSelect(file.path); + } + }; - {openRename ? ( -
- setNewName(e.target.value)} - onBlur={() => { - setOpenRename(false); - handleRename?.(file.id, newName); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - setOpenRename(false); - handleRename?.(file.id, newName); - } - }} + return ( + +
+
+
+ {handleFileSelect && ( +
e.stopPropagation()} - className="h-6 py-1" - data-testid={`rename-input-${file.name}`} - /> -
- ) : ( - + +
+ )} +
+ {file.progress !== undefined && file.progress !== -1 ? ( +
+ {Math.round(file.progress * 100)}% +
+ ) : ( + )} - > - + + {openRename ? ( +
+ 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}`} + /> +
+ ) : ( - {file.name}.{type} + + + {file.name}.{type} + + + + {formatFileSize(file.size)} + -
- - {formatFileSize(file.size)} - - - )} - {file.progress !== undefined && file.progress === -1 ? ( - - Upload failed,{" "} - + Upload failed,{" "} + { + e.stopPropagation(); + if (file.file) { + uploadFile({ file: file.file }); + } + }} + > + try again? + + + ) : ( + <> + )} +
+
+
+ {handleRemove ? ( + + ) : file.progress === undefined ? ( + + + + ) : ( + <> + )} +
-
- {handleRemove ? ( - - ) : file.progress === undefined ? ( - - - - ) : ( - <> - )} -
-
+ ); } diff --git a/src/frontend/src/modals/fileManagerModal/components/filesRendererComponent/index.tsx b/src/frontend/src/modals/fileManagerModal/components/filesRendererComponent/index.tsx index ab5e9d522..1376c71eb 100644 --- a/src/frontend/src/modals/fileManagerModal/components/filesRendererComponent/index.tsx +++ b/src/frontend/src/modals/fileManagerModal/components/filesRendererComponent/index.tsx @@ -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) => ( handleFileSelect(name, index) : undefined + } selectedFiles={selectedFiles} handleRemove={handleRemove} handleRename={handleRename} + isShiftPressed={isShiftPressed} index={index} /> )); diff --git a/src/frontend/src/modals/fileManagerModal/components/recentFilesComponent/index.tsx b/src/frontend/src/modals/fileManagerModal/components/recentFilesComponent/index.tsx index ae67bd5e5..03645ac32 100644 --- a/src/frontend/src/modals/fileManagerModal/components/recentFilesComponent/index.tsx +++ b/src/frontend/src/modals/fileManagerModal/components/recentFilesComponent/index.tsx @@ -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(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 ? ( { - 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} /> ) : (
diff --git a/src/frontend/src/pages/MainPage/pages/filesPage/index.tsx b/src/frontend/src/pages/MainPage/pages/filesPage/index.tsx index 4f900e793..e384312d5 100644 --- a/src/frontend/src/pages/MainPage/pages/filesPage/index.tsx +++ b/src/frontend/src/pages/MainPage/pages/filesPage/index.tsx @@ -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([]); + 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) => { 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 ( -
+
{params.data.progress !== undefined && params.data.progress !== -1 ? (
{Math.round(params.data.progress * 100)}%
) : ( - +
+ +
)}
{ 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 ( @@ -220,6 +320,7 @@ export const FilesPage = () => { }, [uploadFile]); const [quickFilterText, setQuickFilterText] = useState(""); + return (
{
) : files.length > 0 ? ( - { - 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", - }} - /> +
+ { + 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", + }} + /> + +
0 ? "opacity-100" : "opacity-0", + )} + > +
+ + {quantitySelected} selected + +
+ + + 1 ? "s" : "") + } + > + + +
+
+
+
) : ( { + // 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(); + }, +); diff --git a/src/frontend/tests/extended/features/files-page.spec.ts b/src/frontend/tests/extended/features/files-page.spec.ts index 7c61d72de..bd6f3121a 100644 --- a/src/frontend/tests/extended/features/files-page.spec.ts +++ b/src/frontend/tests/extended/features/files-page.spec.ts @@ -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); + }, +);