diff --git a/src/components/nodes/GroupNode.tsx b/src/components/nodes/GroupNode.tsx index 27cceca4..b56e475b 100644 --- a/src/components/nodes/GroupNode.tsx +++ b/src/components/nodes/GroupNode.tsx @@ -80,11 +80,6 @@ export function GroupNode({ data, selected }: NodeProps) { const bgColor = GROUP_COLORS[group.color]; const showResizeControls = selected || isHovered; - const handleResize = (_: unknown, params: { width: number; height: number }) => { - updateGroup(groupId, { - size: { width: params.width, height: params.height }, - }); - }; return ( <> @@ -94,8 +89,6 @@ export function GroupNode({ data, selected }: NodeProps) { minHeight={120} lineClassName="!border-neutral-400/40" handleClassName="!h-4 !w-4 !border-neutral-300 !bg-neutral-700 !shadow-sm !p-0" - onResize={handleResize} - onResizeEnd={handleResize} />
{ expect(Object.keys(store.groups).length).toBe(0); }); + it("redoes creating a group", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 0, y: 0 }); + store.addNode("imageInput", { x: 100, y: 0 }); + }); + + store = useWorkflowStore.getState(); + const nodeIds = store.nodes.map((n) => n.id); + let groupId = ""; + + act(() => { + groupId = store.createGroup(nodeIds); + }); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId]).toBeUndefined(); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId]).toBeDefined(); + expect(store.nodes.some((node) => node.id === `group-node-${groupId}`)).toBe(true); + expect(store.nodes.filter((node) => node.groupId === groupId)).toHaveLength(2); + }); + it("undoes deleting a group", () => { let store = useWorkflowStore.getState(); @@ -626,6 +659,283 @@ describe("Undo/Redo integration", () => { store = useWorkflowStore.getState(); expect(Object.keys(store.groups).length).toBe(1); }); + + it("redoes deleting a group", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 0, y: 0 }); + }); + store = useWorkflowStore.getState(); + const nodeIds = store.nodes.map((n) => n.id); + + let groupId = ""; + act(() => { + groupId = store.createGroup(nodeIds); + }); + + act(() => { + store.deleteGroup(groupId); + }); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId]).toBeDefined(); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId]).toBeUndefined(); + expect(store.nodes.some((node) => node.id === `group-node-${groupId}`)).toBe(false); + expect(store.nodes.every((node) => node.groupId !== groupId && node.parentId !== `group-node-${groupId}`)).toBe(true); + }); + + it("undoes and redoes removing nodes from a group", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 0, y: 0 }); + store.addNode("imageInput", { x: 100, y: 0 }); + }); + + store = useWorkflowStore.getState(); + const nodeIds = store.nodes.map((n) => n.id); + const nodeIdToRemove = nodeIds[0]; + let groupId = ""; + + act(() => { + groupId = store.createGroup(nodeIds); + }); + + act(() => { + store.removeNodesFromGroup([nodeIdToRemove]); + }); + + store = useWorkflowStore.getState(); + expect(store.nodes.find((node) => node.id === nodeIdToRemove)?.groupId).toBeUndefined(); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.nodes.find((node) => node.id === nodeIdToRemove)?.groupId).toBe(groupId); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.nodes.find((node) => node.id === nodeIdToRemove)?.groupId).toBeUndefined(); + }); + + it("undoes and redoes adding nodes to a group", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 0, y: 0 }); + store.addNode("imageInput", { x: 300, y: 0 }); + }); + + store = useWorkflowStore.getState(); + const [firstNodeId, secondNodeId] = store.nodes.map((n) => n.id); + let groupId = ""; + + act(() => { + groupId = store.createGroup([firstNodeId]); + }); + + act(() => { + store.addNodesToGroup([secondNodeId], groupId); + }); + + store = useWorkflowStore.getState(); + expect(store.nodes.find((node) => node.id === secondNodeId)?.groupId).toBe(groupId); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.nodes.find((node) => node.id === secondNodeId)?.groupId).toBeUndefined(); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.nodes.find((node) => node.id === secondNodeId)?.groupId).toBe(groupId); + }); + + it("undoes and redoes group metadata changes", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 0, y: 0 }); + }); + + store = useWorkflowStore.getState(); + let groupId = ""; + act(() => { + groupId = store.createGroup(store.nodes.map((n) => n.id)); + }); + + act(() => { + store.updateGroup(groupId, { name: "Updated group", color: "purple", locked: true }); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].name).toBe("Updated group"); + expect(store.groups[groupId].color).toBe("purple"); + expect(store.groups[groupId].locked).toBe(true); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].name).not.toBe("Updated group"); + expect(store.groups[groupId].color).not.toBe("purple"); + expect(store.groups[groupId].locked).toBeUndefined(); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].name).toBe("Updated group"); + expect(store.groups[groupId].color).toBe("purple"); + expect(store.groups[groupId].locked).toBe(true); + }); + + it("undoes and redoes moving a group through the store API", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 50, y: 80 }); + }); + + store = useWorkflowStore.getState(); + let groupId = ""; + act(() => { + groupId = store.createGroup(store.nodes.map((n) => n.id)); + }); + + store = useWorkflowStore.getState(); + const originalPosition = store.groups[groupId].position; + + act(() => { + store.moveGroupNodes(groupId, { x: 40, y: 25 }); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].position).toEqual({ + x: originalPosition.x + 40, + y: originalPosition.y + 25, + }); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].position).toEqual(originalPosition); + expect(store.nodes.find((node) => node.id === `group-node-${groupId}`)?.position).toEqual(originalPosition); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].position).toEqual({ + x: originalPosition.x + 40, + y: originalPosition.y + 25, + }); + expect(store.nodes.find((node) => node.id === `group-node-${groupId}`)?.position).toEqual({ + x: originalPosition.x + 40, + y: originalPosition.y + 25, + }); + }); + + it("undoes and redoes resizing a group from React Flow dimensions changes", () => { + let store = useWorkflowStore.getState(); + + act(() => { + store.addNode("prompt", { x: 50, y: 80 }); + }); + + store = useWorkflowStore.getState(); + let groupId = ""; + act(() => { + groupId = store.createGroup(store.nodes.map((n) => n.id)); + }); + + store = useWorkflowStore.getState(); + const groupNodeId = `group-node-${groupId}`; + const originalSize = store.groups[groupId].size; + + act(() => { + store.onNodesChange([ + { + id: groupNodeId, + type: "dimensions", + dimensions: { + width: originalSize.width + 80, + height: originalSize.height + 40, + }, + resizing: true, + setAttributes: true, + }, + { + id: groupNodeId, + type: "dimensions", + dimensions: { + width: originalSize.width + 80, + height: originalSize.height + 40, + }, + resizing: false, + setAttributes: true, + }, + ]); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].size).toEqual({ + width: originalSize.width + 80, + height: originalSize.height + 40, + }); + expect(store.nodes.find((node) => node.id === groupNodeId)?.style).toMatchObject({ + width: originalSize.width + 80, + height: originalSize.height + 40, + }); + + act(() => { + store.undo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].size).toEqual(originalSize); + expect(store.nodes.find((node) => node.id === groupNodeId)?.style).toMatchObject(originalSize); + + act(() => { + store.redo(); + }); + + store = useWorkflowStore.getState(); + expect(store.groups[groupId].size).toEqual({ + width: originalSize.width + 80, + height: originalSize.height + 40, + }); + expect(store.nodes.find((node) => node.id === groupNodeId)?.style).toMatchObject({ + width: originalSize.width + 80, + height: originalSize.height + 40, + }); + }); }); describe("hasUnsavedChanges is set on undo/redo", () => { diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 92a56be9..434644b5 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -445,6 +445,7 @@ let autoSaveIntervalId: ReturnType | null = null; // Undo/redo state (module-level, not in Zustand to avoid serialization) const undoManager = new UndoManager(); let isDragging = false; +let isResizing = false; let pendingDataSnapshot: UndoSnapshot | null = null; let dataChangeTimer: ReturnType | null = null; // When true, a remove-checkpoint was already pushed in the current React Flow @@ -1239,6 +1240,9 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ const effectiveChanges = changes.filter((change) => isNodeChangeEffective(change, nodeById)); if (effectiveChanges.length === 0) return; + const hasGroupDimensionChange = effectiveChanges.some( + (c) => c.type === "dimensions" && nodeById.get(c.id)?.type === "group" + ); // Only mark as unsaved for meaningful changes (not selection changes) const hasMeaningfulChange = effectiveChanges.some( (c) => c.type !== "select" && c.type !== "dimensions" @@ -1261,6 +1265,29 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ isDragging = false; } + // Undo: capture snapshot when resizing a group starts. Most dimensions + // changes are measurement-only React Flow updates, so only group resize is + // treated as an undoable canvas edit here. + const hasResizeStart = effectiveChanges.some( + (c) => + c.type === "dimensions" && + nodeById.get(c.id)?.type === "group" && + (c as { resizing?: boolean }).resizing === true + ); + const hasResizeEnd = effectiveChanges.some( + (c) => + c.type === "dimensions" && + nodeById.get(c.id)?.type === "group" && + (c as { resizing?: boolean }).resizing === false + ); + if (hasResizeStart && !isResizing) { + isResizing = true; + pushUndoCheckpoint(get, set); + } + if (hasResizeEnd && isResizing) { + isResizing = false; + } + // Undo: capture snapshot before node removal — but skip if onEdgesChange // already pushed a checkpoint in this same deleteElements cycle // (React Flow v12 fires edge removals BEFORE node removals). @@ -1350,7 +1377,7 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ return { nodes: nextNodes, groups: nextGroups, - ...(hasMeaningfulChange ? { hasUnsavedChanges: true } : {}), + ...(hasMeaningfulChange || hasGroupDimensionChange ? { hasUnsavedChanges: true } : {}), }; }); @@ -1865,9 +1892,14 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ }, moveGroupNodes: (groupId: string, delta: { x: number; y: number }) => { + if (delta.x === 0 && delta.y === 0) return; + const group = get().groups[groupId]; + if (!group) return; + pushUndoCheckpoint(get, set); + const groupNodeId = getGroupNodeId(groupId); set((state) => ({ nodes: state.nodes.map((node) => - node.id === getGroupNodeId(groupId) + node.id === groupNodeId ? { ...node, position: { @@ -1877,6 +1909,16 @@ const workflowStoreImpl: StateCreator = (set, get) => ({ } : node ) as WorkflowNode[], + groups: { + ...state.groups, + [groupId]: { + ...state.groups[groupId], + position: { + x: state.groups[groupId].position.x + delta.x, + y: state.groups[groupId].position.y + delta.y, + }, + }, + }, hasUnsavedChanges: true, })); },