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