From 571d132b037d84778c85f2ba38f9633338b47ed1 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 8 Sep 2026 18:20:54 -0700 Subject: [PATCH 1/2] fix(workflows): preserve valid empty strings during import --- .../operations/import-export.test.ts | 29 +++++++++++++ .../lib/workflows/operations/import-export.ts | 9 ++-- .../workflows/sanitization/subblocks.test.ts | 32 ++++++++++++++ .../lib/workflows/sanitization/subblocks.ts | 43 ++++++++++++++++--- 4 files changed, 102 insertions(+), 11 deletions(-) diff --git a/apps/sim/lib/workflows/operations/import-export.test.ts b/apps/sim/lib/workflows/operations/import-export.test.ts index 26fa1968ad8..db2f383da3c 100644 --- a/apps/sim/lib/workflows/operations/import-export.test.ts +++ b/apps/sim/lib/workflows/operations/import-export.test.ts @@ -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') ) @@ -24,6 +25,7 @@ import { persistImportedWorkflow, sanitizePathSegment, } from '@/lib/workflows/operations/import-export' +import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' function createLegacyState() { return { @@ -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: { diff --git a/apps/sim/lib/workflows/operations/import-export.ts b/apps/sim/lib/workflows/operations/import-export.ts index 13b658cca63..5cfb4c4eb1a 100644 --- a/apps/sim/lib/workflows/operations/import-export.ts +++ b/apps/sim/lib/workflows/operations/import-export.ts @@ -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. @@ -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 diff --git a/apps/sim/lib/workflows/sanitization/subblocks.test.ts b/apps/sim/lib/workflows/sanitization/subblocks.test.ts index 61f0e043bb0..e9058f9bafe 100644 --- a/apps/sim/lib/workflows/sanitization/subblocks.test.ts +++ b/apps/sim/lib/workflows/sanitization/subblocks.test.ts @@ -12,6 +12,7 @@ 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/pagerduty'), await import('@/blocks/blocks/function') ) }) @@ -117,6 +118,37 @@ describe('sanitizeMalformedSubBlocks', () => { }) describe('regular blocks (config is the schema)', () => { + 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', diff --git a/apps/sim/lib/workflows/sanitization/subblocks.ts b/apps/sim/lib/workflows/sanitization/subblocks.ts index 07a2f11cb9b..7e85b7731bf 100644 --- a/apps/sim/lib/workflows/sanitization/subblocks.ts +++ b/apps/sim/lib/workflows/sanitization/subblocks.ts @@ -3,11 +3,33 @@ 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 (EMPTY_STRING_TYPES.has(type) || type.endsWith('-selector')) return true + if (type !== 'dropdown' || config?.multiSelect) 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 } @@ -57,7 +79,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) { @@ -70,10 +93,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 @@ -89,8 +118,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 || @@ -113,7 +142,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 From ec1445a2cf3fb5e9902acadb22ae2ff1111ef642 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 8 Sep 2026 18:37:41 -0700 Subject: [PATCH 2/2] fix(workflows): normalize blank multi-select imports --- .../workflows/sanitization/subblocks.test.ts | 25 +++++++++++++++++++ .../lib/workflows/sanitization/subblocks.ts | 3 ++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/workflows/sanitization/subblocks.test.ts b/apps/sim/lib/workflows/sanitization/subblocks.test.ts index e9058f9bafe..9768e7cd963 100644 --- a/apps/sim/lib/workflows/sanitization/subblocks.test.ts +++ b/apps/sim/lib/workflows/sanitization/subblocks.test.ts @@ -12,6 +12,7 @@ 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') ) @@ -118,6 +119,30 @@ 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( { diff --git a/apps/sim/lib/workflows/sanitization/subblocks.ts b/apps/sim/lib/workflows/sanitization/subblocks.ts index 7e85b7731bf..a32b841d4e5 100644 --- a/apps/sim/lib/workflows/sanitization/subblocks.ts +++ b/apps/sim/lib/workflows/sanitization/subblocks.ts @@ -21,8 +21,9 @@ const EMPTY_STRING_TYPES = new Set([ ]) function acceptsEmptyString(type: string, config?: SubBlockConfig): boolean { + if (config?.multiSelect) return false if (EMPTY_STRING_TYPES.has(type) || type.endsWith('-selector')) return true - if (type !== 'dropdown' || config?.multiSelect) return false + if (type !== 'dropdown') return false const options = typeof config?.options === 'function' ? config.options() : config?.options return options?.some((option) => option.id === '') ?? false