diff --git a/src/backend/base/langflow/api/v1/flows.py b/src/backend/base/langflow/api/v1/flows.py index ed5e5c091..6bef71d8e 100644 --- a/src/backend/base/langflow/api/v1/flows.py +++ b/src/backend/base/langflow/api/v1/flows.py @@ -7,6 +7,8 @@ from uuid import UUID import zipfile from fastapi.responses import StreamingResponse +from langflow.services.database.models.transactions.crud import get_transactions_by_flow_id +from langflow.services.database.models.vertex_builds.crud import get_vertex_builds_by_flow_id import orjson from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.encoders import jsonable_encoder @@ -334,11 +336,20 @@ async def delete_multiple_flows( """ try: - deleted_flows = db.exec(select(Flow).where(col(Flow.id).in_(flow_ids)).where(Flow.user_id == user.id)).all() - for flow in deleted_flows: + flows_to_delete = db.exec(select(Flow).where(col(Flow.id).in_(flow_ids)).where(Flow.user_id == user.id)).all() + for flow in flows_to_delete: + transactions_to_delete = get_transactions_by_flow_id(db, flow.id) + for transaction in transactions_to_delete: + db.delete(transaction) + + builds_to_delete = get_vertex_builds_by_flow_id(db, flow.id) + for build in builds_to_delete: + db.delete(build) + db.delete(flow) + db.commit() - return {"deleted": len(deleted_flows)} + return {"deleted": len(flows_to_delete)} except Exception as exc: logger.exception(exc) raise HTTPException(status_code=500, detail=str(exc)) from exc diff --git a/src/backend/tests/unit/test_database.py b/src/backend/tests/unit/test_database.py index 3de2fda29..ccce97c00 100644 --- a/src/backend/tests/unit/test_database.py +++ b/src/backend/tests/unit/test_database.py @@ -1,12 +1,13 @@ import json from uuid import UUID, uuid4 +from langflow.graph.utils import log_transaction, log_vertex_build import orjson import pytest from fastapi.testclient import TestClient from sqlmodel import Session -from langflow.api.v1.schemas import FlowListCreate +from langflow.api.v1.schemas import FlowListCreate, ResultDataResponse from langflow.initial_setup.setup import load_starter_projects, load_flows_from_directory from langflow.services.database.models.base import orjson_dumps from langflow.services.database.models.flow import Flow, FlowCreate, FlowUpdate @@ -19,6 +20,7 @@ from langflow.services.monitor.utils import ( new_duckdb_locked_connection, add_row_to_table, ) +from collections import namedtuple @pytest.fixture(scope="module") @@ -133,6 +135,66 @@ def test_delete_flows(client: TestClient, json_flow: str, active_user, logged_in assert response.json().get("deleted") == number_of_flows +@pytest.mark.asyncio +async def test_delete_flows_with_transaction_and_build( + client: TestClient, json_flow: str, active_user, logged_in_headers +): + # Create ten flows + number_of_flows = 10 + flows = [FlowCreate(name=f"Flow {i}", description="description", data={}) for i in range(number_of_flows)] + flow_ids = [] + for flow in flows: + response = client.post("api/v1/flows/", json=flow.model_dump(), headers=logged_in_headers) + assert response.status_code == 201 + flow_ids.append(response.json()["id"]) + + # Create a transaction for each flow + + for flow_id in flow_ids: + VertexTuple = namedtuple("VertexTuple", ["id"]) + + await log_transaction( + str(flow_id), source=VertexTuple(id="vid"), target=VertexTuple(id="tid"), status="success" + ) + + # Create a build for each flow + for flow_id in flow_ids: + build = { + "valid": True, + "params": {}, + "data": ResultDataResponse(), + "artifacts": {}, + "vertex_id": "vid", + "flow_id": flow_id, + } + log_vertex_build( + flow_id=build["flow_id"], + vertex_id=build["vertex_id"], + valid=build["valid"], + params=build["params"], + data=build["data"], + artifacts=build.get("artifacts"), + ) + + response = client.request("DELETE", "api/v1/flows/", headers=logged_in_headers, json=flow_ids) + assert response.status_code == 200, response.content + assert response.json().get("deleted") == number_of_flows + + for flow_id in flow_ids: + response = client.request( + "GET", "api/v1/monitor/transactions", params={"flow_id": flow_id}, headers=logged_in_headers + ) + assert response.status_code == 200 + assert response.json() == [] + + for flow_id in flow_ids: + response = client.request( + "GET", "api/v1/monitor/builds", params={"flow_id": flow_id}, headers=logged_in_headers + ) + assert response.status_code == 200 + assert response.json() == {"vertex_builds": {}} + + def test_create_flows(client: TestClient, session: Session, json_flow: str, logged_in_headers): flow = orjson.loads(json_flow) data = flow["data"] diff --git a/src/frontend/src/controllers/API/queries/flows/use-delete-flows.ts b/src/frontend/src/controllers/API/queries/flows/use-delete-flows.ts new file mode 100644 index 000000000..22ac82cb5 --- /dev/null +++ b/src/frontend/src/controllers/API/queries/flows/use-delete-flows.ts @@ -0,0 +1,32 @@ +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 IDeleteFlows { + flow_ids: string[]; +} + +export const useDeleteFlows: useMutationFunctionType< + undefined, + IDeleteFlows +> = (options?) => { + const { mutate } = UseRequestProcessor(); + + const deleteFlowsFn = async (payload: IDeleteFlows): Promise => { + const response = await api.delete(`${getURL("FLOWS")}/`, { + data: payload.flow_ids, + }); + + return response.data; + }; + + const mutation: UseMutationResult = mutate( + ["useLoginUser"], + deleteFlowsFn, + options, + ); + + return mutation; +}; diff --git a/src/frontend/src/pages/MainPage/components/componentsComponent/index.tsx b/src/frontend/src/pages/MainPage/components/componentsComponent/index.tsx index 4a1816598..6a7b98e0f 100644 --- a/src/frontend/src/pages/MainPage/components/componentsComponent/index.tsx +++ b/src/frontend/src/pages/MainPage/components/componentsComponent/index.tsx @@ -1,4 +1,5 @@ import { usePostDownloadMultipleFlows } from "@/controllers/API/queries/flows"; +import { useDeleteFlows } from "@/controllers/API/queries/flows/use-delete-flows"; import { useEffect, useMemo, useState } from "react"; import { FormProvider, useForm, useWatch } from "react-hook-form"; import { useLocation } from "react-router-dom"; @@ -188,19 +189,36 @@ export default function ComponentsComponent({ handleExport, ); - const { handleDeleteMultiple } = useDeleteMultipleFlows( - selectedFlowsComponentsCards, - removeFlow, - resetFilter, - getFoldersApi, - folderId, - myCollectionId!, - getFolderById, - setSuccessData, - setErrorData, - setAllFlows, - setSelectedFolder, - ); + const { mutate: mutateDeleteMultipleFlows } = useDeleteFlows(); + + const handleDeleteMultiple = () => { + mutateDeleteMultipleFlows( + { + flow_ids: selectedFlowsComponentsCards, + }, + { + onSuccess: () => { + setAllFlows([]); + setSelectedFolder(null); + + resetFilter(); + getFoldersApi(true); + if (!folderId || folderId === myCollectionId) { + getFolderById(folderId ? folderId : myCollectionId); + } + setSuccessData({ + title: "Selected items deleted successfully", + }); + }, + onError: () => { + setErrorData({ + title: "Error deleting items", + list: ["Please try again"], + }); + }, + }, + ); + }; useSelectedFlows(entireFormValues, setSelectedFlowsComponentsCards); diff --git a/src/frontend/tests/end-to-end/actionsMainPage-shard-1.spec.ts b/src/frontend/tests/end-to-end/actionsMainPage-shard-1.spec.ts index bca16450d..cca4fb4e0 100644 --- a/src/frontend/tests/end-to-end/actionsMainPage-shard-1.spec.ts +++ b/src/frontend/tests/end-to-end/actionsMainPage-shard-1.spec.ts @@ -36,6 +36,41 @@ test("select and delete all", async ({ page }) => { await page.getByText("Selected items deleted successfully").isVisible(); }); +test("select and delete a flow", async ({ page }) => { + await page.goto("/"); + await page.waitForTimeout(2000); + + let modalCount = 0; + try { + const modalTitleElement = await page?.getByTestId("modal-title"); + if (modalTitleElement) { + modalCount = await modalTitleElement.count(); + } + } catch (error) { + modalCount = 0; + } + + while (modalCount === 0) { + await page.getByText("New Project", { exact: true }).click(); + await page.waitForTimeout(5000); + modalCount = await page.getByTestId("modal-title")?.count(); + } + 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.getByTestId("checkbox-component").first().click(); + await page.getByTestId("icon-Trash2").click(); + await page.getByText("Delete").last().click(); + + await page.waitForTimeout(1000); + await page.getByText("Selected items deleted successfully").isVisible(); +}); + test("search flows", async ({ page }) => { await page.goto("/"); await page.waitForTimeout(2000);