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:
Lucas Oliveira 2025-05-13 15:58:02 -03:00 committed by GitHub
commit 9ee4df696e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 493 additions and 315 deletions

View file

@ -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)}

View file

@ -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}

View file

@ -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"

View file

@ -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;
};

View file

@ -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>

View file

@ -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;

View file

@ -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;

View file

@ -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>
)}
</>

View file

@ -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}

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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}

View file

@ -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>
)

View file

@ -1808,8 +1808,6 @@ export function downloadFlow(
description: flowDescription,
};
console.log(flowData);
const sortedData = sortJsonStructure(flowData);
const sortedJsonString = JSON.stringify(sortedData, null, 2);

View file

@ -33,12 +33,14 @@ const config = {
center: true,
screens: {
"2xl": "1400px",
"3xl": "1500px",
},
},
extend: {
screens: {
xl: "1200px",
"2xl": "1400px",
"3xl": "1500px",
},
keyframes: {
// Overlay animations

View file

@ -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,
});

View 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();
},
);