Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useCallback, useRef } from 'react'
import { generateId } from '@sim/utils/id'
import { Plus } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import 'prismjs/components/prism-json'
Expand All @@ -21,6 +20,7 @@ import {
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
Expand Down Expand Up @@ -74,18 +74,6 @@ const BOOLEAN_OPTIONS: ComboboxOption[] = [
{ label: 'false', value: 'false' },
]

/**
* Creates a new field with default values
*/
const createDefaultField = (): Field => ({
id: generateId(),
name: '',
type: 'string',
value: '',
description: '',
collapsed: false,
})

/**
* Validates and sanitizes field names by removing control characters and quotes
*/
Expand Down Expand Up @@ -127,8 +115,17 @@ export function FieldFormat({
disabled,
})

/**
* Stable fallback field used while the store value is still empty (e.g. a
* newly added block). Caching it in a ref keeps the field id constant across
* renders, so the inputs don't remount on each keystroke and edits commit to
* the same id instead of a freshly generated one.
*/
const fallbackFieldRef = useRef<Field | null>(null)
const fallbackField = (fallbackFieldRef.current ??= createDefaultInputFormatField())

const value = isPreview ? previewValue : storeValue
const fields: Field[] = Array.isArray(value) && value.length > 0 ? value : [createDefaultField()]
const fields: Field[] = Array.isArray(value) && value.length > 0 ? value : [fallbackField]
const isReadOnly = isPreview || disabled

const renderFieldLabel = (label: string) => <Label>{label}</Label>
Expand All @@ -138,7 +135,7 @@ export function FieldFormat({
*/
const addField = () => {
if (isReadOnly) return
setStoreValue([...fields, createDefaultField()])
setStoreValue([...fields, createDefaultInputFormatField()])
}

/**
Expand All @@ -148,15 +145,19 @@ export function FieldFormat({
if (isReadOnly) return

if (fields.length === 1) {
setStoreValue([createDefaultField()])
setStoreValue([createDefaultInputFormatField()])
return
}

setStoreValue(fields.filter((field) => field.id !== id))
}

const storeValueRef = useRef(storeValue)
storeValueRef.current = storeValue
/**
* Mirrors the rendered fields (store value or stable fallback) so updateField
* always commits against the same ids the UI is currently showing.
*/
const fieldsRef = useRef(fields)
fieldsRef.current = fields

const isReadOnlyRef = useRef(isReadOnly)
isReadOnlyRef.current = isReadOnly
Expand All @@ -173,14 +174,8 @@ export function FieldFormat({
? validateFieldName(fieldValue)
: fieldValue

const currentStoreValue = storeValueRef.current
const currentFields: Field[] =
Array.isArray(currentStoreValue) && currentStoreValue.length > 0
? currentStoreValue
: [createDefaultField()]

setStoreValueRef.current(
currentFields.map((f) => (f.id === id ? { ...f, [fieldKey]: updatedValue } : f))
fieldsRef.current.map((f) => (f.id === id ? { ...f, [fieldKey]: updatedValue } : f))
)
},
[]
Expand Down
11 changes: 2 additions & 9 deletions apps/sim/lib/workflows/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { generateId } from '@sim/utils/id'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
import { getBlock } from '@/blocks'
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types'
Expand Down Expand Up @@ -39,15 +40,7 @@ function resolveInitialValue(subBlock: SubBlockConfig): unknown {
}

if (subBlock.type === 'input-format') {
return [
{
id: generateId(),
name: '',
type: 'string',
value: '',
collapsed: false,
},
]
return [createDefaultInputFormatField()]
}

if (subBlock.type === 'table') {
Expand Down
26 changes: 26 additions & 0 deletions apps/sim/lib/workflows/input-format.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
createDefaultInputFormatField,
extractInputFieldsFromBlocks,
normalizeInputFormatValue,
} from '@/lib/workflows/input-format'
Expand Down Expand Up @@ -227,3 +228,28 @@ describe('normalizeInputFormatValue', () => {
expect(normalizeInputFormatValue(input)).toEqual(input)
})
})

describe('createDefaultInputFormatField', () => {
it.concurrent('creates an empty field with the canonical default shape', () => {
const field = createDefaultInputFormatField()
expect(field).toEqual({
id: expect.any(String),
name: '',
type: 'string',
value: '',
collapsed: false,
})
expect(field.id.length).toBeGreaterThan(0)
})

it.concurrent('omits description so it is not persisted by default', () => {
expect('description' in createDefaultInputFormatField()).toBe(false)
})

it.concurrent('returns a fresh id and a new object on each call', () => {
const first = createDefaultInputFormatField()
const second = createDefaultInputFormatField()
expect(first.id).not.toBe(second.id)
expect(first).not.toBe(second)
})
})
31 changes: 31 additions & 0 deletions apps/sim/lib/workflows/input-format.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { generateId } from '@sim/utils/id'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import type { InputFormatField } from '@/lib/workflows/types'

Expand All @@ -10,6 +11,36 @@ export interface WorkflowInputField {
description?: string
}

/**
* Stateful input-format field as stored in sub-block values: the editor's
* per-row shape, including the editor-only `id` and `collapsed` fields. Stricter
* than the wire-level {@link InputFormatField} (required `name`/`type`/`value`).
*/
interface InputFormatFieldState {
id: string
name: string
type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]'
value: string
description?: string
collapsed: boolean
}

/**
* Creates a new empty input-format field with a fresh id.
*
* Single source of truth for the default field shape used when seeding
* input-format / response-format sub-blocks and when adding rows in the editor.
*/
export function createDefaultInputFormatField(): InputFormatFieldState {
return {
id: generateId(),
name: '',
type: 'string',
value: '',
collapsed: false,
}
}

/**
* Extracts input fields from workflow blocks.
* Finds the trigger block (start_trigger, input_trigger, or starter) and extracts its inputFormat.
Expand Down
11 changes: 2 additions & 9 deletions apps/sim/stores/workflows/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Edge } from 'reactflow'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { getBlock } from '@/blocks'
Expand Down Expand Up @@ -151,15 +152,7 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
} else if (subBlock.defaultValue !== undefined) {
initialValue = subBlock.defaultValue
} else if (subBlock.type === 'input-format' || subBlock.type === 'response-format') {
initialValue = [
{
id: generateId(),
name: '',
type: 'string',
value: '',
collapsed: false,
},
]
initialValue = [createDefaultInputFormatField()]
} else if (subBlock.type === 'table') {
initialValue = []
}
Expand Down
68 changes: 0 additions & 68 deletions apps/sim/stores/workflows/workflow/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import type { Edge } from 'reactflow'
import { create } from 'zustand'
Expand All @@ -9,7 +8,6 @@ import {
getDynamicHandleSubblockType,
isDynamicHandleSubblock,
} from '@/lib/workflows/dynamic-handle-topology'
import type { SubBlockConfig } from '@/blocks/types'
import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import {
Expand Down Expand Up @@ -37,72 +35,6 @@ import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation'

const logger = createLogger('WorkflowStore')

/**
* Creates a deep clone of an initial sub-block value to avoid shared references.
*
* @param value - The value to clone.
* @returns A cloned value suitable for initializing sub-block state.
*/
function cloneInitialSubblockValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => cloneInitialSubblockValue(item))
}

if (value && typeof value === 'object') {
return Object.entries(value as Record<string, unknown>).reduce<Record<string, unknown>>(
(acc, [key, entry]) => {
acc[key] = cloneInitialSubblockValue(entry)
return acc
},
{}
)
}

return value ?? null
}

/**
* Resolves the initial value for a sub-block based on its configuration.
*
* @param config - The sub-block configuration.
* @returns The resolved initial value or null when no defaults are defined.
*/
function resolveInitialSubblockValue(config: SubBlockConfig): unknown {
if (typeof config.value === 'function') {
try {
const resolved = config.value({})
return cloneInitialSubblockValue(resolved)
} catch (error) {
logger.warn('Failed to resolve dynamic sub-block default value', {
subBlockId: config.id,
error: toError(error).message,
})
}
}

if (config.defaultValue !== undefined) {
return cloneInitialSubblockValue(config.defaultValue)
}

if (config.type === 'input-format') {
return [
{
id: generateId(),
name: '',
type: 'string',
value: '',
collapsed: false,
},
]
}

if (config.type === 'table') {
return []
}

return null
}

const initialState = {
currentWorkflowId: null,
blocks: {},
Expand Down
Loading