Skip to content
Open
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
29 changes: 29 additions & 0 deletions apps/sim/lib/workflows/operations/import-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ vi.unmock('@/blocks/registry')
vi.mock('@/blocks/registry-maps', async () => {
const { partialBlockRegistry } = await import('@sim/testing/mocks/block-registry.mock')
return partialBlockRegistry(
await import('@/blocks/blocks/function'),
await import('@/blocks/blocks/knowledge'),
await import('@/blocks/blocks/start_trigger')
)
Expand All @@ -24,6 +25,7 @@ import {
persistImportedWorkflow,
sanitizePathSegment,
} from '@/lib/workflows/operations/import-export'
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'

function createLegacyState() {
return {
Expand Down Expand Up @@ -60,6 +62,33 @@ function createLegacyState() {
}

describe('workflow import/export parsing', () => {
it('preserves cleared text through export and import while repairing legacy non-string values', () => {
const state = createLegacyState()
const exported = sanitizeForExport({
...state,
blocks: {
...state.blocks,
function: {
...state.blocks['start-1'],
id: 'function',
type: 'function',
subBlocks: {
code: { id: 'code', type: 'code', value: '' },
language: { id: 'language', type: 'dropdown', value: null },
},
},
},
})
exported.state.blocks['start-1'].subBlocks.inputFormat.value = ''

const result = parseWorkflowJson(JSON.stringify(exported), false)

expect(result.errors).toEqual([])
expect(result.data?.blocks.function.subBlocks.code.value).toBe('')
expect(result.data?.blocks.function.subBlocks.language.value).toBeNull()
expect(result.data?.blocks['start-1'].subBlocks.inputFormat.value).toBeNull()
})

it('parses workflow exports wrapped in an API data envelope', () => {
const content = JSON.stringify({
data: {
Expand Down
9 changes: 4 additions & 5 deletions apps/sim/lib/workflows/operations/import-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,9 +456,9 @@ export function extractWorkflowName(content: string, filename: string): string {
}

/**
* Normalize subblock values by converting empty strings to null and repairing invalid subblocks.
* This provides backwards compatibility for workflows exported before the null sanitization fix,
* preventing Zod validation errors like "Expected array, received string".
* Repair invalid subblocks and legacy empty strings in non-string fields.
* Preserve empty strings accepted by text controls, since clearing a field can
* intentionally suppress its generated default during serialization.
*
* Also filters out subBlocks with the literal key "undefined", which cannot be associated
* with a stable block field.
Expand Down Expand Up @@ -590,8 +590,7 @@ export function parseWorkflowJson(
return { data: null, errors }
}

// Normalize non-string subblock values (convert empty strings to null)
// This handles exported workflows that may have empty strings for non-string types
/** Repair legacy non-string values while preserving intentionally cleared text. */
const normalizedBlocks = normalizeSubblockValues(workflowData.blocks || {})

// Construct the workflow state with defaults
Expand Down
57 changes: 57 additions & 0 deletions apps/sim/lib/workflows/sanitization/subblocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ vi.mock('@/blocks/registry-maps', async () => {
const { partialBlockRegistry } = await import('@sim/testing/mocks/block-registry.mock')
return partialBlockRegistry(
await import('@/blocks/blocks/condition'),
await import('@/blocks/blocks/file'),
await import('@/blocks/blocks/pagerduty'),
await import('@/blocks/blocks/function')
)
})
Expand Down Expand Up @@ -117,6 +119,61 @@ describe('sanitizeMalformedSubBlocks', () => {
})

describe('regular blocks (config is the schema)', () => {
it('repairs scalar blanks for multi-select fields while preserving their empty arrays', () => {
const block = {
id: 'block-1',
type: 'file_v5',
subBlocks: {
folderSelection: { id: 'folderSelection', type: 'folder-selector', value: '' },
},
}
const options = { convertEmptyStringToNull: true }

expect(sanitizeMalformedSubBlocks(block, options).subBlocks.folderSelection.value).toBeNull()
expect(
sanitizeMalformedSubBlocks(
{
...block,
subBlocks: {
folderSelection: { ...block.subBlocks.folderSelection, value: [] },
},
},
options
).subBlocks.folderSelection.value
).toEqual([])
})

it('preserves declared empty dropdown choices and cleared selectors without suppressing dropdown defaults', () => {
const { subBlocks } = sanitizeMalformedSubBlocks(
{
id: 'block-1',
type: 'pagerduty',
subBlocks: {
updateStatus: { id: 'updateStatus', type: 'dropdown', value: '' },
operation: { id: 'operation', type: 'dropdown', value: '' },
assignee: { id: 'assignee', type: 'user-selector', value: '' },
},
},
{ convertEmptyStringToNull: true }
)

expect(subBlocks.updateStatus.value).toBe('')
expect(subBlocks.operation.value).toBeNull()
expect(subBlocks.assignee.value).toBe('')
})

it.each(['', { id: 'code', type: 'table', value: '' }])(
'preserves cleared code using its configured type when repairing %j',
(code) => {
const { subBlocks } = sanitizeMalformedSubBlocks(
{ id: 'block-1', type: 'function', subBlocks: { code } },
{ convertEmptyStringToNull: true }
)

expect(subBlocks.code).toEqual({ id: 'code', type: 'code', value: '' })
}
)

it('still drops an "unknown"-typed entry that matches no configured sub-block', () => {
const { subBlocks, changed } = sanitizeMalformedSubBlocks({
id: 'block-1',
Expand Down
44 changes: 38 additions & 6 deletions apps/sim/lib/workflows/sanitization/subblocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,34 @@ import { isPlainRecord } from '@sim/utils/object'
import { DEFAULT_SUBBLOCK_TYPE } from '@sim/workflow-persistence/subblocks'
import { getBlock } from '@/blocks'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import type { SubBlockConfig } from '@/blocks/types'
import type { BlockState } from '@/stores/workflows/workflow/types'

const logger = createLogger('WorkflowSubblockSanitization')

/** Controls whose stored values accept an explicitly empty string. */
const EMPTY_STRING_TYPES = new Set([
'short-input',
'long-input',
'code',
'combobox',
'response-format',
'time-input',
'oauth-input',
'text',
])

function acceptsEmptyString(type: string, config?: SubBlockConfig): boolean {
if (config?.multiSelect) return false
if (EMPTY_STRING_TYPES.has(type) || type.endsWith('-selector')) return true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Multi-select blanks stay scalar

Multi-select selectors store arrays, but this condition treats every selector as accepting an empty string. When an imported workflow contains a legacy '' for a multi-select selector such as departmentIds, folderSelection, or channelFilter, sanitization preserves that scalar instead of normalizing it, leaving the field with a value that conflicts with its array contract. Exclude multi-select selectors from empty-string preservation.

Suggested change
if (EMPTY_STRING_TYPES.has(type) || type.endsWith('-selector')) return true
if (EMPTY_STRING_TYPES.has(type) || (type.endsWith('-selector') && !config?.multiSelect)) return true

if (type !== 'dropdown') return false

const options = typeof config?.options === 'function' ? config.options() : config?.options
return options?.some((option) => option.id === '') ?? false
}

interface SanitizeMalformedSubBlocksOptions {
/** Repairs legacy empty values only when the field does not accept empty strings. */
convertEmptyStringToNull?: boolean
}

Expand Down Expand Up @@ -57,7 +80,8 @@ export function sanitizeMalformedSubBlocks(
continue
}

const configuredType = blockConfig?.subBlocks?.find((config) => config.id === subBlockId)?.type
const fieldConfig = blockConfig?.subBlocks?.find((config) => config.id === subBlockId)
const configuredType = fieldConfig?.type

if (!isPlainRecord(subBlock)) {
if (!configuredType && !schemaAgnostic) {
Expand All @@ -70,10 +94,16 @@ export function sanitizeMalformedSubBlocks(
}

logger.warn('Repairing malformed subBlock value', { blockId: block.id, subBlockId })
const type = configuredType || DEFAULT_SUBBLOCK_TYPE
result[subBlockId] = {
id: subBlockId,
type: configuredType || DEFAULT_SUBBLOCK_TYPE,
value: options.convertEmptyStringToNull && subBlock === '' ? null : subBlock,
type,
value:
options.convertEmptyStringToNull &&
subBlock === '' &&
!acceptsEmptyString(type, fieldConfig)
? null
: subBlock,
} as BlockState['subBlocks'][string]
changed = true
continue
Expand All @@ -89,8 +119,8 @@ export function sanitizeMalformedSubBlocks(
}

const id = typeof subBlock.id === 'string' && subBlock.id.length > 0 ? subBlock.id : subBlockId
const typeFromConfig =
configuredType || blockConfig?.subBlocks?.find((config) => config.id === id)?.type
const resolvedConfig = fieldConfig ?? blockConfig?.subBlocks?.find((config) => config.id === id)
const typeFromConfig = resolvedConfig?.type
const missingMetadata =
typeof subBlock.id !== 'string' ||
subBlock.id.length === 0 ||
Expand All @@ -113,7 +143,9 @@ export function sanitizeMalformedSubBlocks(
const type = typeFromConfig ?? storedType ?? DEFAULT_SUBBLOCK_TYPE
const hasValue = Object.hasOwn(subBlock, 'value')
const value =
options.convertEmptyStringToNull && subBlock.value === ''
options.convertEmptyStringToNull &&
subBlock.value === '' &&
!acceptsEmptyString(type, resolvedConfig)
? null
: hasValue
? subBlock.value
Expand Down
Loading