mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
v0.5.39: notion, workflow variables fixes
This commit is contained in:
@@ -37,7 +37,7 @@ import { useWand } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-
|
||||
import type { GenerationType } from '@/blocks/types'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import { useTagSelection } from '@/hooks/use-tag-selection'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
|
||||
const logger = createLogger('Code')
|
||||
|
||||
@@ -602,7 +602,7 @@ export function Code({
|
||||
|
||||
const inner = reference.slice(1, -1)
|
||||
const [prefix] = inner.split('.')
|
||||
const normalizedPrefix = normalizeBlockName(prefix)
|
||||
const normalizedPrefix = normalizeName(prefix)
|
||||
|
||||
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
|
||||
return true
|
||||
|
||||
@@ -33,7 +33,7 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import { useTagSelection } from '@/hooks/use-tag-selection'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('ConditionInput')
|
||||
@@ -139,7 +139,7 @@ export function ConditionInput({
|
||||
|
||||
const inner = reference.slice(1, -1)
|
||||
const [prefix] = inner.split('.')
|
||||
const normalizedPrefix = normalizeBlockName(prefix)
|
||||
const normalizedPrefix = normalizeName(prefix)
|
||||
|
||||
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
|
||||
return true
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from 'react'
|
||||
import { splitReferenceSegment } from '@/lib/workflows/sanitization/references'
|
||||
import { REFERENCE } from '@/executor/constants'
|
||||
import { createCombinedPattern } from '@/executor/utils/reference-validation'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
|
||||
export interface HighlightContext {
|
||||
accessiblePrefixes?: Set<string>
|
||||
@@ -31,7 +31,7 @@ export function formatDisplayText(text: string, context?: HighlightContext): Rea
|
||||
|
||||
const inner = reference.slice(1, -1)
|
||||
const [prefix] = inner.split('.')
|
||||
const normalizedPrefix = normalizeBlockName(prefix)
|
||||
const normalizedPrefix = normalizeName(prefix)
|
||||
|
||||
if (SYSTEM_PREFIXES.has(normalizedPrefix)) {
|
||||
return true
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import type { Variable } 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'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
@@ -117,20 +118,6 @@ const TAG_PREFIXES = {
|
||||
VARIABLE: 'variable.',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Normalizes a block name by removing spaces and converting to lowercase
|
||||
*/
|
||||
const normalizeBlockName = (blockName: string): string => {
|
||||
return blockName.replace(/\s+/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a variable name by removing spaces
|
||||
*/
|
||||
const normalizeVariableName = (variableName: string): string => {
|
||||
return variableName.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the root tag is present in the tags array
|
||||
*/
|
||||
@@ -521,7 +508,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
if (sourceBlock.type === 'loop' || sourceBlock.type === 'parallel') {
|
||||
const mockConfig = { outputs: { results: 'array' } }
|
||||
const blockName = sourceBlock.name || sourceBlock.type
|
||||
const normalizedBlockName = normalizeBlockName(blockName)
|
||||
const normalizedBlockName = normalizeName(blockName)
|
||||
|
||||
const outputPaths = generateOutputPaths(mockConfig.outputs)
|
||||
const blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
@@ -542,7 +529,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
}
|
||||
|
||||
const blockName = sourceBlock.name || sourceBlock.type
|
||||
const normalizedBlockName = normalizeBlockName(blockName)
|
||||
const normalizedBlockName = normalizeName(blockName)
|
||||
|
||||
const mergedSubBlocks = getMergedSubBlocks(activeSourceBlockId)
|
||||
const responseFormatValue = mergedSubBlocks?.responseFormat?.value
|
||||
@@ -735,12 +722,12 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
)
|
||||
|
||||
const variableTags = validVariables.map(
|
||||
(variable: Variable) => `${TAG_PREFIXES.VARIABLE}${normalizeVariableName(variable.name)}`
|
||||
(variable: Variable) => `${TAG_PREFIXES.VARIABLE}${normalizeName(variable.name)}`
|
||||
)
|
||||
|
||||
const variableInfoMap = validVariables.reduce(
|
||||
(acc, variable) => {
|
||||
const tagName = `${TAG_PREFIXES.VARIABLE}${normalizeVariableName(variable.name)}`
|
||||
const tagName = `${TAG_PREFIXES.VARIABLE}${normalizeName(variable.name)}`
|
||||
acc[tagName] = {
|
||||
type: variable.type,
|
||||
id: variable.id,
|
||||
@@ -865,7 +852,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
|
||||
const mockConfig = { outputs: { results: 'array' } }
|
||||
const blockName = accessibleBlock.name || accessibleBlock.type
|
||||
const normalizedBlockName = normalizeBlockName(blockName)
|
||||
const normalizedBlockName = normalizeName(blockName)
|
||||
|
||||
const outputPaths = generateOutputPaths(mockConfig.outputs)
|
||||
let blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
@@ -885,7 +872,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
}
|
||||
|
||||
const blockName = accessibleBlock.name || accessibleBlock.type
|
||||
const normalizedBlockName = normalizeBlockName(blockName)
|
||||
const normalizedBlockName = normalizeName(blockName)
|
||||
|
||||
const mergedSubBlocks = getMergedSubBlocks(accessibleBlockId)
|
||||
const responseFormatValue = mergedSubBlocks?.responseFormat?.value
|
||||
|
||||
@@ -9,7 +9,7 @@ import { checkTagTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/co
|
||||
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
@@ -110,7 +110,7 @@ export function useSubflowEditor(currentBlock: BlockState | null, currentBlockId
|
||||
|
||||
const inner = reference.slice(1, -1)
|
||||
const [prefix] = inner.split('.')
|
||||
const normalizedPrefix = normalizeBlockName(prefix)
|
||||
const normalizedPrefix = normalizeName(prefix)
|
||||
|
||||
if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) {
|
||||
return true
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||
import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
|
||||
@@ -53,10 +53,10 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set<str
|
||||
|
||||
const prefixes = new Set<string>()
|
||||
accessibleIds.forEach((id) => {
|
||||
prefixes.add(normalizeBlockName(id))
|
||||
prefixes.add(normalizeName(id))
|
||||
const block = blocks[id]
|
||||
if (block?.name) {
|
||||
prefixes.add(normalizeBlockName(block.name))
|
||||
prefixes.add(normalizeName(block.name))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -52,6 +52,17 @@ interface UIEnvironmentVariable {
|
||||
id?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an environment variable key.
|
||||
* Returns an error message if invalid, undefined if valid.
|
||||
*/
|
||||
function validateEnvVarKey(key: string): string | undefined {
|
||||
if (!key) return undefined
|
||||
if (key.includes(' ')) return 'Spaces are not allowed'
|
||||
if (!ENV_VAR_PATTERN.test(key)) return 'Only letters, numbers, and underscores allowed'
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface EnvironmentVariablesProps {
|
||||
registerBeforeLeaveHandler?: (handler: (onProceed: () => void) => void) => void
|
||||
}
|
||||
@@ -222,6 +233,10 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
return envVars.some((envVar) => !!envVar.key && Object.hasOwn(workspaceVars, envVar.key))
|
||||
}, [envVars, workspaceVars])
|
||||
|
||||
const hasInvalidKeys = useMemo(() => {
|
||||
return envVars.some((envVar) => !!envVar.key && validateEnvVarKey(envVar.key))
|
||||
}, [envVars])
|
||||
|
||||
useEffect(() => {
|
||||
hasChangesRef.current = hasChanges
|
||||
}, [hasChanges])
|
||||
@@ -551,6 +566,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
const renderEnvVarRow = useCallback(
|
||||
(envVar: UIEnvironmentVariable, originalIndex: number) => {
|
||||
const isConflict = !!envVar.key && Object.hasOwn(workspaceVars, envVar.key)
|
||||
const keyError = validateEnvVarKey(envVar.key)
|
||||
const maskedValueStyle =
|
||||
focusedValueIndex !== originalIndex && !isConflict
|
||||
? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)
|
||||
@@ -571,7 +587,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
spellCheck='false'
|
||||
readOnly
|
||||
onFocus={(e) => e.target.removeAttribute('readOnly')}
|
||||
className={`h-9 ${isConflict ? conflictClassName : ''}`}
|
||||
className={`h-9 ${isConflict ? conflictClassName : ''} ${keyError ? 'border-[var(--text-error)]' : ''}`}
|
||||
/>
|
||||
<div />
|
||||
<EmcnInput
|
||||
@@ -627,7 +643,12 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
{isConflict && (
|
||||
{keyError && (
|
||||
<div className='col-span-3 mt-[4px] text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{keyError}
|
||||
</div>
|
||||
)}
|
||||
{isConflict && !keyError && (
|
||||
<div className='col-span-3 mt-[4px] text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
Workspace variable with the same name overrides this. Rename your personal key to use
|
||||
it.
|
||||
@@ -707,14 +728,17 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !hasChanges || hasConflicts}
|
||||
disabled={isLoading || !hasChanges || hasConflicts || hasInvalidKeys}
|
||||
variant='primary'
|
||||
className={`${PRIMARY_BUTTON_STYLES} ${hasConflicts ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
className={`${PRIMARY_BUTTON_STYLES} ${hasConflicts || hasInvalidKeys ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{hasConflicts && <Tooltip.Content>Resolve all conflicts before saving</Tooltip.Content>}
|
||||
{hasInvalidKeys && !hasConflicts && (
|
||||
<Tooltip.Content>Fix invalid variable names before saving</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
|
||||
@@ -808,8 +832,8 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
{hasConflicts
|
||||
? 'You have unsaved changes, but conflicts must be resolved before saving. You can discard your changes to close the modal.'
|
||||
{hasConflicts || hasInvalidKeys
|
||||
? `You have unsaved changes, but ${hasConflicts ? 'conflicts must be resolved' : 'invalid variable names must be fixed'} before saving. You can discard your changes to close the modal.`
|
||||
: 'You have unsaved changes. Do you want to save them before closing?'}
|
||||
</p>
|
||||
</ModalBody>
|
||||
@@ -817,18 +841,22 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
<Button variant='default' onClick={handleCancel}>
|
||||
Discard Changes
|
||||
</Button>
|
||||
{hasConflicts ? (
|
||||
{hasConflicts || hasInvalidKeys ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
disabled={true}
|
||||
variant='primary'
|
||||
className='cursor-not-allowed opacity-50'
|
||||
className={`${PRIMARY_BUTTON_STYLES} cursor-not-allowed opacity-50`}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Resolve all conflicts before saving</Tooltip.Content>
|
||||
<Tooltip.Content>
|
||||
{hasConflicts
|
||||
? 'Resolve all conflicts before saving'
|
||||
: 'Fix invalid variable names before saving'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
<Button onClick={handleSave} variant='primary' className={PRIMARY_BUTTON_STYLES}>
|
||||
|
||||
@@ -35,7 +35,6 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
|
||||
title: 'Notion Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'notion',
|
||||
requiredScopes: ['workspace.content', 'workspace.name', 'page.read', 'page.write'],
|
||||
placeholder: 'Select Notion account',
|
||||
required: true,
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/types'
|
||||
import { collectBlockData } from '@/executor/utils/block-data'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
const logger = createLogger('HumanInTheLoopBlockHandler')
|
||||
@@ -591,7 +591,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
|
||||
|
||||
if (pauseBlockName) {
|
||||
blockNameMappingWithPause[pauseBlockName] = pauseBlockId
|
||||
blockNameMappingWithPause[normalizeBlockName(pauseBlockName)] = pauseBlockId
|
||||
blockNameMappingWithPause[normalizeName(pauseBlockName)] = pauseBlockId
|
||||
}
|
||||
|
||||
const notificationPromises = tools.map<Promise<NotificationToolResult>>(async (toolConfig) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type Resolver,
|
||||
} from '@/executor/variables/resolvers/reference'
|
||||
import type { SerializedWorkflow } from '@/serializer/types'
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
|
||||
export class BlockResolver implements Resolver {
|
||||
private blockByNormalizedName: Map<string, string>
|
||||
@@ -15,7 +15,7 @@ export class BlockResolver implements Resolver {
|
||||
for (const block of workflow.blocks) {
|
||||
this.blockByNormalizedName.set(block.id, block.id)
|
||||
if (block.metadata?.name) {
|
||||
const normalized = normalizeBlockName(block.metadata.name)
|
||||
const normalized = normalizeName(block.metadata.name)
|
||||
this.blockByNormalizedName.set(normalized, block.id)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export class BlockResolver implements Resolver {
|
||||
if (this.blockByNormalizedName.has(name)) {
|
||||
return this.blockByNormalizedName.get(name)
|
||||
}
|
||||
const normalized = normalizeBlockName(name)
|
||||
const normalized = normalizeName(name)
|
||||
return this.blockByNormalizedName.get(normalized)
|
||||
}
|
||||
|
||||
|
||||
219
apps/sim/executor/variables/resolvers/workflow.test.ts
Normal file
219
apps/sim/executor/variables/resolvers/workflow.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ResolutionContext } from './reference'
|
||||
import { WorkflowResolver } from './workflow'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/variables/variable-manager', () => ({
|
||||
VariableManager: {
|
||||
resolveForExecution: vi.fn((value) => value),
|
||||
},
|
||||
}))
|
||||
|
||||
/**
|
||||
* Creates a minimal ResolutionContext for testing.
|
||||
* The WorkflowResolver only uses context.executionContext.workflowVariables,
|
||||
* so we only need to provide that field.
|
||||
*/
|
||||
function createTestContext(workflowVariables: Record<string, any>): ResolutionContext {
|
||||
return {
|
||||
executionContext: { workflowVariables },
|
||||
executionState: {},
|
||||
currentNodeId: 'test-node',
|
||||
} as ResolutionContext
|
||||
}
|
||||
|
||||
describe('WorkflowResolver', () => {
|
||||
describe('canResolve', () => {
|
||||
it.concurrent('should return true for variable references', () => {
|
||||
const resolver = new WorkflowResolver({})
|
||||
expect(resolver.canResolve('<variable.myvar>')).toBe(true)
|
||||
expect(resolver.canResolve('<variable.test>')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return false for non-variable references', () => {
|
||||
const resolver = new WorkflowResolver({})
|
||||
expect(resolver.canResolve('<block.output>')).toBe(false)
|
||||
expect(resolver.canResolve('<loop.index>')).toBe(false)
|
||||
expect(resolver.canResolve('plain text')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve with normalized matching', () => {
|
||||
it.concurrent('should resolve variable with exact name match', () => {
|
||||
const variables = {
|
||||
'var-1': { id: 'var-1', name: 'myvar', type: 'plain', value: 'test-value' },
|
||||
}
|
||||
const resolver = new WorkflowResolver(variables)
|
||||
|
||||
const result = resolver.resolve('<variable.myvar>', createTestContext(variables))
|
||||
expect(result).toBe('test-value')
|
||||
})
|
||||
|
||||
it.concurrent('should resolve variable with normalized name (lowercase)', () => {
|
||||
const variables = {
|
||||
'var-1': { id: 'var-1', name: 'MyVar', type: 'plain', value: 'test-value' },
|
||||
}
|
||||
const resolver = new WorkflowResolver(variables)
|
||||
|
||||
const result = resolver.resolve('<variable.myvar>', createTestContext(variables))
|
||||
expect(result).toBe('test-value')
|
||||
})
|
||||
|
||||
it.concurrent('should resolve variable with normalized name (spaces removed)', () => {
|
||||
const variables = {
|
||||
'var-1': { id: 'var-1', name: 'My Variable', type: 'plain', value: 'test-value' },
|
||||
}
|
||||
const resolver = new WorkflowResolver(variables)
|
||||
|
||||
const result = resolver.resolve('<variable.myvariable>', createTestContext(variables))
|
||||
expect(result).toBe('test-value')
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should resolve variable with fully normalized name (JIRA TEAM UUID case)',
|
||||
() => {
|
||||
const variables = {
|
||||
'var-1': { id: 'var-1', name: 'JIRA TEAM UUID', type: 'plain', value: 'uuid-123' },
|
||||
}
|
||||
const resolver = new WorkflowResolver(variables)
|
||||
|
||||
const result = resolver.resolve('<variable.jirateamuuid>', createTestContext(variables))
|
||||
expect(result).toBe('uuid-123')
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('should resolve variable regardless of reference case', () => {
|
||||
const variables = {
|
||||
'var-1': { id: 'var-1', name: 'jirateamuuid', type: 'plain', value: 'uuid-123' },
|
||||
}
|
||||
const resolver = new WorkflowResolver(variables)
|
||||
|
||||
const result = resolver.resolve('<variable.JIRATEAMUUID>', createTestContext(variables))
|
||||
expect(result).toBe('uuid-123')
|
||||
})
|
||||
|
||||
it.concurrent('should resolve by variable ID (exact match)', () => {
|
||||
const variables = {
|
||||
'my-uuid-id': { id: 'my-uuid-id', name: 'Some Name', type: 'plain', value: 'id-value' },
|
||||
}
|
||||
const resolver = new WorkflowResolver(variables)
|
||||
|
||||
const result = resolver.resolve('<variable.my-uuid-id>', createTestContext(variables))
|
||||
expect(result).toBe('id-value')
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for non-existent variable', () => {
|
||||
const variables = {
|
||||
'var-1': { id: 'var-1', name: 'existing', type: 'plain', value: 'test' },
|
||||
}
|
||||
const resolver = new WorkflowResolver(variables)
|
||||
|
||||
const result = resolver.resolve('<variable.nonexistent>', createTestContext(variables))
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should handle nested path access', () => {
|
||||
const variables = {
|
||||
'var-1': {
|
||||
id: 'var-1',
|
||||
name: 'config',
|
||||
type: 'object',
|
||||
value: { nested: { value: 'deep' } },
|
||||
},
|
||||
}
|
||||
const resolver = new WorkflowResolver(variables)
|
||||
|
||||
const result = resolver.resolve(
|
||||
'<variable.config.nested.value>',
|
||||
createTestContext(variables)
|
||||
)
|
||||
expect(result).toBe('deep')
|
||||
})
|
||||
|
||||
it.concurrent('should resolve with mixed case and spaces in reference', () => {
|
||||
const variables = {
|
||||
'var-1': { id: 'var-1', name: 'api key', type: 'plain', value: 'secret-key' },
|
||||
}
|
||||
const resolver = new WorkflowResolver(variables)
|
||||
|
||||
const result = resolver.resolve('<variable.APIKEY>', createTestContext(variables))
|
||||
expect(result).toBe('secret-key')
|
||||
})
|
||||
|
||||
it.concurrent('should handle real-world variable naming patterns', () => {
|
||||
const testCases = [
|
||||
{ varName: 'User ID', refName: 'userid', value: 'user-123' },
|
||||
{ varName: 'API Key', refName: 'apikey', value: 'key-456' },
|
||||
{ varName: 'STRIPE SECRET KEY', refName: 'stripesecretkey', value: 'sk_test' },
|
||||
{ varName: 'Database URL', refName: 'databaseurl', value: 'postgres://...' },
|
||||
]
|
||||
|
||||
for (const { varName, refName, value } of testCases) {
|
||||
const variables = {
|
||||
'var-1': { id: 'var-1', name: varName, type: 'plain', value },
|
||||
}
|
||||
const resolver = new WorkflowResolver(variables)
|
||||
|
||||
const result = resolver.resolve(`<variable.${refName}>`, createTestContext(variables))
|
||||
expect(result).toBe(value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it.concurrent('should handle empty workflow variables', () => {
|
||||
const resolver = new WorkflowResolver({})
|
||||
|
||||
const result = resolver.resolve('<variable.anyvar>', createTestContext({}))
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should handle invalid reference format', () => {
|
||||
const resolver = new WorkflowResolver({})
|
||||
|
||||
const result = resolver.resolve('<variable>', createTestContext({}))
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should handle null variable values in the map', () => {
|
||||
const variables: Record<string, any> = {
|
||||
'var-1': null,
|
||||
'var-2': { id: 'var-2', name: 'valid', type: 'plain', value: 'exists' },
|
||||
}
|
||||
const resolver = new WorkflowResolver(variables)
|
||||
|
||||
const result = resolver.resolve('<variable.valid>', createTestContext(variables))
|
||||
expect(result).toBe('exists')
|
||||
})
|
||||
|
||||
it.concurrent('should handle variable with empty name', () => {
|
||||
const variables = {
|
||||
'var-1': { id: 'var-1', name: '', type: 'plain', value: 'empty-name' },
|
||||
}
|
||||
const resolver = new WorkflowResolver(variables)
|
||||
|
||||
// Empty name normalizes to empty string, which matches "<variable.>" reference
|
||||
const result = resolver.resolve('<variable.>', createTestContext(variables))
|
||||
expect(result).toBe('empty-name')
|
||||
})
|
||||
|
||||
it.concurrent('should prefer name match over ID match when both could apply', () => {
|
||||
const variables = {
|
||||
apikey: { id: 'apikey', name: 'different', type: 'plain', value: 'by-id' },
|
||||
'var-2': { id: 'var-2', name: 'apikey', type: 'plain', value: 'by-name' },
|
||||
}
|
||||
const resolver = new WorkflowResolver(variables)
|
||||
|
||||
const result = resolver.resolve('<variable.apikey>', createTestContext(variables))
|
||||
expect(['by-id', 'by-name']).toContain(result)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ResolutionContext,
|
||||
type Resolver,
|
||||
} from '@/executor/variables/resolvers/reference'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowResolver')
|
||||
|
||||
@@ -32,12 +33,17 @@ export class WorkflowResolver implements Resolver {
|
||||
}
|
||||
|
||||
const [_, variableName, ...pathParts] = parts
|
||||
const normalizedRefName = normalizeName(variableName)
|
||||
|
||||
const workflowVars = context.executionContext.workflowVariables || this.workflowVariables
|
||||
|
||||
for (const varObj of Object.values(workflowVars)) {
|
||||
const v = varObj as any
|
||||
if (v && (v.name === variableName || v.id === variableName)) {
|
||||
if (!v) continue
|
||||
|
||||
// Match by normalized name or exact ID
|
||||
const normalizedVarName = v.name ? normalizeName(v.name) : ''
|
||||
if (normalizedVarName === normalizedRefName || v.id === variableName) {
|
||||
const normalizedType = (v.type === 'string' ? 'plain' : v.type) || 'plain'
|
||||
let value: any
|
||||
try {
|
||||
|
||||
@@ -1288,7 +1288,6 @@ export function useCollaborativeWorkflow() {
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
immediate: true,
|
||||
})
|
||||
},
|
||||
[
|
||||
|
||||
@@ -212,7 +212,6 @@ export const auth = betterAuth({
|
||||
'github',
|
||||
'email-password',
|
||||
'confluence',
|
||||
// 'supabase',
|
||||
'x',
|
||||
'notion',
|
||||
'microsoft',
|
||||
@@ -950,56 +949,6 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
|
||||
// Supabase provider (unused)
|
||||
// {
|
||||
// providerId: 'supabase',
|
||||
// clientId: env.SUPABASE_CLIENT_ID as string,
|
||||
// clientSecret: env.SUPABASE_CLIENT_SECRET as string,
|
||||
// authorizationUrl: 'https://api.supabase.com/v1/oauth/authorize',
|
||||
// tokenUrl: 'https://api.supabase.com/v1/oauth/token',
|
||||
// userInfoUrl: 'https://dummy-not-used.supabase.co',
|
||||
// scopes: ['database.read', 'database.write', 'projects.read'],
|
||||
// responseType: 'code',
|
||||
// pkce: true,
|
||||
// redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/supabase`,
|
||||
// getUserInfo: async (tokens) => {
|
||||
// try {
|
||||
// logger.info('Creating Supabase user profile from token data')
|
||||
|
||||
// let userId = 'supabase-user'
|
||||
// if (tokens.idToken) {
|
||||
// try {
|
||||
// const decodedToken = JSON.parse(
|
||||
// Buffer.from(tokens.idToken.split('.')[1], 'base64').toString()
|
||||
// )
|
||||
// if (decodedToken.sub) {
|
||||
// userId = decodedToken.sub
|
||||
// }
|
||||
// } catch (e) {
|
||||
// logger.warn('Failed to decode Supabase ID token', {
|
||||
// error: e,
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
// const uniqueId = `${userId}-${Date.now()}`
|
||||
// const now = new Date()
|
||||
|
||||
// return {
|
||||
// id: uniqueId,
|
||||
// name: 'Supabase User',
|
||||
// email: `${uniqueId.replace(/[^a-zA-Z0-9]/g, '')}@supabase.user`,
|
||||
// emailVerified: false,
|
||||
// createdAt: now,
|
||||
// updatedAt: now,
|
||||
// }
|
||||
// } catch (error) {
|
||||
// logger.error('Error creating Supabase user profile:', { error })
|
||||
// return null
|
||||
// }
|
||||
// },
|
||||
// },
|
||||
|
||||
// X provider
|
||||
{
|
||||
providerId: 'x',
|
||||
@@ -1133,57 +1082,6 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
|
||||
// Discord provider (unused)
|
||||
// {
|
||||
// providerId: 'discord',
|
||||
// clientId: env.DISCORD_CLIENT_ID as string,
|
||||
// clientSecret: env.DISCORD_CLIENT_SECRET as string,
|
||||
// authorizationUrl: 'https://discord.com/api/oauth2/authorize',
|
||||
// tokenUrl: 'https://discord.com/api/oauth2/token',
|
||||
// userInfoUrl: 'https://discord.com/api/users/@me',
|
||||
// scopes: ['identify', 'bot', 'messages.read', 'guilds', 'guilds.members.read'],
|
||||
// responseType: 'code',
|
||||
// accessType: 'offline',
|
||||
// authentication: 'basic',
|
||||
// prompt: 'consent',
|
||||
// redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/discord`,
|
||||
// getUserInfo: async (tokens) => {
|
||||
// try {
|
||||
// const response = await fetch('https://discord.com/api/users/@me', {
|
||||
// headers: {
|
||||
// Authorization: `Bearer ${tokens.accessToken}`,
|
||||
// },
|
||||
// })
|
||||
|
||||
// if (!response.ok) {
|
||||
// logger.error('Error fetching Discord user info:', {
|
||||
// status: response.status,
|
||||
// statusText: response.statusText,
|
||||
// })
|
||||
// return null
|
||||
// }
|
||||
|
||||
// const profile = await response.json()
|
||||
// const now = new Date()
|
||||
|
||||
// return {
|
||||
// id: profile.id,
|
||||
// name: profile.username || 'Discord User',
|
||||
// email: profile.email || `${profile.id}@discord.user`,
|
||||
// image: profile.avatar
|
||||
// ? `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`
|
||||
// : undefined,
|
||||
// emailVerified: profile.verified || false,
|
||||
// createdAt: now,
|
||||
// updatedAt: now,
|
||||
// }
|
||||
// } catch (error) {
|
||||
// logger.error('Error in Discord getUserInfo:', { error })
|
||||
// return null
|
||||
// }
|
||||
// },
|
||||
// },
|
||||
|
||||
// Jira provider
|
||||
{
|
||||
providerId: 'jira',
|
||||
@@ -1323,7 +1221,6 @@ export const auth = betterAuth({
|
||||
authorizationUrl: 'https://api.notion.com/v1/oauth/authorize',
|
||||
tokenUrl: 'https://api.notion.com/v1/oauth/token',
|
||||
userInfoUrl: 'https://api.notion.com/v1/users/me',
|
||||
scopes: ['workspace.content', 'workspace.name', 'page.read', 'page.write'],
|
||||
responseType: 'code',
|
||||
pkce: false,
|
||||
accessType: 'offline',
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
AirtableIcon,
|
||||
AsanaIcon,
|
||||
ConfluenceIcon,
|
||||
// DiscordIcon,
|
||||
DropboxIcon,
|
||||
GithubIcon,
|
||||
GmailIcon,
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
ShopifyIcon,
|
||||
SlackIcon,
|
||||
SpotifyIcon,
|
||||
// SupabaseIcon,
|
||||
TrelloIcon,
|
||||
WealthboxIcon,
|
||||
WebflowIcon,
|
||||
@@ -49,12 +47,10 @@ export type OAuthProvider =
|
||||
| 'google'
|
||||
| 'github'
|
||||
| 'x'
|
||||
// | 'supabase'
|
||||
| 'confluence'
|
||||
| 'airtable'
|
||||
| 'notion'
|
||||
| 'jira'
|
||||
// | 'discord'
|
||||
| 'dropbox'
|
||||
| 'microsoft'
|
||||
| 'linear'
|
||||
@@ -86,12 +82,10 @@ export type OAuthService =
|
||||
| 'google-groups'
|
||||
| 'github'
|
||||
| 'x'
|
||||
// | 'supabase'
|
||||
| 'confluence'
|
||||
| 'airtable'
|
||||
| 'notion'
|
||||
| 'jira'
|
||||
// | 'discord'
|
||||
| 'dropbox'
|
||||
| 'microsoft-excel'
|
||||
| 'microsoft-teams'
|
||||
@@ -388,23 +382,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
},
|
||||
defaultService: 'x',
|
||||
},
|
||||
// supabase: {
|
||||
// id: 'supabase',
|
||||
// name: 'Supabase',
|
||||
// icon: (props) => SupabaseIcon(props),
|
||||
// services: {
|
||||
// supabase: {
|
||||
// id: 'supabase',
|
||||
// name: 'Supabase',
|
||||
// description: 'Connect to your Supabase projects and manage data.',
|
||||
// providerId: 'supabase',
|
||||
// icon: (props) => SupabaseIcon(props),
|
||||
// baseProviderIcon: (props) => SupabaseIcon(props),
|
||||
// scopes: ['database.read', 'database.write', 'projects.read'],
|
||||
// },
|
||||
// },
|
||||
// defaultService: 'supabase',
|
||||
// },
|
||||
confluence: {
|
||||
id: 'confluence',
|
||||
name: 'Confluence',
|
||||
@@ -518,23 +495,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
},
|
||||
defaultService: 'airtable',
|
||||
},
|
||||
// discord: {
|
||||
// id: 'discord',
|
||||
// name: 'Discord',
|
||||
// icon: (props) => DiscordIcon(props),
|
||||
// services: {
|
||||
// discord: {
|
||||
// id: 'discord',
|
||||
// name: 'Discord',
|
||||
// description: 'Read and send messages to Discord channels and interact with servers.',
|
||||
// providerId: 'discord',
|
||||
// icon: (props) => DiscordIcon(props),
|
||||
// baseProviderIcon: (props) => DiscordIcon(props),
|
||||
// scopes: ['identify', 'bot', 'messages.read', 'guilds', 'guilds.members.read'],
|
||||
// },
|
||||
// },
|
||||
// defaultService: 'discord',
|
||||
// },
|
||||
notion: {
|
||||
id: 'notion',
|
||||
name: 'Notion',
|
||||
@@ -547,7 +507,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
providerId: 'notion',
|
||||
icon: (props) => NotionIcon(props),
|
||||
baseProviderIcon: (props) => NotionIcon(props),
|
||||
scopes: ['workspace.content', 'workspace.name', 'page.read', 'page.write'],
|
||||
scopes: [],
|
||||
},
|
||||
},
|
||||
defaultService: 'notion',
|
||||
@@ -1272,18 +1232,6 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
||||
useBasicAuth: false,
|
||||
}
|
||||
}
|
||||
// case 'discord': {
|
||||
// const { clientId, clientSecret } = getCredentials(
|
||||
// env.DISCORD_CLIENT_ID,
|
||||
// env.DISCORD_CLIENT_SECRET
|
||||
// )
|
||||
// return {
|
||||
// tokenEndpoint: 'https://discord.com/api/v10/oauth2/token',
|
||||
// clientId,
|
||||
// clientSecret,
|
||||
// useBasicAuth: true,
|
||||
// }
|
||||
// }
|
||||
case 'microsoft': {
|
||||
const { clientId, clientSecret } = getCredentials(
|
||||
env.MICROSOFT_CLIENT_ID,
|
||||
|
||||
@@ -24,7 +24,6 @@ interface EnqueueWorkflowOperationArgs {
|
||||
target: string
|
||||
payload: any
|
||||
workflowId: string
|
||||
immediate?: boolean
|
||||
operationId?: string
|
||||
}
|
||||
|
||||
@@ -37,7 +36,6 @@ export async function enqueueWorkflowOperation({
|
||||
target,
|
||||
payload,
|
||||
workflowId,
|
||||
immediate = false,
|
||||
operationId,
|
||||
}: EnqueueWorkflowOperationArgs): Promise<string> {
|
||||
const userId = await resolveUserId()
|
||||
@@ -52,7 +50,6 @@ export async function enqueueWorkflowOperation({
|
||||
},
|
||||
workflowId,
|
||||
userId,
|
||||
immediate,
|
||||
})
|
||||
|
||||
logger.debug('Queued workflow operation', {
|
||||
@@ -60,7 +57,6 @@ export async function enqueueWorkflowOperation({
|
||||
operation,
|
||||
target,
|
||||
operationId: opId,
|
||||
immediate,
|
||||
})
|
||||
|
||||
return opId
|
||||
@@ -69,7 +65,6 @@ export async function enqueueWorkflowOperation({
|
||||
interface EnqueueReplaceStateArgs {
|
||||
workflowId: string
|
||||
state: WorkflowState
|
||||
immediate?: boolean
|
||||
operationId?: string
|
||||
}
|
||||
|
||||
@@ -79,7 +74,6 @@ interface EnqueueReplaceStateArgs {
|
||||
export async function enqueueReplaceWorkflowState({
|
||||
workflowId,
|
||||
state,
|
||||
immediate,
|
||||
operationId,
|
||||
}: EnqueueReplaceStateArgs): Promise<string> {
|
||||
return enqueueWorkflowOperation({
|
||||
@@ -87,7 +81,6 @@ export async function enqueueReplaceWorkflowState({
|
||||
operation: 'replace-state',
|
||||
target: 'workflow',
|
||||
payload: { state },
|
||||
immediate,
|
||||
operationId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeBlockName } from '@/stores/workflows/utils'
|
||||
import { normalizeName } from '@/stores/workflows/utils'
|
||||
|
||||
export const SYSTEM_REFERENCE_PREFIXES = new Set(['start', 'loop', 'parallel', 'variable'])
|
||||
|
||||
@@ -111,7 +111,7 @@ export function extractReferencePrefixes(value: string): Array<{ raw: string; pr
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = normalizeBlockName(rawPrefix)
|
||||
const normalized = normalizeName(rawPrefix)
|
||||
references.push({ raw: referenceSegment, prefix: normalized })
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface QueuedOperation {
|
||||
retryCount: number
|
||||
status: 'pending' | 'processing' | 'confirmed' | 'failed'
|
||||
userId: string
|
||||
immediate?: boolean // Flag for immediate processing (skips debouncing)
|
||||
}
|
||||
|
||||
interface OperationQueueState {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { create } from 'zustand'
|
||||
import { devtools } from 'zustand/middleware'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
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')
|
||||
|
||||
@@ -177,49 +179,72 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
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 oldVarName = oldVariableName.replace(/\s+/g, '').toLowerCase()
|
||||
const newVarName = newName.replace(/\s+/g, '').toLowerCase()
|
||||
const regex = new RegExp(`<variable\.${oldVarName}>`, 'gi')
|
||||
const updatedValue = updateReferences(value, regex, `<variable.${newVarName}>`)
|
||||
|
||||
updatedWorkflowValues[blockId][subBlockId] = updateReferences(
|
||||
value,
|
||||
regex,
|
||||
`<variable.${newVarName}>`
|
||||
)
|
||||
|
||||
function updateReferences(value: any, regex: RegExp, replacement: string): any {
|
||||
if (typeof value === 'string') {
|
||||
return regex.test(value) ? value.replace(regex, replacement) : value
|
||||
if (JSON.stringify(updatedValue) !== JSON.stringify(value)) {
|
||||
if (!updatedWorkflowValues[blockId]) {
|
||||
updatedWorkflowValues[blockId] = { ...workflowValues[blockId] }
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => updateReferences(item, regex, replacement))
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const result = { ...value }
|
||||
for (const key in result) {
|
||||
result[key] = updateReferences(result[key], regex, replacement)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return value
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,7 +499,6 @@ export const useWorkflowDiffStore = create<WorkflowDiffState & WorkflowDiffActio
|
||||
await enqueueReplaceWorkflowState({
|
||||
workflowId: activeWorkflowId,
|
||||
state: baselineWorkflow,
|
||||
immediate: true,
|
||||
})
|
||||
|
||||
// Persist to database
|
||||
|
||||
93
apps/sim/stores/workflows/utils.test.ts
Normal file
93
apps/sim/stores/workflows/utils.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeName } from './utils'
|
||||
|
||||
describe('normalizeName', () => {
|
||||
it.concurrent('should convert to lowercase', () => {
|
||||
expect(normalizeName('MyVariable')).toBe('myvariable')
|
||||
expect(normalizeName('UPPERCASE')).toBe('uppercase')
|
||||
expect(normalizeName('MixedCase')).toBe('mixedcase')
|
||||
})
|
||||
|
||||
it.concurrent('should remove spaces', () => {
|
||||
expect(normalizeName('my variable')).toBe('myvariable')
|
||||
expect(normalizeName('my variable')).toBe('myvariable')
|
||||
expect(normalizeName(' spaced ')).toBe('spaced')
|
||||
})
|
||||
|
||||
it.concurrent('should handle both lowercase and space removal', () => {
|
||||
expect(normalizeName('JIRA TEAM UUID')).toBe('jirateamuuid')
|
||||
expect(normalizeName('My Block Name')).toBe('myblockname')
|
||||
expect(normalizeName('API 1')).toBe('api1')
|
||||
})
|
||||
|
||||
it.concurrent('should handle edge cases', () => {
|
||||
expect(normalizeName('')).toBe('')
|
||||
expect(normalizeName(' ')).toBe('')
|
||||
expect(normalizeName('a')).toBe('a')
|
||||
expect(normalizeName('already_normalized')).toBe('already_normalized')
|
||||
})
|
||||
|
||||
it.concurrent('should preserve non-space special characters', () => {
|
||||
expect(normalizeName('my-variable')).toBe('my-variable')
|
||||
expect(normalizeName('my_variable')).toBe('my_variable')
|
||||
expect(normalizeName('my.variable')).toBe('my.variable')
|
||||
})
|
||||
|
||||
it.concurrent('should handle tabs and newlines as whitespace', () => {
|
||||
expect(normalizeName('my\tvariable')).toBe('myvariable')
|
||||
expect(normalizeName('my\nvariable')).toBe('myvariable')
|
||||
expect(normalizeName('my\r\nvariable')).toBe('myvariable')
|
||||
})
|
||||
|
||||
it.concurrent('should handle unicode characters', () => {
|
||||
expect(normalizeName('Café')).toBe('café')
|
||||
expect(normalizeName('日本語')).toBe('日本語')
|
||||
})
|
||||
|
||||
it.concurrent('should normalize block names correctly', () => {
|
||||
expect(normalizeName('Agent 1')).toBe('agent1')
|
||||
expect(normalizeName('API Block')).toBe('apiblock')
|
||||
expect(normalizeName('My Custom Block')).toBe('mycustomblock')
|
||||
})
|
||||
|
||||
it.concurrent('should normalize variable names correctly', () => {
|
||||
expect(normalizeName('jira1')).toBe('jira1')
|
||||
expect(normalizeName('JIRA TEAM UUID')).toBe('jirateamuuid')
|
||||
expect(normalizeName('My Variable')).toBe('myvariable')
|
||||
})
|
||||
|
||||
it.concurrent('should produce consistent results for references', () => {
|
||||
const originalName = 'JIRA TEAM UUID'
|
||||
const normalized1 = normalizeName(originalName)
|
||||
const normalized2 = normalizeName(originalName)
|
||||
|
||||
expect(normalized1).toBe(normalized2)
|
||||
expect(normalized1).toBe('jirateamuuid')
|
||||
})
|
||||
|
||||
it.concurrent('should allow matching block references to variable references', () => {
|
||||
const name = 'API Block'
|
||||
const blockRef = `<${normalizeName(name)}.output>`
|
||||
const varRef = `<variable.${normalizeName(name)}>`
|
||||
|
||||
expect(blockRef).toBe('<apiblock.output>')
|
||||
expect(varRef).toBe('<variable.apiblock>')
|
||||
})
|
||||
|
||||
it.concurrent('should handle real-world naming patterns consistently', () => {
|
||||
const realWorldNames = [
|
||||
{ input: 'User ID', expected: 'userid' },
|
||||
{ input: 'API Key', expected: 'apikey' },
|
||||
{ input: 'OAuth Token', expected: 'oauthtoken' },
|
||||
{ input: 'Database URL', expected: 'databaseurl' },
|
||||
{ input: 'STRIPE SECRET KEY', expected: 'stripesecretkey' },
|
||||
{ input: 'openai api key', expected: 'openaiapikey' },
|
||||
{ input: 'Customer Name', expected: 'customername' },
|
||||
{ input: 'Order Total', expected: 'ordertotal' },
|
||||
]
|
||||
|
||||
for (const { input, expected } of realWorldNames) {
|
||||
expect(normalizeName(input)).toBe(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,11 +2,12 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Normalizes a block name for comparison by converting to lowercase and removing spaces
|
||||
* @param name - The block name to normalize
|
||||
* Normalizes a name for comparison by converting to lowercase and removing spaces.
|
||||
* Used for both block names and variable names to ensure consistent matching.
|
||||
* @param name - The name to normalize
|
||||
* @returns The normalized name
|
||||
*/
|
||||
export function normalizeBlockName(name: string): string {
|
||||
export function normalizeName(name: string): string {
|
||||
return name.toLowerCase().replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
@@ -20,7 +21,7 @@ export function normalizeBlockName(name: string): string {
|
||||
export function getUniqueBlockName(baseName: string, existingBlocks: Record<string, any>): string {
|
||||
// Special case: Start blocks should always be named "Start" without numbers
|
||||
// This applies to both "Start" and "Starter" base names
|
||||
const normalizedBaseName = normalizeBlockName(baseName)
|
||||
const normalizedBaseName = normalizeName(baseName)
|
||||
if (normalizedBaseName === 'start' || normalizedBaseName === 'starter') {
|
||||
return 'Start'
|
||||
}
|
||||
@@ -28,13 +29,13 @@ export function getUniqueBlockName(baseName: string, existingBlocks: Record<stri
|
||||
const baseNameMatch = baseName.match(/^(.*?)(\s+\d+)?$/)
|
||||
const namePrefix = baseNameMatch ? baseNameMatch[1].trim() : baseName
|
||||
|
||||
const normalizedBase = normalizeBlockName(namePrefix)
|
||||
const normalizedBase = normalizeName(namePrefix)
|
||||
|
||||
const existingNumbers = Object.values(existingBlocks)
|
||||
.filter((block) => {
|
||||
const blockNameMatch = block.name?.match(/^(.*?)(\s+\d+)?$/)
|
||||
const blockPrefix = blockNameMatch ? blockNameMatch[1].trim() : block.name
|
||||
return blockPrefix && normalizeBlockName(blockPrefix) === normalizedBase
|
||||
return blockPrefix && normalizeName(blockPrefix) === normalizedBase
|
||||
})
|
||||
.map((block) => {
|
||||
const match = block.name?.match(/(\d+)$/)
|
||||
@@ -65,45 +66,34 @@ export function mergeSubblockState(
|
||||
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
// Get all the values stored in the subblock store for this workflow
|
||||
const workflowSubblockValues = workflowId ? subBlockStore.workflowValues[workflowId] || {} : {}
|
||||
|
||||
return Object.entries(blocksToProcess).reduce(
|
||||
(acc, [id, block]) => {
|
||||
// Skip if block is undefined
|
||||
if (!block) {
|
||||
return acc
|
||||
}
|
||||
|
||||
// Initialize subBlocks if not present
|
||||
const blockSubBlocks = block.subBlocks || {}
|
||||
|
||||
// Get stored values for this block
|
||||
const blockValues = workflowSubblockValues[id] || {}
|
||||
|
||||
// Create a deep copy of the block's subBlocks to maintain structure
|
||||
const mergedSubBlocks = Object.entries(blockSubBlocks).reduce(
|
||||
(subAcc, [subBlockId, subBlock]) => {
|
||||
// Skip if subBlock is undefined
|
||||
if (!subBlock) {
|
||||
return subAcc
|
||||
}
|
||||
|
||||
// Get the stored value for this subblock
|
||||
let storedValue = null
|
||||
|
||||
// If workflowId is provided, use it to get the value
|
||||
if (workflowId) {
|
||||
// Try to get the value from the subblock store for this specific workflow
|
||||
if (blockValues[subBlockId] !== undefined) {
|
||||
storedValue = blockValues[subBlockId]
|
||||
}
|
||||
} else {
|
||||
// Fall back to the active workflow if no workflowId is provided
|
||||
storedValue = subBlockStore.getValue(id, subBlockId)
|
||||
}
|
||||
|
||||
// Create a new subblock object with the same structure but updated value
|
||||
subAcc[subBlockId] = {
|
||||
...subBlock,
|
||||
value: storedValue !== undefined && storedValue !== null ? storedValue : subBlock.value,
|
||||
@@ -200,7 +190,24 @@ export async function mergeSubblockStateAsync(
|
||||
subBlockEntries.filter((entry): entry is readonly [string, SubBlockState] => entry !== null)
|
||||
) as Record<string, SubBlockState>
|
||||
|
||||
// Return the full block state with updated subBlocks
|
||||
// Add any values that exist in the store but aren't in the block structure
|
||||
// This handles cases where block config has been updated but values still exist
|
||||
// IMPORTANT: This includes runtime subblock IDs like webhookId, triggerPath, etc.
|
||||
if (workflowId) {
|
||||
const workflowValues = subBlockStore.workflowValues[workflowId]
|
||||
const blockValues = workflowValues?.[id] || {}
|
||||
Object.entries(blockValues).forEach(([subBlockId, value]) => {
|
||||
if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) {
|
||||
mergedSubBlocks[subBlockId] = {
|
||||
id: subBlockId,
|
||||
type: 'short-input',
|
||||
value: value,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Return the full block state with updated subBlocks (including orphaned values)
|
||||
return [
|
||||
id,
|
||||
{
|
||||
|
||||
@@ -9,11 +9,7 @@ import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { isAnnotationOnlyBlock } from '@/executor/constants'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import {
|
||||
getUniqueBlockName,
|
||||
mergeSubblockState,
|
||||
normalizeBlockName,
|
||||
} from '@/stores/workflows/utils'
|
||||
import { getUniqueBlockName, mergeSubblockState, normalizeName } from '@/stores/workflows/utils'
|
||||
import type {
|
||||
Position,
|
||||
SubBlockState,
|
||||
@@ -676,7 +672,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
if (!oldBlock) return { success: false, changedSubblocks: [] }
|
||||
|
||||
// Check for normalized name collisions
|
||||
const normalizedNewName = normalizeBlockName(name)
|
||||
const normalizedNewName = normalizeName(name)
|
||||
const currentBlocks = get().blocks
|
||||
|
||||
// Find any other block with the same normalized name
|
||||
@@ -684,7 +680,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
return (
|
||||
blockId !== id && // Different block
|
||||
block.name && // Has a name
|
||||
normalizeBlockName(block.name) === normalizedNewName // Same normalized name
|
||||
normalizeName(block.name) === normalizedNewName // Same normalized name
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -75,16 +75,10 @@ export const notionCreateDatabaseTool: ToolConfig<NotionCreateDatabaseParams, No
|
||||
}
|
||||
}
|
||||
|
||||
// Format parent ID
|
||||
const formattedParentId = params.parentId.replace(
|
||||
/(.{8})(.{4})(.{4})(.{4})(.{12})/,
|
||||
'$1-$2-$3-$4-$5'
|
||||
)
|
||||
|
||||
const body = {
|
||||
parent: {
|
||||
type: 'page_id',
|
||||
page_id: formattedParentId,
|
||||
page_id: params.parentId,
|
||||
},
|
||||
title: [
|
||||
{
|
||||
|
||||
@@ -54,21 +54,13 @@ export const notionCreatePageTool: ToolConfig<NotionCreatePageParams, NotionResp
|
||||
}
|
||||
},
|
||||
body: (params: NotionCreatePageParams) => {
|
||||
// Format parent ID with hyphens if needed
|
||||
const formattedParentId = params.parentId.replace(
|
||||
/(.{8})(.{4})(.{4})(.{4})(.{12})/,
|
||||
'$1-$2-$3-$4-$5'
|
||||
)
|
||||
|
||||
// Prepare the body for page parent
|
||||
const body: any = {
|
||||
parent: {
|
||||
type: 'page_id',
|
||||
page_id: formattedParentId,
|
||||
page_id: params.parentId,
|
||||
},
|
||||
}
|
||||
|
||||
// Add title if provided
|
||||
if (params.title) {
|
||||
body.properties = {
|
||||
title: {
|
||||
@@ -87,7 +79,6 @@ export const notionCreatePageTool: ToolConfig<NotionCreatePageParams, NotionResp
|
||||
body.properties = {}
|
||||
}
|
||||
|
||||
// Add content if provided
|
||||
if (params.content) {
|
||||
body.children = [
|
||||
{
|
||||
@@ -115,7 +106,6 @@ export const notionCreatePageTool: ToolConfig<NotionCreatePageParams, NotionResp
|
||||
const data = await response.json()
|
||||
let pageTitle = 'Untitled'
|
||||
|
||||
// Try to extract the title from properties
|
||||
if (data.properties?.title) {
|
||||
const titleProperty = data.properties.title
|
||||
if (
|
||||
|
||||
@@ -48,11 +48,7 @@ export const notionQueryDatabaseTool: ToolConfig<NotionQueryDatabaseParams, Noti
|
||||
|
||||
request: {
|
||||
url: (params: NotionQueryDatabaseParams) => {
|
||||
const formattedId = params.databaseId.replace(
|
||||
/(.{8})(.{4})(.{4})(.{4})(.{12})/,
|
||||
'$1-$2-$3-$4-$5'
|
||||
)
|
||||
return `https://api.notion.com/v1/databases/${formattedId}/query`
|
||||
return `https://api.notion.com/v1/databases/${params.databaseId}/query`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params: NotionQueryDatabaseParams) => {
|
||||
|
||||
@@ -29,11 +29,7 @@ export const notionReadTool: ToolConfig<NotionReadParams, NotionResponse> = {
|
||||
|
||||
request: {
|
||||
url: (params: NotionReadParams) => {
|
||||
// Format page ID with hyphens if needed
|
||||
const formattedId = params.pageId.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5')
|
||||
|
||||
// Use the page endpoint to get page properties
|
||||
return `https://api.notion.com/v1/pages/${formattedId}`
|
||||
return `https://api.notion.com/v1/pages/${params.pageId}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: NotionReadParams) => {
|
||||
@@ -85,12 +81,9 @@ export const notionReadTool: ToolConfig<NotionReadParams, NotionResponse> = {
|
||||
}
|
||||
}
|
||||
|
||||
// Format page ID for blocks endpoint
|
||||
const formattedId = pageId.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5')
|
||||
|
||||
// Fetch page content using blocks endpoint
|
||||
const blocksResponse = await fetch(
|
||||
`https://api.notion.com/v1/blocks/${formattedId}/children?page_size=100`,
|
||||
`https://api.notion.com/v1/blocks/${pageId}/children?page_size=100`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
||||
@@ -34,13 +34,7 @@ export const notionReadDatabaseTool: ToolConfig<NotionReadDatabaseParams, Notion
|
||||
|
||||
request: {
|
||||
url: (params: NotionReadDatabaseParams) => {
|
||||
// Format database ID with hyphens if needed
|
||||
const formattedId = params.databaseId.replace(
|
||||
/(.{8})(.{4})(.{4})(.{4})(.{12})/,
|
||||
'$1-$2-$3-$4-$5'
|
||||
)
|
||||
|
||||
return `https://api.notion.com/v1/databases/${formattedId}`
|
||||
return `https://api.notion.com/v1/databases/${params.databaseId}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: NotionReadDatabaseParams) => {
|
||||
|
||||
@@ -35,9 +35,7 @@ export const notionUpdatePageTool: ToolConfig<NotionUpdatePageParams, NotionResp
|
||||
|
||||
request: {
|
||||
url: (params: NotionUpdatePageParams) => {
|
||||
// Format page ID with hyphens if needed
|
||||
const formattedId = params.pageId.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5')
|
||||
return `https://api.notion.com/v1/pages/${formattedId}`
|
||||
return `https://api.notion.com/v1/pages/${params.pageId}`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params: NotionUpdatePageParams) => {
|
||||
|
||||
@@ -35,9 +35,7 @@ export const notionWriteTool: ToolConfig<NotionWriteParams, NotionResponse> = {
|
||||
|
||||
request: {
|
||||
url: (params: NotionWriteParams) => {
|
||||
// Format page ID with hyphens if needed
|
||||
const formattedId = params.pageId.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5')
|
||||
return `https://api.notion.com/v1/blocks/${formattedId}/children`
|
||||
return `https://api.notion.com/v1/blocks/${params.pageId}/children`
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params: NotionWriteParams) => {
|
||||
|
||||
Reference in New Issue
Block a user