chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { del, get, set } from 'idb-keyval'
|
||||
import type { StateStorage } from 'zustand/middleware'
|
||||
|
||||
const logger = createLogger('CodeUndoRedoStorage')
|
||||
|
||||
export const codeUndoRedoStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const value = await get<string>(name)
|
||||
return value ?? null
|
||||
} catch (error) {
|
||||
logger.warn('IndexedDB read failed', { name, error })
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
await set(name, value)
|
||||
} catch (error) {
|
||||
logger.warn('IndexedDB write failed', { name, error })
|
||||
}
|
||||
},
|
||||
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
await del(name)
|
||||
} catch (error) {
|
||||
logger.warn('IndexedDB delete failed', { name, error })
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
|
||||
import { codeUndoRedoStorage } from '@/stores/undo-redo/code-storage'
|
||||
|
||||
interface CodeUndoRedoEntry {
|
||||
id: string
|
||||
createdAt: number
|
||||
workflowId: string
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
before: string
|
||||
after: string
|
||||
}
|
||||
|
||||
interface CodeUndoRedoStack {
|
||||
undo: CodeUndoRedoEntry[]
|
||||
redo: CodeUndoRedoEntry[]
|
||||
lastUpdated?: number
|
||||
}
|
||||
|
||||
interface CodeUndoRedoState {
|
||||
stacks: Record<string, CodeUndoRedoStack>
|
||||
capacity: number
|
||||
push: (entry: CodeUndoRedoEntry) => void
|
||||
undo: (workflowId: string, blockId: string, subBlockId: string) => CodeUndoRedoEntry | null
|
||||
redo: (workflowId: string, blockId: string, subBlockId: string) => CodeUndoRedoEntry | null
|
||||
clear: (workflowId: string, blockId: string, subBlockId: string) => void
|
||||
}
|
||||
|
||||
const DEFAULT_CAPACITY = 500
|
||||
const MAX_STACKS = 50
|
||||
|
||||
function getStackKey(workflowId: string, blockId: string, subBlockId: string): string {
|
||||
return `${workflowId}:${blockId}:${subBlockId}`
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
stacks: {} as Record<string, CodeUndoRedoStack>,
|
||||
capacity: DEFAULT_CAPACITY,
|
||||
}
|
||||
|
||||
export const useCodeUndoRedoStore = create<CodeUndoRedoState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
push: (entry) => {
|
||||
if (entry.before === entry.after) return
|
||||
|
||||
const state = get()
|
||||
const key = getStackKey(entry.workflowId, entry.blockId, entry.subBlockId)
|
||||
const currentStacks = { ...state.stacks }
|
||||
|
||||
const stackKeys = Object.keys(currentStacks)
|
||||
if (stackKeys.length >= MAX_STACKS && !currentStacks[key]) {
|
||||
let oldestKey: string | null = null
|
||||
let oldestTime = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const stackKey of stackKeys) {
|
||||
const t = currentStacks[stackKey].lastUpdated ?? 0
|
||||
if (t < oldestTime) {
|
||||
oldestTime = t
|
||||
oldestKey = stackKey
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestKey) {
|
||||
delete currentStacks[oldestKey]
|
||||
}
|
||||
}
|
||||
|
||||
const stack = currentStacks[key] || { undo: [], redo: [] }
|
||||
|
||||
const newUndo = [...stack.undo, entry]
|
||||
if (newUndo.length > state.capacity) {
|
||||
newUndo.shift()
|
||||
}
|
||||
|
||||
currentStacks[key] = {
|
||||
undo: newUndo,
|
||||
redo: [],
|
||||
lastUpdated: Date.now(),
|
||||
}
|
||||
|
||||
set({ stacks: currentStacks })
|
||||
},
|
||||
undo: (workflowId, blockId, subBlockId) => {
|
||||
const key = getStackKey(workflowId, blockId, subBlockId)
|
||||
const state = get()
|
||||
const stack = state.stacks[key]
|
||||
if (!stack || stack.undo.length === 0) return null
|
||||
|
||||
const entry = stack.undo[stack.undo.length - 1]
|
||||
const newUndo = stack.undo.slice(0, -1)
|
||||
const newRedo = [...stack.redo, entry]
|
||||
|
||||
set({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[key]: {
|
||||
undo: newUndo,
|
||||
redo: newRedo.slice(-state.capacity),
|
||||
lastUpdated: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return entry
|
||||
},
|
||||
redo: (workflowId, blockId, subBlockId) => {
|
||||
const key = getStackKey(workflowId, blockId, subBlockId)
|
||||
const state = get()
|
||||
const stack = state.stacks[key]
|
||||
if (!stack || stack.redo.length === 0) return null
|
||||
|
||||
const entry = stack.redo[stack.redo.length - 1]
|
||||
const newRedo = stack.redo.slice(0, -1)
|
||||
const newUndo = [...stack.undo, entry]
|
||||
|
||||
set({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[key]: {
|
||||
undo: newUndo.slice(-state.capacity),
|
||||
redo: newRedo,
|
||||
lastUpdated: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return entry
|
||||
},
|
||||
clear: (workflowId, blockId, subBlockId) => {
|
||||
const key = getStackKey(workflowId, blockId, subBlockId)
|
||||
const state = get()
|
||||
const { [key]: _, ...rest } = state.stacks
|
||||
set({ stacks: rest })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'code-undo-redo-store',
|
||||
storage: createJSONStorage(() => codeUndoRedoStorage),
|
||||
partialize: (state) => ({
|
||||
stacks: state.stacks,
|
||||
capacity: state.capacity,
|
||||
}),
|
||||
}
|
||||
),
|
||||
{ name: 'code-undo-redo-store' }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useCodeUndoRedoStore } from './code-store'
|
||||
export { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from './store'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
@@ -0,0 +1,765 @@
|
||||
/**
|
||||
* Tests for the undo/redo store.
|
||||
*
|
||||
* These tests cover:
|
||||
* - Basic push/undo/redo operations
|
||||
* - Stack capacity limits
|
||||
* - Move operation coalescing
|
||||
* - Recording suspension
|
||||
* - Stack pruning
|
||||
* - Multi-workflow/user isolation
|
||||
*/
|
||||
|
||||
import {
|
||||
createAddBlockEntry,
|
||||
createAddEdgeEntry,
|
||||
createBatchRemoveEdgesEntry,
|
||||
createBlock,
|
||||
createMockStorage,
|
||||
createMoveBlockEntry,
|
||||
createRemoveBlockEntry,
|
||||
createUpdateParentEntry,
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from '@/stores/undo-redo/store'
|
||||
import type { UpdateParentOperation } from '@/stores/undo-redo/types'
|
||||
|
||||
describe('useUndoRedoStore', () => {
|
||||
const workflowId = 'wf-test'
|
||||
const userId = 'user-test'
|
||||
|
||||
beforeEach(() => {
|
||||
global.localStorage = createMockStorage()
|
||||
|
||||
useUndoRedoStore.setState({
|
||||
stacks: {},
|
||||
capacity: 100,
|
||||
})
|
||||
})
|
||||
|
||||
describe('push', () => {
|
||||
it('should add an operation to the undo stack', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
const entry = createAddBlockEntry('block-1', { workflowId, userId })
|
||||
|
||||
push(workflowId, userId, entry)
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 1,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear redo stack when pushing new operation', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
|
||||
undo(workflowId, userId)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 2,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should respect capacity limit', () => {
|
||||
useUndoRedoStore.setState({ capacity: 3 })
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
|
||||
}
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(3)
|
||||
})
|
||||
|
||||
it('should limit number of stacks to 5', () => {
|
||||
const { push } = useUndoRedoStore.getState()
|
||||
|
||||
// Create 6 different workflow/user combinations
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const wfId = `wf-${i}`
|
||||
const uId = `user-${i}`
|
||||
push(wfId, uId, createAddBlockEntry(`block-${i}`, { workflowId: wfId, userId: uId }))
|
||||
}
|
||||
|
||||
const { stacks } = useUndoRedoStore.getState()
|
||||
expect(Object.keys(stacks).length).toBe(5)
|
||||
})
|
||||
|
||||
it('should remove oldest stack when limit exceeded', () => {
|
||||
const { push } = useUndoRedoStore.getState()
|
||||
|
||||
// Create stacks with varying timestamps
|
||||
for (let i = 0; i < 5; i++) {
|
||||
push(`wf-${i}`, `user-${i}`, createAddBlockEntry(`block-${i}`))
|
||||
}
|
||||
|
||||
// Add a 6th stack - should remove the oldest
|
||||
push('wf-new', 'user-new', createAddBlockEntry('block-new'))
|
||||
|
||||
const { stacks } = useUndoRedoStore.getState()
|
||||
expect(Object.keys(stacks).length).toBe(5)
|
||||
expect(stacks['wf-new:user-new']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('undo', () => {
|
||||
it('should return the last operation and move it to redo', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
const entry = createAddBlockEntry('block-1', { workflowId, userId })
|
||||
|
||||
push(workflowId, userId, entry)
|
||||
const result = undo(workflowId, userId)
|
||||
|
||||
expect(result).toEqual(entry)
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 0,
|
||||
redoSize: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when undo stack is empty', () => {
|
||||
const { undo } = useUndoRedoStore.getState()
|
||||
|
||||
const result = undo(workflowId, userId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should undo operations in LIFO order', () => {
|
||||
const { push, undo } = useUndoRedoStore.getState()
|
||||
|
||||
const entry1 = createAddBlockEntry('block-1', { workflowId, userId })
|
||||
const entry2 = createAddBlockEntry('block-2', { workflowId, userId })
|
||||
const entry3 = createAddBlockEntry('block-3', { workflowId, userId })
|
||||
|
||||
push(workflowId, userId, entry1)
|
||||
push(workflowId, userId, entry2)
|
||||
push(workflowId, userId, entry3)
|
||||
|
||||
expect(undo(workflowId, userId)).toEqual(entry3)
|
||||
expect(undo(workflowId, userId)).toEqual(entry2)
|
||||
expect(undo(workflowId, userId)).toEqual(entry1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redo', () => {
|
||||
it('should return the last undone operation and move it back to undo', () => {
|
||||
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
|
||||
const entry = createAddBlockEntry('block-1', { workflowId, userId })
|
||||
|
||||
push(workflowId, userId, entry)
|
||||
undo(workflowId, userId)
|
||||
const result = redo(workflowId, userId)
|
||||
|
||||
expect(result).toEqual(entry)
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 1,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return null when redo stack is empty', () => {
|
||||
const { redo } = useUndoRedoStore.getState()
|
||||
|
||||
const result = redo(workflowId, userId)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should redo operations in LIFO order', () => {
|
||||
const { push, undo, redo } = useUndoRedoStore.getState()
|
||||
|
||||
const entry1 = createAddBlockEntry('block-1', { workflowId, userId })
|
||||
const entry2 = createAddBlockEntry('block-2', { workflowId, userId })
|
||||
|
||||
push(workflowId, userId, entry1)
|
||||
push(workflowId, userId, entry2)
|
||||
undo(workflowId, userId)
|
||||
undo(workflowId, userId)
|
||||
|
||||
expect(redo(workflowId, userId)).toEqual(entry1)
|
||||
expect(redo(workflowId, userId)).toEqual(entry2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear both undo and redo stacks', () => {
|
||||
const { push, undo, clear, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
|
||||
undo(workflowId, userId)
|
||||
|
||||
clear(workflowId, userId)
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 0,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should only clear stacks for specified workflow/user', () => {
|
||||
const { push, clear, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
'wf-1',
|
||||
'user-1',
|
||||
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
|
||||
)
|
||||
push(
|
||||
'wf-2',
|
||||
'user-2',
|
||||
createAddBlockEntry('block-2', { workflowId: 'wf-2', userId: 'user-2' })
|
||||
)
|
||||
|
||||
clear('wf-1', 'user-1')
|
||||
|
||||
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(0)
|
||||
expect(getStackSizes('wf-2', 'user-2').undoSize).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearRedo', () => {
|
||||
it('should only clear the redo stack', () => {
|
||||
const { push, undo, clearRedo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
|
||||
undo(workflowId, userId)
|
||||
|
||||
clearRedo(workflowId, userId)
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 1,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStackSizes', () => {
|
||||
it('should return zero sizes for non-existent stack', () => {
|
||||
const { getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
expect(getStackSizes('non-existent', 'user')).toEqual({
|
||||
undoSize: 0,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return correct sizes', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
|
||||
undo(workflowId, userId)
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 2,
|
||||
redoSize: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCapacity', () => {
|
||||
it('should update capacity', () => {
|
||||
const { setCapacity } = useUndoRedoStore.getState()
|
||||
|
||||
setCapacity(50)
|
||||
|
||||
expect(useUndoRedoStore.getState().capacity).toBe(50)
|
||||
})
|
||||
|
||||
it('should truncate existing stacks to new capacity', () => {
|
||||
const { push, setCapacity, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
|
||||
}
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(10)
|
||||
|
||||
setCapacity(5)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('move-block coalescing', () => {
|
||||
it('should coalesce consecutive moves of the same block', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 10, y: 10 },
|
||||
})
|
||||
)
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 10, y: 10 },
|
||||
after: { x: 20, y: 20 },
|
||||
})
|
||||
)
|
||||
|
||||
// Should coalesce into a single operation
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
|
||||
it('should not coalesce moves of different blocks', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 10, y: 10 },
|
||||
})
|
||||
)
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-2', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 20, y: 20 },
|
||||
})
|
||||
)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
|
||||
})
|
||||
|
||||
it('should skip no-op moves', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 100, y: 100 },
|
||||
after: { x: 100, y: 100 },
|
||||
})
|
||||
)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
|
||||
})
|
||||
|
||||
it('should preserve original position when coalescing results in no-op', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
// Move block from (0,0) to (10,10)
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 10, y: 10 },
|
||||
})
|
||||
)
|
||||
|
||||
// Move block back to (0,0) - coalesces to a no-op
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 10, y: 10 },
|
||||
after: { x: 0, y: 0 },
|
||||
})
|
||||
)
|
||||
|
||||
// Should result in no operations since it's a round-trip
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recording suspension', () => {
|
||||
it('should skip operations when recording is suspended', async () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
await runWithUndoRedoRecordingSuspended(() => {
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
})
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
|
||||
})
|
||||
|
||||
it('should resume recording after suspension ends', async () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
await runWithUndoRedoRecordingSuspended(() => {
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
})
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle nested suspension correctly', async () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
await runWithUndoRedoRecordingSuspended(async () => {
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
|
||||
await runWithUndoRedoRecordingSuspended(() => {
|
||||
push(workflowId, userId, createAddBlockEntry('block-2', { workflowId, userId }))
|
||||
})
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-3', { workflowId, userId }))
|
||||
})
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(0)
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-4', { workflowId, userId }))
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pruneInvalidEntries', () => {
|
||||
it('should remove entries for non-existent blocks', () => {
|
||||
const { push, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
// Add entries for blocks
|
||||
push(workflowId, userId, createRemoveBlockEntry('block-1', null, { workflowId, userId }))
|
||||
push(workflowId, userId, createRemoveBlockEntry('block-2', null, { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
|
||||
|
||||
// Prune with only block-1 existing
|
||||
const graph = {
|
||||
blocksById: {
|
||||
'block-1': createBlock({ id: 'block-1' }),
|
||||
},
|
||||
edgesById: {},
|
||||
}
|
||||
|
||||
pruneInvalidEntries(workflowId, userId, graph)
|
||||
|
||||
// Only the entry for block-1 should remain (inverse is add-block which requires block NOT exist)
|
||||
// Actually, remove-block inverse is add-block, which is applicable when block doesn't exist
|
||||
// Let me reconsider: the pruneInvalidEntries checks if the INVERSE is applicable
|
||||
// For remove-block, inverse is add-block, which is applicable when block doesn't exist
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
|
||||
it('should remove redo entries with non-applicable operations', () => {
|
||||
const { push, undo, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createRemoveBlockEntry('block-1', null, { workflowId, userId }))
|
||||
undo(workflowId, userId)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
|
||||
|
||||
// Prune - block-1 doesn't exist, so remove-block is not applicable
|
||||
pruneInvalidEntries(workflowId, userId, { blocksById: {}, edgesById: {} })
|
||||
|
||||
expect(getStackSizes(workflowId, userId).redoSize).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflow/user isolation', () => {
|
||||
it('should keep stacks isolated by workflow and user', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
'wf-1',
|
||||
'user-1',
|
||||
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
|
||||
)
|
||||
push(
|
||||
'wf-1',
|
||||
'user-2',
|
||||
createAddBlockEntry('block-2', { workflowId: 'wf-1', userId: 'user-2' })
|
||||
)
|
||||
push(
|
||||
'wf-2',
|
||||
'user-1',
|
||||
createAddBlockEntry('block-3', { workflowId: 'wf-2', userId: 'user-1' })
|
||||
)
|
||||
|
||||
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(1)
|
||||
expect(getStackSizes('wf-1', 'user-2').undoSize).toBe(1)
|
||||
expect(getStackSizes('wf-2', 'user-1').undoSize).toBe(1)
|
||||
})
|
||||
|
||||
it('should not affect other stacks when undoing', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
'wf-1',
|
||||
'user-1',
|
||||
createAddBlockEntry('block-1', { workflowId: 'wf-1', userId: 'user-1' })
|
||||
)
|
||||
push(
|
||||
'wf-2',
|
||||
'user-1',
|
||||
createAddBlockEntry('block-2', { workflowId: 'wf-2', userId: 'user-1' })
|
||||
)
|
||||
|
||||
undo('wf-1', 'user-1')
|
||||
|
||||
expect(getStackSizes('wf-1', 'user-1').undoSize).toBe(0)
|
||||
expect(getStackSizes('wf-2', 'user-1').undoSize).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle rapid consecutive operations', () => {
|
||||
const { push, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
push(workflowId, userId, createAddBlockEntry(`block-${i}`, { workflowId, userId }))
|
||||
}
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(50)
|
||||
})
|
||||
|
||||
it('should handle multiple undo/redo cycles', () => {
|
||||
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
undo(workflowId, userId)
|
||||
redo(workflowId, userId)
|
||||
}
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 1,
|
||||
redoSize: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle mixed operation types', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('block-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 100, y: 100 },
|
||||
})
|
||||
)
|
||||
push(workflowId, userId, createRemoveBlockEntry('block-2', null, { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(4)
|
||||
|
||||
undo(workflowId, userId)
|
||||
undo(workflowId, userId)
|
||||
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({
|
||||
undoSize: 2,
|
||||
redoSize: 2,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge operations', () => {
|
||||
it('should handle add-edge operations', () => {
|
||||
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddEdgeEntry('edge-2', { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
|
||||
|
||||
const entry = undo(workflowId, userId)
|
||||
expect(entry?.operation.type).toBe('batch-add-edges')
|
||||
expect(getStackSizes(workflowId, userId).redoSize).toBe(1)
|
||||
|
||||
redo(workflowId, userId)
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle batch-remove-edges operations', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
const edgeSnapshot = { id: 'edge-1', source: 'block-1', target: 'block-2' }
|
||||
push(workflowId, userId, createBatchRemoveEdgesEntry([edgeSnapshot], { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
|
||||
const entry = undo(workflowId, userId)
|
||||
expect(entry?.operation.type).toBe('batch-remove-edges')
|
||||
expect(entry?.inverse.type).toBe('batch-add-edges')
|
||||
})
|
||||
})
|
||||
|
||||
describe('update-parent operations', () => {
|
||||
it('should handle update-parent operations', () => {
|
||||
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createUpdateParentEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
oldParentId: undefined,
|
||||
newParentId: 'loop-1',
|
||||
oldPosition: { x: 100, y: 100 },
|
||||
newPosition: { x: 50, y: 50 },
|
||||
})
|
||||
)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
|
||||
const entry = undo(workflowId, userId)
|
||||
expect(entry?.operation.type).toBe('update-parent')
|
||||
expect(entry?.inverse.type).toBe('update-parent')
|
||||
|
||||
redo(workflowId, userId)
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
|
||||
it('should correctly swap parent IDs in inverse operation', () => {
|
||||
const { push, undo } = useUndoRedoStore.getState()
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createUpdateParentEntry('block-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
oldParentId: 'loop-1',
|
||||
newParentId: 'loop-2',
|
||||
oldPosition: { x: 0, y: 0 },
|
||||
newPosition: { x: 100, y: 100 },
|
||||
})
|
||||
)
|
||||
|
||||
const entry = undo(workflowId, userId)
|
||||
const inverse = entry?.inverse as UpdateParentOperation
|
||||
expect(inverse.data.oldParentId).toBe('loop-2')
|
||||
expect(inverse.data.newParentId).toBe('loop-1')
|
||||
expect(inverse.data.oldPosition).toEqual({ x: 100, y: 100 })
|
||||
expect(inverse.data.newPosition).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('pruneInvalidEntries with edges', () => {
|
||||
it('should remove entries for non-existent edges', () => {
|
||||
const { push, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
const edge1 = { id: 'edge-1', source: 'a', target: 'b' }
|
||||
const edge2 = { id: 'edge-2', source: 'c', target: 'd' }
|
||||
push(workflowId, userId, createBatchRemoveEdgesEntry([edge1], { workflowId, userId }))
|
||||
push(workflowId, userId, createBatchRemoveEdgesEntry([edge2], { workflowId, userId }))
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(2)
|
||||
|
||||
const graph = {
|
||||
blocksById: {},
|
||||
edgesById: {
|
||||
'edge-1': { id: 'edge-1', source: 'a', target: 'b' },
|
||||
},
|
||||
}
|
||||
|
||||
pruneInvalidEntries(workflowId, userId, graph as any)
|
||||
|
||||
// edge-1 exists in graph, so we can't undo its removal (can't add it back) → pruned
|
||||
// edge-2 doesn't exist, so we can undo its removal (can add it back) → kept
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('complex scenarios', () => {
|
||||
it('should handle a complete workflow creation scenario', () => {
|
||||
const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('starter', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddBlockEntry('agent-1', { workflowId, userId }))
|
||||
push(workflowId, userId, createAddEdgeEntry('edge-1', { workflowId, userId }))
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('agent-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 200, y: 100 },
|
||||
})
|
||||
)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(4)
|
||||
|
||||
undo(workflowId, userId)
|
||||
undo(workflowId, userId)
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 2, redoSize: 2 })
|
||||
|
||||
redo(workflowId, userId)
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 3, redoSize: 1 })
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('agent-2', { workflowId, userId }))
|
||||
expect(getStackSizes(workflowId, userId)).toEqual({ undoSize: 4, redoSize: 0 })
|
||||
})
|
||||
|
||||
it('should handle loop workflow with child blocks', () => {
|
||||
const { push, undo, getStackSizes } = useUndoRedoStore.getState()
|
||||
|
||||
push(workflowId, userId, createAddBlockEntry('loop-1', { workflowId, userId }))
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createUpdateParentEntry('child-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
oldParentId: undefined,
|
||||
newParentId: 'loop-1',
|
||||
})
|
||||
)
|
||||
|
||||
push(
|
||||
workflowId,
|
||||
userId,
|
||||
createMoveBlockEntry('child-1', {
|
||||
workflowId,
|
||||
userId,
|
||||
before: { x: 0, y: 0 },
|
||||
after: { x: 50, y: 50 },
|
||||
})
|
||||
)
|
||||
|
||||
expect(getStackSizes(workflowId, userId).undoSize).toBe(3)
|
||||
|
||||
const moveEntry = undo(workflowId, userId)
|
||||
expect(moveEntry?.operation.type).toBe('batch-move-blocks')
|
||||
|
||||
const parentEntry = undo(workflowId, userId)
|
||||
expect(parentEntry?.operation.type).toBe('update-parent')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,511 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { UNDO_REDO_OPERATIONS } from '@sim/realtime-protocol/constants'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
import type {
|
||||
BatchAddBlocksOperation,
|
||||
BatchAddEdgesOperation,
|
||||
BatchMoveBlocksOperation,
|
||||
BatchRemoveBlocksOperation,
|
||||
BatchRemoveEdgesOperation,
|
||||
BatchUpdateParentOperation,
|
||||
Operation,
|
||||
OperationEntry,
|
||||
UndoRedoState,
|
||||
} from '@/stores/undo-redo/types'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('UndoRedoStore')
|
||||
const DEFAULT_CAPACITY = 100
|
||||
const MAX_STACKS = 5
|
||||
|
||||
let recordingSuspendDepth = 0
|
||||
|
||||
function isRecordingSuspended(): boolean {
|
||||
return recordingSuspendDepth > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily suspends undo/redo recording while the provided callback runs.
|
||||
*
|
||||
* @param callback - Function to execute while recording is disabled.
|
||||
* @returns The callback result.
|
||||
*/
|
||||
export async function runWithUndoRedoRecordingSuspended<T>(
|
||||
callback: () => Promise<T> | T
|
||||
): Promise<T> {
|
||||
recordingSuspendDepth += 1
|
||||
try {
|
||||
return await Promise.resolve(callback())
|
||||
} finally {
|
||||
recordingSuspendDepth = Math.max(0, recordingSuspendDepth - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function getStackKey(workflowId: string, userId: string): string {
|
||||
return `${workflowId}:${userId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom storage adapter for Zustand's persist middleware.
|
||||
* We need this wrapper to gracefully handle 'QuotaExceededError' when localStorage is full,
|
||||
* Without this, the default storage engine would throw and crash the application.
|
||||
* and to properly handle SSR/Node.js environments.
|
||||
*/
|
||||
const safeStorageAdapter = {
|
||||
getItem: (name: string): string | null => {
|
||||
if (typeof localStorage === 'undefined') return null
|
||||
try {
|
||||
return localStorage.getItem(name)
|
||||
} catch (e) {
|
||||
logger.warn('Failed to read from localStorage', e)
|
||||
return null
|
||||
}
|
||||
},
|
||||
setItem: (name: string, value: string): void => {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.setItem(name, value)
|
||||
} catch (e) {
|
||||
// Log warning but don't crash - this handles QuotaExceededError
|
||||
logger.warn('Failed to save to localStorage', e)
|
||||
}
|
||||
},
|
||||
removeItem: (name: string): void => {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
try {
|
||||
localStorage.removeItem(name)
|
||||
} catch (e) {
|
||||
logger.warn('Failed to remove from localStorage', e)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
function isOperationApplicable(
|
||||
operation: Operation,
|
||||
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
|
||||
): boolean {
|
||||
switch (operation.type) {
|
||||
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: {
|
||||
const op = operation as BatchRemoveBlocksOperation
|
||||
return op.data.blockSnapshots.every((block) => Boolean(graph.blocksById[block.id]))
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: {
|
||||
const op = operation as BatchAddBlocksOperation
|
||||
return op.data.blockSnapshots.every((block) => !graph.blocksById[block.id])
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: {
|
||||
const op = operation as BatchMoveBlocksOperation
|
||||
return op.data.moves.every((move) => Boolean(graph.blocksById[move.blockId]))
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.UPDATE_PARENT: {
|
||||
const blockId = operation.data.blockId
|
||||
return Boolean(graph.blocksById[blockId])
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: {
|
||||
const op = operation as BatchUpdateParentOperation
|
||||
return op.data.updates.every((u) => Boolean(graph.blocksById[u.blockId]))
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: {
|
||||
const op = operation as BatchRemoveEdgesOperation
|
||||
return op.data.edgeSnapshots.every((edge) => Boolean(graph.edgesById[edge.id]))
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: {
|
||||
const op = operation as BatchAddEdgesOperation
|
||||
return op.data.edgeSnapshots.every((edge) => !graph.edgesById[edge.id])
|
||||
}
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const useUndoRedoStore = create<UndoRedoState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
stacks: {},
|
||||
capacity: DEFAULT_CAPACITY,
|
||||
|
||||
push: (workflowId: string, userId: string, entry: OperationEntry) => {
|
||||
if (isRecordingSuspended()) {
|
||||
logger.debug('Skipped push while undo/redo recording suspended', {
|
||||
workflowId,
|
||||
userId,
|
||||
operationType: entry.operation.type,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const currentStacks = { ...state.stacks }
|
||||
|
||||
// Limit number of stacks
|
||||
const stackKeys = Object.keys(currentStacks)
|
||||
if (stackKeys.length >= MAX_STACKS && !currentStacks[key]) {
|
||||
let oldestKey: string | null = null
|
||||
let oldestTime = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const k of stackKeys) {
|
||||
const t = currentStacks[k].lastUpdated ?? 0
|
||||
if (t < oldestTime) {
|
||||
oldestTime = t
|
||||
oldestKey = k
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestKey) {
|
||||
delete currentStacks[oldestKey]
|
||||
}
|
||||
}
|
||||
|
||||
const stack = currentStacks[key] || { undo: [], redo: [] }
|
||||
|
||||
// Prevent duplicate diff operations (apply-diff, accept-diff, reject-diff)
|
||||
if (['apply-diff', 'accept-diff', 'reject-diff'].includes(entry.operation.type)) {
|
||||
const lastEntry = stack.undo[stack.undo.length - 1]
|
||||
if (lastEntry && lastEntry.operation.type === entry.operation.type) {
|
||||
// Check if it's a duplicate by comparing the relevant state data
|
||||
const lastData = lastEntry.operation.data as any
|
||||
const newData = entry.operation.data as any
|
||||
|
||||
// For each diff operation type, check the relevant state
|
||||
let isDuplicate = false
|
||||
if (entry.operation.type === 'apply-diff') {
|
||||
isDuplicate =
|
||||
JSON.stringify(lastData.baselineSnapshot?.blocks) ===
|
||||
JSON.stringify(newData.baselineSnapshot?.blocks) &&
|
||||
JSON.stringify(lastData.proposedState?.blocks) ===
|
||||
JSON.stringify(newData.proposedState?.blocks)
|
||||
} else if (entry.operation.type === 'accept-diff') {
|
||||
isDuplicate =
|
||||
JSON.stringify(lastData.afterAccept?.blocks) ===
|
||||
JSON.stringify(newData.afterAccept?.blocks)
|
||||
} else if (entry.operation.type === 'reject-diff') {
|
||||
isDuplicate =
|
||||
JSON.stringify(lastData.afterReject?.blocks) ===
|
||||
JSON.stringify(newData.afterReject?.blocks)
|
||||
}
|
||||
|
||||
if (isDuplicate) {
|
||||
logger.debug('Skipping duplicate diff operation', {
|
||||
type: entry.operation.type,
|
||||
workflowId,
|
||||
userId,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Coalesce consecutive batch-move-blocks operations for overlapping blocks
|
||||
if (entry.operation.type === 'batch-move-blocks') {
|
||||
const incoming = entry.operation as BatchMoveBlocksOperation
|
||||
const last = stack.undo[stack.undo.length - 1]
|
||||
|
||||
// Skip no-op moves (all moves have same before/after)
|
||||
const allNoOp = incoming.data.moves.every((move) => {
|
||||
const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null)
|
||||
return move.before.x === move.after.x && move.before.y === move.after.y && sameParent
|
||||
})
|
||||
if (allNoOp) {
|
||||
logger.debug('Skipped no-op batch move push')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
last &&
|
||||
last.operation.type === 'batch-move-blocks' &&
|
||||
last.inverse.type === 'batch-move-blocks'
|
||||
) {
|
||||
const prev = last.operation as BatchMoveBlocksOperation
|
||||
const prevBlockIds = new Set(prev.data.moves.map((m) => m.blockId))
|
||||
const incomingBlockIds = new Set(incoming.data.moves.map((m) => m.blockId))
|
||||
|
||||
// Check if same set of blocks
|
||||
const sameBlocks =
|
||||
prevBlockIds.size === incomingBlockIds.size &&
|
||||
[...prevBlockIds].every((id) => incomingBlockIds.has(id))
|
||||
|
||||
if (sameBlocks) {
|
||||
// Merge: keep earliest before, latest after for each block
|
||||
const mergedMoves = incoming.data.moves.map((incomingMove) => {
|
||||
const prevMove = prev.data.moves.find((m) => m.blockId === incomingMove.blockId)!
|
||||
return {
|
||||
blockId: incomingMove.blockId,
|
||||
before: prevMove.before,
|
||||
after: incomingMove.after,
|
||||
}
|
||||
})
|
||||
|
||||
// Check if all moves result in same position (net no-op)
|
||||
const allSameAfter = mergedMoves.every((move) => {
|
||||
const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null)
|
||||
return (
|
||||
move.before.x === move.after.x && move.before.y === move.after.y && sameParent
|
||||
)
|
||||
})
|
||||
|
||||
const newUndoCoalesced: OperationEntry[] = allSameAfter
|
||||
? stack.undo.slice(0, -1)
|
||||
: (() => {
|
||||
const op = entry.operation as BatchMoveBlocksOperation
|
||||
const inv = entry.inverse as BatchMoveBlocksOperation
|
||||
const newEntry: OperationEntry = {
|
||||
id: entry.id,
|
||||
createdAt: entry.createdAt,
|
||||
operation: {
|
||||
id: op.id,
|
||||
type: 'batch-move-blocks',
|
||||
timestamp: op.timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: { moves: mergedMoves },
|
||||
},
|
||||
inverse: {
|
||||
id: inv.id,
|
||||
type: 'batch-move-blocks',
|
||||
timestamp: inv.timestamp,
|
||||
workflowId,
|
||||
userId,
|
||||
data: {
|
||||
moves: mergedMoves.map((m) => ({
|
||||
blockId: m.blockId,
|
||||
before: m.after,
|
||||
after: m.before,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
return [...stack.undo.slice(0, -1), newEntry]
|
||||
})()
|
||||
|
||||
currentStacks[key] = {
|
||||
undo: newUndoCoalesced,
|
||||
redo: [],
|
||||
lastUpdated: Date.now(),
|
||||
}
|
||||
|
||||
set({ stacks: currentStacks })
|
||||
|
||||
logger.debug('Coalesced consecutive batch move operations', {
|
||||
workflowId,
|
||||
userId,
|
||||
blockCount: mergedMoves.length,
|
||||
undoSize: newUndoCoalesced.length,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newUndo = [...stack.undo, entry]
|
||||
if (newUndo.length > state.capacity) {
|
||||
newUndo.shift()
|
||||
}
|
||||
|
||||
currentStacks[key] = {
|
||||
undo: newUndo,
|
||||
redo: [],
|
||||
lastUpdated: Date.now(),
|
||||
}
|
||||
|
||||
set({ stacks: currentStacks })
|
||||
|
||||
logger.debug('Pushed operation to undo stack', {
|
||||
workflowId,
|
||||
userId,
|
||||
operationType: entry.operation.type,
|
||||
undoSize: newUndo.length,
|
||||
})
|
||||
},
|
||||
|
||||
undo: (workflowId: string, userId: string) => {
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const stack = state.stacks[key]
|
||||
|
||||
if (!stack || stack.undo.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entry = stack.undo[stack.undo.length - 1]
|
||||
const newUndo = stack.undo.slice(0, -1)
|
||||
const newRedo = [...stack.redo, entry]
|
||||
|
||||
if (newRedo.length > state.capacity) {
|
||||
newRedo.shift()
|
||||
}
|
||||
|
||||
set({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[key]: {
|
||||
undo: newUndo,
|
||||
redo: newRedo,
|
||||
lastUpdated: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
logger.debug('Undo operation', {
|
||||
workflowId,
|
||||
userId,
|
||||
operationType: entry.operation.type,
|
||||
undoSize: newUndo.length,
|
||||
redoSize: newRedo.length,
|
||||
})
|
||||
|
||||
return entry
|
||||
},
|
||||
|
||||
redo: (workflowId: string, userId: string) => {
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const stack = state.stacks[key]
|
||||
|
||||
if (!stack || stack.redo.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entry = stack.redo[stack.redo.length - 1]
|
||||
const newRedo = stack.redo.slice(0, -1)
|
||||
const newUndo = [...stack.undo, entry]
|
||||
|
||||
if (newUndo.length > state.capacity) {
|
||||
newUndo.shift()
|
||||
}
|
||||
|
||||
set({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[key]: {
|
||||
undo: newUndo,
|
||||
redo: newRedo,
|
||||
lastUpdated: Date.now(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
logger.debug('Redo operation', {
|
||||
workflowId,
|
||||
userId,
|
||||
operationType: entry.operation.type,
|
||||
undoSize: newUndo.length,
|
||||
redoSize: newRedo.length,
|
||||
})
|
||||
|
||||
return entry
|
||||
},
|
||||
|
||||
clear: (workflowId: string, userId: string) => {
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const { [key]: _, ...rest } = state.stacks
|
||||
|
||||
set({ stacks: rest })
|
||||
|
||||
logger.debug('Cleared undo/redo stacks', { workflowId, userId })
|
||||
},
|
||||
|
||||
clearRedo: (workflowId: string, userId: string) => {
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const stack = state.stacks[key]
|
||||
|
||||
if (!stack) return
|
||||
|
||||
set({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[key]: { ...stack, redo: [] },
|
||||
},
|
||||
})
|
||||
|
||||
logger.debug('Cleared redo stack', { workflowId, userId })
|
||||
},
|
||||
|
||||
getStackSizes: (workflowId: string, userId: string) => {
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const stack = state.stacks[key]
|
||||
|
||||
if (!stack) {
|
||||
return { undoSize: 0, redoSize: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
undoSize: stack.undo.length,
|
||||
redoSize: stack.redo.length,
|
||||
}
|
||||
},
|
||||
|
||||
setCapacity: (capacity: number) => {
|
||||
const state = get()
|
||||
const newStacks: typeof state.stacks = {}
|
||||
|
||||
for (const [key, stack] of Object.entries(state.stacks)) {
|
||||
newStacks[key] = {
|
||||
undo: stack.undo.slice(-capacity),
|
||||
redo: stack.redo.slice(-capacity),
|
||||
lastUpdated: stack.lastUpdated,
|
||||
}
|
||||
}
|
||||
|
||||
set({ capacity, stacks: newStacks })
|
||||
|
||||
logger.debug('Set capacity', { capacity })
|
||||
},
|
||||
|
||||
pruneInvalidEntries: (
|
||||
workflowId: string,
|
||||
userId: string,
|
||||
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
|
||||
) => {
|
||||
const key = getStackKey(workflowId, userId)
|
||||
const state = get()
|
||||
const stack = state.stacks[key]
|
||||
|
||||
if (!stack) return
|
||||
|
||||
const originalUndoCount = stack.undo.length
|
||||
const originalRedoCount = stack.redo.length
|
||||
|
||||
const validUndo = stack.undo.filter((entry) => isOperationApplicable(entry.inverse, graph))
|
||||
|
||||
const validRedo = stack.redo.filter((entry) =>
|
||||
isOperationApplicable(entry.operation, graph)
|
||||
)
|
||||
|
||||
const prunedUndoCount = originalUndoCount - validUndo.length
|
||||
const prunedRedoCount = originalRedoCount - validRedo.length
|
||||
|
||||
if (prunedUndoCount > 0 || prunedRedoCount > 0) {
|
||||
set({
|
||||
stacks: {
|
||||
...state.stacks,
|
||||
[key]: { ...stack, undo: validUndo, redo: validRedo },
|
||||
},
|
||||
})
|
||||
|
||||
logger.debug('Pruned invalid entries', {
|
||||
workflowId,
|
||||
userId,
|
||||
prunedUndo: prunedUndoCount,
|
||||
prunedRedo: prunedRedoCount,
|
||||
remainingUndo: validUndo.length,
|
||||
remainingRedo: validRedo.length,
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'workflow-undo-redo',
|
||||
storage: createJSONStorage(() => safeStorageAdapter),
|
||||
partialize: (state) => ({
|
||||
stacks: state.stacks,
|
||||
capacity: state.capacity,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { UNDO_REDO_OPERATIONS, UndoRedoOperation } from '@sim/realtime-protocol/constants'
|
||||
import type { Edge } from 'reactflow'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export type OperationType = UndoRedoOperation
|
||||
|
||||
interface BaseOperation {
|
||||
id: string
|
||||
type: OperationType
|
||||
timestamp: number
|
||||
workflowId: string
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface BatchAddBlocksOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS
|
||||
data: {
|
||||
blockSnapshots: BlockState[]
|
||||
edgeSnapshots: Edge[]
|
||||
subBlockValues: Record<string, Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
export interface BatchRemoveBlocksOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS
|
||||
data: {
|
||||
blockSnapshots: BlockState[]
|
||||
edgeSnapshots: Edge[]
|
||||
subBlockValues: Record<string, Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
export interface BatchAddEdgesOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES
|
||||
data: {
|
||||
edgeSnapshots: Edge[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface BatchRemoveEdgesOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES
|
||||
data: {
|
||||
edgeSnapshots: Edge[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface BatchMoveBlocksOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS
|
||||
data: {
|
||||
moves: Array<{
|
||||
blockId: string
|
||||
before: { x: number; y: number; parentId?: string }
|
||||
after: { x: number; y: number; parentId?: string }
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateParentOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.UPDATE_PARENT
|
||||
data: {
|
||||
blockId: string
|
||||
oldParentId?: string
|
||||
newParentId?: string
|
||||
oldPosition: { x: number; y: number }
|
||||
newPosition: { x: number; y: number }
|
||||
affectedEdges?: Edge[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface BatchUpdateParentOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT
|
||||
data: {
|
||||
updates: Array<{
|
||||
blockId: string
|
||||
oldParentId?: string
|
||||
newParentId?: string
|
||||
oldPosition: { x: number; y: number }
|
||||
newPosition: { x: number; y: number }
|
||||
affectedEdges?: Edge[]
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export interface BatchToggleEnabledOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED
|
||||
data: {
|
||||
blockIds: string[]
|
||||
previousStates: Record<string, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
export interface BatchToggleHandlesOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES
|
||||
data: {
|
||||
blockIds: string[]
|
||||
previousStates: Record<string, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
export interface BatchToggleLockedOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED
|
||||
data: {
|
||||
blockIds: string[]
|
||||
previousStates: Record<string, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
export interface BatchUpdateSubblocksOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS
|
||||
data: {
|
||||
updates: Array<{
|
||||
blockId: string
|
||||
subBlockId: string
|
||||
before: unknown
|
||||
after: unknown
|
||||
}>
|
||||
subflowUpdates?: Array<{
|
||||
blockId: string
|
||||
blockType: 'loop' | 'parallel'
|
||||
fieldId: string
|
||||
before: unknown
|
||||
after: unknown
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
interface ApplyDiffOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF
|
||||
data: {
|
||||
baselineSnapshot: any // WorkflowState snapshot before diff
|
||||
proposedState: any // WorkflowState with diff applied
|
||||
diffAnalysis: any // DiffAnalysis for re-applying markers
|
||||
}
|
||||
}
|
||||
|
||||
interface AcceptDiffOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.ACCEPT_DIFF
|
||||
data: {
|
||||
beforeAccept: any // WorkflowState with diff markers
|
||||
afterAccept: any // WorkflowState without diff markers
|
||||
diffAnalysis: any // DiffAnalysis to restore markers on undo
|
||||
baselineSnapshot: any // Baseline workflow state
|
||||
}
|
||||
}
|
||||
|
||||
interface RejectDiffOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.REJECT_DIFF
|
||||
data: {
|
||||
beforeReject: any // WorkflowState with diff markers
|
||||
afterReject: any // WorkflowState baseline (after reject)
|
||||
diffAnalysis: any // DiffAnalysis to restore markers on undo
|
||||
baselineSnapshot: any // Baseline workflow state
|
||||
}
|
||||
}
|
||||
|
||||
export type Operation =
|
||||
| BatchAddBlocksOperation
|
||||
| BatchRemoveBlocksOperation
|
||||
| BatchAddEdgesOperation
|
||||
| BatchRemoveEdgesOperation
|
||||
| BatchMoveBlocksOperation
|
||||
| UpdateParentOperation
|
||||
| BatchUpdateParentOperation
|
||||
| BatchToggleEnabledOperation
|
||||
| BatchToggleHandlesOperation
|
||||
| BatchToggleLockedOperation
|
||||
| BatchUpdateSubblocksOperation
|
||||
| ApplyDiffOperation
|
||||
| AcceptDiffOperation
|
||||
| RejectDiffOperation
|
||||
|
||||
export interface OperationEntry {
|
||||
id: string
|
||||
operation: Operation
|
||||
inverse: Operation
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface UndoRedoState {
|
||||
stacks: Record<
|
||||
string,
|
||||
{
|
||||
undo: OperationEntry[]
|
||||
redo: OperationEntry[]
|
||||
lastUpdated?: number
|
||||
}
|
||||
>
|
||||
capacity: number
|
||||
push: (workflowId: string, userId: string, entry: OperationEntry) => void
|
||||
undo: (workflowId: string, userId: string) => OperationEntry | null
|
||||
redo: (workflowId: string, userId: string) => OperationEntry | null
|
||||
clear: (workflowId: string, userId: string) => void
|
||||
clearRedo: (workflowId: string, userId: string) => void
|
||||
getStackSizes: (workflowId: string, userId: string) => { undoSize: number; redoSize: number }
|
||||
setCapacity: (capacity: number) => void
|
||||
pruneInvalidEntries: (
|
||||
workflowId: string,
|
||||
userId: string,
|
||||
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
|
||||
) => void
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import type { Edge } from 'reactflow'
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
vi.mock('@/stores/workflows/utils', () => ({
|
||||
mergeSubblockState: vi.fn(),
|
||||
}))
|
||||
|
||||
import { UNDO_REDO_OPERATIONS } from '@sim/realtime-protocol/constants'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { captureLatestEdges, captureLatestSubBlockValues, createInverseOperation } from './utils'
|
||||
|
||||
const mockMergeSubblockState = mergeSubblockState as Mock
|
||||
|
||||
describe('captureLatestEdges', () => {
|
||||
const createEdge = (id: string, source: string, target: string): Edge => ({
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
})
|
||||
|
||||
it('should return edges where blockId is the source', () => {
|
||||
const edges = [
|
||||
createEdge('edge-1', 'block-1', 'block-2'),
|
||||
createEdge('edge-2', 'block-3', 'block-4'),
|
||||
]
|
||||
|
||||
const result = captureLatestEdges(edges, ['block-1'])
|
||||
|
||||
expect(result).toEqual([createEdge('edge-1', 'block-1', 'block-2')])
|
||||
})
|
||||
|
||||
it('should return edges where blockId is the target', () => {
|
||||
const edges = [
|
||||
createEdge('edge-1', 'block-1', 'block-2'),
|
||||
createEdge('edge-2', 'block-3', 'block-4'),
|
||||
]
|
||||
|
||||
const result = captureLatestEdges(edges, ['block-2'])
|
||||
|
||||
expect(result).toEqual([createEdge('edge-1', 'block-1', 'block-2')])
|
||||
})
|
||||
|
||||
it('should return edges for multiple blocks', () => {
|
||||
const edges = [
|
||||
createEdge('edge-1', 'block-1', 'block-2'),
|
||||
createEdge('edge-2', 'block-3', 'block-4'),
|
||||
createEdge('edge-3', 'block-2', 'block-5'),
|
||||
]
|
||||
|
||||
const result = captureLatestEdges(edges, ['block-1', 'block-2'])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
|
||||
expect(result).toContainEqual(createEdge('edge-3', 'block-2', 'block-5'))
|
||||
})
|
||||
|
||||
it('should return empty array when no edges match', () => {
|
||||
const edges = [
|
||||
createEdge('edge-1', 'block-1', 'block-2'),
|
||||
createEdge('edge-2', 'block-3', 'block-4'),
|
||||
]
|
||||
|
||||
const result = captureLatestEdges(edges, ['block-99'])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array when blockIds is empty', () => {
|
||||
const edges = [
|
||||
createEdge('edge-1', 'block-1', 'block-2'),
|
||||
createEdge('edge-2', 'block-3', 'block-4'),
|
||||
]
|
||||
|
||||
const result = captureLatestEdges(edges, [])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return edge when block has both source and target edges', () => {
|
||||
const edges = [
|
||||
createEdge('edge-1', 'block-1', 'block-2'),
|
||||
createEdge('edge-2', 'block-2', 'block-3'),
|
||||
createEdge('edge-3', 'block-4', 'block-2'),
|
||||
]
|
||||
|
||||
const result = captureLatestEdges(edges, ['block-2'])
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
|
||||
expect(result).toContainEqual(createEdge('edge-2', 'block-2', 'block-3'))
|
||||
expect(result).toContainEqual(createEdge('edge-3', 'block-4', 'block-2'))
|
||||
})
|
||||
|
||||
it('should handle empty edges array', () => {
|
||||
const result = captureLatestEdges([], ['block-1'])
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should not duplicate edges when block appears in multiple blockIds', () => {
|
||||
const edges = [createEdge('edge-1', 'block-1', 'block-2')]
|
||||
|
||||
const result = captureLatestEdges(edges, ['block-1', 'block-2'])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result).toContainEqual(createEdge('edge-1', 'block-1', 'block-2'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureLatestSubBlockValues', () => {
|
||||
const workflowId = 'wf-test'
|
||||
|
||||
const createBlockState = (
|
||||
id: string,
|
||||
subBlocks: Record<string, { id: string; type: string; value: unknown }>
|
||||
): BlockState =>
|
||||
({
|
||||
id,
|
||||
type: 'function',
|
||||
name: 'Test Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: Object.fromEntries(
|
||||
Object.entries(subBlocks).map(([subId, sb]) => [
|
||||
subId,
|
||||
{ id: sb.id, type: sb.type, value: sb.value },
|
||||
])
|
||||
),
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}) as BlockState
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should capture single block with single subblock value', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'console.log("hello")' },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { code: 'console.log("hello")' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should capture single block with multiple subblock values', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'test code' },
|
||||
model: { id: 'model', type: 'dropdown', value: 'gpt-4' },
|
||||
temperature: { id: 'temperature', type: 'slider', value: 0.7 },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': {
|
||||
code: 'test code',
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should capture multiple blocks with values', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'code 1' },
|
||||
}),
|
||||
'block-2': createBlockState('block-2', {
|
||||
prompt: { id: 'prompt', type: 'long-input', value: 'hello world' },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockImplementation((_blocks, _wfId, blockId) => {
|
||||
if (blockId === 'block-1') return { 'block-1': blocks['block-1'] }
|
||||
if (blockId === 'block-2') return { 'block-2': blocks['block-2'] }
|
||||
return {}
|
||||
})
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1', 'block-2'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { code: 'code 1' },
|
||||
'block-2': { prompt: 'hello world' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip null values', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'valid code' },
|
||||
empty: { id: 'empty', type: 'short-input', value: null },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { code: 'valid code' },
|
||||
})
|
||||
expect(result['block-1']).not.toHaveProperty('empty')
|
||||
})
|
||||
|
||||
it('should skip undefined values', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'valid code' },
|
||||
empty: { id: 'empty', type: 'short-input', value: undefined },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { code: 'valid code' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty object for block with no subBlocks', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'function',
|
||||
name: 'Test Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
} as BlockState,
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should return empty object for non-existent blockId', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'test' },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue({})
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['non-existent'])
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should return empty object when blockIds is empty', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'test' },
|
||||
}),
|
||||
}
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, [])
|
||||
|
||||
expect(result).toEqual({})
|
||||
expect(mockMergeSubblockState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle various value types (string, number, array)', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
text: { id: 'text', type: 'short-input', value: 'string value' },
|
||||
number: { id: 'number', type: 'slider', value: 42 },
|
||||
array: {
|
||||
id: 'array',
|
||||
type: 'table',
|
||||
value: [
|
||||
['a', 'b'],
|
||||
['c', 'd'],
|
||||
],
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': {
|
||||
text: 'string value',
|
||||
number: 42,
|
||||
array: [
|
||||
['a', 'b'],
|
||||
['c', 'd'],
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should only capture values for blockIds in the list', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: 'code 1' },
|
||||
}),
|
||||
'block-2': createBlockState('block-2', {
|
||||
code: { id: 'code', type: 'code', value: 'code 2' },
|
||||
}),
|
||||
'block-3': createBlockState('block-3', {
|
||||
code: { id: 'code', type: 'code', value: 'code 3' },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockImplementation((_blocks, _wfId, blockId) => {
|
||||
if (blockId === 'block-1') return { 'block-1': blocks['block-1'] }
|
||||
if (blockId === 'block-3') return { 'block-3': blocks['block-3'] }
|
||||
return {}
|
||||
})
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1', 'block-3'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { code: 'code 1' },
|
||||
'block-3': { code: 'code 3' },
|
||||
})
|
||||
expect(result).not.toHaveProperty('block-2')
|
||||
})
|
||||
|
||||
it('should handle block without subBlocks property', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': {
|
||||
id: 'block-1',
|
||||
type: 'function',
|
||||
name: 'Test Block',
|
||||
position: { x: 0, y: 0 },
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
} as BlockState,
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should handle empty string values', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
code: { id: 'code', type: 'code', value: '' },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { code: '' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle zero numeric values', () => {
|
||||
const blocks: Record<string, BlockState> = {
|
||||
'block-1': createBlockState('block-1', {
|
||||
temperature: { id: 'temperature', type: 'slider', value: 0 },
|
||||
}),
|
||||
}
|
||||
|
||||
mockMergeSubblockState.mockReturnValue(blocks)
|
||||
|
||||
const result = captureLatestSubBlockValues(blocks, workflowId, ['block-1'])
|
||||
|
||||
expect(result).toEqual({
|
||||
'block-1': { temperature: 0 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('createInverseOperation', () => {
|
||||
it('inverts batch subblock updates', () => {
|
||||
const operation = {
|
||||
id: 'op-1',
|
||||
type: UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS,
|
||||
timestamp: 1,
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'user-1',
|
||||
data: {
|
||||
updates: [
|
||||
{
|
||||
blockId: 'block-1',
|
||||
subBlockId: 'prompt',
|
||||
before: 'old',
|
||||
after: 'new',
|
||||
},
|
||||
],
|
||||
subflowUpdates: [
|
||||
{
|
||||
blockId: 'loop-1',
|
||||
blockType: 'loop',
|
||||
fieldId: 'subflowIterations',
|
||||
before: 2,
|
||||
after: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
expect(createInverseOperation(operation)).toEqual({
|
||||
...operation,
|
||||
data: {
|
||||
updates: [
|
||||
{
|
||||
blockId: 'block-1',
|
||||
subBlockId: 'prompt',
|
||||
before: 'new',
|
||||
after: 'old',
|
||||
},
|
||||
],
|
||||
subflowUpdates: [
|
||||
{
|
||||
blockId: 'loop-1',
|
||||
blockType: 'loop',
|
||||
fieldId: 'subflowIterations',
|
||||
before: 3,
|
||||
after: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { UNDO_REDO_OPERATIONS } from '@sim/realtime-protocol/constants'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import type { Edge } from 'reactflow'
|
||||
import type {
|
||||
BatchAddBlocksOperation,
|
||||
BatchAddEdgesOperation,
|
||||
BatchMoveBlocksOperation,
|
||||
BatchRemoveBlocksOperation,
|
||||
BatchRemoveEdgesOperation,
|
||||
BatchUpdateParentOperation,
|
||||
BatchUpdateSubblocksOperation,
|
||||
Operation,
|
||||
OperationEntry,
|
||||
} from '@/stores/undo-redo/types'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
|
||||
return {
|
||||
id: generateId(),
|
||||
operation,
|
||||
inverse,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
export function createInverseOperation(operation: Operation): Operation {
|
||||
switch (operation.type) {
|
||||
case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: {
|
||||
const op = operation as BatchAddBlocksOperation
|
||||
return {
|
||||
...operation,
|
||||
type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS,
|
||||
data: {
|
||||
blockSnapshots: op.data.blockSnapshots,
|
||||
edgeSnapshots: op.data.edgeSnapshots,
|
||||
subBlockValues: op.data.subBlockValues,
|
||||
},
|
||||
} as BatchRemoveBlocksOperation
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: {
|
||||
const op = operation as BatchRemoveBlocksOperation
|
||||
return {
|
||||
...operation,
|
||||
type: UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS,
|
||||
data: {
|
||||
blockSnapshots: op.data.blockSnapshots,
|
||||
edgeSnapshots: op.data.edgeSnapshots,
|
||||
subBlockValues: op.data.subBlockValues,
|
||||
},
|
||||
} as BatchAddBlocksOperation
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: {
|
||||
const op = operation as BatchAddEdgesOperation
|
||||
return {
|
||||
...operation,
|
||||
type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES,
|
||||
data: {
|
||||
edgeSnapshots: op.data.edgeSnapshots,
|
||||
},
|
||||
} as BatchRemoveEdgesOperation
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: {
|
||||
const op = operation as BatchRemoveEdgesOperation
|
||||
return {
|
||||
...operation,
|
||||
type: UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES,
|
||||
data: {
|
||||
edgeSnapshots: op.data.edgeSnapshots,
|
||||
},
|
||||
} as BatchAddEdgesOperation
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: {
|
||||
const op = operation as BatchMoveBlocksOperation
|
||||
return {
|
||||
...operation,
|
||||
type: UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS,
|
||||
data: {
|
||||
moves: op.data.moves.map((m) => ({
|
||||
blockId: m.blockId,
|
||||
before: m.after,
|
||||
after: m.before,
|
||||
})),
|
||||
},
|
||||
} as BatchMoveBlocksOperation
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.UPDATE_PARENT:
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
blockId: operation.data.blockId,
|
||||
oldParentId: operation.data.newParentId,
|
||||
newParentId: operation.data.oldParentId,
|
||||
oldPosition: operation.data.newPosition,
|
||||
newPosition: operation.data.oldPosition,
|
||||
affectedEdges: operation.data.affectedEdges,
|
||||
},
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: {
|
||||
const op = operation as BatchUpdateParentOperation
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
updates: op.data.updates.map((u) => ({
|
||||
blockId: u.blockId,
|
||||
oldParentId: u.newParentId,
|
||||
newParentId: u.oldParentId,
|
||||
oldPosition: u.newPosition,
|
||||
newPosition: u.oldPosition,
|
||||
affectedEdges: u.affectedEdges,
|
||||
})),
|
||||
},
|
||||
} as BatchUpdateParentOperation
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.APPLY_DIFF:
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
baselineSnapshot: operation.data.proposedState,
|
||||
proposedState: operation.data.baselineSnapshot,
|
||||
diffAnalysis: operation.data.diffAnalysis,
|
||||
},
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.ACCEPT_DIFF:
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
beforeAccept: operation.data.afterAccept,
|
||||
afterAccept: operation.data.beforeAccept,
|
||||
diffAnalysis: operation.data.diffAnalysis,
|
||||
baselineSnapshot: operation.data.baselineSnapshot,
|
||||
},
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.REJECT_DIFF:
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
beforeReject: operation.data.afterReject,
|
||||
afterReject: operation.data.beforeReject,
|
||||
diffAnalysis: operation.data.diffAnalysis,
|
||||
baselineSnapshot: operation.data.baselineSnapshot,
|
||||
},
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED:
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
blockIds: operation.data.blockIds,
|
||||
previousStates: operation.data.previousStates,
|
||||
},
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES:
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
blockIds: operation.data.blockIds,
|
||||
previousStates: operation.data.previousStates,
|
||||
},
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED:
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
blockIds: operation.data.blockIds,
|
||||
previousStates: operation.data.previousStates,
|
||||
},
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.BATCH_UPDATE_SUBBLOCKS: {
|
||||
const op = operation as BatchUpdateSubblocksOperation
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
updates: op.data.updates.map((update) => ({
|
||||
blockId: update.blockId,
|
||||
subBlockId: update.subBlockId,
|
||||
before: update.after,
|
||||
after: update.before,
|
||||
})),
|
||||
subflowUpdates: op.data.subflowUpdates?.map((update) => ({
|
||||
blockId: update.blockId,
|
||||
blockType: update.blockType,
|
||||
fieldId: update.fieldId,
|
||||
before: update.after,
|
||||
after: update.before,
|
||||
})),
|
||||
},
|
||||
} as BatchUpdateSubblocksOperation
|
||||
}
|
||||
|
||||
default: {
|
||||
const exhaustiveCheck: never = operation
|
||||
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function captureLatestEdges(edges: Edge[], blockIds: string[]): Edge[] {
|
||||
return edges.filter((e) => blockIds.includes(e.source) || blockIds.includes(e.target))
|
||||
}
|
||||
|
||||
export function captureLatestSubBlockValues(
|
||||
blocks: Record<string, BlockState>,
|
||||
workflowId: string,
|
||||
blockIds: string[]
|
||||
): Record<string, Record<string, unknown>> {
|
||||
const values: Record<string, Record<string, unknown>> = {}
|
||||
blockIds.forEach((blockId) => {
|
||||
const merged = mergeSubblockState(blocks, workflowId, blockId)
|
||||
const block = merged[blockId]
|
||||
if (block?.subBlocks) {
|
||||
const blockValues: Record<string, unknown> = {}
|
||||
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
|
||||
if (subBlock.value !== null && subBlock.value !== undefined) {
|
||||
blockValues[subBlockId] = subBlock.value
|
||||
}
|
||||
})
|
||||
if (Object.keys(blockValues).length > 0) {
|
||||
values[blockId] = blockValues
|
||||
}
|
||||
}
|
||||
})
|
||||
return values
|
||||
}
|
||||
Reference in New Issue
Block a user