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 re
|
||||||
import uuid
|
import uuid
|
||||||
|
import zipfile
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
from datetime import datetime
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
from fastapi.responses import StreamingResponse
|
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
|
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}")
|
@router.get("/{file_id}")
|
||||||
async def download_file(
|
async def download_file(
|
||||||
file_id: uuid.UUID,
|
file_id: uuid.UUID,
|
||||||
|
|
@ -177,6 +267,10 @@ async def download_file(
|
||||||
# Get file stream
|
# Get file stream
|
||||||
file_stream = await storage_service.get_file(flow_id=str(current_user.id), file_name=file_name)
|
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
|
# Ensure file_stream is an async iterator returning bytes
|
||||||
byte_stream = byte_stream_generator(file_stream)
|
byte_stream = byte_stream_generator(file_stream)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -186,7 +280,7 @@ async def download_file(
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
byte_stream,
|
byte_stream,
|
||||||
media_type="application/octet-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,
|
handleRemove,
|
||||||
handleRename,
|
handleRename,
|
||||||
index,
|
index,
|
||||||
|
isShiftPressed,
|
||||||
}: {
|
}: {
|
||||||
file: FileType;
|
file: FileType;
|
||||||
handleFileSelect?: (path: string) => void;
|
handleFileSelect?: (path: string) => void;
|
||||||
|
|
@ -25,6 +26,7 @@ export default function FileRendererComponent({
|
||||||
handleRemove?: (path: string) => void;
|
handleRemove?: (path: string) => void;
|
||||||
handleRename?: (id: string, name: string) => void;
|
handleRename?: (id: string, name: string) => void;
|
||||||
index: number;
|
index: number;
|
||||||
|
isShiftPressed?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const type = file.path.split(".").pop() ?? "";
|
const type = file.path.split(".").pop() ?? "";
|
||||||
|
|
||||||
|
|
@ -41,158 +43,181 @@ export default function FileRendererComponent({
|
||||||
setNewName(file.name);
|
setNewName(file.name);
|
||||||
}, [openRename]);
|
}, [openRename]);
|
||||||
|
|
||||||
return (
|
const handleItemClick = () => {
|
||||||
<div
|
if (!file.progress && handleFileSelect) {
|
||||||
key={index}
|
handleFileSelect(file.path);
|
||||||
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),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{openRename ? (
|
return (
|
||||||
<div className="w-full">
|
<ShadTooltip
|
||||||
<Input
|
content={file.disabled ? "Type not supported by component" : ""}
|
||||||
value={newName}
|
side="bottom"
|
||||||
autoFocus
|
align="start"
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
>
|
||||||
onBlur={() => {
|
<div
|
||||||
setOpenRename(false);
|
className={cn(
|
||||||
handleRename?.(file.id, newName);
|
file.disabled ? "cursor-not-allowed" : "",
|
||||||
}}
|
isShiftPressed && "select-none",
|
||||||
onKeyDown={(e) => {
|
)}
|
||||||
if (e.key === "Enter") {
|
>
|
||||||
setOpenRename(false);
|
<div
|
||||||
handleRename?.(file.id, newName);
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="h-6 py-1"
|
>
|
||||||
data-testid={`rename-input-${file.name}`}
|
<Checkbox
|
||||||
/>
|
data-testid={`checkbox-${file.name}`}
|
||||||
</div>
|
checked={selectedFiles?.includes(file.path)}
|
||||||
) : (
|
onCheckedChange={handleItemClick}
|
||||||
<span
|
disabled={file.disabled}
|
||||||
className={cn(
|
className="focus-visible:ring-0"
|
||||||
"flex flex-1 items-center gap-2 overflow-hidden text-sm font-medium",
|
/>
|
||||||
file.progress !== undefined &&
|
</div>
|
||||||
file.progress === -1 &&
|
)}
|
||||||
"pointer-events-none text-placeholder-foreground",
|
<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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full cursor-pointer overflow-hidden truncate",
|
"flex items-center gap-2 overflow-hidden text-sm font-medium",
|
||||||
handleRemove && "cursor-default",
|
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>
|
</span>
|
||||||
</ShadTooltip>
|
)}
|
||||||
<span className="shrink-0 text-xs font-normal text-muted-foreground">
|
{file.progress !== undefined && file.progress === -1 ? (
|
||||||
{formatFileSize(file.size)}
|
<span className="text-mmd text-primary">
|
||||||
</span>
|
Upload failed,{" "}
|
||||||
</span>
|
<span
|
||||||
)}
|
className="cursor-pointer text-accent-pink-foreground underline"
|
||||||
{file.progress !== undefined && file.progress === -1 ? (
|
onClick={(e) => {
|
||||||
<span className="text-mmd text-primary">
|
e.stopPropagation();
|
||||||
Upload failed,{" "}
|
if (file.file) {
|
||||||
<span
|
uploadFile({ file: file.file });
|
||||||
className="cursor-pointer text-accent-pink-foreground underline"
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (file.file) {
|
handleRemove?.(file.path);
|
||||||
uploadFile({ file: file.file });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
try again?
|
<ForwardedIconComponent
|
||||||
</span>
|
name="X"
|
||||||
</span>
|
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>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
</ShadTooltip>
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,27 @@ export default function FilesRendererComponent({
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
handleRemove,
|
handleRemove,
|
||||||
handleRename,
|
handleRename,
|
||||||
|
isShiftPressed,
|
||||||
}: {
|
}: {
|
||||||
files: FileType[];
|
files: FileType[];
|
||||||
isSearch?: boolean;
|
isSearch?: boolean;
|
||||||
handleFileSelect?: (name: string) => void;
|
handleFileSelect?: (name: string, index: number) => void;
|
||||||
selectedFiles?: string[];
|
selectedFiles?: string[];
|
||||||
handleRemove?: (name: string) => void;
|
handleRemove?: (name: string) => void;
|
||||||
handleRename?: (id: string, name: string) => void;
|
handleRename?: (id: string, name: string) => void;
|
||||||
|
isShiftPressed?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return files.map((file, index) => (
|
return files.map((file, index) => (
|
||||||
<FileRendererComponent
|
<FileRendererComponent
|
||||||
key={index}
|
key={index}
|
||||||
file={file}
|
file={file}
|
||||||
handleFileSelect={handleFileSelect}
|
handleFileSelect={
|
||||||
|
handleFileSelect ? (name) => handleFileSelect(name, index) : undefined
|
||||||
|
}
|
||||||
selectedFiles={selectedFiles}
|
selectedFiles={selectedFiles}
|
||||||
handleRemove={handleRemove}
|
handleRemove={handleRemove}
|
||||||
handleRename={handleRename}
|
handleRename={handleRename}
|
||||||
|
isShiftPressed={isShiftPressed}
|
||||||
index={index}
|
index={index}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { Input } from "@/components/ui/input";
|
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 { usePostRenameFileV2 } from "@/controllers/API/queries/file-management/use-put-rename-file";
|
||||||
import { CustomLink } from "@/customization/components/custom-link";
|
import { CustomLink } from "@/customization/components/custom-link";
|
||||||
import { sortByBoolean, sortByDate } from "@/pages/MainPage/utils/sort-flows";
|
import { sortByBoolean, sortByDate } from "@/pages/MainPage/utils/sort-flows";
|
||||||
import { FileType } from "@/types/file_management";
|
import { FileType } from "@/types/file_management";
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import FilesRendererComponent from "../filesRendererComponent";
|
import FilesRendererComponent from "../filesRendererComponent";
|
||||||
|
|
||||||
export default function RecentFilesComponent({
|
export default function RecentFilesComponent({
|
||||||
|
|
@ -20,49 +21,111 @@ export default function RecentFilesComponent({
|
||||||
types: string[];
|
types: string[];
|
||||||
isList: boolean;
|
isList: boolean;
|
||||||
}) {
|
}) {
|
||||||
const filesWithType = useMemo(
|
const filesWithDisabled = useMemo(
|
||||||
() =>
|
() =>
|
||||||
files.map((file) => ({
|
files.map((file) => {
|
||||||
...file,
|
const fileExtension = file.path.split(".").pop()?.toLowerCase();
|
||||||
type: file.path.split(".").pop()?.toLowerCase(),
|
return {
|
||||||
})),
|
...file,
|
||||||
[files],
|
type: fileExtension,
|
||||||
|
disabled: !fileExtension || !types.includes(fileExtension),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[files, types],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fuse = useMemo(
|
const fuse = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new Fuse(filesWithType, {
|
new Fuse(filesWithDisabled, {
|
||||||
keys: ["name", "type"],
|
keys: ["name", "type"],
|
||||||
threshold: 0.3,
|
threshold: 0.3,
|
||||||
}),
|
}),
|
||||||
[filesWithType],
|
[filesWithDisabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||||
|
const [isShiftPressed, setIsShiftPressed] = useState(false);
|
||||||
|
|
||||||
const { mutate: renameFile } = usePostRenameFileV2();
|
const { mutate: renameFile } = usePostRenameFileV2();
|
||||||
|
|
||||||
const searchResults = useMemo(() => {
|
const searchResults = useMemo(() => {
|
||||||
const filteredFiles = (
|
const filteredFiles = searchQuery
|
||||||
searchQuery
|
? fuse.search(searchQuery).map(({ item }) => item)
|
||||||
? fuse.search(searchQuery).map(({ item }) => item)
|
: (filesWithDisabled ?? []);
|
||||||
: (filesWithType ?? [])
|
|
||||||
).filter((file) => {
|
|
||||||
const fileExtension = file.path.split(".").pop()?.toLowerCase();
|
|
||||||
return fileExtension && (!types || types.includes(fileExtension));
|
|
||||||
});
|
|
||||||
return filteredFiles;
|
return filteredFiles;
|
||||||
}, [searchQuery, filesWithType, types]);
|
}, [searchQuery, filesWithDisabled, types]);
|
||||||
|
|
||||||
const handleFileSelect = (filePath: string) => {
|
const sortedSearchResults = useMemo(() => {
|
||||||
setSelectedFiles(
|
return searchResults.toSorted((a, b) => {
|
||||||
selectedFiles.includes(filePath)
|
const selectedOrder = sortByBoolean(
|
||||||
? selectedFiles.filter((path) => path !== filePath)
|
a.progress !== undefined,
|
||||||
: isList
|
b.progress !== undefined,
|
||||||
? [...selectedFiles, filePath]
|
);
|
||||||
: [filePath],
|
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) => {
|
const handleRename = (id: string, name: string) => {
|
||||||
renameFile({ id, name });
|
renameFile({ id, name });
|
||||||
|
|
@ -90,23 +153,11 @@ export default function RecentFilesComponent({
|
||||||
>
|
>
|
||||||
{searchResults.length > 0 ? (
|
{searchResults.length > 0 ? (
|
||||||
<FilesRendererComponent
|
<FilesRendererComponent
|
||||||
files={searchResults
|
files={sortedSearchResults}
|
||||||
.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)}
|
|
||||||
handleFileSelect={handleFileSelect}
|
handleFileSelect={handleFileSelect}
|
||||||
selectedFiles={selectedFiles}
|
selectedFiles={selectedFiles}
|
||||||
handleRename={handleRename}
|
handleRename={handleRename}
|
||||||
|
isShiftPressed={isShiftPressed}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm">
|
<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 Loading from "@/components/ui/loading";
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import {
|
import {
|
||||||
|
useGetDownloadFileV2,
|
||||||
useGetFilesV2,
|
useGetFilesV2,
|
||||||
usePostUploadFileV2,
|
usePostUploadFileV2,
|
||||||
} from "@/controllers/API/queries/file-management";
|
} 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 { usePostRenameFileV2 } from "@/controllers/API/queries/file-management/use-put-rename-file";
|
||||||
import useUploadFile from "@/hooks/files/use-upload-file";
|
import useUploadFile from "@/hooks/files/use-upload-file";
|
||||||
|
import DeleteConfirmationModal from "@/modals/deleteConfirmationModal";
|
||||||
import FilesContextMenuComponent from "@/modals/fileManagerModal/components/filesContextMenuComponent";
|
import FilesContextMenuComponent from "@/modals/fileManagerModal/components/filesContextMenuComponent";
|
||||||
import useAlertStore from "@/stores/alertStore";
|
import useAlertStore from "@/stores/alertStore";
|
||||||
import { formatFileSize } from "@/utils/stringManipulation";
|
import { formatFileSize } from "@/utils/stringManipulation";
|
||||||
import { FILE_ICONS } from "@/utils/styleUtils";
|
import { FILE_ICONS } from "@/utils/styleUtils";
|
||||||
import { cn } from "@/utils/utils";
|
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 { 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 { sortByDate } from "../../utils/sort-flows";
|
||||||
import DragWrapComponent from "./components/dragWrapComponent";
|
import DragWrapComponent from "./components/dragWrapComponent";
|
||||||
|
|
||||||
|
|
@ -29,8 +37,50 @@ export const FilesPage = () => {
|
||||||
const setErrorData = useAlertStore((state) => state.setErrorData);
|
const setErrorData = useAlertStore((state) => state.setErrorData);
|
||||||
const setSuccessData = useAlertStore((state) => state.setSuccessData);
|
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: rename } = usePostRenameFileV2();
|
||||||
|
|
||||||
|
const { mutate: deleteFiles, isPending: isDeleting } = useDeleteFilesV2();
|
||||||
|
const { mutate: downloadFiles, isPending: isDownloading } =
|
||||||
|
useGetDownloadFilesV2();
|
||||||
|
|
||||||
const handleRename = (params: NewValueParams<any, any>) => {
|
const handleRename = (params: NewValueParams<any, any>) => {
|
||||||
rename({
|
rename({
|
||||||
id: params.data.id,
|
id: params.data.id,
|
||||||
|
|
@ -72,32 +122,37 @@ export const FilesPage = () => {
|
||||||
headerName: "Name",
|
headerName: "Name",
|
||||||
field: "name",
|
field: "name",
|
||||||
flex: 2,
|
flex: 2,
|
||||||
|
headerCheckboxSelection: true,
|
||||||
|
checkboxSelection: true,
|
||||||
editable: true,
|
editable: true,
|
||||||
filter: "agTextColumnFilter",
|
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) => {
|
cellRenderer: (params) => {
|
||||||
const type = params.data.path.split(".")[1]?.toLowerCase();
|
const type = params.data.path.split(".")[1]?.toLowerCase();
|
||||||
return (
|
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 !== undefined &&
|
||||||
params.data.progress !== -1 ? (
|
params.data.progress !== -1 ? (
|
||||||
<div className="flex h-6 items-center justify-center text-xs font-semibold text-muted-foreground">
|
<div className="flex h-6 items-center justify-center text-xs font-semibold text-muted-foreground">
|
||||||
{Math.round(params.data.progress * 100)}%
|
{Math.round(params.data.progress * 100)}%
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ForwardedIconComponent
|
<div className="file-icon pointer-events-none relative">
|
||||||
name={FILE_ICONS[type]?.icon ?? "File"}
|
<ForwardedIconComponent
|
||||||
className={cn(
|
name={FILE_ICONS[type]?.icon ?? "File"}
|
||||||
"h-6 w-6 shrink-0",
|
className={cn(
|
||||||
params.data.progress !== undefined
|
"-mx-[3px] h-6 w-6 shrink-0",
|
||||||
? "text-placeholder-foreground"
|
params.data.progress !== undefined
|
||||||
: (FILE_ICONS[type]?.color ?? undefined),
|
? "text-placeholder-foreground"
|
||||||
)}
|
: (FILE_ICONS[type]?.color ?? undefined),
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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 !== undefined &&
|
||||||
params.data.progress === -1 &&
|
params.data.progress === -1 &&
|
||||||
"pointer-events-none text-placeholder-foreground",
|
"pointer-events-none text-placeholder-foreground",
|
||||||
|
|
@ -137,7 +192,8 @@ export const FilesPage = () => {
|
||||||
valueFormatter: (params) => {
|
valueFormatter: (params) => {
|
||||||
return params.value.split(".")[1]?.toUpperCase();
|
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",
|
headerName: "Size",
|
||||||
|
|
@ -147,7 +203,8 @@ export const FilesPage = () => {
|
||||||
return formatFileSize(params.value);
|
return formatFileSize(params.value);
|
||||||
},
|
},
|
||||||
editable: false,
|
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",
|
headerName: "Modified",
|
||||||
|
|
@ -160,7 +217,8 @@ export const FilesPage = () => {
|
||||||
editable: false,
|
editable: false,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
resizable: false,
|
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,
|
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(() => {
|
const UploadButtonComponent = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<ShadTooltip content="Upload File" side="bottom">
|
<ShadTooltip content="Upload File" side="bottom">
|
||||||
|
|
@ -220,6 +320,7 @@ export const FilesPage = () => {
|
||||||
}, [uploadFile]);
|
}, [uploadFile]);
|
||||||
|
|
||||||
const [quickFilterText, setQuickFilterText] = useState("");
|
const [quickFilterText, setQuickFilterText] = useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-full w-full flex-col overflow-y-auto"
|
className="flex h-full w-full flex-col overflow-y-auto"
|
||||||
|
|
@ -276,39 +377,89 @@ export const FilesPage = () => {
|
||||||
</div>
|
</div>
|
||||||
) : files.length > 0 ? (
|
) : files.length > 0 ? (
|
||||||
<DragWrapComponent onFileDrop={onFileDrop}>
|
<DragWrapComponent onFileDrop={onFileDrop}>
|
||||||
<TableComponent
|
<div className="relative h-full">
|
||||||
rowHeight={45}
|
<TableComponent
|
||||||
headerHeight={45}
|
rowHeight={45}
|
||||||
cellSelection={false}
|
headerHeight={45}
|
||||||
tableOptions={{
|
cellSelection={false}
|
||||||
hide_options: true,
|
tableOptions={{
|
||||||
}}
|
hide_options: true,
|
||||||
editable={[
|
}}
|
||||||
{
|
suppressRowClickSelection={!isShiftPressed}
|
||||||
field: "name",
|
editable={[
|
||||||
onUpdate: handleRename,
|
{
|
||||||
editableCell: true,
|
field: "name",
|
||||||
},
|
onUpdate: handleRename,
|
||||||
]}
|
editableCell: true,
|
||||||
enableCellTextSelection={false}
|
},
|
||||||
columnDefs={colDefs}
|
]}
|
||||||
rowData={files.sort((a, b) => {
|
rowSelection="multiple"
|
||||||
return sortByDate(
|
onSelectionChanged={handleSelectionChanged}
|
||||||
a.updated_at ?? a.created_at,
|
columnDefs={colDefs}
|
||||||
b.updated_at ?? b.created_at,
|
rowData={files.sort((a, b) => {
|
||||||
);
|
return sortByDate(
|
||||||
})}
|
a.updated_at ?? a.created_at,
|
||||||
className="ag-no-border w-full"
|
b.updated_at ?? b.created_at,
|
||||||
pagination
|
);
|
||||||
ref={tableRef}
|
})}
|
||||||
quickFilterText={quickFilterText}
|
className={cn(
|
||||||
gridOptions={{
|
"ag-no-border group w-full",
|
||||||
enableCellTextSelection: true,
|
isShiftPressed &&
|
||||||
stopEditingWhenCellsLoseFocus: true,
|
quantitySelected > 0 &&
|
||||||
ensureDomOrder: true,
|
"no-select-cells",
|
||||||
colResizeDefault: "shift",
|
)}
|
||||||
}}
|
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>
|
</DragWrapComponent>
|
||||||
) : (
|
) : (
|
||||||
<CardsWrapComponent
|
<CardsWrapComponent
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
--ag-header-background-color: hsl(var(--background)) !important;
|
--ag-header-background-color: hsl(var(--background)) !important;
|
||||||
--ag-tooltip-background-color: hsl(var(--muted)) !important;
|
--ag-tooltip-background-color: hsl(var(--muted)) !important;
|
||||||
--ag-disabled-foreground-color: hsl(var(--muted-foreground)) !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-border-color: hsl(var(--border)) !important;
|
||||||
--ag-selected-row-background-color: hsl(var(--accent)) !important;
|
--ag-selected-row-background-color: hsl(var(--accent)) !important;
|
||||||
--ag-menu-background-color: hsl(var(--accent)) !important;
|
--ag-menu-background-color: hsl(var(--accent)) !important;
|
||||||
|
|
@ -99,6 +100,36 @@
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
outline: 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 {
|
.ag-tool-mode .ag-root-wrapper {
|
||||||
border: none !important;
|
border: none !important;
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,5 @@ export type FileType = {
|
||||||
progress?: number;
|
progress?: number;
|
||||||
file?: File;
|
file?: File;
|
||||||
type?: string;
|
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