mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-26 12:16:12 +08:00
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
This commit is contained in:
@ -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()
|
||||
|
||||
293
src/backend/tests/unit/api/v1/test_flow_folder_integrity.py
Normal file
293
src/backend/tests/unit/api/v1/test_flow_folder_integrity.py
Normal file
@ -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()
|
||||
@ -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 (
|
||||
<div>
|
||||
<Select
|
||||
@ -61,16 +57,14 @@ export const SelectOptions = ({
|
||||
</SelectTrigger>
|
||||
</ShadTooltip>
|
||||
<SelectContent align="end" alignOffset={-16} position="popper">
|
||||
{item.name !== defaultFolderName && (
|
||||
<SelectItem
|
||||
id="rename-button"
|
||||
value="rename"
|
||||
data-testid="btn-rename-project"
|
||||
className="text-xs"
|
||||
>
|
||||
<FolderSelectItem name="Rename" iconName="SquarePen" />
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem
|
||||
id="rename-button"
|
||||
value="rename"
|
||||
data-testid="btn-rename-project"
|
||||
className="text-xs"
|
||||
>
|
||||
<FolderSelectItem name="Rename" iconName="SquarePen" />
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="download"
|
||||
data-testid="btn-download-project"
|
||||
@ -78,15 +72,13 @@ export const SelectOptions = ({
|
||||
>
|
||||
<FolderSelectItem name="Download" iconName="Download" />
|
||||
</SelectItem>
|
||||
{index > 0 && (
|
||||
<SelectItem
|
||||
value="delete"
|
||||
data-testid="btn-delete-project"
|
||||
className="text-xs"
|
||||
>
|
||||
<FolderSelectItem name="Delete" iconName="Trash2" />
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem
|
||||
value="delete"
|
||||
data-testid="btn-delete-project"
|
||||
className="text-xs"
|
||||
>
|
||||
<FolderSelectItem name="Delete" iconName="Trash2" />
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@ -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 = ({
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{!loading ? (
|
||||
folders.map((item, index) => {
|
||||
const editFolderName = editFolders?.filter(
|
||||
(folder) => folder.name === item.name,
|
||||
)[0];
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={index}
|
||||
className="group/menu-button"
|
||||
onMouseEnter={() => setHoveredFolderId(item.id!)}
|
||||
onMouseLeave={() => setHoveredFolderId(null)}
|
||||
>
|
||||
<div className="relative flex w-full">
|
||||
<SidebarMenuButton
|
||||
size="md"
|
||||
onDragOver={(e) => 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!),
|
||||
)}
|
||||
>
|
||||
<div
|
||||
onDoubleClick={(event) => {
|
||||
handleDoubleClick(event, item);
|
||||
}}
|
||||
className="flex w-full items-center justify-between gap-2"
|
||||
folders.length === 0 ? (
|
||||
<div className="px-2 py-5 text-center text-sm text-muted-foreground">
|
||||
Start creating a project or flow
|
||||
</div>
|
||||
) : (
|
||||
folders.map((item, index) => {
|
||||
const editFolderName = editFolders?.filter(
|
||||
(folder) => folder.name === item.name,
|
||||
)[0];
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={index}
|
||||
className="group/menu-button"
|
||||
onMouseEnter={() => setHoveredFolderId(item.id!)}
|
||||
onMouseLeave={() => setHoveredFolderId(null)}
|
||||
>
|
||||
<div className="relative flex w-full">
|
||||
<SidebarMenuButton
|
||||
size="md"
|
||||
onDragOver={(e) => 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!),
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
{editFolderName?.edit && !isUpdatingFolder ? (
|
||||
<InputEditFolderName
|
||||
handleEditFolderName={handleEditFolderName}
|
||||
item={item}
|
||||
refInput={refInput}
|
||||
handleKeyDownFn={handleKeyDownFn}
|
||||
handleEditNameFolder={handleEditNameFolder}
|
||||
editFolderName={editFolderName}
|
||||
foldersNames={foldersNames}
|
||||
handleKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<span className="block w-0 grow truncate text-sm opacity-100">
|
||||
{item.name}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
onDoubleClick={(event) => {
|
||||
handleDoubleClick(event, item);
|
||||
}}
|
||||
className="flex w-full items-center justify-between gap-2"
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
{editFolderName?.edit && !isUpdatingFolder ? (
|
||||
<InputEditFolderName
|
||||
handleEditFolderName={handleEditFolderName}
|
||||
item={item}
|
||||
refInput={refInput}
|
||||
handleKeyDownFn={handleKeyDownFn}
|
||||
handleEditNameFolder={handleEditNameFolder}
|
||||
editFolderName={editFolderName}
|
||||
foldersNames={foldersNames}
|
||||
handleKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<span className="block w-0 grow truncate text-sm opacity-100">
|
||||
{item.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
<div
|
||||
className="absolute right-2 top-[0.45rem] flex items-center hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SelectOptions
|
||||
item={item}
|
||||
handleDeleteFolder={handleDeleteFolder}
|
||||
handleDownloadFolder={() =>
|
||||
handleDownloadFolder(item.id!, item.name)
|
||||
}
|
||||
handleSelectFolderToRename={
|
||||
handleSelectFolderToRename
|
||||
}
|
||||
checkPathName={checkPathName}
|
||||
/>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
<div
|
||||
className="absolute right-2 top-[0.45rem] flex items-center hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SelectOptions
|
||||
item={item}
|
||||
index={index}
|
||||
handleDeleteFolder={handleDeleteFolder}
|
||||
handleDownloadFolder={() =>
|
||||
handleDownloadFolder(item.id!, item.name)
|
||||
}
|
||||
handleSelectFolderToRename={
|
||||
handleSelectFolderToRename
|
||||
}
|
||||
checkPathName={checkPathName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<SidebarFolderSkeleton />
|
||||
|
||||
@ -33,6 +33,7 @@ export const useDeleteFolders: useMutationFunctionType<
|
||||
...options,
|
||||
onSettled: (id) => {
|
||||
queryClient.refetchQueries({ queryKey: ["useGetFolders", id] });
|
||||
queryClient.invalidateQueries({ queryKey: ["useGetFolders"] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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<string> => {
|
||||
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<string>((resolve, reject) => {
|
||||
postAddFlow(newFlow, {
|
||||
onSuccess: (createdFlow) => {
|
||||
// Add the new flow to the list of flows.
|
||||
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user