From 39a276d96da51693c52b8de02abb42dce22f7c17 Mon Sep 17 00:00:00 2001 From: Viktor Avelino <64113566+viktoravelino@users.noreply.github.com> Date: Fri, 30 Jan 2026 14:57:43 -0500 Subject: [PATCH] feat: Allow renaming and deleting the Starter Project folder (#11462) * feat: Allow renaming and deleting the Starter Project folder * feat: Ensure flows always have a valid folder_id and add tests for folder integrity * refactor: remove obsolete test file for folder utility functions * fix: remove unused variable * fix: enhance folder handling in flow creation process --- src/backend/base/langflow/api/v1/flows.py | 34 +- .../unit/api/v1/test_flow_folder_integrity.py | 293 +++++++++++++ .../components/select-options.tsx | 38 +- .../components/sideBarFolderButtons/index.tsx | 155 ++++--- .../API/queries/folders/use-delete-folders.ts | 1 + .../API/queries/folders/use-get-folders.ts | 4 +- src/frontend/src/hooks/flows/use-add-flow.ts | 91 ++-- .../folder-deletion-integrity.spec.ts | 403 ++++++++++++++++++ 8 files changed, 879 insertions(+), 140 deletions(-) create mode 100644 src/backend/tests/unit/api/v1/test_flow_folder_integrity.py create mode 100644 src/frontend/tests/core/features/folder-deletion-integrity.spec.ts diff --git a/src/backend/base/langflow/api/v1/flows.py b/src/backend/base/langflow/api/v1/flows.py index 3381f6faa7..1716792f92 100644 --- a/src/backend/base/langflow/api/v1/flows.py +++ b/src/backend/base/langflow/api/v1/flows.py @@ -37,6 +37,7 @@ from langflow.services.database.models.flow.model import ( from langflow.services.database.models.flow.utils import get_webhook_component_in_flow from langflow.services.database.models.folder.constants import DEFAULT_FOLDER_NAME from langflow.services.database.models.folder.model import Folder +from langflow.services.database.models.folder.utils import get_default_folder_id from langflow.services.deps import get_settings_service, get_storage_service from langflow.services.storage.service import StorageService from langflow.utils.compression import compress_response @@ -261,13 +262,18 @@ async def _new_flow( db_flow.updated_at = datetime.now(timezone.utc) - if db_flow.folder_id is None: - # Make sure flows always have a folder - default_folder = ( - await session.exec(select(Folder).where(Folder.name == DEFAULT_FOLDER_NAME, Folder.user_id == user_id)) + # Validate folder_id exists, or fall back to default folder + if db_flow.folder_id is not None: + folder_exists = ( + await session.exec(select(Folder).where(Folder.id == db_flow.folder_id, Folder.user_id == user_id)) ).first() - if default_folder: - db_flow.folder_id = default_folder.id + if not folder_exists: + # Folder doesn't exist or doesn't belong to user, use default + db_flow.folder_id = None + + if db_flow.folder_id is None: + # Make sure flows always have a folder (auto-create default folder if needed) + db_flow.folder_id = await get_default_folder_id(session, user_id) session.add(db_flow) @@ -488,10 +494,20 @@ async def update_flow( db_flow.webhook = webhook_component is not None db_flow.updated_at = datetime.now(timezone.utc) + # Validate folder_id exists, or fall back to default folder + if db_flow.folder_id is not None: + folder_exists = ( + await session.exec( + select(Folder).where(Folder.id == db_flow.folder_id, Folder.user_id == current_user.id) + ) + ).first() + if not folder_exists: + # Folder doesn't exist or doesn't belong to user, use default + db_flow.folder_id = None + if db_flow.folder_id is None: - default_folder = (await session.exec(select(Folder).where(Folder.name == DEFAULT_FOLDER_NAME))).first() - if default_folder: - db_flow.folder_id = default_folder.id + # Make sure flows always have a folder (auto-create default folder if needed) + db_flow.folder_id = await get_default_folder_id(session, current_user.id) session.add(db_flow) await session.flush() diff --git a/src/backend/tests/unit/api/v1/test_flow_folder_integrity.py b/src/backend/tests/unit/api/v1/test_flow_folder_integrity.py new file mode 100644 index 0000000000..de11906981 --- /dev/null +++ b/src/backend/tests/unit/api/v1/test_flow_folder_integrity.py @@ -0,0 +1,293 @@ +"""Tests for flow-folder integrity to prevent orphaned flows. + +These tests verify the fix for the bug where flows could be created without a valid folder_id +when all folders were deleted (zero folders scenario), resulting in orphaned flows that were +unreachable in the UI. + +The fix ensures: +1. Flows always have a valid folder_id +2. If a non-existent folder_id is provided, the system falls back to the default folder +3. If no folders exist, a default folder is auto-created +""" + +import uuid + +from fastapi import status +from httpx import AsyncClient +from langflow.services.database.models.folder.constants import DEFAULT_FOLDER_NAME +from langflow.services.database.models.folder.model import Folder +from langflow.services.deps import session_scope +from sqlmodel import select + + +async def test_create_flow_with_nonexistent_folder_id_assigns_default_folder( + client: AsyncClient, logged_in_headers, active_user +): + """Test that creating a flow with a non-existent folder_id assigns it to the default folder. + + This prevents orphaned flows when a folder is deleted between the UI loading and flow creation. + """ + non_existent_folder_id = str(uuid.uuid4()) + + flow_data = { + "name": "Test Flow with Bad Folder", + "data": {}, + "folder_id": non_existent_folder_id, + } + + response = await client.post("api/v1/flows/", json=flow_data, headers=logged_in_headers) + + assert response.status_code == status.HTTP_201_CREATED + result = response.json() + + # The flow should have been assigned to a valid folder (not the non-existent one) + assert result["folder_id"] is not None + assert result["folder_id"] != non_existent_folder_id + + # Verify the folder actually exists + async with session_scope() as session: + folder = await session.get(Folder, uuid.UUID(result["folder_id"])) + assert folder is not None + assert folder.user_id == active_user.id + + +async def test_create_flow_without_folder_id_assigns_default_folder( + client: AsyncClient, logged_in_headers, active_user +): + """Test that creating a flow without a folder_id assigns it to the default folder.""" + flow_data = { + "name": "Test Flow without Folder ID", + "data": {}, + } + + response = await client.post("api/v1/flows/", json=flow_data, headers=logged_in_headers) + + assert response.status_code == status.HTTP_201_CREATED + result = response.json() + + # The flow should have been assigned to a valid folder + assert result["folder_id"] is not None + + # Verify the folder actually exists and belongs to the user + async with session_scope() as session: + folder = await session.get(Folder, uuid.UUID(result["folder_id"])) + assert folder is not None + assert folder.user_id == active_user.id + + +async def test_create_flow_after_all_folders_deleted_creates_default_folder( + client: AsyncClient, logged_in_headers, active_user +): + """Test the zero-folder scenario: creating a flow after deleting all folders. + + This is the critical bug fix test. When all folders are deleted, creating a new flow + should automatically create a default folder instead of creating an orphaned flow. + """ + # First, delete all folders for this user + async with session_scope() as session: + stmt = select(Folder).where(Folder.user_id == active_user.id) + folders = (await session.exec(stmt)).all() + for folder in folders: + await session.delete(folder) + await session.commit() + + # Verify no folders exist for this user + async with session_scope() as session: + stmt = select(Folder).where(Folder.user_id == active_user.id) + folders = (await session.exec(stmt)).all() + assert len(folders) == 0, "All folders should be deleted" + + # Now create a flow - this should auto-create a default folder + flow_data = { + "name": "Flow Created After All Folders Deleted", + "data": {}, + } + + response = await client.post("api/v1/flows/", json=flow_data, headers=logged_in_headers) + + assert response.status_code == status.HTTP_201_CREATED + result = response.json() + + # The flow should NOT be orphaned - it should have a valid folder_id + assert result["folder_id"] is not None + + # Verify the folder was auto-created and exists + async with session_scope() as session: + folder = await session.get(Folder, uuid.UUID(result["folder_id"])) + assert folder is not None + assert folder.user_id == active_user.id + assert folder.name == DEFAULT_FOLDER_NAME + + +async def test_update_flow_with_nonexistent_folder_id_assigns_default_folder( + client: AsyncClient, logged_in_headers, active_user +): + """Test that updating a flow with a non-existent folder_id falls back to default folder. + + This handles the case where a user tries to move a flow to a folder that doesn't exist. + """ + # Configure client to follow redirects (folders API uses redirects) + client.follow_redirects = True + + # Create a flow in the default folder + flow_data = { + "name": "Flow to Update", + "data": {}, + } + flow_response = await client.post("api/v1/flows/", json=flow_data, headers=logged_in_headers) + assert flow_response.status_code == status.HTTP_201_CREATED + flow_id = flow_response.json()["id"] + + # Now try to update the flow with a non-existent folder_id + non_existent_folder_id = str(uuid.uuid4()) + update_data = { + "name": "Updated Flow Name", + "folder_id": non_existent_folder_id, # This folder doesn't exist + } + + update_response = await client.patch(f"api/v1/flows/{flow_id}", json=update_data, headers=logged_in_headers) + + assert update_response.status_code == status.HTTP_200_OK + result = update_response.json() + + # The flow should be reassigned to a valid folder (not the non-existent one) + assert result["folder_id"] is not None + assert result["folder_id"] != non_existent_folder_id + + # Verify the folder exists + async with session_scope() as session: + folder = await session.get(Folder, uuid.UUID(result["folder_id"])) + assert folder is not None + assert folder.user_id == active_user.id + + +async def test_update_flow_without_folder_id_keeps_existing_folder(client: AsyncClient, logged_in_headers): + """Test that updating a flow without specifying folder_id keeps the existing folder assignment.""" + # Configure client to follow redirects + client.follow_redirects = True + + # Create a flow + flow_data = { + "name": "Flow to Update", + "data": {}, + } + create_response = await client.post("api/v1/flows/", json=flow_data, headers=logged_in_headers) + assert create_response.status_code == status.HTTP_201_CREATED + flow_id = create_response.json()["id"] + original_folder_id = create_response.json()["folder_id"] + + # Update the flow without specifying folder_id (only update name) + update_data = { + "name": "Updated Flow Name", + } + + update_response = await client.patch(f"api/v1/flows/{flow_id}", json=update_data, headers=logged_in_headers) + + assert update_response.status_code == status.HTTP_200_OK + result = update_response.json() + + # The folder_id should remain unchanged + assert result["folder_id"] == original_folder_id + + +async def test_upload_flow_with_nonexistent_folder_id_assigns_default( + client: AsyncClient, logged_in_headers, active_user +): + """Test that uploading a flow with a non-existent folder_id assigns it to the default folder. + + The upload endpoint uses _new_flow internally, which includes folder_id validation. + """ + import json + + non_existent_folder_id = str(uuid.uuid4()) + + flow_data = { + "name": "Uploaded Flow with Bad Folder", + "data": {}, + "folder_id": non_existent_folder_id, + } + + # Create a JSON file content for upload + file_content = json.dumps(flow_data) + + response = await client.post( + "api/v1/flows/upload/", + files={"file": ("flow.json", file_content, "application/json")}, + headers=logged_in_headers, + ) + + assert response.status_code == status.HTTP_201_CREATED + results = response.json() + + # The result is a list (even for single flow upload) + assert len(results) == 1 + result = results[0] + + # The flow should have a valid folder_id (not the non-existent one) + assert result["folder_id"] is not None + assert result["folder_id"] != non_existent_folder_id + + # Verify the folder exists + async with session_scope() as session: + folder = await session.get(Folder, uuid.UUID(result["folder_id"])) + assert folder is not None + assert folder.user_id == active_user.id + + +async def test_flow_created_is_retrievable_in_folder(client: AsyncClient, logged_in_headers): + """Test that a created flow can be retrieved by listing flows in its folder. + + This verifies the flow is not orphaned and appears in the UI. + """ + # Configure client to follow redirects + client.follow_redirects = True + + # Create a flow + flow_data = { + "name": "Retrievable Flow", + "data": {}, + } + create_response = await client.post("api/v1/flows/", json=flow_data, headers=logged_in_headers) + assert create_response.status_code == status.HTTP_201_CREATED + flow_id = create_response.json()["id"] + folder_id = create_response.json()["folder_id"] + + # List flows in the folder + response = await client.get(f"api/v1/folders/{folder_id}", headers=logged_in_headers) + assert response.status_code == status.HTTP_200_OK + + # Check if the flow is in the folder's flows list + result = response.json() + # Handle different response structures + if "flows" in result: + flows = result["flows"] + elif "folder" in result and "flows" in result["folder"]: + flows = result["folder"]["flows"] + else: + # Response might be paginated or have different structure + flows = result.get("flows", []) + + # Get flow IDs from the response + flow_ids_in_folder = [f["id"] if isinstance(f, dict) else str(f) for f in flows] + + # The created flow should be in the folder's flow list + assert flow_id in flow_ids_in_folder, f"Flow {flow_id} should be retrievable in folder {folder_id}" + + +async def test_upsert_flow_with_nonexistent_folder_id_on_create(client: AsyncClient, logged_in_headers): + """Test that PUT (upsert) with non-existent folder_id creates flow with default folder.""" + specified_id = str(uuid.uuid4()) + non_existent_folder_id = str(uuid.uuid4()) + + flow_data = { + "name": "Upsert Flow with Bad Folder", + "data": {}, + "folder_id": non_existent_folder_id, + } + + response = await client.put(f"api/v1/flows/{specified_id}", json=flow_data, headers=logged_in_headers) + + # The request should be rejected with 400 Bad Request since folder doesn't exist + # This is the expected behavior based on the existing test_upsert_flow_returns_400_for_invalid_folder_id + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "folder not found" in response.json()["detail"].lower() diff --git a/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/select-options.tsx b/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/select-options.tsx index 6edd10bff5..ea282766c0 100644 --- a/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/select-options.tsx +++ b/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/components/select-options.tsx @@ -8,27 +8,23 @@ import { SelectTrigger, } from "@/components/ui/select-custom"; import type { FolderType } from "@/pages/MainPage/entities"; -import { useUtilityStore } from "@/stores/utilityStore"; import { cn } from "@/utils/utils"; import { handleSelectChange } from "../helpers/handle-select-change"; import { FolderSelectItem } from "./folder-select-item"; export const SelectOptions = ({ item, - index, handleDeleteFolder, handleDownloadFolder, handleSelectFolderToRename, checkPathName, }: { item: FolderType; - index: number; handleDeleteFolder: ((folder: FolderType) => void) | undefined; handleDownloadFolder: (folderId: string) => void; handleSelectFolderToRename: (folder: FolderType) => void; checkPathName: (folderId: string) => boolean; }) => { - const defaultFolderName = useUtilityStore((state) => state.defaultFolderName); return (
diff --git a/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/index.tsx b/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/index.tsx index 949a86625d..0ec1ae2908 100644 --- a/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/index.tsx +++ b/src/frontend/src/components/core/folderSidebarComponent/components/sideBarFolderButtons/index.tsx @@ -40,7 +40,6 @@ import type { FolderType } from "../../../../../pages/MainPage/entities"; import useAlertStore from "../../../../../stores/alertStore"; import useFlowsManagerStore from "../../../../../stores/flowsManagerStore"; import { useFolderStore } from "../../../../../stores/foldersStore"; -import { useUtilityStore } from "../../../../../stores/utilityStore"; import { handleKeyDown } from "../../../../../utils/reactflowUtils"; import { cn } from "../../../../../utils/utils"; import useFileDrop from "../../hooks/use-on-file-drop"; @@ -86,7 +85,6 @@ const SideBarFoldersButtonsComponent = ({ const folderIdDragging = useFolderStore((state) => state.folderIdDragging); const myCollectionId = useFolderStore((state) => state.myCollectionId); const takeSnapshot = useFlowsManagerStore((state) => state.takeSnapshot); - const defaultFolderName = useUtilityStore((state) => state.defaultFolderName); const folderId = useParams().folderId ?? myCollectionId ?? ""; @@ -276,10 +274,6 @@ const SideBarFoldersButtonsComponent = ({ }; const handleDoubleClick = (event, item) => { - if (item.name === defaultFolderName) { - return; - } - event.stopPropagation(); event.preventDefault(); @@ -382,82 +376,87 @@ const SideBarFoldersButtonsComponent = ({ {!loading ? ( - folders.map((item, index) => { - const editFolderName = editFolders?.filter( - (folder) => folder.name === item.name, - )[0]; - return ( - setHoveredFolderId(item.id!)} - onMouseLeave={() => setHoveredFolderId(null)} - > -
- dragOver(e, item.id!)} - onDragEnter={(e) => dragEnter(e, item.id!)} - onDragLeave={dragLeave} - onDrop={(e) => onDrop(e, item.id!)} - key={item.id} - data-testid={`sidebar-nav-${item.name}`} - id={`sidebar-nav-${item.name}`} - isActive={checkPathName(item.id!)} - onClick={() => handleChangeFolder!(item.id!)} - className={cn( - "flex-grow pr-8", - hoveredFolderId === item.id && "bg-accent", - checkHoveringFolder(item.id!), - )} - > -
{ - handleDoubleClick(event, item); - }} - className="flex w-full items-center justify-between gap-2" + folders.length === 0 ? ( +
+ Start creating a project or flow +
+ ) : ( + folders.map((item, index) => { + const editFolderName = editFolders?.filter( + (folder) => folder.name === item.name, + )[0]; + return ( + setHoveredFolderId(item.id!)} + onMouseLeave={() => setHoveredFolderId(null)} + > +
+ dragOver(e, item.id!)} + onDragEnter={(e) => dragEnter(e, item.id!)} + onDragLeave={dragLeave} + onDrop={(e) => onDrop(e, item.id!)} + key={item.id} + data-testid={`sidebar-nav-${item.name}`} + id={`sidebar-nav-${item.name}`} + isActive={checkPathName(item.id!)} + onClick={() => handleChangeFolder!(item.id!)} + className={cn( + "flex-grow pr-8", + hoveredFolderId === item.id && "bg-accent", + checkHoveringFolder(item.id!), + )} > -
- {editFolderName?.edit && !isUpdatingFolder ? ( - - ) : ( - - {item.name} - - )} +
{ + handleDoubleClick(event, item); + }} + className="flex w-full items-center justify-between gap-2" + > +
+ {editFolderName?.edit && !isUpdatingFolder ? ( + + ) : ( + + {item.name} + + )} +
+ +
e.stopPropagation()} + > + + handleDownloadFolder(item.id!, item.name) + } + handleSelectFolderToRename={ + handleSelectFolderToRename + } + checkPathName={checkPathName} + />
- -
e.stopPropagation()} - > - - handleDownloadFolder(item.id!, item.name) - } - handleSelectFolderToRename={ - handleSelectFolderToRename - } - checkPathName={checkPathName} - />
-
- - ); - }) + + ); + }) + ) ) : ( <> diff --git a/src/frontend/src/controllers/API/queries/folders/use-delete-folders.ts b/src/frontend/src/controllers/API/queries/folders/use-delete-folders.ts index d4397b74b5..fbf5b7fe02 100644 --- a/src/frontend/src/controllers/API/queries/folders/use-delete-folders.ts +++ b/src/frontend/src/controllers/API/queries/folders/use-delete-folders.ts @@ -33,6 +33,7 @@ export const useDeleteFolders: useMutationFunctionType< ...options, onSettled: (id) => { queryClient.refetchQueries({ queryKey: ["useGetFolders", id] }); + queryClient.invalidateQueries({ queryKey: ["useGetFolders"] }); }, }); diff --git a/src/frontend/src/controllers/API/queries/folders/use-get-folders.ts b/src/frontend/src/controllers/API/queries/folders/use-get-folders.ts index a41efe6b70..d02de07070 100644 --- a/src/frontend/src/controllers/API/queries/folders/use-get-folders.ts +++ b/src/frontend/src/controllers/API/queries/folders/use-get-folders.ts @@ -22,7 +22,9 @@ export const useGetFoldersQuery: useQueryFunctionType< const res = await api.get(`${getURL("PROJECTS")}/`); const data = res.data; - const myCollectionId = data?.find((f) => f.name === defaultFolderName)?.id; + // Find default folder by name, or fall back to first folder if not found + const myCollectionId = + data?.find((f) => f.name === defaultFolderName)?.id ?? data?.[0]?.id; setMyCollectionId(myCollectionId); setFolders(data); diff --git a/src/frontend/src/hooks/flows/use-add-flow.ts b/src/frontend/src/hooks/flows/use-add-flow.ts index 2ae12c8117..43d9318901 100644 --- a/src/frontend/src/hooks/flows/use-add-flow.ts +++ b/src/frontend/src/hooks/flows/use-add-flow.ts @@ -2,11 +2,14 @@ import { cloneDeep } from "lodash"; import { useParams } from "react-router-dom"; import { UUID_PARSING_ERROR } from "@/constants/constants"; import { usePostAddFlow } from "@/controllers/API/queries/flows/use-post-add-flow"; +import { usePostFolders } from "@/controllers/API/queries/folders"; import useAlertStore from "@/stores/alertStore"; +import useAuthStore from "@/stores/authStore"; import useFlowsManagerStore from "@/stores/flowsManagerStore"; import { useFolderStore } from "@/stores/foldersStore"; import { useGlobalVariablesStore } from "@/stores/globalVariablesStore/globalVariables"; import { useTypesStore } from "@/stores/typesStore"; +import { useUtilityStore } from "@/stores/utilityStore"; import type { FlowType } from "@/types/flow"; import { addVersionToDuplicates, @@ -31,6 +34,15 @@ const useAddFlow = () => { const setNoticeData = useAlertStore.getState().setNoticeData; const { folderId } = useParams(); const myCollectionId = useFolderStore((state) => state.myCollectionId); + const folders = useFolderStore((state) => state.folders); + const setMyCollectionId = useFolderStore((state) => state.setMyCollectionId); + + const userData = useAuthStore((state) => state.userData); + const hideGettingStartedProgress = useUtilityStore( + (state) => state.hideGettingStartedProgress, + ); + const isOnboarding = + !hideGettingStartedProgress && !userData?.optins?.dialog_dismissed; const unavailableFields = useGlobalVariablesStore( (state) => state.unavailableFields, @@ -40,42 +52,63 @@ const useAddFlow = () => { ); const { mutate: postAddFlow } = usePostAddFlow(); + const { mutateAsync: postAddFolder } = usePostFolders(); const addFlow = async (params?: { flow?: FlowType; override?: boolean; new_blank?: boolean; - }) => { - return new Promise(async (resolve, reject) => { - const flow = cloneDeep(params?.flow) ?? undefined; - const flowData = flow - ? await processDataFromFlow(flow) - : { nodes: [], edges: [], viewport: { zoom: 1, x: 0, y: 0 } }; - flowData?.nodes.forEach((node) => { - updateGroupRecursion( - node, - flowData?.edges, - unavailableFields, - globalVariablesEntries, - ); - }); - // Create a new flow with a default name if no flow is provided. - if (params?.override && flow) { - const flowId = flows?.find((f) => f.name === flow.name); - if (flowId) { - await deleteFlow({ id: flowId.id }); - } - } - - const folder_id = folderId ?? myCollectionId ?? ""; - const flowsToCheckNames = flows?.filter( - (f) => f.folder_id === myCollectionId, + }): Promise => { + const flow = cloneDeep(params?.flow) ?? undefined; + const flowData = flow + ? await processDataFromFlow(flow) + : { nodes: [], edges: [], viewport: { zoom: 1, x: 0, y: 0 } }; + flowData?.nodes.forEach((node) => { + updateGroupRecursion( + node, + flowData?.edges, + unavailableFields, + globalVariablesEntries, ); - const newFlow = createNewFlow(flowData!, folder_id, flow); - const newName = addVersionToDuplicates(newFlow, flowsToCheckNames ?? []); - newFlow.name = newName; - newFlow.folder_id = folder_id; + }); + // Create a new flow with a default name if no flow is provided. + if (params?.override && flow) { + const flowId = flows?.find((f) => f.name === flow.name); + if (flowId) { + await deleteFlow({ id: flowId.id }); + } + } + // Determine folder_id, creating a new folder if needed + let folder_id = folderId ?? myCollectionId ?? ""; + + // If no folder exists, create one with the appropriate name based on onboarding state + if (!folder_id && (!folders || folders.length === 0)) { + try { + const projectName = isOnboarding ? "Starter Project" : "New Project"; + const newFolder = await postAddFolder({ + data: { + name: projectName, + parent_id: null, + description: "", + }, + }); + folder_id = newFolder.id; + setMyCollectionId(folder_id); + } catch { + // Continue with empty folder_id - backend will create default folder + } + } + + const flowsToCheckNames = flows?.filter( + (f) => f.folder_id === myCollectionId, + ); + const newFlow = createNewFlow(flowData!, folder_id, flow); + const newName = addVersionToDuplicates(newFlow, flowsToCheckNames ?? []); + newFlow.name = newName; + newFlow.folder_id = folder_id; + + return new Promise((resolve, reject) => { postAddFlow(newFlow, { onSuccess: (createdFlow) => { // Add the new flow to the list of flows. diff --git a/src/frontend/tests/core/features/folder-deletion-integrity.spec.ts b/src/frontend/tests/core/features/folder-deletion-integrity.spec.ts new file mode 100644 index 0000000000..b09010113b --- /dev/null +++ b/src/frontend/tests/core/features/folder-deletion-integrity.spec.ts @@ -0,0 +1,403 @@ +import { expect, test } from "../../fixtures"; +import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; + +/** + * Tests for folder deletion integrity + * + * These tests verify that: + * 1. After deleting a folder, the UI properly updates (no stale data) + * 2. Deleting a folder when another exists keeps the app functional + * 3. Creating folders after deletion works correctly + */ + +test( + "deleting a folder should update the folder list immediately", + { tag: ["@release", "@api", "@folder"] }, + async ({ page }) => { + await awaitBootstrapTest(page); + + // Navigate to templates and create a flow first + await page.getByTestId("side_nav_options_all-templates").click(); + await page.getByRole("heading", { name: "Basic Prompting" }).click(); + + await page.waitForSelector('[data-testid="sidebar-search-input"]', { + timeout: 30000, + }); + + // Go back to folder view + await page.getByTestId("icon-ChevronLeft").first().click(); + + await page.waitForSelector('[data-testid="add-project-button"]', { + timeout: 30000, + }); + + // Create a new folder + await page.getByTestId("add-project-button").click(); + + await page + .locator("[data-testid='project-sidebar']") + .getByText("New Project") + .last() + .waitFor({ state: "visible", timeout: 10000 }); + + // Rename the folder for easier identification + await page + .locator("[data-testid='project-sidebar']") + .getByText("New Project") + .last() + .dblclick(); + + const folderInput = page.getByTestId("input-project"); + await folderInput.fill("test-folder-to-delete"); + await page.keyboard.press("Enter"); + + // Wait for the folder to be renamed + await page.getByText("test-folder-to-delete").last().waitFor({ + state: "visible", + timeout: 10000, + }); + + // Verify the folder exists in the sidebar + const folderBeforeDelete = page.getByTestId( + "sidebar-nav-test-folder-to-delete", + ); + await expect(folderBeforeDelete).toBeVisible({ timeout: 5000 }); + + // Delete the folder + await folderBeforeDelete.hover(); + await page.getByTestId("more-options-button_test-folder-to-delete").click(); + await page.getByTestId("btn-delete-project").click(); + await page.getByText("Delete").last().click(); + + // Verify success message + await expect(page.getByText("Project deleted successfully")).toBeVisible({ + timeout: 5000, + }); + + // Verify the folder is removed from the sidebar immediately (no stale data) + await expect( + page.getByTestId("sidebar-nav-test-folder-to-delete"), + ).not.toBeVisible({ timeout: 5000 }); + + // Verify the page is still functional by checking for the add project button + await expect(page.getByTestId("add-project-button")).toBeVisible({ + timeout: 5000, + }); + }, +); + +test( + "deleting one folder should not affect other folders", + { tag: ["@release", "@api", "@folder"] }, + async ({ page }) => { + await awaitBootstrapTest(page); + + // Navigate to templates + await page.getByTestId("side_nav_options_all-templates").click(); + await page.getByRole("heading", { name: "Basic Prompting" }).click(); + + await page.waitForSelector('[data-testid="sidebar-search-input"]', { + timeout: 30000, + }); + + // Go back to folder view + await page.getByTestId("icon-ChevronLeft").first().click(); + + await page.waitForSelector('[data-testid="add-project-button"]', { + timeout: 30000, + }); + + // Create first folder + await page.getByTestId("add-project-button").click(); + + await page + .locator("[data-testid='project-sidebar']") + .getByText("New Project") + .last() + .waitFor({ state: "visible", timeout: 10000 }); + + await page + .locator("[data-testid='project-sidebar']") + .getByText("New Project") + .last() + .dblclick(); + + await page.getByTestId("input-project").fill("folder-alpha"); + await page.keyboard.press("Enter"); + + await page.getByText("folder-alpha").last().waitFor({ + state: "visible", + timeout: 10000, + }); + + // Create second folder + await page.getByTestId("add-project-button").click(); + + await page + .locator("[data-testid='project-sidebar']") + .getByText("New Project") + .last() + .waitFor({ state: "visible", timeout: 10000 }); + + await page + .locator("[data-testid='project-sidebar']") + .getByText("New Project") + .last() + .dblclick(); + + await page.getByTestId("input-project").fill("folder-beta"); + await page.keyboard.press("Enter"); + + await page.getByText("folder-beta").last().waitFor({ + state: "visible", + timeout: 10000, + }); + + // Verify both folders exist + await expect(page.getByTestId("sidebar-nav-folder-alpha")).toBeVisible({ + timeout: 5000, + }); + await expect(page.getByTestId("sidebar-nav-folder-beta")).toBeVisible({ + timeout: 5000, + }); + + // Delete the first folder + const folderAlpha = page.getByTestId("sidebar-nav-folder-alpha"); + await folderAlpha.hover(); + await page.getByTestId("more-options-button_folder-alpha").click(); + await page.getByTestId("btn-delete-project").click(); + await page.getByText("Delete").last().click(); + + // Verify success message + await expect(page.getByText("Project deleted successfully")).toBeVisible({ + timeout: 5000, + }); + + // Verify folder-alpha is removed + await expect(page.getByTestId("sidebar-nav-folder-alpha")).not.toBeVisible({ + timeout: 5000, + }); + + // Verify folder-beta still exists and is accessible + const folderBeta = page.getByTestId("sidebar-nav-folder-beta"); + await expect(folderBeta).toBeVisible({ timeout: 5000 }); + + // Click on folder-beta to ensure the app is functional + await folderBeta.click(); + + // The page should still be functional + await page.waitForSelector('[data-testid="mainpage_title"]', { + timeout: 10000, + }); + + // Clean up - delete the remaining folder + await folderBeta.hover(); + await page.getByTestId("more-options-button_folder-beta").click(); + await page.getByTestId("btn-delete-project").click(); + await page.getByText("Delete").last().click(); + + await expect(page.getByText("Project deleted successfully")).toBeVisible({ + timeout: 5000, + }); + }, +); + +test( + "creating a new folder after deletion should work correctly", + { tag: ["@release", "@api", "@folder"] }, + async ({ page }) => { + await awaitBootstrapTest(page); + + // Navigate to templates + await page.getByTestId("side_nav_options_all-templates").click(); + await page.getByRole("heading", { name: "Basic Prompting" }).click(); + + await page.waitForSelector('[data-testid="sidebar-search-input"]', { + timeout: 30000, + }); + + // Go back to folder view + await page.getByTestId("icon-ChevronLeft").first().click(); + + await page.waitForSelector('[data-testid="add-project-button"]', { + timeout: 30000, + }); + + // Create first folder + await page.getByTestId("add-project-button").click(); + + await page + .locator("[data-testid='project-sidebar']") + .getByText("New Project") + .last() + .waitFor({ state: "visible", timeout: 10000 }); + + await page + .locator("[data-testid='project-sidebar']") + .getByText("New Project") + .last() + .dblclick(); + + await page.getByTestId("input-project").fill("folder-one"); + await page.keyboard.press("Enter"); + + await page.getByText("folder-one").last().waitFor({ + state: "visible", + timeout: 10000, + }); + + // Delete the folder + const folderOne = page.getByTestId("sidebar-nav-folder-one"); + await folderOne.hover(); + await page.getByTestId("more-options-button_folder-one").click(); + await page.getByTestId("btn-delete-project").click(); + await page.getByText("Delete").last().click(); + + await expect(page.getByText("Project deleted successfully")).toBeVisible({ + timeout: 5000, + }); + + // Verify folder is deleted + await expect(page.getByTestId("sidebar-nav-folder-one")).not.toBeVisible({ + timeout: 5000, + }); + + // Create a new folder immediately after deletion + await page.getByTestId("add-project-button").click(); + + await page + .locator("[data-testid='project-sidebar']") + .getByText("New Project") + .last() + .waitFor({ state: "visible", timeout: 10000 }); + + await page + .locator("[data-testid='project-sidebar']") + .getByText("New Project") + .last() + .dblclick(); + + await page.getByTestId("input-project").fill("folder-two"); + await page.keyboard.press("Enter"); + + // The new folder should be created successfully without any stale data issues + await page.getByText("folder-two").last().waitFor({ + state: "visible", + timeout: 10000, + }); + + const folderTwo = page.getByTestId("sidebar-nav-folder-two"); + await expect(folderTwo).toBeVisible({ timeout: 5000 }); + + // Clean up + await folderTwo.hover(); + await page.getByTestId("more-options-button_folder-two").click(); + await page.getByTestId("btn-delete-project").click(); + await page.getByText("Delete").last().click(); + + await expect(page.getByText("Project deleted successfully")).toBeVisible({ + timeout: 5000, + }); + }, +); + +test( + "creating a flow after deleting all folders should create a default folder", + { tag: ["@release", "@api", "@folder"] }, + async ({ page }) => { + await awaitBootstrapTest(page, { skipModal: true }); + + // Get all folders in the sidebar and delete them one by one + const projectSidebar = page.locator("[data-testid='project-sidebar']"); + + // Delete all folders until none are left + let folderCount = await projectSidebar + .locator('[data-testid^="sidebar-nav-"]') + .filter({ hasNotText: "add_note" }) + .count(); + + while (folderCount > 0) { + // Get the first folder + const firstFolder = projectSidebar + .locator('[data-testid^="sidebar-nav-"]') + .filter({ hasNotText: "add_note" }) + .first(); + const folderTestId = await firstFolder.getAttribute("data-testid"); + + if (!folderTestId) { + break; + } + + // Extract folder name from testid (e.g., "sidebar-nav-Starter Project" -> "starter-project") + const folderName = folderTestId.replace("sidebar-nav-", ""); + const kebabName = folderName.toLowerCase().replace(/\s+/g, "-"); + + // Hover and click more options + await firstFolder.hover(); + + // Try to find and click the more options button + const moreOptionsButton = page.getByTestId( + `more-options-button_${kebabName}`, + ); + + if (await moreOptionsButton.isVisible()) { + await moreOptionsButton.click(); + } else { + // Try with the original name format + const altMoreOptions = page + .locator(`[data-testid^="more-options-button_"]`) + .first(); + await altMoreOptions.click(); + } + + await page.getByTestId("btn-delete-project").click(); + await page.getByText("Delete").last().click(); + + // Wait for deletion to complete + await expect(page.getByText("Project deleted successfully")).toBeVisible({ + timeout: 5000, + }); + + // Wait a bit for UI to update + await page.waitForTimeout(500); + + // Recount folders + folderCount = await projectSidebar + .locator('[data-testid^="sidebar-nav-"]') + .filter({ hasNotText: "add_note" }) + .count(); + } + + // Now create a new flow using the empty state button on main page + // This should trigger creation of a default folder + await page.waitForSelector('[data-testid="new_project_btn_empty_page"]', { + timeout: 30000, + }); + + await page.getByTestId("new_project_btn_empty_page").click(); + + // Navigate to templates + await page.getByTestId("side_nav_options_all-templates").click(); + await page.getByRole("heading", { name: "Basic Prompting" }).click(); + + await page.waitForSelector('[data-testid="sidebar-search-input"]', { + timeout: 30000, + }); + + // Go back to folder view + await page.getByTestId("icon-ChevronLeft").first().click(); + + // Verify that a default folder ("Starter Project") was created + await expect(page.getByTestId("sidebar-nav-Starter Project")).toBeVisible({ + timeout: 10000, + }); + + // Verify we can click on the folder and see the flow + await page.getByTestId("sidebar-nav-Starter Project").click(); + + // The folder should contain our newly created flow + await expect(page.getByTestId("list-card")).toBeVisible({ + timeout: 5000, + }); + }, +);