feat: add bulk download and delete flows (#7849)
* update input to have h-fit * Update McpServerTab text size * Update Tools Component * Update header text sizes * Update list component to match design * Update home page paddings * Update home page to use ListComponent in both views * Delete Grid * Update skeleton to match design * Remove old grid reference * Implemented different border on checkbox * Added selected flows * Added selected flows action buttons * Added flow selection on list component * Added get download flows * Added download and delete functions * change download flows to download one flow directly * implement shift selection * Fix ctrl and meta behavior on selection * remove selected flows if they dont exist * added control just if its not mac * Updated deletion modal * Fixed delete confirmation modal taking up space in grid * Fixed data-testids and success messages * Added bulk actions test and fixed actionsMainPage * added max width to home page * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
327c0fd791
commit
9ee4df696e
17 changed files with 493 additions and 315 deletions
|
|
@ -68,7 +68,7 @@ export default function ToolsComponent({
|
|||
disabled={!value || disabled}
|
||||
size={"iconMd"}
|
||||
className={cn(
|
||||
"absolute -top-8 right-0 font-semibold text-muted-foreground group-hover:text-primary",
|
||||
"absolute -top-8 right-0 !text-mmd font-normal text-muted-foreground group-hover:text-primary",
|
||||
)}
|
||||
data-testid="button_open_actions"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const Checkbox = React.forwardRef<
|
|||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-muted-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
({ className, inputClassName, icon = "", type, ...props }, ref) => {
|
||||
if (icon) {
|
||||
return (
|
||||
<label className={cn("relative block w-full", className)}>
|
||||
<label className={cn("relative block h-fit w-full", className)}>
|
||||
<ForwardedIconComponent
|
||||
name={icon}
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { FlowType } from "@/types/flow";
|
||||
import { downloadFlow, processFlows } from "@/utils/reactflowUtils";
|
||||
import { useMutationFunctionType } from "../../../../types/api";
|
||||
import { api } from "../../api";
|
||||
import { getURL } from "../../helpers/constants";
|
||||
import { UseRequestProcessor } from "../../services/request-processor";
|
||||
|
||||
interface DownloadFlowsQueryParams {
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
export const useGetDownloadFlows: useMutationFunctionType<
|
||||
undefined,
|
||||
DownloadFlowsQueryParams
|
||||
> = (options) => {
|
||||
const { mutate } = UseRequestProcessor();
|
||||
|
||||
const getDownloadFlowsFn = 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 api.get<FlowType>(`${getURL("FLOWS")}/${params.ids[0]}`);
|
||||
|
||||
const flowsArrayToProcess = [response.data];
|
||||
const { flows } = processFlows(flowsArrayToProcess);
|
||||
|
||||
const flow = flows[0];
|
||||
if (flow) {
|
||||
downloadFlow(flow, flow.name, flow.description);
|
||||
}
|
||||
} else {
|
||||
response = await fetch(`${getURL("FLOWS", { mode: "download/" })}`, {
|
||||
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 flows: ${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 = "flows.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(
|
||||
["useGetDownloadFlowsV2"],
|
||||
getDownloadFlowsFn,
|
||||
options,
|
||||
);
|
||||
|
||||
return queryResult;
|
||||
};
|
||||
|
|
@ -19,7 +19,7 @@ export default function DeleteConfirmationModal({
|
|||
setOpen,
|
||||
note = "",
|
||||
}: {
|
||||
children: JSX.Element;
|
||||
children?: JSX.Element;
|
||||
onConfirm: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
description?: string;
|
||||
asChild?: boolean;
|
||||
|
|
@ -29,31 +29,24 @@ export default function DeleteConfirmationModal({
|
|||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild={asChild} tabIndex={-1}>
|
||||
{children}
|
||||
<DialogTrigger asChild={!children ? true : asChild} tabIndex={-1}>
|
||||
{children ?? <></>}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center">
|
||||
<span className="pr-2">Delete</span>
|
||||
<Trash2
|
||||
className="h-6 w-6 pl-1 text-foreground"
|
||||
className="h-6 w-6 pr-1 text-foreground"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<span className="pl-2">Delete</span>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<span>
|
||||
Are you sure you want to delete the selected{" "}
|
||||
{description ?? "component"}?<br></br>
|
||||
{note && (
|
||||
<>
|
||||
{note}
|
||||
<br></br>
|
||||
</>
|
||||
)}
|
||||
Note: This action is irreversible.
|
||||
<span className="pb-3 text-sm">
|
||||
This will permanently delete the {description ?? "flow"}
|
||||
{note ? " " + note : ""}.<br></br>This can't be undone.
|
||||
</span>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
|
|
|
|||
|
|
@ -1,172 +0,0 @@
|
|||
import ForwardedIconComponent from "@/components/common/genericIconComponent";
|
||||
import useDragStart from "@/components/core/cardComponent/hooks/use-on-drag-start";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate";
|
||||
import useDeleteFlow from "@/hooks/flows/use-delete-flow";
|
||||
import DeleteConfirmationModal from "@/modals/deleteConfirmationModal";
|
||||
import FlowSettingsModal from "@/modals/flowSettingsModal";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import useFlowsManagerStore from "@/stores/flowsManagerStore";
|
||||
import { FlowType } from "@/types/flow";
|
||||
import { swatchColors } from "@/utils/styleUtils";
|
||||
import { cn, getNumberFromString } from "@/utils/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import useDescriptionModal from "../../hooks/use-description-modal";
|
||||
import { useGetTemplateStyle } from "../../utils/get-template-style";
|
||||
import { timeElapsed } from "../../utils/time-elapse";
|
||||
import DropdownComponent from "../dropdown";
|
||||
|
||||
const GridComponent = ({ flowData }: { flowData: FlowType }) => {
|
||||
const navigate = useCustomNavigate();
|
||||
|
||||
const [openDelete, setOpenDelete] = useState(false);
|
||||
const [openSettings, setOpenSettings] = useState(false);
|
||||
const setSuccessData = useAlertStore((state) => state.setSuccessData);
|
||||
const { deleteFlow } = useDeleteFlow();
|
||||
|
||||
const setErrorData = useAlertStore((state) => state.setErrorData);
|
||||
const { folderId } = useParams();
|
||||
const isComponent = flowData.is_component ?? false;
|
||||
|
||||
const { getIcon } = useGetTemplateStyle(flowData);
|
||||
|
||||
const [icon, setIcon] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
getIcon().then(setIcon);
|
||||
}, [getIcon]);
|
||||
|
||||
const editFlowLink = `/flow/${flowData.id}${folderId ? `/folder/${folderId}` : ""}`;
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!isComponent) {
|
||||
navigate(editFlowLink);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteFlow({ id: [flowData.id] })
|
||||
.then(() => {
|
||||
setSuccessData({
|
||||
title: "Selected items deleted successfully",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorData({
|
||||
title: "Error deleting items",
|
||||
list: ["Please try again"],
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const descriptionModal = useDescriptionModal(
|
||||
[flowData?.id],
|
||||
flowData.is_component ? "component" : "flow",
|
||||
);
|
||||
|
||||
const { onDragStart } = useDragStart(flowData);
|
||||
|
||||
const swatchIndex =
|
||||
(flowData.gradient && !isNaN(parseInt(flowData.gradient))
|
||||
? parseInt(flowData.gradient)
|
||||
: getNumberFromString(flowData.gradient ?? flowData.id)) %
|
||||
swatchColors.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
key={flowData.id}
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onClick={handleClick}
|
||||
className={`my-1 flex flex-col rounded-lg border border-border bg-background p-4 hover:border-placeholder-foreground hover:shadow-sm ${
|
||||
isComponent ? "cursor-default" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className={cn(`flex rounded-lg p-3`, swatchColors[swatchIndex])}>
|
||||
<ForwardedIconComponent
|
||||
name={flowData?.icon || icon}
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full min-w-0 items-center justify-between">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="text-md truncate font-semibold">
|
||||
{flowData.name}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
Edited {timeElapsed(flowData.updated_at)} ago
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-testid="home-dropdown-menu"
|
||||
size="iconMd"
|
||||
className="group"
|
||||
>
|
||||
<ForwardedIconComponent
|
||||
name="Ellipsis"
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 text-muted-foreground group-hover:text-foreground"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[185px]"
|
||||
sideOffset={5}
|
||||
side="bottom"
|
||||
>
|
||||
<DropdownComponent
|
||||
flowData={flowData}
|
||||
setOpenDelete={setOpenDelete}
|
||||
handleEdit={() => {
|
||||
setOpenSettings(true);
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="line-clamp-2 h-full pt-3 text-sm text-primary">
|
||||
{flowData.description}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{openDelete && (
|
||||
<DeleteConfirmationModal
|
||||
open={openDelete}
|
||||
setOpen={setOpenDelete}
|
||||
onConfirm={handleDelete}
|
||||
description={descriptionModal}
|
||||
note={
|
||||
!flowData.is_component
|
||||
? "Deleting the selected flow will remove all associated messages."
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<></>
|
||||
</DeleteConfirmationModal>
|
||||
)}
|
||||
<FlowSettingsModal
|
||||
open={openSettings}
|
||||
setOpen={setOpenSettings}
|
||||
flowData={flowData}
|
||||
details
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridComponent;
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { Card } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const GridSkeleton = () => {
|
||||
return (
|
||||
<Card className="my-1 flex flex-col rounded-lg border border-border bg-background p-4">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
{/* Icon skeleton */}
|
||||
<div className="flex rounded-lg">
|
||||
<Skeleton className="h-[44px] w-[44px] rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div className="flex w-full min-w-0 items-center justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
{/* Title skeleton */}
|
||||
<Skeleton className="h-5 w-[120px]" />
|
||||
{/* Time skeleton */}
|
||||
<Skeleton className="h-4 w-[150px]" />
|
||||
</div>
|
||||
{/* Dropdown button skeleton */}
|
||||
<Skeleton className="ml-2 h-10 w-10 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description skeleton */}
|
||||
<div className="pt-5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="mt-2 h-4 w-3/4" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridSkeleton;
|
||||
|
|
@ -7,7 +7,11 @@ import {
|
|||
DEFAULT_FOLDER,
|
||||
DEFAULT_FOLDER_DEPRECATED,
|
||||
} from "@/constants/constants";
|
||||
import { useDeleteDeleteFlows } from "@/controllers/API/queries/flows/use-delete-delete-flows";
|
||||
import { useGetDownloadFlows } from "@/controllers/API/queries/flows/use-get-download-flows";
|
||||
import { ENABLE_MCP } from "@/customization/feature-flags";
|
||||
import DeleteConfirmationModal from "@/modals/deleteConfirmationModal";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import { cn } from "@/utils/utils";
|
||||
import { debounce } from "lodash";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
|
@ -22,6 +26,7 @@ interface HeaderComponentProps {
|
|||
folderName?: string;
|
||||
setSearch: (search: string) => void;
|
||||
isEmptyFolder: boolean;
|
||||
selectedFlows: string[];
|
||||
}
|
||||
|
||||
const HeaderComponent = ({
|
||||
|
|
@ -33,9 +38,11 @@ const HeaderComponent = ({
|
|||
setNewProjectModal,
|
||||
setSearch,
|
||||
isEmptyFolder,
|
||||
selectedFlows,
|
||||
}: HeaderComponentProps) => {
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const isMCPEnabled = ENABLE_MCP;
|
||||
const setSuccessData = useAlertStore((state) => state.setSuccessData);
|
||||
// Debounce the setSearch function from the parent
|
||||
const debouncedSetSearch = useCallback(
|
||||
debounce((value: string) => {
|
||||
|
|
@ -44,6 +51,10 @@ const HeaderComponent = ({
|
|||
[setSearch],
|
||||
);
|
||||
|
||||
const { mutate: downloadFlows, isPending: isDownloading } =
|
||||
useGetDownloadFlows();
|
||||
const { mutate: deleteFlows, isPending: isDeleting } = useDeleteDeleteFlows();
|
||||
|
||||
useEffect(() => {
|
||||
debouncedSetSearch(debouncedSearch);
|
||||
|
||||
|
|
@ -69,10 +80,25 @@ const HeaderComponent = ({
|
|||
// Determine which tabs to show based on feature flag
|
||||
const tabTypes = isMCPEnabled ? ["mcp", "flows"] : ["components", "flows"];
|
||||
|
||||
const handleDownload = () => {
|
||||
downloadFlows({ ids: selectedFlows });
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteFlows(
|
||||
{ flow_ids: selectedFlows },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessData({ title: "Flows deleted successfully" });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center pb-8 text-xl font-semibold"
|
||||
className="flex items-center pb-4 text-sm font-medium"
|
||||
data-testid="mainpage_title"
|
||||
>
|
||||
<div className="h-7 w-10 transition-all group-data-[open=true]/sidebar-wrapper:md:w-0 lg:hidden">
|
||||
|
|
@ -90,12 +116,7 @@ const HeaderComponent = ({
|
|||
</div>
|
||||
{!isEmptyFolder && (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row-reverse",
|
||||
flowType !== "mcp" && "pb-8",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex flex-row-reverse pb-4")}>
|
||||
<div className="w-full border-b dark:border-border" />
|
||||
{tabTypes.map((type) => (
|
||||
<Button
|
||||
|
|
@ -110,7 +131,7 @@ const HeaderComponent = ({
|
|||
flowType === type
|
||||
? "border-b-2 border-foreground text-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
} text-nowrap px-3 pb-2 text-sm`}
|
||||
} text-nowrap px-2 pb-2 pt-1 text-mmd`}
|
||||
>
|
||||
<div className={flowType === type ? "-mb-px" : ""}>
|
||||
{type === "mcp"
|
||||
|
|
@ -130,13 +151,14 @@ const HeaderComponent = ({
|
|||
type="text"
|
||||
placeholder={`Search ${flowType}...`}
|
||||
className="mr-2"
|
||||
inputClassName="!text-mmd"
|
||||
value={debouncedSearch}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<div className="relative top-[3px] mr-2 flex h-fit rounded-lg border border-muted bg-muted">
|
||||
<div className="relative mr-2 flex h-fit rounded-lg border border-muted bg-muted">
|
||||
{/* Sliding Indicator */}
|
||||
<div
|
||||
className={`absolute top-[3px] h-[33px] w-8 transform rounded-lg bg-background shadow-md transition-transform duration-300 ${
|
||||
className={`absolute top-[2px] h-[32px] w-8 transform rounded-md bg-background shadow-md transition-transform duration-300 ${
|
||||
view === "list"
|
||||
? "left-[2px] translate-x-0"
|
||||
: "left-[6px] translate-x-full"
|
||||
|
|
@ -149,7 +171,7 @@ const HeaderComponent = ({
|
|||
key={viewType}
|
||||
unstyled
|
||||
size="icon"
|
||||
className={`group relative z-10 mx-[2px] my-[3px] flex-1 rounded-lg p-2 ${
|
||||
className={`group relative z-10 m-[2px] flex-1 rounded-lg p-2 ${
|
||||
view === viewType
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted"
|
||||
|
|
@ -159,30 +181,69 @@ const HeaderComponent = ({
|
|||
<ForwardedIconComponent
|
||||
name={viewType === "list" ? "Menu" : "LayoutGrid"}
|
||||
aria-hidden="true"
|
||||
className="relative bottom-[1px] h-4 w-4 group-hover:text-foreground"
|
||||
className="h-4 w-4 group-hover:text-foreground"
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ShadTooltip content="New Flow" side="bottom">
|
||||
<Button
|
||||
variant="default"
|
||||
className="!px-3 md:!px-4 md:!pl-3.5"
|
||||
onClick={() => setNewProjectModal(true)}
|
||||
id="new-project-btn"
|
||||
data-testid="new-project-btn"
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"-mr-4 flex w-0 items-center gap-2 overflow-hidden opacity-0 transition-all duration-300",
|
||||
selectedFlows.length > 0 && "w-36 opacity-100",
|
||||
)}
|
||||
>
|
||||
<ForwardedIconComponent
|
||||
name="Plus"
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="hidden whitespace-nowrap font-semibold md:inline">
|
||||
New Flow
|
||||
</span>
|
||||
</Button>
|
||||
</ShadTooltip>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="iconMd"
|
||||
onClick={handleDownload}
|
||||
loading={isDownloading}
|
||||
>
|
||||
<ForwardedIconComponent name="Download" />
|
||||
</Button>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
onConfirm={handleDelete}
|
||||
description={"flow" + (selectedFlows.length > 1 ? "s" : "")}
|
||||
note={
|
||||
"and " +
|
||||
(selectedFlows.length > 1 ? "their" : "its") +
|
||||
" message history"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="iconMd"
|
||||
className="px-2.5 !text-mmd"
|
||||
data-testid="delete-bulk-btn"
|
||||
loading={isDeleting}
|
||||
>
|
||||
<ForwardedIconComponent name="Trash2" />
|
||||
Delete
|
||||
</Button>
|
||||
</DeleteConfirmationModal>
|
||||
</div>
|
||||
<ShadTooltip content="New Flow" side="bottom">
|
||||
<Button
|
||||
variant="default"
|
||||
size="iconMd"
|
||||
className="z-50 px-2.5 !text-mmd"
|
||||
onClick={() => setNewProjectModal(true)}
|
||||
id="new-project-btn"
|
||||
data-testid="new-project-btn"
|
||||
>
|
||||
<ForwardedIconComponent
|
||||
name="Plus"
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="hidden whitespace-nowrap font-semibold md:inline">
|
||||
New Flow
|
||||
</span>
|
||||
</Button>
|
||||
</ShadTooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import ForwardedIconComponent from "@/components/common/genericIconComponent";
|
|||
import useDragStart from "@/components/core/cardComponent/hooks/use-on-drag-start";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -23,9 +24,18 @@ import { useGetTemplateStyle } from "../../utils/get-template-style";
|
|||
import { timeElapsed } from "../../utils/time-elapse";
|
||||
import DropdownComponent from "../dropdown";
|
||||
|
||||
const ListComponent = ({ flowData }: { flowData: FlowType }) => {
|
||||
const ListComponent = ({
|
||||
flowData,
|
||||
selected,
|
||||
setSelected,
|
||||
shiftPressed,
|
||||
}: {
|
||||
flowData: FlowType;
|
||||
selected: boolean;
|
||||
setSelected: (selected: boolean) => void;
|
||||
shiftPressed: boolean;
|
||||
}) => {
|
||||
const navigate = useCustomNavigate();
|
||||
|
||||
const [openDelete, setOpenDelete] = useState(false);
|
||||
const setSuccessData = useAlertStore((state) => state.setSuccessData);
|
||||
const { deleteFlow } = useDeleteFlow();
|
||||
|
|
@ -39,8 +49,12 @@ const ListComponent = ({ flowData }: { flowData: FlowType }) => {
|
|||
const editFlowLink = `/flow/${flowData.id}${folderId ? `/folder/${folderId}` : ""}`;
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!isComponent) {
|
||||
navigate(editFlowLink);
|
||||
if (shiftPressed) {
|
||||
setSelected(!selected);
|
||||
} else {
|
||||
if (!isComponent) {
|
||||
navigate(editFlowLink);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -85,9 +99,9 @@ const ListComponent = ({ flowData }: { flowData: FlowType }) => {
|
|||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onClick={handleClick}
|
||||
className={`my-2 flex flex-row bg-background ${
|
||||
className={`flex flex-row bg-background ${
|
||||
isComponent ? "cursor-default" : "cursor-pointer"
|
||||
} group justify-between rounded-lg border border-border p-4 hover:border-placeholder-foreground hover:shadow-sm`}
|
||||
} group justify-between rounded-lg border-none px-4 py-3 shadow-none hover:bg-muted`}
|
||||
data-testid="list-card"
|
||||
>
|
||||
<div
|
||||
|
|
@ -95,29 +109,59 @@ const ListComponent = ({ flowData }: { flowData: FlowType }) => {
|
|||
isComponent ? "cursor-default" : "cursor-pointer"
|
||||
} items-center gap-4`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
`item-center flex justify-center rounded-lg p-3`,
|
||||
swatchColors[swatchIndex],
|
||||
)}
|
||||
>
|
||||
<ForwardedIconComponent
|
||||
name={flowData?.icon || icon}
|
||||
aria-hidden="true"
|
||||
className="flex h-5 w-5 items-center justify-center"
|
||||
/>
|
||||
<div className="group/checkbox relative flex items-center">
|
||||
<div
|
||||
className={cn(
|
||||
"z-20 flex w-0 items-center transition-all duration-300",
|
||||
selected && "w-10",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => setSelected(checked as boolean)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"ml-2 transition-opacity focus-visible:ring-0",
|
||||
!selected && "opacity-0 group-hover/checkbox:opacity-100",
|
||||
)}
|
||||
data-testid={`checkbox-${flowData.id}`}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
`item-center flex justify-center rounded-lg p-1.5 transition-opacity duration-200`,
|
||||
swatchColors[swatchIndex],
|
||||
selected
|
||||
? "duration-300"
|
||||
: "group-hover/checkbox:pointer-events-none group-hover/checkbox:opacity-0",
|
||||
)}
|
||||
>
|
||||
<ForwardedIconComponent
|
||||
name={flowData?.icon || icon}
|
||||
aria-hidden="true"
|
||||
className="flex h-5 w-5 items-center justify-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col justify-start">
|
||||
<div className="line-clamp-1 flex min-w-0 items-baseline truncate max-md:flex-col">
|
||||
<div className="text-md flex truncate pr-2 font-semibold max-md:w-full">
|
||||
<span className="truncate">{flowData.name}</span>
|
||||
<div
|
||||
className="flex truncate pr-2 text-sm font-semibold max-md:w-full"
|
||||
data-testid={`flow-name-div`}
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
data-testid={`flow-name-${flowData.id}`}
|
||||
>
|
||||
{flowData.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="item-baseline flex text-xs text-muted-foreground">
|
||||
Edited {timeElapsed(flowData.updated_at)} ago
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden text-sm text-primary">
|
||||
<div className="overflow-hidden text-mmd text-muted-foreground">
|
||||
<span className="block max-w-[110ch] truncate">
|
||||
{flowData.description}
|
||||
</span>
|
||||
|
|
@ -167,14 +211,8 @@ const ListComponent = ({ flowData }: { flowData: FlowType }) => {
|
|||
setOpen={setOpenDelete}
|
||||
onConfirm={handleDelete}
|
||||
description={descriptionModal}
|
||||
note={
|
||||
!flowData.is_component
|
||||
? "Deleting the selected flow will remove all associated messages."
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<></>
|
||||
</DeleteConfirmationModal>
|
||||
note={!flowData.is_component ? "and its message history" : ""}
|
||||
/>
|
||||
)}
|
||||
<FlowSettingsModal
|
||||
open={openSettings}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
|
||||
const ListSkeleton = () => {
|
||||
return (
|
||||
<Card className="my-2 flex flex-row justify-between rounded-lg border border-border bg-background p-4">
|
||||
<div className="flex flex-row justify-between rounded-lg bg-background px-4 py-3">
|
||||
{/* left side */}
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{/* Icon skeleton */}
|
||||
<div className="flex h-[52px] w-[52px] items-center justify-center rounded-lg">
|
||||
<div className="flex h-[32px] w-[32px] items-center justify-center rounded-lg">
|
||||
<Skeleton className="h-full w-full rounded-lg" />
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-col justify-start gap-2">
|
||||
<div className="flex min-w-0 flex-col justify-start gap-[7px]">
|
||||
{/* Title and time skeleton */}
|
||||
<div className="flex min-w-0 items-baseline max-md:flex-col">
|
||||
<Skeleton className="h-5 w-[150px]" />
|
||||
<Skeleton className="h-4 w-[150px]" />
|
||||
<Skeleton className="ml-2 h-4 w-[180px]" />
|
||||
</div>
|
||||
{/* Description skeleton */}
|
||||
|
|
@ -24,9 +24,9 @@ const ListSkeleton = () => {
|
|||
|
||||
{/* right side */}
|
||||
<div className="ml-5 flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-10 rounded-md" />
|
||||
<Skeleton className="h-6 w-6 rounded-md" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -28,9 +28,7 @@ const ModalsComponent = ({
|
|||
setOpenDeleteFolderModal(false);
|
||||
}}
|
||||
description="folder"
|
||||
note={
|
||||
"Deleting the selected project will remove all associated flows and components."
|
||||
}
|
||||
note={"and all associated flows and components"}
|
||||
>
|
||||
<></>
|
||||
</DeleteConfirmationModal>
|
||||
|
|
|
|||
|
|
@ -117,13 +117,10 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="text-md -mt-2 pb-2 font-bold"
|
||||
data-testid="mcp-server-title"
|
||||
>
|
||||
<div className="pb-2 text-sm font-medium" data-testid="mcp-server-title">
|
||||
MCP Server
|
||||
</div>
|
||||
<div className="pb-4 text-sm text-muted-foreground">
|
||||
<div className="pb-4 text-mmd text-muted-foreground">
|
||||
Access your Project's flows as Actions within a MCP Server. Learn more
|
||||
in our
|
||||
<a
|
||||
|
|
@ -143,7 +140,7 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
|
|||
content="Flows in this project can be exposed as callable MCP actions."
|
||||
side="right"
|
||||
>
|
||||
<div className="flex items-center text-sm font-medium hover:cursor-help">
|
||||
<div className="flex items-center text-mmd font-medium hover:cursor-help">
|
||||
Flows/Actions
|
||||
<ForwardedIconComponent
|
||||
name="info"
|
||||
|
|
@ -217,7 +214,7 @@ const McpServerTab = ({ folderName }: { folderName: string }) => {
|
|||
{MCP_SERVER_JSON}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
<div className="p-2 text-sm text-muted-foreground">
|
||||
<div className="p-2 text-mmd text-muted-foreground">
|
||||
Add this config to your client of choice. Need help? See the{" "}
|
||||
<a
|
||||
href={MCP_SERVER_TUTORIAL_LINK}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import PaginatorComponent from "@/components/common/paginatorComponent";
|
||||
import CardsWrapComponent from "@/components/core/cardsWrapComponent";
|
||||
import { IS_MAC } from "@/constants/constants";
|
||||
import { useGetFolderQuery } from "@/controllers/API/queries/folders/use-get-folder";
|
||||
import { CustomBanner } from "@/customization/components/custom-banner";
|
||||
import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags";
|
||||
import useFlowsManagerStore from "@/stores/flowsManagerStore";
|
||||
import { useFolderStore } from "@/stores/foldersStore";
|
||||
import { FlowType } from "@/types/flow";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import GridComponent from "../../components/grid";
|
||||
import GridSkeleton from "../../components/gridSkeleton";
|
||||
import HeaderComponent from "../../components/header";
|
||||
import ListComponent from "../../components/list";
|
||||
import ListSkeleton from "../../components/listSkeleton";
|
||||
|
|
@ -95,6 +95,81 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
|
|||
}
|
||||
}, [isEmptyFolder]);
|
||||
|
||||
const [selectedFlows, setSelectedFlows] = useState<string[]>([]);
|
||||
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [isShiftPressed, setIsShiftPressed] = useState(false);
|
||||
const [isCtrlPressed, setIsCtrlPressed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Shift") {
|
||||
setIsShiftPressed(true);
|
||||
} else if ((!IS_MAC && e.key === "Control") || e.key === "Meta") {
|
||||
setIsCtrlPressed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "Shift") {
|
||||
setIsShiftPressed(false);
|
||||
} else if ((!IS_MAC && e.key === "Control") || e.key === "Meta") {
|
||||
setIsCtrlPressed(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setSelectedFlow = useCallback(
|
||||
(selected: boolean, flowId: string, index: number) => {
|
||||
setLastSelectedIndex(index);
|
||||
if (isShiftPressed && lastSelectedIndex !== null) {
|
||||
// Find the indices of the last selected and current flow
|
||||
const flows = data.flows;
|
||||
|
||||
// Determine the range to select
|
||||
const start = Math.min(lastSelectedIndex, index);
|
||||
const end = Math.max(lastSelectedIndex, index);
|
||||
// Get all flow IDs in the range
|
||||
const flowsToSelect = flows
|
||||
.slice(start, end + 1)
|
||||
.map((flow) => flow.id);
|
||||
|
||||
// Update selection
|
||||
if (selected) {
|
||||
setSelectedFlows((prev) =>
|
||||
Array.from(new Set([...prev, ...flowsToSelect])),
|
||||
);
|
||||
} else {
|
||||
setSelectedFlows((prev) =>
|
||||
prev.filter((id) => !flowsToSelect.includes(id)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (selected) {
|
||||
setSelectedFlows([...selectedFlows, flowId]);
|
||||
} else {
|
||||
setSelectedFlows(selectedFlows.filter((id) => id !== flowId));
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedFlows, lastSelectedIndex, data.flows, isShiftPressed],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedFlows((old) =>
|
||||
old.filter((id) => data.flows.some((flow) => flow.id === id)),
|
||||
);
|
||||
}, [data.flows]);
|
||||
|
||||
return (
|
||||
<CardsWrapComponent
|
||||
onFileDrop={handleFileDrop}
|
||||
|
|
@ -104,9 +179,9 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
|
|||
className="flex h-full w-full flex-col overflow-y-auto"
|
||||
data-testid="cards-wrapper"
|
||||
>
|
||||
<div className="flex h-full w-full flex-col xl:container">
|
||||
<div className="flex h-full w-full flex-col 3xl:container">
|
||||
{ENABLE_DATASTAX_LANGFLOW && <CustomBanner />}
|
||||
<div className="flex flex-1 flex-col justify-start px-5 pt-10">
|
||||
<div className="flex flex-1 flex-col justify-start p-4">
|
||||
<div className="flex h-full flex-col justify-start">
|
||||
<HeaderComponent
|
||||
folderName={folderName}
|
||||
|
|
@ -117,19 +192,20 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
|
|||
setNewProjectModal={setNewProjectModal}
|
||||
setSearch={onSearch}
|
||||
isEmptyFolder={isEmptyFolder}
|
||||
selectedFlows={selectedFlows}
|
||||
/>
|
||||
{isEmptyFolder ? (
|
||||
<EmptyFolder setOpenModal={setNewProjectModal} />
|
||||
) : (
|
||||
<div className="mt-6">
|
||||
<div className="">
|
||||
{isLoading ? (
|
||||
view === "grid" ? (
|
||||
<div className="mt-1 grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
<GridSkeleton />
|
||||
<GridSkeleton />
|
||||
<div className="mt-4 grid grid-cols-1 gap-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
<ListSkeleton />
|
||||
<ListSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="mt-4 flex flex-col gap-1">
|
||||
<ListSkeleton />
|
||||
<ListSkeleton />
|
||||
</div>
|
||||
|
|
@ -140,15 +216,31 @@ const HomePage = ({ type }: { type: "flows" | "components" | "mcp" }) => {
|
|||
data &&
|
||||
data.pagination.total > 0 ? (
|
||||
view === "grid" ? (
|
||||
<div className="mt-1 grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data.flows.map((flow) => (
|
||||
<GridComponent key={flow.id} flowData={flow} />
|
||||
<div className="mt-4 grid grid-cols-1 gap-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data.flows.map((flow, index) => (
|
||||
<ListComponent
|
||||
key={flow.id}
|
||||
flowData={flow}
|
||||
selected={selectedFlows.includes(flow.id)}
|
||||
setSelected={(selected) =>
|
||||
setSelectedFlow(selected, flow.id, index)
|
||||
}
|
||||
shiftPressed={isShiftPressed || isCtrlPressed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{data.flows.map((flow) => (
|
||||
<ListComponent key={flow.id} flowData={flow} />
|
||||
<div className="mt-4 flex flex-col gap-1">
|
||||
{data.flows.map((flow, index) => (
|
||||
<ListComponent
|
||||
key={flow.id}
|
||||
flowData={flow}
|
||||
selected={selectedFlows.includes(flow.id)}
|
||||
setSelected={(selected) =>
|
||||
setSelectedFlow(selected, flow.id, index)
|
||||
}
|
||||
shiftPressed={isShiftPressed || isCtrlPressed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1808,8 +1808,6 @@ export function downloadFlow(
|
|||
description: flowDescription,
|
||||
};
|
||||
|
||||
console.log(flowData);
|
||||
|
||||
const sortedData = sortJsonStructure(flowData);
|
||||
const sortedJsonString = JSON.stringify(sortedData, null, 2);
|
||||
|
||||
|
|
|
|||
|
|
@ -33,12 +33,14 @@ const config = {
|
|||
center: true,
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
"3xl": "1500px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
screens: {
|
||||
xl: "1200px",
|
||||
"2xl": "1400px",
|
||||
"3xl": "1500px",
|
||||
},
|
||||
keyframes: {
|
||||
// Overlay animations
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ test(
|
|||
});
|
||||
// click on the delete button
|
||||
await page.getByText("Delete").last().click();
|
||||
await page.getByText("Note: This action is irreversible.").isVisible({
|
||||
await page.getByText("This can't be undone.").isVisible({
|
||||
timeout: 1000,
|
||||
});
|
||||
|
||||
|
|
|
|||
128
src/frontend/tests/extended/features/bulk-actions.spec.ts
Normal file
128
src/frontend/tests/extended/features/bulk-actions.spec.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
import { adjustScreenView } from "../../utils/adjust-screen-view";
|
||||
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
|
||||
|
||||
test(
|
||||
"user should be able to select flows with different methods and perform bulk actions",
|
||||
{ tag: ["@release", "@workspace"] },
|
||||
async ({ page }) => {
|
||||
await awaitBootstrapTest(page);
|
||||
|
||||
// Add some flows to test with
|
||||
await page.getByTestId("side_nav_options_all-templates").click();
|
||||
await page.getByRole("heading", { name: "Basic Prompting" }).click();
|
||||
await adjustScreenView(page);
|
||||
|
||||
// Go back to main page
|
||||
await page.waitForSelector('[data-testid="icon-ChevronLeft"]', {
|
||||
timeout: 100000,
|
||||
});
|
||||
await page.getByTestId("icon-ChevronLeft").first().click();
|
||||
|
||||
await page.getByText("Projects").first().isVisible();
|
||||
await page.getByText("New Flow", { exact: true }).click();
|
||||
await page.getByTestId("side_nav_options_all-templates").click();
|
||||
await page.getByRole("heading", { name: "Document Q&A" }).click();
|
||||
await page.waitForSelector('[data-testid="icon-ChevronLeft"]', {
|
||||
timeout: 100000,
|
||||
});
|
||||
await page.getByTestId("icon-ChevronLeft").first().click();
|
||||
|
||||
await page.getByText("Projects").first().isVisible();
|
||||
await page.getByText("New Flow", { exact: true }).click();
|
||||
await page.getByTestId("side_nav_options_all-templates").click();
|
||||
await page.getByRole("heading", { name: "Basic Prompting" }).click();
|
||||
await page.waitForSelector('[data-testid="icon-ChevronLeft"]', {
|
||||
timeout: 100000,
|
||||
});
|
||||
await page.getByTestId("icon-ChevronLeft").first().click();
|
||||
|
||||
await page.getByText("Projects").first().isVisible();
|
||||
await page.waitForSelector('[data-testid="home-dropdown-menu"]', {
|
||||
timeout: 100000,
|
||||
});
|
||||
await page.getByTestId("list-card").first().isVisible({ timeout: 3000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Test shift selection
|
||||
await page.keyboard.down("Shift");
|
||||
await page.getByTestId("list-card").first().click();
|
||||
await page.getByTestId("list-card").nth(2).click();
|
||||
await page.keyboard.up("Shift");
|
||||
|
||||
// Verify both flows are selected
|
||||
const firstCheckbox = await page.getByTestId(/^checkbox-/).first();
|
||||
const secondCheckbox = await page.getByTestId(/^checkbox-/).nth(1);
|
||||
const thirdCheckbox = await page.getByTestId(/^checkbox-/).nth(2);
|
||||
await expect(firstCheckbox).toBeChecked();
|
||||
await expect(secondCheckbox).toBeChecked();
|
||||
await expect(thirdCheckbox).toBeChecked();
|
||||
// Test bulk download
|
||||
await page.getByTestId("home-dropdown-menu").first().click();
|
||||
await page.getByTestId("btn-download-json").last().click();
|
||||
await expect(page.getByText(/.*exported successfully/)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Deselect all
|
||||
await page.keyboard.down("Shift");
|
||||
await page.getByTestId("list-card").first().click();
|
||||
await page.keyboard.up("Shift");
|
||||
|
||||
// Verify both flows are deselected
|
||||
await expect(firstCheckbox).not.toBeChecked();
|
||||
await expect(secondCheckbox).not.toBeChecked();
|
||||
await expect(thirdCheckbox).not.toBeChecked();
|
||||
|
||||
// Test Ctrl/Cmd selection
|
||||
await page.keyboard.down("ControlOrMeta");
|
||||
await page.getByTestId("list-card").first().click();
|
||||
await page.getByTestId("list-card").nth(2).click();
|
||||
await page.keyboard.up("ControlOrMeta");
|
||||
|
||||
// Verify both flows are selected again
|
||||
await expect(firstCheckbox).toBeChecked();
|
||||
await expect(secondCheckbox).not.toBeChecked();
|
||||
await expect(thirdCheckbox).toBeChecked();
|
||||
|
||||
const firstFlowName =
|
||||
(await page
|
||||
.locator("[data-testid='flow-name-div']")
|
||||
.first()
|
||||
.locator("span")
|
||||
.textContent()) ?? "";
|
||||
const secondFlowName =
|
||||
(await page
|
||||
.locator("[data-testid='flow-name-div']")
|
||||
.nth(1)
|
||||
.locator("span")
|
||||
.textContent()) ?? "";
|
||||
const thirdFlowName =
|
||||
(await page
|
||||
.locator("[data-testid='flow-name-div']")
|
||||
.nth(2)
|
||||
.locator("span")
|
||||
.textContent()) ?? "";
|
||||
|
||||
// Test bulk delete
|
||||
await page.getByTestId("delete-bulk-btn").first().click();
|
||||
await page.getByText("This can't be undone.").isVisible({
|
||||
timeout: 1000,
|
||||
});
|
||||
await page.getByText("Delete").last().click();
|
||||
|
||||
// Verify deletion success message
|
||||
await expect(page.getByText("Flows deleted successfully")).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Verify flows are deleted
|
||||
await expect(
|
||||
page.getByText(firstFlowName, { exact: true }),
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByText(secondFlowName, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(thirdFlowName, { exact: true }),
|
||||
).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue