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:
parent
596a219de3
commit
a72995c408
11 changed files with 1244 additions and 235 deletions
|
|
@ -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}"'},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -10,4 +10,5 @@ export type FileType = {
|
|||
progress?: number;
|
||||
file?: File;
|
||||
type?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue