Files
sim/apps/sim/stores/panel/variables/store.ts
Vikhyath Mondreti bf5d0a5573 feat(copy-paste): allow cross workflow selection, paste, move for blocks (#2649)
* feat(copy-paste): allow cross workflow selection, paste, move for blocks

* fix drag options

* add keyboard and mouse controls into docs

* refactor sockets and undo/redo for batch additions and removals

* fix tests

* cleanup more code

* fix perms issue

* fix subflow copy/paste

* remove log file

* fit paste in viewport bounds

* fix deselection
2025-12-31 02:47:06 -08:00

291 lines
9.2 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
import type { Variable, VariablesStore } from '@/stores/panel/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { normalizeName } from '@/stores/workflows/utils'
const logger = createLogger('VariablesStore')
function validateVariable(variable: Variable): string | undefined {
try {
switch (variable.type) {
case 'number':
if (Number.isNaN(Number(variable.value))) {
return 'Not a valid number'
}
break
case 'boolean':
if (!/^(true|false)$/i.test(String(variable.value).trim())) {
return 'Expected "true" or "false"'
}
break
case 'object':
try {
const valueToEvaluate = String(variable.value).trim()
if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) {
return 'Not a valid object format'
}
const parsed = new Function(`return ${valueToEvaluate}`)()
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return 'Not a valid object'
}
return undefined
} catch (e) {
logger.error('Object parsing error:', e)
return 'Invalid object syntax'
}
case 'array':
try {
const parsed = JSON.parse(String(variable.value))
if (!Array.isArray(parsed)) {
return 'Not a valid JSON array'
}
} catch {
return 'Invalid JSON array syntax'
}
break
}
return undefined
} catch (e) {
return e instanceof Error ? e.message : 'Invalid format'
}
}
function migrateStringToPlain(variable: Variable): Variable {
if (variable.type !== 'string') {
return variable
}
const updated = {
...variable,
type: 'plain' as const,
}
return updated
}
export const useVariablesStore = create<VariablesStore>()(
devtools((set, get) => ({
variables: {},
isLoading: false,
error: null,
isEditing: null,
async loadForWorkflow(workflowId) {
try {
set({ isLoading: true, error: null })
const res = await fetch(`/api/workflows/${workflowId}/variables`, { method: 'GET' })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `Failed to load variables: ${res.statusText}`)
}
const data = await res.json()
const variables = (data?.data as Record<string, Variable>) || {}
set((state) => {
const withoutWorkflow = Object.fromEntries(
Object.entries(state.variables).filter(
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
)
)
return {
variables: { ...withoutWorkflow, ...variables },
isLoading: false,
error: null,
}
})
} catch (e) {
const message = e instanceof Error ? e.message : 'Unknown error'
set({ isLoading: false, error: message })
}
},
addVariable: (variable, providedId?: string) => {
const id = providedId || crypto.randomUUID()
const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId)
if (!variable.name || /^variable\d+$/.test(variable.name)) {
const existingNumbers = workflowVariables
.map((v) => {
const match = v.name.match(/^variable(\d+)$/)
return match ? Number.parseInt(match[1]) : 0
})
.filter((n) => !Number.isNaN(n))
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
variable.name = `variable${nextNumber}`
}
let uniqueName = variable.name
let nameIndex = 1
while (workflowVariables.some((v) => v.name === uniqueName)) {
uniqueName = `${variable.name} (${nameIndex})`
nameIndex++
}
if (variable.type === 'string') {
variable.type = 'plain'
}
const newVariable: Variable = {
id,
workflowId: variable.workflowId,
name: uniqueName,
type: variable.type,
value: variable.value || '',
validationError: undefined,
}
const validationError = validateVariable(newVariable)
if (validationError) {
newVariable.validationError = validationError
}
set((state) => ({
variables: {
...state.variables,
[id]: newVariable,
},
}))
return id
},
updateVariable: (id, update) => {
set((state) => {
if (!state.variables[id]) return state
if (update.name !== undefined) {
const oldVariable = state.variables[id]
const oldVariableName = oldVariable.name
const newName = update.name.trim()
if (!newName) {
update = { ...update }
update.name = undefined
} else if (newName !== oldVariableName) {
const subBlockStore = useSubBlockStore.getState()
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) {
const workflowValues = subBlockStore.workflowValues[activeWorkflowId] || {}
const updatedWorkflowValues = { ...workflowValues }
const changedSubBlocks: Array<{ blockId: string; subBlockId: string; value: any }> =
[]
const oldVarName = normalizeName(oldVariableName)
const newVarName = normalizeName(newName)
const regex = new RegExp(`<variable\\.${oldVarName}>`, 'gi')
const updateReferences = (value: any, pattern: RegExp, replacement: string): any => {
if (typeof value === 'string') {
return pattern.test(value) ? value.replace(pattern, replacement) : value
}
if (Array.isArray(value)) {
return value.map((item) => updateReferences(item, pattern, replacement))
}
if (value !== null && typeof value === 'object') {
const result = { ...value }
for (const key in result) {
result[key] = updateReferences(result[key], pattern, replacement)
}
return result
}
return value
}
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
Object.entries(blockValues as Record<string, any>).forEach(
([subBlockId, value]) => {
const updatedValue = updateReferences(value, regex, `<variable.${newVarName}>`)
if (JSON.stringify(updatedValue) !== JSON.stringify(value)) {
if (!updatedWorkflowValues[blockId]) {
updatedWorkflowValues[blockId] = { ...workflowValues[blockId] }
}
updatedWorkflowValues[blockId][subBlockId] = updatedValue
changedSubBlocks.push({ blockId, subBlockId, value: updatedValue })
}
}
)
})
// Update local state
useSubBlockStore.setState({
workflowValues: {
...subBlockStore.workflowValues,
[activeWorkflowId]: updatedWorkflowValues,
},
})
// Queue operations for persistence via socket
const operationQueue = useOperationQueueStore.getState()
for (const { blockId, subBlockId, value } of changedSubBlocks) {
operationQueue.addToQueue({
id: crypto.randomUUID(),
operation: {
operation: 'subblock-update',
target: 'subblock',
payload: { blockId, subblockId: subBlockId, value },
},
workflowId: activeWorkflowId,
userId: 'system',
})
}
}
}
}
if (update.type === 'string') {
update = { ...update, type: 'plain' }
}
const updatedVariable: Variable = {
...state.variables[id],
...update,
validationError: undefined,
}
if (update.type || update.value !== undefined) {
updatedVariable.validationError = validateVariable(updatedVariable)
}
const updated = {
...state.variables,
[id]: updatedVariable,
}
return { variables: updated }
})
},
deleteVariable: (id) => {
set((state) => {
if (!state.variables[id]) return state
const workflowId = state.variables[id].workflowId
const { [id]: _, ...rest } = state.variables
return { variables: rest }
})
},
getVariablesByWorkflowId: (workflowId) => {
return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId)
},
}))
)