diff --git a/src/backend/base/langflow/api/v1/flows.py b/src/backend/base/langflow/api/v1/flows.py index f955fe37233..788c8629fc4 100644 --- a/src/backend/base/langflow/api/v1/flows.py +++ b/src/backend/base/langflow/api/v1/flows.py @@ -11,6 +11,8 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.encoders import jsonable_encoder from fastapi.responses import StreamingResponse +from fastapi_pagination import Page, Params, add_pagination +from fastapi_pagination.ext.sqlalchemy import paginate from loguru import logger from sqlmodel import Session, and_, col, select @@ -19,6 +21,7 @@ from langflow.initial_setup.setup import STARTER_FOLDER_NAME from langflow.services.auth.utils import get_current_active_user from langflow.services.database.models.flow import Flow, FlowCreate, FlowRead, FlowUpdate +from langflow.services.database.models.flow.model import FlowHeader 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 @@ -120,7 +123,7 @@ def create_flow( raise HTTPException(status_code=500, detail=str(e)) from e -@router.get("/", response_model=list[FlowRead], status_code=200) +@router.get("/", response_model=list[FlowRead] | Page[FlowRead] | list[FlowHeader], status_code=200) def read_flows( *, current_user: User = Depends(get_current_active_user), @@ -128,56 +131,72 @@ def read_flows( settings_service: SettingsService = Depends(get_settings_service), remove_example_flows: bool = False, components_only: bool = False, + get_all: bool = False, + folder_id: UUID | None = None, + params: Params = Depends(), + header_flows: bool = False, ): """ - Retrieve a list of flows. + Retrieve a list of flows with pagination support. Args: current_user (User): The current authenticated user. session (Session): The database session. settings_service (SettingsService): The settings service. - remove_example_flows (bool, optional): Whether to remove example flows. Defaults to False. components_only (bool, optional): Whether to return only components. Defaults to False. - + get_all (bool, optional): Whether to return all flows without pagination. Defaults to False. + folder_id (UUID, optional): The folder ID. Defaults to None. + params (Params): Pagination parameters. + remove_example_flows (bool, optional): Whether to remove example flows. Defaults to False. Returns: - List[Dict]: A list of flows in JSON format. + Union[list[FlowRead], Page[FlowRead]]: A list of flows or a paginated response containing the list of flows. """ try: auth_settings = settings_service.auth_settings + + default_folder = session.exec(select(Folder).where(Folder.name == DEFAULT_FOLDER_NAME)).first() + default_folder_id = default_folder.id if default_folder else None + + starter_folder = session.exec(select(Folder).where(Folder.name == STARTER_FOLDER_NAME)).first() + starter_folder_id = starter_folder.id if starter_folder else None + + if not folder_id: + folder_id = default_folder_id + if auth_settings.AUTO_LOGIN: stmt = select(Flow).where( (Flow.user_id == None) | (Flow.user_id == current_user.id) # noqa ) - if components_only: - stmt = stmt.where(Flow.is_component == True) # noqa - flows = session.exec(stmt).all() - else: - flows = current_user.flows + stmt = select(Flow).where(Flow.user_id == current_user.id) + + if remove_example_flows: + stmt = stmt.where(Flow.folder_id != starter_folder_id) - flows = validate_is_component(flows) # type: ignore if components_only: - flows = [flow for flow in flows if flow.is_component] - flow_ids = [flow.id for flow in flows] - # with the session get the flows that DO NOT have a user_id - folder = session.exec(select(Folder).where(Folder.name == STARTER_FOLDER_NAME)).first() - - if not remove_example_flows and not components_only: - try: - example_flows = folder.flows if folder else [] - for example_flow in example_flows: - if example_flow.id not in flow_ids: - flows.append(example_flow) # type: ignore - except Exception as e: - logger.error(e) + stmt = stmt.where(Flow.is_component == True) # noqa - if remove_example_flows: - flows = [flow for flow in flows if flow.folder_id != folder.id] + if not get_all: + stmt = stmt.where(Flow.folder_id == folder_id) + + if get_all: + flows = session.exec(stmt).all() + flows = validate_is_component(flows) # type: ignore + if components_only: + flows = [flow for flow in flows if flow.is_component] + if remove_example_flows and starter_folder_id: + flows = [flow for flow in flows if flow.folder_id != starter_folder_id] + if header_flows: + return [ + {"id": flow.id, "name": flow.name, "folder_id": flow.folder_id, "is_component": flow.is_component} + for flow in flows + ] # type: ignore + return flows + return paginate(session, stmt, params=params) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e - return [jsonable_encoder(flow) for flow in flows] @router.get("/{flow_id}", response_model=FlowRead, status_code=200) @@ -399,3 +418,35 @@ async def download_multiple_file( headers={"Content-Disposition": f"attachment; filename={filename}"}, ) return flows_without_api_keys[0] + + +@router.get("/basic_examples/", response_model=list[FlowRead], status_code=200) +def read_basic_examples( + *, + session: Session = Depends(get_session), +): + """ + Retrieve a list of basic example flows. + + Args: + session (Session): The database session. + + Returns: + list[FlowRead]: A list of basic example flows. + """ + + try: + # Get the starter folder + starter_folder = session.exec(select(Folder).where(Folder.name == STARTER_FOLDER_NAME)).first() + + if not starter_folder: + return [] + + # Get all flows in the starter folder + return session.exec(select(Flow).where(Flow.folder_id == starter_folder.id)).all() + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +add_pagination(router) diff --git a/src/backend/base/langflow/api/v1/folders.py b/src/backend/base/langflow/api/v1/folders.py index d3af3b1b699..905fde671f2 100644 --- a/src/backend/base/langflow/api/v1/folders.py +++ b/src/backend/base/langflow/api/v1/folders.py @@ -1,5 +1,7 @@ import orjson from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status +from fastapi_pagination import Params +from fastapi_pagination.ext.sqlmodel import paginate from sqlalchemy import or_, update from sqlmodel import Session, select @@ -8,6 +10,7 @@ from langflow.api.v1.schemas import FlowListCreate, FlowListReadWithFolderName from langflow.helpers.flow import generate_unique_flow_name from langflow.helpers.folders import generate_unique_folder_name +from langflow.initial_setup.setup import STARTER_FOLDER_NAME from langflow.services.auth.utils import get_current_active_user from langflow.services.database.models.flow.model import Flow, FlowCreate, FlowRead from langflow.services.database.models.folder.constants import DEFAULT_FOLDER_NAME @@ -15,9 +18,9 @@ Folder, FolderCreate, FolderRead, - FolderReadWithFlows, FolderUpdate, ) +from langflow.services.database.models.folder.pagination_model import FolderWithPaginatedFlows from langflow.services.database.models.user.model import User from langflow.services.deps import get_session @@ -89,25 +92,42 @@ def read_folders( or_(Folder.user_id == current_user.id, Folder.user_id == None) # type: ignore # noqa: E711 ) ).all() + folders = [folder for folder in folders if folder.name != STARTER_FOLDER_NAME] return sorted(folders, key=lambda x: x.name != DEFAULT_FOLDER_NAME) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) from e -@router.get("/{folder_id}", response_model=FolderReadWithFlows, status_code=200) +@router.get("/{folder_id}", response_model=FolderWithPaginatedFlows, status_code=200) def read_folder( *, session: Session = Depends(get_session), folder_id: str, current_user: User = Depends(get_current_active_user), + params: Params = Depends(), + is_component: bool = False, + is_flow: bool = False, + search: str = "", ): try: folder = session.exec(select(Folder).where(Folder.id == folder_id, Folder.user_id == current_user.id)).first() if not folder: raise HTTPException(status_code=404, detail="Folder not found") - flows_from_current_user_in_folder = [flow for flow in folder.flows if flow.user_id == current_user.id] - folder.flows = flows_from_current_user_in_folder - return folder + + stmt = ( + select(Flow) + .where(Flow.folder_id == folder_id, Flow.user_id == current_user.id) + .order_by(Flow.updated_at.desc()) # type: ignore + ) + if is_component: + stmt = stmt.where(Flow.is_component == True) # type: ignore # noqa: E712 + if is_flow: + stmt = stmt.where(Flow.is_component == False) # type: ignore # noqa: E712 + if search: + stmt = stmt.where(Flow.name.like(f"%{search}%")) # type: ignore + paginated_flows = paginate(session, stmt, params=params) + + return FolderWithPaginatedFlows(folder=FolderRead.model_validate(folder), flows=paginated_flows) except Exception as e: if "No result found" in str(e): raise HTTPException(status_code=404, detail="Folder not found") from e diff --git a/src/backend/base/langflow/services/database/models/flow/model.py b/src/backend/base/langflow/services/database/models/flow/model.py index 30693eb2d5b..e20646de338 100644 --- a/src/backend/base/langflow/services/database/models/flow/model.py +++ b/src/backend/base/langflow/services/database/models/flow/model.py @@ -9,7 +9,7 @@ from emoji import purely_emoji # type: ignore from fastapi import HTTPException, status from loguru import logger -from pydantic import field_serializer, field_validator +from pydantic import BaseModel, field_serializer, field_validator from sqlalchemy import Text, UniqueConstraint from sqlmodel import JSON, Column, Field, Relationship, SQLModel @@ -188,6 +188,20 @@ class FlowRead(FlowBase): folder_id: UUID | None = Field() +class FlowHeader(BaseModel): + id: UUID + name: str + folder_id: UUID | None = Field() + is_component: bool | None = Field() + + +class PaginatedFlowResponse(BaseModel): + flows: list[FlowRead] + total: int + page_size: int + page_index: int + + class FlowUpdate(SQLModel): name: str | None = None description: str | None = None diff --git a/src/backend/base/langflow/services/database/models/folder/pagination_model.py b/src/backend/base/langflow/services/database/models/folder/pagination_model.py new file mode 100644 index 00000000000..46dcdc68783 --- /dev/null +++ b/src/backend/base/langflow/services/database/models/folder/pagination_model.py @@ -0,0 +1,10 @@ +from fastapi_pagination import Page + +from langflow.helpers.base_model import BaseModel +from langflow.services.database.models.flow.model import Flow +from langflow.services.database.models.folder.model import FolderRead + + +class FolderWithPaginatedFlows(BaseModel): + folder: FolderRead + flows: Page[Flow] diff --git a/src/backend/base/pyproject.toml b/src/backend/base/pyproject.toml index 2df390eb3c4..65d15443be0 100644 --- a/src/backend/base/pyproject.toml +++ b/src/backend/base/pyproject.toml @@ -282,7 +282,8 @@ dependencies = [ "spider-client>=0.0.27", "diskcache>=5.6.3", "clickhouse-connect==0.7.19", - "assemblyai>=0.33.0" + "assemblyai>=0.33.0", + "fastapi-pagination>=0.12.29", ] # Optional dependencies for uv diff --git a/src/backend/tests/unit/test_database.py b/src/backend/tests/unit/test_database.py index 9705df15e4a..657a497d8e6 100644 --- a/src/backend/tests/unit/test_database.py +++ b/src/backend/tests/unit/test_database.py @@ -67,8 +67,30 @@ async def test_read_flows(client: TestClient, json_flow: str, active_user, logge assert len(response.json()) > 0 +async def test_read_flows_pagination(client: TestClient, json_flow: str, active_user, logged_in_headers): + response = await client.get("api/v1/flows/", headers=logged_in_headers) + assert response.status_code == 200 + assert response.json()["page"] == 1 + assert response.json()["size"] == 50 + assert response.json()["pages"] == 0 + assert response.json()["total"] == 0 + assert len(response.json()["items"]) == 0 + + +async def test_read_flows_pagination_with_params(client: TestClient, json_flow: str, active_user, logged_in_headers): + response = await client.get("api/v1/flows/", headers=logged_in_headers, params={"page": 3, "size": 10}) + assert response.status_code == 200 + assert response.json()["page"] == 3 + assert response.json()["size"] == 10 + assert response.json()["pages"] == 0 + assert response.json()["total"] == 0 + assert len(response.json()["items"]) == 0 + + async def test_read_flows_components_only(client: TestClient, flow_component: dict, logged_in_headers): - response = await client.get("api/v1/flows/", headers=logged_in_headers, params={"components_only": True}) + response = await client.get( + "api/v1/flows/", headers=logged_in_headers, params={"components_only": True, "get_all": True} + ) assert response.status_code == 200 names = [flow["name"] for flow in response.json()] assert any("Chat Input Component" in name for name in names) @@ -267,6 +289,52 @@ async def test_delete_folder_with_flows_with_transaction_and_build( assert response.json() == {"vertex_builds": {}} +async def test_get_flows_from_folder_pagination(client: TestClient, logged_in_headers): + # Create a new folder + folder_name = f"Test Folder {uuid4()}" + folder = FolderCreate(name=folder_name, description="Test folder description", components_list=[], flows_list=[]) + + response = await client.post("api/v1/folders/", json=folder.model_dump(), headers=logged_in_headers) + assert response.status_code == 201, f"Expected status code 201, but got {response.status_code}" + + created_folder = response.json() + folder_id = created_folder["id"] + + response = await client.get(f"api/v1/folders/{folder_id}", headers=logged_in_headers) + assert response.status_code == 200 + assert response.json()["folder"]["name"] == folder_name + assert response.json()["folder"]["description"] == "Test folder description" + assert response.json()["flows"]["page"] == 1 + assert response.json()["flows"]["size"] == 50 + assert response.json()["flows"]["pages"] == 0 + assert response.json()["flows"]["total"] == 0 + assert len(response.json()["flows"]["items"]) == 0 + + +async def test_get_flows_from_folder_pagination_with_params(client: TestClient, logged_in_headers): + # Create a new folder + folder_name = f"Test Folder {uuid4()}" + folder = FolderCreate(name=folder_name, description="Test folder description", components_list=[], flows_list=[]) + + response = await client.post("api/v1/folders/", json=folder.model_dump(), headers=logged_in_headers) + assert response.status_code == 201, f"Expected status code 201, but got {response.status_code}" + + created_folder = response.json() + folder_id = created_folder["id"] + + response = await client.get( + f"api/v1/folders/{folder_id}", headers=logged_in_headers, params={"page": 3, "size": 10} + ) + assert response.status_code == 200 + assert response.json()["folder"]["name"] == folder_name + assert response.json()["folder"]["description"] == "Test folder description" + assert response.json()["flows"]["page"] == 3 + assert response.json()["flows"]["size"] == 10 + assert response.json()["flows"]["pages"] == 0 + assert response.json()["flows"]["total"] == 0 + assert len(response.json()["flows"]["items"]) == 0 + + async def test_create_flows(client: TestClient, session: Session, json_flow: str, logged_in_headers): flow = orjson.loads(json_flow) data = flow["data"] @@ -412,7 +480,7 @@ async def test_delete_nonexistent_flow(client: TestClient, active_user, logged_i async def test_read_only_starter_projects(client: TestClient, active_user, logged_in_headers): - response = await client.get("api/v1/flows/", headers=logged_in_headers) + response = await client.get("api/v1/flows/basic_examples/", headers=logged_in_headers) starter_projects = load_starter_projects() assert response.status_code == 200 assert len(response.json()) == len(starter_projects) diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index b07ae39d770..e54783c6692 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -858,7 +858,6 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/src/frontend/src/App.css b/src/frontend/src/App.css index 6a91177c6df..e41830102d9 100644 --- a/src/frontend/src/App.css +++ b/src/frontend/src/App.css @@ -153,6 +153,7 @@ body { width: 100%; height: 100%; } + .react-flow__resize-control.handle { width: 0.75rem !important; height: 0.75rem !important; diff --git a/src/frontend/src/CustomNodes/GenericNode/components/NodeDescription/index.tsx b/src/frontend/src/CustomNodes/GenericNode/components/NodeDescription/index.tsx index 5d687de9164..bae3600f22a 100644 --- a/src/frontend/src/CustomNodes/GenericNode/components/NodeDescription/index.tsx +++ b/src/frontend/src/CustomNodes/GenericNode/components/NodeDescription/index.tsx @@ -36,10 +36,6 @@ export default function NodeDescription({ //timeout to wait for the dom to update setTimeout(() => { if (overflowRef.current) { - console.log( - overflowRef.current.clientHeight, - overflowRef.current.scrollHeight, - ); if ( overflowRef.current.clientHeight < overflowRef.current.scrollHeight ) { diff --git a/src/frontend/src/components/cardComponent/index.tsx b/src/frontend/src/components/cardComponent/index.tsx index e207b88533d..763e37754e7 100644 --- a/src/frontend/src/components/cardComponent/index.tsx +++ b/src/frontend/src/components/cardComponent/index.tsx @@ -42,6 +42,7 @@ export default function CollectionCardComponent({ const selectedFlowsComponentsCards = useFlowsManagerStore( (state) => state.selectedFlowsComponentsCards, ); + function hasPlayground(flow?: FlowType) { if (!flow) { return false; @@ -60,9 +61,9 @@ export default function CollectionCardComponent({ e.stopPropagation(); track("Playground Button Clicked", { flowId: data.id }); setLoadingPlayground(true); - const flow = getFlowById(data.id); - if (flow) { - if (!hasPlayground(flow)) { + + if (data) { + if (!hasPlayground(data)) { setErrorData({ title: "Error", list: ["This flow doesn't have a playground."], @@ -70,7 +71,7 @@ export default function CollectionCardComponent({ setLoadingPlayground(false); return; } - setCurrentFlow(flow); + setCurrentFlow(data); setOpenPlayground(true); setLoadingPlayground(false); } else { diff --git a/src/frontend/src/components/folderSidebarComponent/index.tsx b/src/frontend/src/components/folderSidebarComponent/index.tsx index 25df8aa2642..499d625aa48 100644 --- a/src/frontend/src/components/folderSidebarComponent/index.tsx +++ b/src/frontend/src/components/folderSidebarComponent/index.tsx @@ -1,4 +1,6 @@ import { useGetFoldersQuery } from "@/controllers/API/queries/folders/use-get-folders"; +import { useFolderStore } from "@/stores/foldersStore"; +import { useIsFetching } from "@tanstack/react-query"; import { useLocation } from "react-router-dom"; import { FolderType } from "../../pages/MainPage/entities"; import { cn } from "../../utils/utils"; @@ -19,8 +21,12 @@ export default function FolderSidebarNav({ }: SidebarNavProps) { const location = useLocation(); const pathname = location.pathname; + const folders = useFolderStore((state) => state.folders); - const { data: folders, isPending } = useGetFoldersQuery(); + const isPending = !!useIsFetching({ + queryKey: ["useGetFolders"], + exact: false, + }); return (