From 5a8fd170a2c74fd579171094e092f173afeb55ff Mon Sep 17 00:00:00 2001 From: Hamza Rashid Date: Sun, 31 May 2026 03:59:43 +0000 Subject: [PATCH] feat(collab): add frontend collaboration editing and edge-case hardening Introduce an opt-in beta collaboration mode on the flow canvas that emits local graph operation batches over the collab WebSocket, applies remote forward_ops without full-flow autosave, and shows active collaborators. Add operation diff/adapter helpers, collaboration-aware save flushing, and coverage for drag, delete, paste/import, undo/redo, metadata, and reload boundaries, including clearing stale undo history after remote ops. --- .../collaboration-edge-cases.test.ts | 127 +++++++++++ .../__tests__/flow-operation-adapter.test.ts | 145 +++++++++++++ .../flows/__tests__/use-autosave-flow.test.ts | 37 +++- .../use-flow-collaboration-editing.test.ts | 168 +++++++++++++++ .../__tests__/use-flow-collaboration.test.ts | 6 + .../flows/__tests__/use-save-flow.test.ts | 109 +++++++++- .../flows/__tests__/use-upload-flow.test.ts | 62 ++++++ .../flows/collaboration-operation-beta.ts | 22 ++ .../src/hooks/flows/flow-operation-adapter.ts | 199 ++++++++++++++++++ .../src/hooks/flows/flow-operation-diff.ts | 161 ++++++++++++++ .../src/hooks/flows/use-autosave-flow.ts | 3 +- .../flows/use-flow-collaboration-editing.ts | 182 ++++++++++++++++ src/frontend/src/hooks/flows/use-save-flow.ts | 38 +++- .../CollaborationBetaToggle/index.tsx | 54 +++++ .../__tests__/CollaborationPresence.test.tsx | 36 ++++ .../CollaborationPresence/index.tsx | 68 ++++++ .../components/PageComponent/index.tsx | 98 +++++++-- src/frontend/src/stores/flowStore.ts | 159 +++++++++++--- src/frontend/src/stores/flowsManagerStore.ts | 51 ++++- .../src/types/flow-operations/index.ts | 4 + src/frontend/src/types/zustand/flow/index.ts | 42 +++- .../src/types/zustand/flowsManager/index.ts | 1 + 22 files changed, 1689 insertions(+), 83 deletions(-) create mode 100644 src/frontend/src/hooks/flows/__tests__/collaboration-edge-cases.test.ts create mode 100644 src/frontend/src/hooks/flows/__tests__/flow-operation-adapter.test.ts create mode 100644 src/frontend/src/hooks/flows/__tests__/use-flow-collaboration-editing.test.ts create mode 100644 src/frontend/src/hooks/flows/__tests__/use-upload-flow.test.ts create mode 100644 src/frontend/src/hooks/flows/collaboration-operation-beta.ts create mode 100644 src/frontend/src/hooks/flows/flow-operation-adapter.ts create mode 100644 src/frontend/src/hooks/flows/flow-operation-diff.ts create mode 100644 src/frontend/src/hooks/flows/use-flow-collaboration-editing.ts create mode 100644 src/frontend/src/pages/FlowPage/components/CollaborationBetaToggle/index.tsx create mode 100644 src/frontend/src/pages/FlowPage/components/CollaborationPresence/__tests__/CollaborationPresence.test.tsx create mode 100644 src/frontend/src/pages/FlowPage/components/CollaborationPresence/index.tsx diff --git a/src/frontend/src/hooks/flows/__tests__/collaboration-edge-cases.test.ts b/src/frontend/src/hooks/flows/__tests__/collaboration-edge-cases.test.ts new file mode 100644 index 0000000000..8d3c081816 --- /dev/null +++ b/src/frontend/src/hooks/flows/__tests__/collaboration-edge-cases.test.ts @@ -0,0 +1,127 @@ +import useFlowStore from "@/stores/flowStore"; +import useFlowsManagerStore from "@/stores/flowsManagerStore"; +import type { AllNodeType, EdgeType } from "@/types/flow"; +import { applyRemoteFlowOperations } from "../flow-operation-adapter"; + +const nodeA = { + id: "a", + type: "genericNode", + position: { x: 0, y: 0 }, + data: { id: "a", type: "TextInput", node: {} }, +} as AllNodeType; + +const nodeB = { + id: "b", + type: "genericNode", + position: { x: 100, y: 0 }, + data: { id: "b", type: "TextOutput", node: {} }, +} as AllNodeType; + +const movedNodeA = { + ...nodeA, + position: { x: 25, y: 25 }, +} as AllNodeType; + +const edgeAb = { + id: "e-ab", + source: "a", + target: "b", +} as EdgeType; + +function seedFlow(flowId: string, emit = jest.fn()) { + useFlowsManagerStore.setState({ + currentFlowId: flowId, + currentFlow: { + id: flowId, + name: "Flow", + description: "", + data: { nodes: [nodeA, nodeB], edges: [edgeAb] }, + }, + }); + useFlowStore.setState({ + nodes: [nodeA, nodeB], + edges: [edgeAb], + currentFlow: { + id: flowId, + name: "Flow", + description: "", + data: { nodes: [nodeA, nodeB], edges: [edgeAb] }, + }, + collaborationOperationMode: true, + isApplyingRemoteOperations: false, + onCollaborationOperations: emit, + }); + return emit; +} + +describe("collaboration edge-case store wiring", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("cut emits one combined delete batch", () => { + const emit = seedFlow("cut-flow"); + + useFlowStore.getState().setLastCopiedSelection( + { + nodes: [nodeA], + edges: [], + }, + true, + ); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith([ + { type: "delete_nodes", ids: ["a"] }, + { type: "delete_edges", ids: ["e-ab"] }, + ]); + }); + + it("undo emits the resulting graph delta once", () => { + const emit = seedFlow("undo-flow"); + useFlowsManagerStore.getState().takeSnapshot(); + useFlowStore.setState({ + nodes: [movedNodeA, nodeB], + edges: [edgeAb], + }); + + useFlowsManagerStore.getState().undo(); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith([ + { type: "update_nodes", nodes: [expect.objectContaining({ id: "a" })] }, + ]); + }); + + it("redo emits the resulting graph delta once", () => { + const emit = seedFlow("redo-flow"); + useFlowsManagerStore.getState().takeSnapshot(); + useFlowStore.setState({ + nodes: [movedNodeA, nodeB], + edges: [edgeAb], + }); + useFlowsManagerStore.getState().undo(); + emit.mockClear(); + + useFlowsManagerStore.getState().redo(); + + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith([ + { type: "update_nodes", nodes: [expect.objectContaining({ id: "a" })] }, + ]); + }); + + it("remote operations clear stale undo history so undo does not resurrect remote deletes", () => { + const emit = seedFlow("remote-delete-flow"); + useFlowsManagerStore.getState().takeSnapshot(); + + applyRemoteFlowOperations([{ type: "delete_nodes", ids: ["a"] }]); + emit.mockClear(); + + useFlowsManagerStore.getState().undo(); + + expect(useFlowStore.getState().nodes.map((node) => node.id)).toEqual(["b"]); + expect(useFlowStore.getState().edges).toEqual([]); + expect(emit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/src/hooks/flows/__tests__/flow-operation-adapter.test.ts b/src/frontend/src/hooks/flows/__tests__/flow-operation-adapter.test.ts new file mode 100644 index 0000000000..cf32cbf693 --- /dev/null +++ b/src/frontend/src/hooks/flows/__tests__/flow-operation-adapter.test.ts @@ -0,0 +1,145 @@ +import useFlowStore from "@/stores/flowStore"; +import type { AllNodeType, EdgeType } from "@/types/flow"; + +import { + applyFlowOperationsLocally, + applyRemoteFlowOperations, + buildGraphDiffOperations, + buildUpdateMetadataOperation, +} from "../flow-operation-adapter"; + +const nodeA = { + id: "a", + type: "genericNode", + position: { x: 0, y: 0 }, + data: { id: "a", type: "TextInput", node: {} }, +} as AllNodeType; + +const nodeB = { + id: "b", + type: "genericNode", + position: { x: 100, y: 0 }, + data: { id: "b", type: "TextOutput", node: {} }, +} as AllNodeType; + +const edgeAb = { + id: "e-ab", + source: "a", + target: "b", +} as EdgeType; + +describe("flow-operation-adapter", () => { + beforeEach(() => { + useFlowStore.setState({ + nodes: [nodeA, nodeB], + edges: [edgeAb], + currentFlow: { + id: "flow-1", + name: "Flow", + description: "", + data: { nodes: [nodeA, nodeB], edges: [edgeAb] }, + }, + collaborationOperationMode: false, + isApplyingRemoteOperations: false, + onCollaborationOperations: undefined, + }); + }); + + it("buildGraphDiffOperations detects node updates and edge deletes", () => { + const updatedA = { + ...nodeA, + position: { x: 10, y: 10 }, + } as AllNodeType; + + const operations = buildGraphDiffOperations( + [nodeA, nodeB], + [edgeAb], + [updatedA, nodeB], + [], + ); + + expect(operations).toEqual( + expect.arrayContaining([ + { type: "update_nodes", nodes: [expect.objectContaining({ id: "a" })] }, + { type: "delete_edges", ids: ["e-ab"] }, + ]), + ); + }); + + it("buildGraphDiffOperations represents edge reconnect as delete plus add", () => { + const reconnectedEdge = { + ...edgeAb, + id: "e-reconnected", + source: "b", + target: "a", + } as EdgeType; + + const operations = buildGraphDiffOperations( + [nodeA, nodeB], + [edgeAb], + [nodeA, nodeB], + [reconnectedEdge], + ); + + expect(operations).toEqual([ + { type: "delete_edges", ids: ["e-ab"] }, + { type: "add_edges", edges: [reconnectedEdge] }, + ]); + }); + + it("buildUpdateMetadataOperation ignores graph collections and viewport", () => { + const operation = buildUpdateMetadataOperation( + { + nodes: [nodeA], + edges: [edgeAb], + viewport: { x: 0, y: 0, zoom: 1 }, + theme: "old", + removed: true, + }, + { + nodes: [], + edges: [], + viewport: { x: 10, y: 10, zoom: 2 }, + theme: "new", + }, + ); + + expect(operation).toEqual({ + type: "update_metadata", + fields: { theme: "new" }, + delete_keys: ["removed"], + }); + }); + + it("applyFlowOperationsLocally applies delete_nodes with incident edges", () => { + const result = applyFlowOperationsLocally( + [nodeA, nodeB], + [edgeAb], + [{ type: "delete_nodes", ids: ["a"] }], + ); + + expect(result.nodes.map((node) => node.id)).toEqual(["b"]); + expect(result.edges).toEqual([]); + }); + + it("applyRemoteFlowOperations updates the store without emitting collaboration ops", () => { + const emit = jest.fn(); + useFlowStore.setState({ + onCollaborationOperations: emit, + collaborationOperationMode: true, + }); + + applyRemoteFlowOperations([ + { + type: "update_nodes", + nodes: [{ ...nodeA, position: { x: 25, y: 25 } } as AllNodeType], + }, + ]); + + expect(useFlowStore.getState().nodes[0]?.position).toEqual({ + x: 25, + y: 25, + }); + expect(emit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/src/hooks/flows/__tests__/use-autosave-flow.test.ts b/src/frontend/src/hooks/flows/__tests__/use-autosave-flow.test.ts index a606d0270d..246f517fea 100644 --- a/src/frontend/src/hooks/flows/__tests__/use-autosave-flow.test.ts +++ b/src/frontend/src/hooks/flows/__tests__/use-autosave-flow.test.ts @@ -1,5 +1,7 @@ import { renderHook } from "@testing-library/react"; +import useFlowStore from "@/stores/flowStore"; import useFlowsManagerStore from "@/stores/flowsManagerStore"; +import type { FlowType } from "@/types/flow"; import { useDebounce } from "../../use-debounce"; import useAutoSaveFlow from "../use-autosave-flow"; import useSaveFlow from "../use-save-flow"; @@ -8,6 +10,7 @@ import useSaveFlow from "../use-save-flow"; jest.mock("../use-save-flow"); jest.mock("../../use-debounce"); jest.mock("@/stores/flowsManagerStore"); +jest.mock("@/stores/flowStore"); describe("useAutoSaveFlow", () => { const mockSaveFlow = jest.fn(); @@ -21,6 +24,11 @@ describe("useAutoSaveFlow", () => { mockDebouncedFn.mockImplementation(fn); return mockDebouncedFn; }); + (useFlowStore as unknown as { getState: jest.Mock }).getState = jest.fn( + () => ({ + collaborationOperationMode: false, + }), + ); }); it("should return a debounced autosave function", () => { @@ -54,12 +62,35 @@ describe("useAutoSaveFlow", () => { const { result } = renderHook(() => useAutoSaveFlow()); const autoSaveFlow = result.current; - const mockFlow = { id: "flow-1", name: "Test Flow" } as any; + const mockFlow = { id: "flow-1", name: "Test Flow" } as FlowType; autoSaveFlow(mockFlow); expect(mockSaveFlow).toHaveBeenCalledWith(mockFlow); }); + it("should not call saveFlow when collaboration operation mode is enabled", () => { + (useFlowsManagerStore as unknown as jest.Mock).mockImplementation( + (selector) => { + const state = { + autoSaving: true, + autoSavingInterval: 3000, + }; + return selector(state); + }, + ); + (useFlowStore as unknown as jest.Mock).mockImplementation(() => undefined); + (useFlowStore as unknown as { getState: jest.Mock }).getState = jest.fn( + () => ({ + collaborationOperationMode: true, + }), + ); + + const { result } = renderHook(() => useAutoSaveFlow()); + result.current({ id: "flow-1" } as never); + + expect(mockSaveFlow).not.toHaveBeenCalled(); + }); + it("should not call saveFlow when autoSaving is disabled", () => { (useFlowsManagerStore as unknown as jest.Mock).mockImplementation( (selector) => { @@ -74,7 +105,7 @@ describe("useAutoSaveFlow", () => { const { result } = renderHook(() => useAutoSaveFlow()); const autoSaveFlow = result.current; - const mockFlow = { id: "flow-1", name: "Test Flow" } as any; + const mockFlow = { id: "flow-1", name: "Test Flow" } as FlowType; autoSaveFlow(mockFlow); expect(mockSaveFlow).not.toHaveBeenCalled(); @@ -158,7 +189,7 @@ describe("useAutoSaveFlow", () => { ); const { result, rerender } = renderHook(() => useAutoSaveFlow()); - const mockFlow = { id: "flow-1", name: "Test Flow" } as any; + const mockFlow = { id: "flow-1", name: "Test Flow" } as FlowType; // AutoSaving enabled result.current(mockFlow); diff --git a/src/frontend/src/hooks/flows/__tests__/use-flow-collaboration-editing.test.ts b/src/frontend/src/hooks/flows/__tests__/use-flow-collaboration-editing.test.ts new file mode 100644 index 0000000000..724499203d --- /dev/null +++ b/src/frontend/src/hooks/flows/__tests__/use-flow-collaboration-editing.test.ts @@ -0,0 +1,168 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; + +import useFlowStore from "@/stores/flowStore"; + +import { + readCollaborationOperationBetaEnabled, + writeCollaborationOperationBetaEnabled, +} from "../collaboration-operation-beta"; +import { useFlowCollaborationEditing } from "../use-flow-collaboration-editing"; + +const mockSubmitOperations = jest.fn().mockResolvedValue({ + type: "operation.accepted", + revision: 1, +}); +const mockDisconnect = jest.fn(); +const mockGetFlow = jest.fn(); +const mockApplyFlowToCanvas = jest.fn(); +const mockClearUndoRedoHistory = jest.fn(); + +jest.mock("../use-flow-collaboration", () => ({ + useFlowCollaboration: jest.fn(() => ({ + status: "ready", + connectionId: "conn-1", + currentRevision: 0, + users: [], + isReady: true, + submitOperations: mockSubmitOperations, + disconnect: mockDisconnect, + })), +})); + +jest.mock("@/controllers/API/queries/flows/use-get-flow", () => ({ + useGetFlow: () => ({ mutateAsync: mockGetFlow }), +})); + +jest.mock("@/hooks/flows/use-apply-flow-to-canvas", () => ({ + __esModule: true, + default: () => mockApplyFlowToCanvas, +})); + +jest.mock("@/stores/flowsManagerStore", () => { + const state = { + currentFlowId: "flow-1", + flows: [], + setFlows: jest.fn(), + setCurrentFlow: jest.fn(), + clearUndoRedoHistory: mockClearUndoRedoHistory, + }; + const useFlowsManagerStore = (selector?: (value: typeof state) => unknown) => + selector ? selector(state) : state; + useFlowsManagerStore.getState = () => state; + return { __esModule: true, default: useFlowsManagerStore }; +}); + +describe("useFlowCollaborationEditing", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSubmitOperations.mockResolvedValue({ + type: "operation.accepted", + revision: 1, + }); + writeCollaborationOperationBetaEnabled(false); + useFlowStore.setState({ + collaborationOperationMode: false, + onCollaborationOperations: undefined, + flushCollaborationSave: undefined, + nodes: [], + edges: [], + currentFlow: { + id: "flow-1", + name: "Flow", + description: "", + data: { nodes: [], edges: [] }, + }, + }); + mockGetFlow.mockResolvedValue({ + id: "flow-1", + name: "Flow", + description: "", + data: { nodes: [], edges: [] }, + }); + }); + + it("does not enable collaboration mode when the beta toggle is off", async () => { + const { useFlowCollaboration } = jest.requireMock( + "../use-flow-collaboration", + ); + + renderHook(() => + useFlowCollaborationEditing({ + flowId: "flow-1", + }), + ); + + await act(async () => {}); + + expect(useFlowCollaboration).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + expect(useFlowStore.getState().collaborationOperationMode).toBe(false); + }); + + it("enables collaboration mode and reloads when the beta toggle is turned on", async () => { + const { result } = renderHook(() => + useFlowCollaborationEditing({ + flowId: "flow-1", + }), + ); + + await act(async () => { + await result.current.setBetaEnabled(true); + }); + + expect(readCollaborationOperationBetaEnabled()).toBe(true); + expect(mockGetFlow).toHaveBeenCalledWith({ id: "flow-1" }); + expect(mockApplyFlowToCanvas).toHaveBeenCalled(); + expect(useFlowStore.getState().collaborationOperationMode).toBe(true); + }); + + it("submits operations emitted from the flow store when collaboration mode is active", async () => { + writeCollaborationOperationBetaEnabled(true); + + renderHook(() => + useFlowCollaborationEditing({ + flowId: "flow-1", + }), + ); + + await act(async () => {}); + + await act(async () => { + useFlowStore + .getState() + .onCollaborationOperations?.([{ type: "delete_edges", ids: ["e1"] }]); + }); + + await waitFor(() => { + expect(mockSubmitOperations).toHaveBeenCalledWith([ + { type: "delete_edges", ids: ["e1"] }, + ]); + }); + }); + + it("reloads and drops pending operations when submit fails", async () => { + mockSubmitOperations.mockRejectedValueOnce( + new Error("Stale base revision"), + ); + writeCollaborationOperationBetaEnabled(true); + + renderHook(() => + useFlowCollaborationEditing({ + flowId: "flow-1", + }), + ); + + await act(async () => {}); + + await act(async () => { + useFlowStore + .getState() + .onCollaborationOperations?.([{ type: "delete_edges", ids: ["e1"] }]); + }); + + await waitFor(() => { + expect(mockGetFlow).toHaveBeenCalledWith({ id: "flow-1" }); + }); + }); +}); diff --git a/src/frontend/src/hooks/flows/__tests__/use-flow-collaboration.test.ts b/src/frontend/src/hooks/flows/__tests__/use-flow-collaboration.test.ts index 1fb355d6d0..3527e2f4ad 100644 --- a/src/frontend/src/hooks/flows/__tests__/use-flow-collaboration.test.ts +++ b/src/frontend/src/hooks/flows/__tests__/use-flow-collaboration.test.ts @@ -154,6 +154,12 @@ describe("useFlowCollaboration", () => { expect(instances).toHaveLength(0); }); + it("should not open a socket when enabled is false", async () => { + await mountHook({ flowId: "flow-1", enabled: false }); + + expect(instances).toHaveLength(0); + }); + it("should open the socket, send session.start, and become ready on session.ready", async () => { const { result } = await mountHook({ flowId: "flow-1" }); diff --git a/src/frontend/src/hooks/flows/__tests__/use-save-flow.test.ts b/src/frontend/src/hooks/flows/__tests__/use-save-flow.test.ts index 2f4dff08a2..c78ffb5267 100644 --- a/src/frontend/src/hooks/flows/__tests__/use-save-flow.test.ts +++ b/src/frontend/src/hooks/flows/__tests__/use-save-flow.test.ts @@ -1,4 +1,6 @@ import { renderHook } from "@testing-library/react"; +import type { FlowType } from "@/types/flow"; +import type { FlowOperation } from "@/types/flow-operations"; import useSaveFlow from "../use-save-flow"; const mockSetFlows = jest.fn(); @@ -8,8 +10,31 @@ const mockSetCurrentFlow = jest.fn(); const mockGetFlow = jest.fn(); const mockMutate = jest.fn(); -let flowStoreState: any; -let flowsManagerState: any; +type MockFlowStoreState = { + collaborationOperationMode: boolean; + flushCollaborationSave?: () => Promise; + onCollaborationOperations?: (operations: FlowOperation[]) => void; + currentFlow: FlowType | null; + nodes: FlowType["data"]["nodes"]; + edges: FlowType["data"]["edges"]; + reactFlowInstance: { + getViewport: jest.Mock; + } | null; + onFlowPage: boolean; + setCurrentFlow: typeof mockSetCurrentFlow; +}; + +type MockFlowsManagerState = { + currentFlow: FlowType | null; + flows: FlowType[]; + setFlows: typeof mockSetFlows; + setSaveLoading: typeof mockSetSaveLoading; +}; + +type StoreSelector = (state: T) => unknown; + +let flowStoreState: MockFlowStoreState; +let flowsManagerState: MockFlowsManagerState; jest.mock("@/controllers/API/queries/flows/use-get-flow", () => ({ useGetFlow: () => ({ mutate: mockGetFlow }), @@ -21,14 +46,16 @@ jest.mock("@/controllers/API/queries/flows/use-patch-update-flow", () => ({ jest.mock("@/stores/alertStore", () => ({ __esModule: true, - default: (selector: any) => + default: ( + selector: StoreSelector<{ setErrorData: typeof mockSetErrorData }>, + ) => selector({ setErrorData: mockSetErrorData, }), })); jest.mock("@/stores/flowStore", () => { - const useFlowStore = (selector: any) => + const useFlowStore = (selector?: StoreSelector) => selector ? selector(flowStoreState) : flowStoreState; useFlowStore.getState = () => flowStoreState; @@ -39,8 +66,9 @@ jest.mock("@/stores/flowStore", () => { }); jest.mock("@/stores/flowsManagerStore", () => { - const useFlowsManagerStore = (selector: any) => - selector ? selector(flowsManagerState) : flowsManagerState; + const useFlowsManagerStore = ( + selector?: StoreSelector, + ) => (selector ? selector(flowsManagerState) : flowsManagerState); useFlowsManagerStore.getState = () => flowsManagerState; return { @@ -65,9 +93,11 @@ describe("useSaveFlow", () => { folder_id: "folder-1", endpoint_name: "saved-flow", locked: false, - }; + } as FlowType; flowStoreState = { + collaborationOperationMode: false, + flushCollaborationSave: undefined, currentFlow: { ...savedFlow, data: { @@ -104,6 +134,68 @@ describe("useSaveFlow", () => { }); }); + it("flushes collaboration operations instead of PATCH when collaboration mode is active", async () => { + const flushCollaborationSave = jest.fn().mockResolvedValue(undefined); + flowStoreState = { + ...flowStoreState, + collaborationOperationMode: true, + flushCollaborationSave, + }; + + const { result } = renderHook(() => useSaveFlow()); + + await expect(result.current()).resolves.toBeUndefined(); + + expect(flushCollaborationSave).toHaveBeenCalledTimes(1); + expect(mockMutate).not.toHaveBeenCalled(); + expect(mockSetSaveLoading).toHaveBeenCalledWith(true); + expect(mockSetSaveLoading).toHaveBeenCalledWith(false); + }); + + it("emits flow.data metadata updates before flushing collaboration save", async () => { + const flushCollaborationSave = jest.fn().mockResolvedValue(undefined); + const onCollaborationOperations = jest.fn(); + flowsManagerState.currentFlow = { + ...flowsManagerState.currentFlow, + data: { + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + theme: "old", + stale_key: true, + }, + } as FlowType; + flowStoreState = { + ...flowStoreState, + collaborationOperationMode: true, + flushCollaborationSave, + onCollaborationOperations, + currentFlow: { + ...flowStoreState.currentFlow, + data: { + nodes: [], + edges: [], + viewport: { x: 10, y: 10, zoom: 2 }, + theme: "new", + }, + } as FlowType, + }; + + const { result } = renderHook(() => useSaveFlow()); + + await expect(result.current()).resolves.toBeUndefined(); + + expect(onCollaborationOperations).toHaveBeenCalledWith([ + { + type: "update_metadata", + fields: { theme: "new" }, + delete_keys: ["stale_key"], + }, + ]); + expect(flushCollaborationSave).toHaveBeenCalledTimes(1); + expect(mockMutate).not.toHaveBeenCalled(); + }); + it("persists empty-node flows instead of leaving the save promise pending", async () => { const { result } = renderHook(() => useSaveFlow()); @@ -142,7 +234,7 @@ describe("useSaveFlow", () => { endpoint_name: "saved-flow", locked: false, is_component: false, - }; + } as FlowType; flowStoreState = { currentFlow: null, @@ -151,6 +243,7 @@ describe("useSaveFlow", () => { reactFlowInstance: null, onFlowPage: false, setCurrentFlow: mockSetCurrentFlow, + collaborationOperationMode: false, }; flowsManagerState = { diff --git a/src/frontend/src/hooks/flows/__tests__/use-upload-flow.test.ts b/src/frontend/src/hooks/flows/__tests__/use-upload-flow.test.ts new file mode 100644 index 0000000000..79adfb954f --- /dev/null +++ b/src/frontend/src/hooks/flows/__tests__/use-upload-flow.test.ts @@ -0,0 +1,62 @@ +import { renderHook } from "@testing-library/react"; + +import useUploadFlow from "../use-upload-flow"; + +const mockGetObjectsFromFilelist = jest.fn(); +const mockProcessDataFromFlow = jest.fn(); +const mockAddFlow = jest.fn(); +const mockPaste = jest.fn(); + +jest.mock("@/helpers/get-objects-from-filelist", () => ({ + getObjectsFromFilelist: (...args: unknown[]) => + mockGetObjectsFromFilelist(...args), +})); + +jest.mock("@/utils/reactflowUtils", () => ({ + processDataFromFlow: (...args: unknown[]) => mockProcessDataFromFlow(...args), +})); + +jest.mock("../use-add-flow", () => ({ + __esModule: true, + default: () => mockAddFlow, +})); + +jest.mock("@/stores/flowStore", () => { + const useFlowStoreMock = ( + selector: (state: { paste: jest.Mock }) => unknown, + ) => selector({ paste: mockPaste }); + return { + __esModule: true, + default: useFlowStoreMock, + }; +}); + +describe("useUploadFlow", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("uses paste for canvas JSON drop/import so collaboration mode can emit add operations", async () => { + const flow = { + id: "flow-1", + name: "Imported Flow", + data: { + nodes: [{ id: "n1" }], + edges: [{ id: "e1", source: "n1", target: "n2" }], + }, + }; + mockGetObjectsFromFilelist.mockResolvedValue([flow]); + mockProcessDataFromFlow.mockResolvedValue(undefined); + const { result } = renderHook(() => useUploadFlow()); + const position = { x: 10, y: 20 }; + + await result.current({ + files: [new File(["{}"], "flow.json", { type: "application/json" })], + position, + }); + + expect(mockProcessDataFromFlow).toHaveBeenCalledWith(flow); + expect(mockPaste).toHaveBeenCalledWith(flow.data, position); + expect(mockAddFlow).not.toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/src/hooks/flows/collaboration-operation-beta.ts b/src/frontend/src/hooks/flows/collaboration-operation-beta.ts new file mode 100644 index 0000000000..9518cf78a6 --- /dev/null +++ b/src/frontend/src/hooks/flows/collaboration-operation-beta.ts @@ -0,0 +1,22 @@ +const COLLABORATION_OPERATION_BETA_STORAGE_KEY = + "langflow_collaboration_operation_beta"; + +export function readCollaborationOperationBetaEnabled(): boolean { + if (typeof window === "undefined") { + return false; + } + return ( + window.localStorage.getItem(COLLABORATION_OPERATION_BETA_STORAGE_KEY) === + "true" + ); +} + +export function writeCollaborationOperationBetaEnabled(enabled: boolean): void { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem( + COLLABORATION_OPERATION_BETA_STORAGE_KEY, + enabled ? "true" : "false", + ); +} diff --git a/src/frontend/src/hooks/flows/flow-operation-adapter.ts b/src/frontend/src/hooks/flows/flow-operation-adapter.ts new file mode 100644 index 0000000000..fa7e33a89f --- /dev/null +++ b/src/frontend/src/hooks/flows/flow-operation-adapter.ts @@ -0,0 +1,199 @@ +import { cloneDeep } from "lodash"; +import useFlowStore from "@/stores/flowStore"; +import useFlowsManagerStore from "@/stores/flowsManagerStore"; +import type { AllNodeType, EdgeType, FlowType } from "@/types/flow"; +import type { FlowOperation } from "@/types/flow-operations"; +import { cleanEdges } from "@/utils/reactflowUtils"; +import { getInputsAndOutputs } from "@/utils/storeUtils"; +import { coalesceDeleteIds } from "./flow-operation-diff"; + +export type { FlowMutationOptions } from "@/types/flow-operations"; +export { + buildGraphDiffOperations, + buildUpdateMetadataOperation, + buildUpdateNodesOperation, +} from "./flow-operation-diff"; + +function applyDeleteNodes( + nodes: AllNodeType[], + edges: EdgeType[], + ids: string[], +): { nodes: AllNodeType[]; edges: EdgeType[] } { + const nodeIds = new Set(coalesceDeleteIds(ids)); + const nextNodes = nodes.filter((node) => !nodeIds.has(node.id)); + const nextEdges = edges.filter( + (edge) => !nodeIds.has(edge.source) && !nodeIds.has(edge.target), + ); + return { nodes: nextNodes, edges: nextEdges }; +} + +function applyDeleteEdges(edges: EdgeType[], ids: string[]): EdgeType[] { + const edgeIds = new Set(coalesceDeleteIds(ids)); + return edges.filter((edge) => !edgeIds.has(edge.id)); +} + +export function applyFlowOperationsLocally( + nodes: AllNodeType[], + edges: EdgeType[], + operations: FlowOperation[], +): { nodes: AllNodeType[]; edges: EdgeType[] } { + let nextNodes = cloneDeep(nodes); + let nextEdges = cloneDeep(edges); + + for (const operation of operations) { + switch (operation.type) { + case "add_nodes": { + const existingIds = new Set(nextNodes.map((node) => node.id)); + for (const node of operation.nodes) { + if (!existingIds.has(node.id)) { + nextNodes.push(cloneDeep(node)); + existingIds.add(node.id); + } + } + break; + } + case "update_nodes": { + const nodeMap = new Map(nextNodes.map((node) => [node.id, node])); + for (const node of operation.nodes) { + if (nodeMap.has(node.id)) { + nodeMap.set(node.id, cloneDeep(node)); + } + } + nextNodes = Array.from(nodeMap.values()); + break; + } + case "delete_nodes": { + const deleted = applyDeleteNodes(nextNodes, nextEdges, operation.ids); + nextNodes = deleted.nodes; + nextEdges = deleted.edges; + break; + } + case "add_edges": { + const existingIds = new Set(nextEdges.map((edge) => edge.id)); + for (const edge of operation.edges) { + if (!existingIds.has(edge.id)) { + nextEdges.push(cloneDeep(edge)); + existingIds.add(edge.id); + } + } + break; + } + case "delete_edges": { + nextEdges = applyDeleteEdges(nextEdges, operation.ids); + break; + } + case "update_metadata": + break; + default: + break; + } + } + + const cleaned = cleanEdges(nextNodes, nextEdges); + return { nodes: nextNodes, edges: cleaned.edges }; +} + +export function applyRemoteFlowOperations(operations: FlowOperation[]): void { + const store = useFlowStore.getState(); + const metadataOps = operations.filter( + (operation) => operation.type === "update_metadata", + ); + const graphOps = operations.filter( + (operation) => operation.type !== "update_metadata", + ); + + const { nodes, edges } = applyFlowOperationsLocally( + store.nodes, + store.edges, + graphOps, + ); + + const { inputs, outputs } = getInputsAndOutputs(nodes); + useFlowStore.setState({ isApplyingRemoteOperations: true }); + try { + useFlowStore.setState({ + nodes, + edges, + flowState: undefined, + inputs, + outputs, + hasIO: inputs.length > 0 || outputs.length > 0, + }); + useFlowStore.getState().updateCurrentFlow({ nodes, edges }); + + const currentFlow = useFlowStore.getState().currentFlow; + if (metadataOps.length > 0 && currentFlow?.data) { + const nextData = { ...currentFlow.data }; + for (const operation of metadataOps) { + if (operation.type !== "update_metadata") { + continue; + } + for (const [key, value] of Object.entries(operation.fields)) { + if (key === "nodes" || key === "edges") { + continue; + } + nextData[key] = value; + } + for (const key of operation.delete_keys ?? []) { + if (key === "nodes" || key === "edges") { + continue; + } + delete nextData[key]; + } + } + useFlowStore.getState().setCurrentFlow({ + ...currentFlow, + data: { + ...nextData, + nodes, + edges, + }, + }); + } + } finally { + useFlowStore.setState({ isApplyingRemoteOperations: false }); + } + useFlowsManagerStore.getState().clearUndoRedoHistory?.(); +} + +export function syncSavedFlowStateFromCanvas(): void { + const flowStore = useFlowStore.getState(); + const currentFlow = flowStore.currentFlow; + if (!currentFlow) { + return; + } + + const viewport = flowStore.reactFlowInstance?.getViewport() ?? + currentFlow.data?.viewport ?? { + x: 0, + y: 0, + zoom: 1, + }; + + const updatedFlow: FlowType = { + ...currentFlow, + data: { + ...currentFlow.data, + nodes: flowStore.nodes, + edges: flowStore.edges, + viewport, + }, + }; + + flowStore.setCurrentFlow(updatedFlow); + + const flows = useFlowsManagerStore.getState().flows; + if (!flows) { + return; + } + + useFlowsManagerStore + .getState() + .setFlows( + flows.map((flow) => (flow.id === updatedFlow.id ? updatedFlow : flow)), + ); + + if (flowStore.onFlowPage) { + useFlowsManagerStore.getState().setCurrentFlow(updatedFlow); + } +} diff --git a/src/frontend/src/hooks/flows/flow-operation-diff.ts b/src/frontend/src/hooks/flows/flow-operation-diff.ts new file mode 100644 index 0000000000..e5a252a5d0 --- /dev/null +++ b/src/frontend/src/hooks/flows/flow-operation-diff.ts @@ -0,0 +1,161 @@ +import { cloneDeep, isEqual } from "lodash"; +import type { AllNodeType, EdgeType } from "@/types/flow"; +import type { FlowOperation, UpdateMetadataOp } from "@/types/flow-operations"; + +export function coalesceDeleteIds(ids: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const id of ids) { + if (!seen.has(id)) { + seen.add(id); + result.push(id); + } + } + return result; +} + +function nodeSnapshot(node: AllNodeType): AllNodeType { + const { selected: _selected, ...rest } = node; + return rest as AllNodeType; +} + +function edgeSnapshot(edge: EdgeType): EdgeType { + const { selected: _selected, ...rest } = edge; + return rest as EdgeType; +} + +function nodesEqual(a: AllNodeType, b: AllNodeType): boolean { + return isEqual(nodeSnapshot(a), nodeSnapshot(b)); +} + +function edgesEqual(a: EdgeType, b: EdgeType): boolean { + return isEqual(edgeSnapshot(a), edgeSnapshot(b)); +} + +export function buildGraphDiffOperations( + prevNodes: AllNodeType[], + prevEdges: EdgeType[], + nextNodes: AllNodeType[], + nextEdges: EdgeType[], +): FlowOperation[] { + const operations: FlowOperation[] = []; + + const prevNodeMap = new Map(prevNodes.map((node) => [node.id, node])); + const nextNodeMap = new Map(nextNodes.map((node) => [node.id, node])); + + const addedNodes: AllNodeType[] = []; + const updatedNodes: AllNodeType[] = []; + const deletedNodeIds: string[] = []; + + for (const node of nextNodes) { + const previous = prevNodeMap.get(node.id); + if (!previous) { + addedNodes.push(cloneDeep(node)); + continue; + } + if (!nodesEqual(previous, node)) { + updatedNodes.push(cloneDeep(node)); + } + } + + for (const node of prevNodes) { + if (!nextNodeMap.has(node.id)) { + deletedNodeIds.push(node.id); + } + } + + if (addedNodes.length > 0) { + operations.push({ type: "add_nodes", nodes: addedNodes }); + } + if (updatedNodes.length > 0) { + operations.push({ type: "update_nodes", nodes: updatedNodes }); + } + if (deletedNodeIds.length > 0) { + operations.push({ + type: "delete_nodes", + ids: coalesceDeleteIds(deletedNodeIds), + }); + } + + const prevEdgeMap = new Map(prevEdges.map((edge) => [edge.id, edge])); + const nextEdgeMap = new Map(nextEdges.map((edge) => [edge.id, edge])); + + const addedEdges: EdgeType[] = []; + const deletedEdgeIds: string[] = []; + + for (const edge of nextEdges) { + const previous = prevEdgeMap.get(edge.id); + if (!previous) { + addedEdges.push(cloneDeep(edge)); + continue; + } + if (!edgesEqual(previous, edge)) { + deletedEdgeIds.push(edge.id); + addedEdges.push(cloneDeep(edge)); + } + } + + for (const edge of prevEdges) { + if (!nextEdgeMap.has(edge.id)) { + deletedEdgeIds.push(edge.id); + } + } + + if (deletedEdgeIds.length > 0) { + operations.push({ + type: "delete_edges", + ids: coalesceDeleteIds(deletedEdgeIds), + }); + } + if (addedEdges.length > 0) { + operations.push({ type: "add_edges", edges: addedEdges }); + } + + return operations; +} + +export function buildUpdateNodesOperation(nodes: AllNodeType[]): FlowOperation { + return { + type: "update_nodes", + nodes: nodes.map((node) => cloneDeep(node)), + }; +} + +export function buildUpdateMetadataOperation( + previousData: Record | null | undefined, + nextData: Record | null | undefined, +): UpdateMetadataOp | null { + const previous = previousData ?? {}; + const next = nextData ?? {}; + const fields: Record = {}; + const deleteKeys: string[] = []; + const ignoredKeys = new Set(["nodes", "edges", "viewport"]); + + for (const [key, value] of Object.entries(next)) { + if (ignoredKeys.has(key)) { + continue; + } + if (!isEqual(previous[key], value)) { + fields[key] = cloneDeep(value); + } + } + + for (const key of Object.keys(previous)) { + if (ignoredKeys.has(key)) { + continue; + } + if (!(key in next)) { + deleteKeys.push(key); + } + } + + if (Object.keys(fields).length === 0 && deleteKeys.length === 0) { + return null; + } + + return { + type: "update_metadata", + fields, + delete_keys: coalesceDeleteIds(deleteKeys), + }; +} diff --git a/src/frontend/src/hooks/flows/use-autosave-flow.ts b/src/frontend/src/hooks/flows/use-autosave-flow.ts index cd52c6dd0c..c1ce7273fd 100644 --- a/src/frontend/src/hooks/flows/use-autosave-flow.ts +++ b/src/frontend/src/hooks/flows/use-autosave-flow.ts @@ -1,3 +1,4 @@ +import useFlowStore from "@/stores/flowStore"; import useFlowsManagerStore from "@/stores/flowsManagerStore"; import type { FlowType } from "@/types/flow"; import { useDebounce } from "../use-debounce"; @@ -11,7 +12,7 @@ const useAutoSaveFlow = () => { ); const autoSaveFlow = useDebounce((flow?: FlowType) => { - if (autoSaving) { + if (autoSaving && !useFlowStore.getState().collaborationOperationMode) { saveFlow(flow); } }, autoSavingInterval); diff --git a/src/frontend/src/hooks/flows/use-flow-collaboration-editing.ts b/src/frontend/src/hooks/flows/use-flow-collaboration-editing.ts new file mode 100644 index 0000000000..c79f89e518 --- /dev/null +++ b/src/frontend/src/hooks/flows/use-flow-collaboration-editing.ts @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useGetFlow } from "@/controllers/API/queries/flows/use-get-flow"; +import { + readCollaborationOperationBetaEnabled, + writeCollaborationOperationBetaEnabled, +} from "@/hooks/flows/collaboration-operation-beta"; +import { + applyRemoteFlowOperations, + syncSavedFlowStateFromCanvas, +} from "@/hooks/flows/flow-operation-adapter"; +import useApplyFlowToCanvas from "@/hooks/flows/use-apply-flow-to-canvas"; +import { useFlowCollaboration } from "@/hooks/flows/use-flow-collaboration"; +import useFlowStore from "@/stores/flowStore"; +import useFlowsManagerStore from "@/stores/flowsManagerStore"; +import type { CollaborationPresenceUser } from "@/types/flow-collaboration"; +import type { FlowOperation } from "@/types/flow-operations"; + +type UseFlowCollaborationEditingOptions = { + flowId: string | undefined; +}; + +export type UseFlowCollaborationEditingReturn = { + betaEnabled: boolean; + setBetaEnabled: (enabled: boolean) => Promise; + users: CollaborationPresenceUser[]; + isCollaborationReady: boolean; + collaborationStatus: ReturnType["status"]; +}; + +export function useFlowCollaborationEditing({ + flowId, +}: UseFlowCollaborationEditingOptions): UseFlowCollaborationEditingReturn { + const [betaEnabled, setBetaEnabledState] = useState( + readCollaborationOperationBetaEnabled, + ); + const applyFlowToCanvas = useApplyFlowToCanvas(); + const { mutateAsync: getFlow } = useGetFlow(); + + const submitChainRef = useRef>(Promise.resolve()); + const queuedOperationsRef = useRef([]); + const collaborationReadyRef = useRef(false); + const reloadingRef = useRef(false); + + const reloadFlowFromServer = useCallback(async () => { + if (!flowId) { + return; + } + queuedOperationsRef.current = []; + submitChainRef.current = Promise.resolve(); + reloadingRef.current = true; + useFlowStore.setState({ isApplyingRemoteOperations: true }); + try { + const response = await getFlow({ id: flowId }); + if (useFlowsManagerStore.getState().currentFlowId !== flowId) { + return; + } + applyFlowToCanvas(response); + syncSavedFlowStateFromCanvas(); + useFlowsManagerStore.getState().clearUndoRedoHistory?.(flowId); + } finally { + reloadingRef.current = false; + useFlowStore.setState({ isApplyingRemoteOperations: false }); + } + }, [applyFlowToCanvas, flowId, getFlow]); + + const submitOperationsRef = useRef< + ((operations: FlowOperation[]) => Promise) | null + >(null); + + const drainQueue = useCallback(async () => { + if ( + !collaborationReadyRef.current || + reloadingRef.current || + queuedOperationsRef.current.length === 0 + ) { + return; + } + + const submit = submitOperationsRef.current; + if (!submit) { + return; + } + + const batch = queuedOperationsRef.current.splice( + 0, + queuedOperationsRef.current.length, + ); + try { + await submit(batch); + } catch { + queuedOperationsRef.current = []; + await reloadFlowFromServer(); + } + }, [reloadFlowFromServer]); + + const enqueueOperations = useCallback( + (operations: FlowOperation[]) => { + if (!operations.length || reloadingRef.current) { + return; + } + queuedOperationsRef.current.push(...operations); + if (!collaborationReadyRef.current) { + return; + } + submitChainRef.current = submitChainRef.current + .catch(() => {}) + .then(drainQueue); + }, + [drainQueue], + ); + + const flushCollaborationSave = useCallback(async () => { + await submitChainRef.current; + if (queuedOperationsRef.current.length > 0) { + if (!collaborationReadyRef.current) { + throw new Error("Collaboration session is not ready"); + } + await drainQueue(); + } + syncSavedFlowStateFromCanvas(); + }, [drainQueue]); + + const collaboration = useFlowCollaboration({ + flowId, + enabled: betaEnabled && Boolean(flowId), + onRemoteOperation: (message) => { + applyRemoteFlowOperations(message.forward_ops); + }, + onReloadRequired: () => { + queuedOperationsRef.current = []; + submitChainRef.current = Promise.resolve(); + void reloadFlowFromServer(); + }, + }); + + submitOperationsRef.current = collaboration.submitOperations; + collaborationReadyRef.current = collaboration.isReady; + + useEffect(() => { + if (!collaboration.isReady || queuedOperationsRef.current.length === 0) { + return; + } + submitChainRef.current = submitChainRef.current + .catch(() => {}) + .then(drainQueue); + }, [collaboration.isReady, drainQueue]); + + useEffect(() => { + useFlowStore.setState({ + collaborationOperationMode: betaEnabled, + onCollaborationOperations: betaEnabled ? enqueueOperations : undefined, + flushCollaborationSave: betaEnabled ? flushCollaborationSave : undefined, + }); + + return () => { + useFlowStore.setState({ + collaborationOperationMode: false, + onCollaborationOperations: undefined, + flushCollaborationSave: undefined, + }); + }; + }, [betaEnabled, enqueueOperations, flushCollaborationSave]); + + const setBetaEnabled = useCallback( + async (enabled: boolean) => { + writeCollaborationOperationBetaEnabled(enabled); + setBetaEnabledState(enabled); + if (flowId) { + await reloadFlowFromServer(); + } + }, + [flowId, reloadFlowFromServer], + ); + + return { + betaEnabled, + setBetaEnabled, + users: collaboration.users, + isCollaborationReady: collaboration.isReady, + collaborationStatus: collaboration.status, + }; +} diff --git a/src/frontend/src/hooks/flows/use-save-flow.ts b/src/frontend/src/hooks/flows/use-save-flow.ts index a9e0fc038a..d371e58e88 100644 --- a/src/frontend/src/hooks/flows/use-save-flow.ts +++ b/src/frontend/src/hooks/flows/use-save-flow.ts @@ -2,6 +2,8 @@ import type { ReactFlowJsonObject } from "@xyflow/react"; import { useTranslation } from "react-i18next"; import { useGetFlow } from "@/controllers/API/queries/flows/use-get-flow"; import { usePatchUpdateFlow } from "@/controllers/API/queries/flows/use-patch-update-flow"; +import { syncSavedFlowStateFromCanvas } from "@/hooks/flows/flow-operation-adapter"; +import { buildUpdateMetadataOperation } from "@/hooks/flows/flow-operation-diff"; import useAlertStore from "@/stores/alertStore"; import useFlowStore from "@/stores/flowStore"; import useFlowsManagerStore from "@/stores/flowsManagerStore"; @@ -19,8 +21,42 @@ const useSaveFlow = () => { const { mutate } = usePatchUpdateFlow(); const saveFlow = async (flow?: FlowType): Promise => { - const currentFlow = useFlowStore.getState().currentFlow; + const flowStore = useFlowStore.getState(); + const currentFlow = flowStore.currentFlow; const currentSavedFlow = useFlowsManagerStore.getState().currentFlow; + + if (flowStore.collaborationOperationMode) { + setSaveLoading(true); + try { + const flowToSave = flow ?? currentFlow; + const metadataOperation = buildUpdateMetadataOperation( + currentSavedFlow?.data as Record | null | undefined, + flowToSave?.data as Record | null | undefined, + ); + if (metadataOperation) { + flowStore.onCollaborationOperations?.([metadataOperation]); + } + if (flowStore.flushCollaborationSave) { + await flowStore.flushCollaborationSave(); + } else { + syncSavedFlowStateFromCanvas(); + } + setSaveLoading(false); + return; + } catch (error) { + setSaveLoading(false); + const detail = + error instanceof Error + ? error.message + : "Unknown collaboration save error"; + setErrorData({ + title: t("errors.failedToSaveFlow"), + list: [detail], + }); + throw error; + } + } + if ( customStringify(flow || currentFlow) !== customStringify(currentSavedFlow) ) { diff --git a/src/frontend/src/pages/FlowPage/components/CollaborationBetaToggle/index.tsx b/src/frontend/src/pages/FlowPage/components/CollaborationBetaToggle/index.tsx new file mode 100644 index 0000000000..c652061325 --- /dev/null +++ b/src/frontend/src/pages/FlowPage/components/CollaborationBetaToggle/index.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from "react-i18next"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/utils/utils"; + +type CollaborationBetaToggleProps = { + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + disabled?: boolean; + className?: string; +}; + +export default function CollaborationBetaToggle({ + enabled, + onEnabledChange, + disabled = false, + className, +}: CollaborationBetaToggleProps): JSX.Element { + const { t } = useTranslation(); + + return ( +
+ +
+ +

+ {t("flow.collaboration.betaToggleDescription", { + defaultValue: + "Uses realtime operation batches instead of full-flow autosave. Turning this on reloads the flow from the server.", + })} +

+
+
+ ); +} diff --git a/src/frontend/src/pages/FlowPage/components/CollaborationPresence/__tests__/CollaborationPresence.test.tsx b/src/frontend/src/pages/FlowPage/components/CollaborationPresence/__tests__/CollaborationPresence.test.tsx new file mode 100644 index 0000000000..bc0dfc243f --- /dev/null +++ b/src/frontend/src/pages/FlowPage/components/CollaborationPresence/__tests__/CollaborationPresence.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; + +import CollaborationPresence from "../index"; + +describe("CollaborationPresence", () => { + it("renders other collaborators and hides the current user", () => { + render( + , + ); + + expect(screen.getByTestId("collaboration-presence")).toBeInTheDocument(); + expect( + screen.getByTestId("collaboration-presence-user-user-2"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("collaboration-presence-user-user-1"), + ).not.toBeInTheDocument(); + }); + + it("renders nothing when only the current user is present", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/frontend/src/pages/FlowPage/components/CollaborationPresence/index.tsx b/src/frontend/src/pages/FlowPage/components/CollaborationPresence/index.tsx new file mode 100644 index 0000000000..64033a3daf --- /dev/null +++ b/src/frontend/src/pages/FlowPage/components/CollaborationPresence/index.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from "react-i18next"; +import { BASE_URL_API } from "@/customization/config-constants"; +import type { CollaborationPresenceUser } from "@/types/flow-collaboration"; +import { cn } from "@/utils/utils"; + +type CollaborationPresenceProps = { + users: CollaborationPresenceUser[]; + currentUserId?: string | null; + className?: string; +}; + +function profileImageUrl(profileImage?: string | null): string { + return `${BASE_URL_API}files/profile_pictures/${ + profileImage ?? "Space/046-rocket.svg" + }`; +} + +export default function CollaborationPresence({ + users, + currentUserId, + className, +}: CollaborationPresenceProps): JSX.Element | null { + const { t } = useTranslation(); + + const collaborators = users.filter((user) => user.user_id !== currentUserId); + + if (collaborators.length === 0) { + return null; + } + + const visibleUsers = collaborators.slice(0, 5); + const overflowCount = collaborators.length - visibleUsers.length; + + return ( +
+ + {t("flow.collaboration.editing", { defaultValue: "Editing" })} + +
+ {visibleUsers.map((user) => ( + {user.username} + ))} + {overflowCount > 0 ? ( + + +{overflowCount} + + ) : null} +
+
+ ); +} diff --git a/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx b/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx index aae798d7ba..d82fe36557 100644 --- a/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx +++ b/src/frontend/src/pages/FlowPage/components/PageComponent/index.tsx @@ -32,13 +32,19 @@ import { getURL } from "@/controllers/API/helpers/constants"; import { useGetBuildsQuery } from "@/controllers/API/queries/_builds"; import CustomLoader from "@/customization/components/custom-loader"; import { track } from "@/customization/utils/analytics"; +import { + buildGraphDiffOperations, + buildUpdateNodesOperation, +} from "@/hooks/flows/flow-operation-diff"; import useApplyFlowToCanvas from "@/hooks/flows/use-apply-flow-to-canvas"; import useAutoSaveFlow from "@/hooks/flows/use-autosave-flow"; +import { useFlowCollaborationEditing } from "@/hooks/flows/use-flow-collaboration-editing"; import { useFlowEvents } from "@/hooks/flows/use-flow-events"; import useUploadFlow from "@/hooks/flows/use-upload-flow"; import { useAddComponent } from "@/hooks/use-add-component"; import InspectionPanel from "@/pages/FlowPage/components/InspectionPanel"; import useAssistantManagerStore from "@/stores/assistantManagerStore"; +import useAuthStore from "@/stores/authStore"; import useFlowBuilderWelcomeStore from "@/stores/flowBuilderWelcomeStore"; import { nodeColorsName } from "@/utils/styleUtils"; import { isSupportedNodeTypes } from "@/utils/utils"; @@ -66,6 +72,8 @@ import { validateSelection, } from "../../../../utils/reactflowUtils"; import { edgeTypes, nodeTypes } from "../../consts"; +import CollaborationBetaToggle from "../CollaborationBetaToggle"; +import CollaborationPresence from "../CollaborationPresence"; import ConnectionLineComponent from "../ConnectionLineComponent"; import FlowBuildingComponent from "../flowBuildingComponent"; import SelectionMenu from "../SelectionMenuComponent"; @@ -152,6 +160,21 @@ export default function Page({ const [lastSelection, setLastSelection] = useState(null); const currentFlowId = useFlowsManagerStore((state) => state.currentFlowId); + const userData = useAuthStore((state) => state.userData); + const collaborationOperationMode = useFlowStore( + (state) => state.collaborationOperationMode, + ); + const onCollaborationOperations = useFlowStore( + (state) => state.onCollaborationOperations, + ); + + const { + betaEnabled: collaborationBetaEnabled, + setBetaEnabled: setCollaborationBetaEnabled, + users: collaborationUsers, + } = useFlowCollaborationEditing({ + flowId: currentFlowId || undefined, + }); const { isAgentWorking, events, lastSettledAt, clearEvents } = useFlowEvents( currentFlowId || undefined, @@ -468,8 +491,31 @@ export default function Page({ track("Component Deleted", { componentType: n.data.type }); }); } - deleteNode(lastSelection.nodes.map((node) => node.id)); - deleteEdge(lastSelection.edges.map((edge) => edge.id)); + if (collaborationOperationMode && onCollaborationOperations) { + const prevNodes = useFlowStore.getState().nodes; + const prevEdges = useFlowStore.getState().edges; + deleteNode( + lastSelection.nodes.map((node) => node.id), + { skipCollaborationEmit: true }, + ); + deleteEdge( + lastSelection.edges.map((edge) => edge.id), + { skipCollaborationEmit: true }, + ); + const nextState = useFlowStore.getState(); + const operations = buildGraphDiffOperations( + prevNodes, + prevEdges, + nextState.nodes, + nextState.edges, + ); + if (operations.length > 0) { + onCollaborationOperations(operations); + } + } else { + deleteNode(lastSelection.nodes.map((node) => node.id)); + deleteEdge(lastSelection.edges.map((edge) => edge.id)); + } } } @@ -557,20 +603,28 @@ export default function Page({ const onNodeDragStop: OnNodeDrag = useCallback( (_, node) => { - // 👇 make moving the canvas undoable - autoSaveFlow(); + if (collaborationOperationMode && onCollaborationOperations) { + const movedNodes = nodes.filter( + (canvasNode) => canvasNode.selected || canvasNode.id === node.id, + ); + if (movedNodes.length > 0) { + onCollaborationOperations([buildUpdateNodesOperation(movedNodes)]); + } + } else { + autoSaveFlow(); + } updateCurrentFlow({ nodes }); setPositionDictionary({}); setIsDragging(false); setHelperLines({}); }, [ - takeSnapshot, autoSaveFlow, + collaborationOperationMode, nodes, - edges, - reactFlowInstance, + onCollaborationOperations, setPositionDictionary, + updateCurrentFlow, ], ); @@ -863,19 +917,6 @@ export default function Page({ ? (nodes.find((n) => n.id === selectedNodeId) as AllNodeType) : null; - // Determine if InspectionPanel should be visible - const showInspectionPanel = inspectionPanelVisible && !!selectedNode; - - // Handler to close the inspection panel by deselecting all nodes - const handleCloseInspectionPanel = useCallback(() => { - setNodes((nds) => - nds.map((node) => ({ - ...node, - selected: false, - })), - ); - }, [setNodes]); - useEffect(() => { if (inspectionPanelVisible) { setSelectionMenuVisible(false); @@ -894,6 +935,23 @@ export default function Page({ isAgentWorking={isAgentWorking} /> {!isPreviewActive && } + {!isPreviewActive && ( + { + void setCollaborationBetaEnabled(enabled); + }} + /> + )} + {!isPreviewActive && collaborationBetaEnabled ? ( + + ) : null} {inspectionPanelVisible && ( )} diff --git a/src/frontend/src/stores/flowStore.ts b/src/frontend/src/stores/flowStore.ts index 80ee99f487..c098b86f32 100644 --- a/src/frontend/src/stores/flowStore.ts +++ b/src/frontend/src/stores/flowStore.ts @@ -19,6 +19,8 @@ import { trackDataLoaded, trackFlowBuild, } from "@/customization/utils/analytics"; +import type { FlowMutationOptions } from "@/hooks/flows/flow-operation-adapter"; +import { buildGraphDiffOperations } from "@/hooks/flows/flow-operation-diff"; import { brokenEdgeMessage } from "@/utils/utils"; import { BuildStatus, EventDeliveryType } from "../constants/enums"; import i18n from "../i18n"; @@ -31,6 +33,7 @@ import type { sourceHandleType, targetHandleType, } from "../types/flow"; +import type { FlowOperation } from "../types/flow-operations"; import type { ComponentsToUpdateType, FlowStoreType, @@ -136,6 +139,10 @@ const useFlowStore = create((set, get) => ({ } }, autoSaveFlow: undefined, + collaborationOperationMode: false, + isApplyingRemoteOperations: false, + onCollaborationOperations: undefined, + flushCollaborationSave: undefined, componentsToUpdate: [], setComponentsToUpdate: (change) => { const newChange = @@ -408,7 +415,9 @@ const useFlowStore = create((set, get) => ({ edges: applyEdgeChanges(changes, get().edges), }); }, - setNodes: (change) => { + setNodes: (change, options?: FlowMutationOptions) => { + const prevNodes = get().nodes; + const prevEdges = get().edges; const newChange = typeof change === "function" ? change(get().nodes) : change; const { edges: newEdges } = cleanEdges(newChange, get().edges); @@ -423,11 +432,28 @@ const useFlowStore = create((set, get) => ({ hasIO: inputs.length > 0 || outputs.length > 0, }); get().updateCurrentFlow({ nodes: newChange, edges: newEdges }); - if (get().autoSaveFlow) { + if ( + get().collaborationOperationMode && + !get().isApplyingRemoteOperations && + !options?.skipCollaborationEmit + ) { + const operations = buildGraphDiffOperations( + prevNodes, + prevEdges, + newChange, + newEdges, + ); + if (operations.length > 0) { + get().onCollaborationOperations?.(operations); + } + } + if (get().autoSaveFlow && !get().collaborationOperationMode) { get().autoSaveFlow!(); } }, - setEdges: (change) => { + setEdges: (change, options?: FlowMutationOptions) => { + const prevNodes = get().nodes; + const prevEdges = get().edges; const newChange = typeof change === "function" ? change(get().edges) : change; set({ @@ -435,7 +461,22 @@ const useFlowStore = create((set, get) => ({ flowState: undefined, }); get().updateCurrentFlow({ edges: newChange }); - if (get().autoSaveFlow) { + if ( + get().collaborationOperationMode && + !get().isApplyingRemoteOperations && + !options?.skipCollaborationEmit + ) { + const operations = buildGraphDiffOperations( + prevNodes, + prevEdges, + get().nodes, + newChange, + ); + if (operations.length > 0) { + get().onCollaborationOperations?.(operations); + } + } + if (get().autoSaveFlow && !get().collaborationOperationMode) { get().autoSaveFlow!(); } }, @@ -444,11 +485,15 @@ const useFlowStore = create((set, get) => ({ change: AllNodeType | ((oldState: AllNodeType) => AllNodeType), isUserChange: boolean = true, callback?: () => void, + options?: FlowMutationOptions, ) => { if (!get().nodes.find((node) => node.id === id)) { throw new Error("Node not found"); } + const prevNodes = get().nodes; + const prevEdges = get().edges; + const newChange = typeof change === "function" ? change(get().nodes.find((node) => node.id === id)!) @@ -480,14 +525,29 @@ const useFlowStore = create((set, get) => ({ }; }); get().updateCurrentFlow({ nodes: newNodes, edges: newEdges }); - if (get().autoSaveFlow) { + if ( + get().collaborationOperationMode && + !get().isApplyingRemoteOperations && + !options?.skipCollaborationEmit + ) { + const operations = buildGraphDiffOperations( + prevNodes, + prevEdges, + newNodes, + newEdges, + ); + if (operations.length > 0) { + get().onCollaborationOperations?.(operations); + } + } + if (get().autoSaveFlow && !get().collaborationOperationMode) { get().autoSaveFlow!(); } }, getNode: (id: string) => { return get().nodes.find((node) => node.id === id); }, - deleteNode: (nodeId) => { + deleteNode: (nodeId, options?: FlowMutationOptions) => { const { filteredNodes, deletedNode } = get().nodes.reduce<{ filteredNodes: AllNodeType[]; deletedNode: AllNodeType | null; @@ -509,7 +569,7 @@ const useFlowStore = create((set, get) => ({ { filteredNodes: [], deletedNode: null }, ); - get().setNodes(filteredNodes); + get().setNodes(filteredNodes, options); // Clear rightClickedNodeId if the deleted node was right-clicked const rightClickedNodeId = get().rightClickedNodeId; @@ -528,13 +588,14 @@ const useFlowStore = create((set, get) => ({ track("Component Deleted", { componentType: deletedNode.data.type }); } }, - deleteEdge: (edgeId) => { + deleteEdge: (edgeId, options?: FlowMutationOptions) => { get().setEdges( get().edges.filter((edge) => typeof edgeId === "string" ? edge.id !== edgeId : !edgeId.includes(edge.id), ), + options, ); track("Component Connection Deleted", { edgeId }); }, @@ -577,6 +638,8 @@ const useFlowStore = create((set, get) => ({ const idsMap = {}; let newNodes: AllNodeType[] = get().nodes; let newEdges = get().edges; + const addedNodes: AllNodeType[] = []; + const addedEdges: EdgeType[] = []; selection.nodes.forEach((node: Node) => { if (node.position.y < minimumY) { minimumY = node.position.y; @@ -634,11 +697,13 @@ const useFlowStore = create((set, get) => ({ ); // Add the new node to the list of nodes in state + const pastedNode = { ...newNode, selected: true } as AllNodeType; + addedNodes.push(pastedNode); newNodes = newNodes .map((node) => ({ ...node, selected: false })) - .concat({ ...newNode, selected: true }); + .concat(pastedNode); }); - get().setNodes(newNodes); + get().setNodes(newNodes, { skipCollaborationEmit: true }); selection.edges.forEach((edge: EdgeType) => { const source = idsMap[edge.source]; @@ -667,42 +732,72 @@ const useFlowStore = create((set, get) => ({ }; const id = getHandleId(source, sourceHandle, target, targetHandle); + const pastedEdge = { + source, + target, + sourceHandle, + targetHandle, + id, + data: cloneDeep(edge.data), + selected: false, + } as EdgeType; + addedEdges.push(pastedEdge); newEdges = addEdge( - { - source, - target, - sourceHandle, - targetHandle, - id, - data: cloneDeep(edge.data), - selected: false, - }, + pastedEdge, newEdges.map((edge) => ({ ...edge, selected: false })), ); }); - get().setEdges(newEdges); + get().setEdges(newEdges, { skipCollaborationEmit: true }); + + if ( + get().collaborationOperationMode && + !get().isApplyingRemoteOperations && + (addedNodes.length > 0 || addedEdges.length > 0) + ) { + const operations: FlowOperation[] = []; + if (addedNodes.length > 0) { + operations.push({ type: "add_nodes", nodes: addedNodes }); + } + if (addedEdges.length > 0) { + operations.push({ type: "add_edges", edges: addedEdges }); + } + get().onCollaborationOperations?.(operations); + } }, setLastCopiedSelection: (newSelection, isCrop = false) => { if (isCrop) { + const prevNodes = get().nodes; + const prevEdges = get().edges; const nodesIdsSelected = newSelection!.nodes.map((node) => node.id); const edgesIdsSelected = newSelection!.edges.map((edge) => edge.id); - nodesIdsSelected.forEach((id) => { - get().deleteNode(id); - }); - - edgesIdsSelected.forEach((id) => { - get().deleteEdge(id); - }); - - const newNodes = get().nodes.filter( + const newNodes = prevNodes.filter( (node) => !nodesIdsSelected.includes(node.id), ); - const newEdges = get().edges.filter( - (edge) => !edgesIdsSelected.includes(edge.id), + const newEdges = prevEdges.filter( + (edge) => + !edgesIdsSelected.includes(edge.id) && + !nodesIdsSelected.includes(edge.source) && + !nodesIdsSelected.includes(edge.target), ); - set({ nodes: newNodes, edges: newEdges }); + get().setNodes(newNodes, { skipCollaborationEmit: true }); + get().setEdges(newEdges, { skipCollaborationEmit: true }); + + if ( + get().collaborationOperationMode && + !get().isApplyingRemoteOperations + ) { + const operations = buildGraphDiffOperations( + prevNodes, + prevEdges, + newNodes, + newEdges, + ); + if (operations.length > 0) { + get().onCollaborationOperations?.(operations); + } + } } set({ lastCopiedSelection: newSelection }); diff --git a/src/frontend/src/stores/flowsManagerStore.ts b/src/frontend/src/stores/flowsManagerStore.ts index 585fc7df08..85b42c6856 100644 --- a/src/frontend/src/stores/flowsManagerStore.ts +++ b/src/frontend/src/stores/flowsManagerStore.ts @@ -1,7 +1,8 @@ import { cloneDeep } from "lodash"; import { create } from "zustand"; import { SAVE_DEBOUNCE_TIME } from "@/constants/constants"; -import type { FlowType } from "../types/flow"; +import { buildGraphDiffOperations } from "@/hooks/flows/flow-operation-diff"; +import type { AllNodeType, EdgeType, FlowType } from "../types/flow"; import type { FlowsManagerStoreType, UseUndoRedoOptions, @@ -14,8 +15,14 @@ const defaultOptions: UseUndoRedoOptions = { enableShortcuts: true, }; -const past = {}; -const future = {}; +const past: Record< + string, + Array<{ nodes: AllNodeType[]; edges: EdgeType[] }> +> = {}; +const future: Record< + string, + Array<{ nodes: AllNodeType[]; edges: EdgeType[] }> +> = {}; const useFlowsManagerStore = create((set, get) => ({ IOModalOpen: false, @@ -87,6 +94,12 @@ const useFlowsManagerStore = create((set, get) => ({ future[currentFlowId] = []; }, + clearUndoRedoHistory: (flowId?: string) => { + const targetFlowId = flowId ?? get().currentFlowId; + if (!targetFlowId) return; + past[targetFlowId] = []; + future[targetFlowId] = []; + }, undo: () => { const newState = useFlowStore.getState(); const currentFlowId = get().currentFlowId; @@ -102,8 +115,20 @@ const useFlowsManagerStore = create((set, get) => ({ edges: newState.edges, }); - newState.setNodes(pastState.nodes); - newState.setEdges(pastState.edges); + newState.setNodes(pastState.nodes, { skipCollaborationEmit: true }); + newState.setEdges(pastState.edges, { skipCollaborationEmit: true }); + + if (newState.collaborationOperationMode) { + const operations = buildGraphDiffOperations( + newState.nodes, + newState.edges, + pastState.nodes, + pastState.edges, + ); + if (operations.length > 0) { + newState.onCollaborationOperations?.(operations); + } + } } }, redo: () => { @@ -121,8 +146,20 @@ const useFlowsManagerStore = create((set, get) => ({ edges: newState.edges, }); - newState.setNodes(futureState.nodes); - newState.setEdges(futureState.edges); + newState.setNodes(futureState.nodes, { skipCollaborationEmit: true }); + newState.setEdges(futureState.edges, { skipCollaborationEmit: true }); + + if (newState.collaborationOperationMode) { + const operations = buildGraphDiffOperations( + newState.nodes, + newState.edges, + futureState.nodes, + futureState.edges, + ); + if (operations.length > 0) { + newState.onCollaborationOperations?.(operations); + } + } } }, searchFlowsComponents: "", diff --git a/src/frontend/src/types/flow-operations/index.ts b/src/frontend/src/types/flow-operations/index.ts index 626c4987b3..612caad4ab 100644 --- a/src/frontend/src/types/flow-operations/index.ts +++ b/src/frontend/src/types/flow-operations/index.ts @@ -42,6 +42,10 @@ export type FlowOperation = | DeleteEdgesOp | UpdateMetadataOp; +export type FlowMutationOptions = { + skipCollaborationEmit?: boolean; +}; + export type FlowOperationSubmitPayload = { request_id: string; base_revision: number; diff --git a/src/frontend/src/types/zustand/flow/index.ts b/src/frontend/src/types/zustand/flow/index.ts index 08928b463a..d2e9833048 100644 --- a/src/frontend/src/types/zustand/flow/index.ts +++ b/src/frontend/src/types/zustand/flow/index.ts @@ -6,7 +6,12 @@ import type { ReactFlowInstance, Viewport, } from "@xyflow/react"; +import type { groupedObjType } from "@/types/components"; import type { AllNodeType, EdgeType, FlowType } from "@/types/flow"; +import type { + FlowMutationOptions, + FlowOperation, +} from "@/types/flow-operations"; import type { BuildStatus, EventDeliveryType } from "../../../constants/enums"; import type { LogsLogType, VertexBuildTypeAPI } from "../../api"; import type { ChatInputType, ChatOutputType } from "../../chat"; @@ -17,8 +22,8 @@ export type FlowPoolObjectType = { valid: boolean; messages: Array | []; data: { - artifacts: any | ChatOutputType | ChatInputType; - results: any | ChatOutputType | ChatInputType; + artifacts: unknown | ChatOutputType | ChatInputType; + results: unknown | ChatOutputType | ChatInputType; }; duration?: string; progress?: number; @@ -33,8 +38,8 @@ export type FlowPoolObjectTypeNew = { timestamp: string; valid: boolean; data: { - outputs?: any | ChatOutputType | ChatInputType; - results: any | ChatOutputType | ChatInputType; + outputs?: unknown | ChatOutputType | ChatInputType; + results: unknown | ChatOutputType | ChatInputType; }; duration?: string; progress?: number; @@ -76,6 +81,12 @@ export type FlowStoreType = { }) => void; fitViewNode: (nodeId: string) => void; autoSaveFlow: ((flow?: FlowType) => void) | undefined; + collaborationOperationMode: boolean; + isApplyingRemoteOperations: boolean; + onCollaborationOperations: + | ((operations: FlowOperation[]) => void) + | undefined; + flushCollaborationSave: (() => Promise) | undefined; componentsToUpdate: ComponentsToUpdateType[]; setComponentsToUpdate: ( update: @@ -142,31 +153,40 @@ export type FlowStoreType = { onEdgesChange: OnEdgesChange; setNodes: ( update: AllNodeType[] | ((oldState: AllNodeType[]) => AllNodeType[]), + options?: FlowMutationOptions, ) => void; setEdges: ( update: EdgeType[] | ((oldState: EdgeType[]) => EdgeType[]), + options?: FlowMutationOptions, ) => void; setNode: ( id: string, update: AllNodeType | ((oldState: AllNodeType) => AllNodeType), isUserChange?: boolean, callback?: () => void, + options?: FlowMutationOptions, ) => void; getNode: (id: string) => AllNodeType | undefined; - deleteNode: (nodeId: string | Array) => void; - deleteEdge: (edgeId: string | Array) => void; + deleteNode: ( + nodeId: string | Array, + options?: FlowMutationOptions, + ) => void; + deleteEdge: ( + edgeId: string | Array, + options?: FlowMutationOptions, + ) => void; paste: ( - selection: { nodes: any; edges: any }, + selection: { nodes: AllNodeType[]; edges: EdgeType[] }, position: { x: number; y: number; paneX?: number; paneY?: number }, ) => void; - lastCopiedSelection: { nodes: any; edges: any } | null; + lastCopiedSelection: { nodes: AllNodeType[]; edges: EdgeType[] } | null; setLastCopiedSelection: ( - newSelection: { nodes: any; edges: any } | null, + newSelection: { nodes: AllNodeType[]; edges: EdgeType[] } | null, isCrop?: boolean, ) => void; cleanFlow: () => void; - setFilterEdge: (newState) => void; - getFilterEdge: any[]; + setFilterEdge: (newState: groupedObjType[]) => void; + getFilterEdge: groupedObjType[]; setFilterComponent: (newState) => void; getFilterComponent: string; rightClickedNodeId: string | null; diff --git a/src/frontend/src/types/zustand/flowsManager/index.ts b/src/frontend/src/types/zustand/flowsManager/index.ts index 554f122da3..231c28fb24 100644 --- a/src/frontend/src/types/zustand/flowsManager/index.ts +++ b/src/frontend/src/types/zustand/flowsManager/index.ts @@ -15,6 +15,7 @@ export type FlowsManagerStoreType = { undo: () => void; redo: () => void; takeSnapshot: () => void; + clearUndoRedoHistory: (flowId?: string) => void; examples: Array; setExamples: (examples: FlowType[]) => void; setCurrentFlow: (flow?: FlowType) => void;