diff --git a/src/backend/base/langflow/alembic/versions/e3162c1804e6_add_persistent_locked_state.py b/src/backend/base/langflow/alembic/versions/e3162c1804e6_add_persistent_locked_state.py new file mode 100644 index 000000000..80de87a35 --- /dev/null +++ b/src/backend/base/langflow/alembic/versions/e3162c1804e6_add_persistent_locked_state.py @@ -0,0 +1,35 @@ +"""add persistent locked state + +Revision ID: e3162c1804e6 +Revises: 1eab2c3eb45e +Create Date: 2024-11-07 14:50:35.201760 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +from sqlalchemy.engine.reflection import Inspector +from langflow.utils import migration + + +# revision identifiers, used by Alembic. +revision: str = 'e3162c1804e6' +down_revision: Union[str, None] = '1eab2c3eb45e' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + with op.batch_alter_table('flow', schema=None) as batch_op: + if not migration.column_exists(table_name='flow', column_name='locked', conn=conn): + batch_op.add_column(sa.Column('locked', sa.Boolean(), nullable=True)) + + +def downgrade() -> None: + conn = op.get_bind() + with op.batch_alter_table('flow', schema=None) as batch_op: + if migration.column_exists(table_name='flow', column_name='locked', conn=conn): + batch_op.drop_column('locked') 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 11092165d..7fc64e078 100644 --- a/src/backend/base/langflow/services/database/models/flow/model.py +++ b/src/backend/base/langflow/services/database/models/flow/model.py @@ -37,6 +37,7 @@ class FlowBase(SQLModel): webhook: bool | None = Field(default=False, nullable=True, description="Can be used on the webhook endpoint") endpoint_name: str | None = Field(default=None, nullable=True, index=True) tags: list[str] | None = None + locked: bool | None = Field(default=False, nullable=True) @field_validator("endpoint_name") @classmethod @@ -161,6 +162,7 @@ class Flow(FlowBase, table=True): # type: ignore[call-arg] user: "User" = Relationship(back_populates="flows") icon: str | None = Field(default=None, nullable=True) tags: list[str] | None = Field(sa_column=Column(JSON), default=[]) + locked: bool | None = Field(default=False, nullable=True) folder_id: UUID | None = Field(default=None, foreign_key="folder.id", nullable=True, index=True) folder: Optional["Folder"] = Relationship(back_populates="flows") messages: list["MessageTable"] = Relationship(back_populates="flow") @@ -228,6 +230,7 @@ class FlowUpdate(SQLModel): data: dict | None = None folder_id: UUID | None = None endpoint_name: str | None = None + locked: bool | None = None @field_validator("endpoint_name") @classmethod diff --git a/src/frontend/src/components/core/canvasControlsComponent/index.tsx b/src/frontend/src/components/core/canvasControlsComponent/index.tsx index 22c699d3a..5df5a5631 100644 --- a/src/frontend/src/components/core/canvasControlsComponent/index.tsx +++ b/src/frontend/src/components/core/canvasControlsComponent/index.tsx @@ -1,6 +1,11 @@ import IconComponent from "@/components/common/genericIconComponent"; import ShadTooltip from "@/components/common/shadTooltipComponent"; +import useSaveFlow from "@/hooks/flows/use-save-flow"; +import useFlowsManagerStore from "@/stores/flowsManagerStore"; +import useFlowStore from "@/stores/flowStore"; import { cn } from "@/utils/utils"; +import { cloneDeep } from "lodash"; +import { useEffect } from "react"; import { ControlButton, Panel, @@ -64,6 +69,30 @@ const CanvasControls = ({ children }) => { selector, shallow, ); + const saveFlow = useSaveFlow(); + const currentFlow = useFlowStore((state) => state.currentFlow); + const setCurrentFlow = useFlowStore((state) => state.setCurrentFlow); + const autoSaving = useFlowsManagerStore((state) => state.autoSaving); + + useEffect(() => { + const isLocked = currentFlow?.locked; + store.setState({ + nodesDraggable: !isLocked, + nodesConnectable: !isLocked, + elementsSelectable: !isLocked, + }); + }, [currentFlow?.locked]); + + const handleSaveFlow = () => { + if (!currentFlow) return; + const newFlow = cloneDeep(currentFlow); + newFlow.locked = isInteractive; + if (autoSaving) { + saveFlow(newFlow); + } else { + setCurrentFlow(newFlow); + } + }; const onToggleInteractivity = () => { store.setState({ @@ -71,6 +100,7 @@ const CanvasControls = ({ children }) => { nodesConnectable: !isInteractive, elementsSelectable: !isInteractive, }); + handleSaveFlow(); }; return ( diff --git a/src/frontend/src/components/ui/popover.tsx b/src/frontend/src/components/ui/popover.tsx index 1a71de7e0..70d3e62b5 100644 --- a/src/frontend/src/components/ui/popover.tsx +++ b/src/frontend/src/components/ui/popover.tsx @@ -6,10 +6,10 @@ import { cn } from "../../utils/utils"; const Popover = PopoverPrimitive.Root; -const PopoverAnchor = PopoverPrimitive.Anchor; - const PopoverTrigger = PopoverPrimitive.Trigger; +const PopoverAnchor = PopoverPrimitive.Anchor; + const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef diff --git a/src/frontend/src/controllers/API/queries/flows/use-patch-update-flow.ts b/src/frontend/src/controllers/API/queries/flows/use-patch-update-flow.ts index ca66a32df..e818319c3 100644 --- a/src/frontend/src/controllers/API/queries/flows/use-patch-update-flow.ts +++ b/src/frontend/src/controllers/API/queries/flows/use-patch-update-flow.ts @@ -12,6 +12,7 @@ interface IPatchUpdateFlow { description: string; folder_id: string | null | undefined; endpoint_name: string | null | undefined; + locked: boolean | null | undefined; } export const usePatchUpdateFlow: useMutationFunctionType< @@ -27,6 +28,7 @@ export const usePatchUpdateFlow: useMutationFunctionType< description: payload.description, folder_id: payload.folder_id || null, endpoint_name: payload.endpoint_name || null, + locked: payload.locked || null, }); return response.data; diff --git a/src/frontend/src/hooks/flows/use-save-flow.ts b/src/frontend/src/hooks/flows/use-save-flow.ts index 4dca8d191..6ff7ac554 100644 --- a/src/frontend/src/hooks/flows/use-save-flow.ts +++ b/src/frontend/src/hooks/flows/use-save-flow.ts @@ -58,11 +58,26 @@ const useSaveFlow = () => { ); } - const { id, name, data, description, folder_id, endpoint_name } = - flow; + const { + id, + name, + data, + description, + folder_id, + endpoint_name, + locked, + } = flow; if (!currentSavedFlow?.data?.nodes.length || data!.nodes.length > 0) { mutate( - { id, name, data: data!, description, folder_id, endpoint_name }, + { + id, + name, + data: data!, + description, + folder_id, + endpoint_name, + locked, + }, { onSuccess: (updatedFlow) => { setSaveLoading(false); diff --git a/src/frontend/src/types/flow/index.ts b/src/frontend/src/types/flow/index.ts index cdfb7af00..db740eb44 100644 --- a/src/frontend/src/types/flow/index.ts +++ b/src/frontend/src/types/flow/index.ts @@ -30,6 +30,7 @@ export type FlowType = { icon_bg_color?: string; folder_id?: string; webhook?: boolean; + locked?: boolean | null; }; export type NodeType = { diff --git a/src/frontend/tests/extended/features/lock-flow.spec.ts b/src/frontend/tests/extended/features/lock-flow.spec.ts new file mode 100644 index 000000000..406f1dad2 --- /dev/null +++ b/src/frontend/tests/extended/features/lock-flow.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from "@playwright/test"; +import * as dotenv from "dotenv"; +import path from "path"; + +test( + "user must be able to lock a flow and it must be saved", + { tag: ["@release"] }, + async ({ page }) => { + test.skip( + !process?.env?.OPENAI_API_KEY, + "OPENAI_API_KEY required to run this test", + ); + + if (!process.env.CI) { + dotenv.config({ path: path.resolve(__dirname, "../../.env") }); + } + + await page.goto("/"); + await page.waitForSelector('[data-testid="mainpage_title"]', { + timeout: 30000, + }); + + await page.waitForSelector('[id="new-project-btn"]', { + timeout: 30000, + }); + + let modalCount = 0; + try { + const modalTitleElement = await page?.getByTestId("modal-title"); + if (modalTitleElement) { + modalCount = await modalTitleElement.count(); + } + } catch (error) { + modalCount = 0; + } + + while (modalCount === 0) { + await page.getByText("New Flow", { exact: true }).click(); + await page.waitForSelector('[data-testid="modal-title"]', { + timeout: 3000, + }); + modalCount = await page.getByTestId("modal-title")?.count(); + } + + await page.getByTestId("side_nav_options_all-templates").click(); + await page.getByRole("heading", { name: "Basic Prompting" }).click(); + + await page.waitForSelector('[data-testid="fit_view"]', { + timeout: 100000, + }); + + await page.getByTestId("lock_unlock").click(); + await page.waitForSelector('[data-testid="icon-Lock"]', { + timeout: 3000, + }); + expect(page.getByTestId("icon-Lock")).toBeVisible(); + + await page.getByTestId("icon-ChevronLeft").click(); + await page.waitForSelector('[data-testid="mainpage_title"]', { + timeout: 3000, + }); + + await page.getByTestId("list-card").first().click(); + await page.waitForSelector('[data-testid="fit_view"]', { + timeout: 100000, + }); + + await page.waitForSelector('[data-testid="icon-Lock"]', { + timeout: 3000, + }); + expect(page.getByTestId("icon-Lock")).toBeVisible(); + + await page.getByTestId("lock_unlock").click(); + await page.waitForSelector('[data-testid="icon-LockOpen"]', { + timeout: 3000, + }); + expect(page.getByTestId("icon-LockOpen")).toBeVisible(); + + await page.getByTestId("icon-ChevronLeft").click(); + await page.waitForSelector('[data-testid="mainpage_title"]', { + timeout: 3000, + }); + + await page.getByTestId("list-card").first().click(); + await page.waitForSelector('[data-testid="fit_view"]', { + timeout: 100000, + }); + + await page.waitForSelector('[data-testid="icon-LockOpen"]', { + timeout: 3000, + }); + expect(page.getByTestId("icon-LockOpen")).toBeVisible(); + }, +);