diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/parseAndValidateVariableFriendlyStringifiedJson.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/parseAndValidateVariableFriendlyStringifiedJson.test.ts index b090109fb9e..a088ceb9c1f 100644 --- a/packages/twenty-front/src/modules/workflow/utils/__tests__/parseAndValidateVariableFriendlyStringifiedJson.test.ts +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/parseAndValidateVariableFriendlyStringifiedJson.test.ts @@ -71,53 +71,6 @@ describe('parseAndValidateVariableFriendlyStringifiedJson', () => { }); }); - describe('Invalid keys with whitespace', () => { - it('should reject key with space', () => { - const result = parseAndValidateVariableFriendlyStringifiedJson( - '{"key with space": "value"}', - ); - - expect(result.isValid).toBe(false); - expect(result.error).toBe('JSON keys cannot contain spaces'); - }); - - it('should reject key with leading space', () => { - const result = parseAndValidateVariableFriendlyStringifiedJson( - '{" leadingSpace": "value"}', - ); - - expect(result.isValid).toBe(false); - expect(result.error).toBe('JSON keys cannot contain spaces'); - }); - - it('should reject key with trailing space', () => { - const result = parseAndValidateVariableFriendlyStringifiedJson( - '{"trailingSpace ": "value"}', - ); - - expect(result.isValid).toBe(false); - expect(result.error).toBe('JSON keys cannot contain spaces'); - }); - - it('should reject key with tab character', () => { - const result = parseAndValidateVariableFriendlyStringifiedJson( - '{"key\\twith\\ttab": "value"}', - ); - - expect(result.isValid).toBe(false); - expect(result.error).toBe('JSON keys cannot contain spaces'); - }); - - it('should reject when one of multiple keys has space', () => { - const result = parseAndValidateVariableFriendlyStringifiedJson( - '{"validKey": "value", "invalid key": "another"}', - ); - - expect(result.isValid).toBe(false); - expect(result.error).toBe('JSON keys cannot contain spaces'); - }); - }); - describe('Malformed JSON', () => { it('should reject invalid JSON syntax', () => { const result = diff --git a/packages/twenty-front/src/modules/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson.ts b/packages/twenty-front/src/modules/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson.ts index ac6390d2499..242eed50d41 100644 --- a/packages/twenty-front/src/modules/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson.ts +++ b/packages/twenty-front/src/modules/workflow/utils/parseAndValidateVariableFriendlyStringifiedJson.ts @@ -1,10 +1,6 @@ import { z } from 'zod'; -const schema = z - .record(z.string(), z.any()) - .refine((data) => Object.keys(data).every((key) => !key.match(/\s/)), { - error: 'JSON keys cannot contain spaces', - }); +const schema = z.record(z.string(), z.any()); export const parseAndValidateVariableFriendlyStringifiedJson = ( expectedJson: string, diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getVariableTemplateFromPath.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getVariableTemplateFromPath.ts index 321bc528ecc..f166b02b47e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getVariableTemplateFromPath.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getVariableTemplateFromPath.ts @@ -1,3 +1,5 @@ +import { joinVariablePath } from 'twenty-shared/workflow'; + export const getVariableTemplateFromPath = ({ stepId, path, @@ -9,5 +11,5 @@ export const getVariableTemplateFromPath = ({ return `{{${stepId}}}`; } - return `{{${stepId}.${path.join('.')}}}`; + return `{{${stepId}.${joinVariablePath(path)}}}`; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts index dda08f8d490..8918f0ebcd1 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema.ts @@ -2,16 +2,21 @@ import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/u import { isDefined } from 'twenty-shared/utils'; import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, type BaseOutputSchemaV2, } from 'twenty-shared/workflow'; +/** + * Parses a variable name to extract its components + * Example: "{{step1.field.value}}" -> { stepId: "step1", pathSegments: ["field"], targetFieldName: "value" } + */ const parseVariableName = (rawVariableName: string) => { const variableWithoutBrackets = rawVariableName.replace( CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); const stepId = parts.at(0); return { diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts index cd8b157d2e0..5047f2b5a9f 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema.ts @@ -3,7 +3,10 @@ import type { FindRecordsOutputSchema } from '@/workflow/workflow-variables/type import { searchRecordOutputSchema as searchRecordOutputSchemaUtil } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow'; +import { + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, +} from 'twenty-shared/workflow'; type SearchResultKey = 'first' | 'all' | 'totalCount'; @@ -18,7 +21,7 @@ const parseVariableName = (rawVariableName: string) => { (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); const stepId = parts.at(0); const searchResultKey = parts.at(1) as SearchResultKey; const remainingParts = parts.slice(2); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts index a1a98bff716..43e43b53883 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema.ts @@ -2,7 +2,10 @@ import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/u import type { FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema'; import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; import { isDefined } from 'twenty-shared/utils'; -import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow'; +import { + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, +} from 'twenty-shared/workflow'; /** * Parses a variable name to extract its components for Form outputs @@ -15,7 +18,7 @@ const parseVariableName = (rawVariableName: string) => { (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); const stepId = parts.at(0); const fieldName = parts.at(1); const remainingParts = parts.slice(2); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts index 5fb51fa03b5..5bee261aa45 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughIteratorOutputSchema.ts @@ -6,20 +6,27 @@ import { searchBaseOutputSchema } from '@/workflow/workflow-variables/utils/sear import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow'; +import { + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, +} from 'twenty-shared/workflow'; type IteratorResultKey = | 'currentItem' | 'currentItemIndex' | 'hasProcessedAllItems'; +/** + * Parses a variable name to extract its components + * Example: "{{step1.currentItem.field}}" -> { stepId: "step1", iteratorResultKey: "currentItem", pathSegments: [], fieldName: "field" } + */ const parseVariableName = (rawVariableName: string) => { const variableWithoutBrackets = rawVariableName.replace( CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); const stepId = parts.at(0); const iteratorResultKey = parts.at(1) as IteratorResultKey; const remainingParts = parts.slice(2); diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts index d647c39bc44..16aef78b1bb 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema.ts @@ -2,7 +2,10 @@ import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/u import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema'; import { isDefined } from 'twenty-shared/utils'; -import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow'; +import { + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, +} from 'twenty-shared/workflow'; /** * Parses a variable name to extract its components @@ -14,10 +17,10 @@ const parseVariableName = (rawVariableName: string) => { (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); const stepId = parts.at(0); // after stepId, we have a prefix (properties.after or properties.before). Path segments are the rest of the string - // join the first 3 parts to get the event prefix + // join the next 3 parts to get the event prefix (properties, after/before, objectName) const firstFieldWithEventPrefix = parts.slice(1, 4).join('.'); const remainingParts = parts.slice(4); const partsWithoutStepId = [firstFieldWithEventPrefix, ...remainingParts]; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts index 6c14f57ee03..5bae4ccfa53 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema.ts @@ -6,7 +6,10 @@ import { } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2'; import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2'; import { isDefined } from 'twenty-shared/utils'; -import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow'; +import { + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, +} from 'twenty-shared/workflow'; const getRecordObjectLabel = ( recordSchema: RecordOutputSchemaV2, @@ -160,7 +163,7 @@ const parseVariableName = (rawVariableName: string) => { (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); return { stepId: parts.at(0), diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/extract-property-path-from-variable.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/extract-property-path-from-variable.ts index 00e310390ac..082c3d0b9f2 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/extract-property-path-from-variable.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/extract-property-path-from-variable.ts @@ -1,4 +1,7 @@ -import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow'; +import { + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + parseVariablePath, +} from 'twenty-shared/workflow'; export const extractPropertyPathFromVariable = ( rawVariableName: string, @@ -8,7 +11,7 @@ export const extractPropertyPathFromVariable = ( (_, variableName) => variableName, ); - const parts = variableWithoutBrackets.split('.'); + const parts = parseVariablePath(variableWithoutBrackets); return parts.slice(1); }; diff --git a/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts b/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts index c7e9402b7a3..4912493359b 100644 --- a/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts @@ -129,4 +129,54 @@ describe('resolveInput', () => { }), ).toBe('{ "a": "str" }'); }); + + describe('bracket notation for keys with special characters', () => { + it('should resolve variables with keys containing spaces', () => { + const contextWithSpaces = { + step: { + 'key with space': 'value from space key', + }, + }; + expect(resolveInput('{{step.[key with space]}}', contextWithSpaces)).toBe( + 'value from space key', + ); + }); + + it('should resolve nested variables with keys containing spaces', () => { + const contextWithSpaces = { + step: { + 'first key': { + 'nested key': 'nested value', + }, + }, + }; + expect( + resolveInput('{{step.[first key].[nested key]}}', contextWithSpaces), + ).toBe('nested value'); + }); + + it('should resolve mixed normal and bracket notation paths', () => { + const contextWithMixed = { + step: { + normal: { + 'key with space': 42, + }, + }, + }; + expect( + resolveInput('{{step.normal.[key with space]}}', contextWithMixed), + ).toBe(42); + }); + + it('should resolve variables with keys containing dots', () => { + const contextWithDots = { + step: { + 'key.with.dots': 'dotted value', + }, + }; + expect(resolveInput('{{step.[key.with.dots]}}', contextWithDots)).toBe( + 'dotted value', + ); + }); + }); }); diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 27321ec42a6..be58f0d7fe9 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -75,6 +75,12 @@ export { extractRawVariableNamePart } from './utils/extractRawVariableNameParts' export { getWorkflowRunContext } from './utils/getWorkflowRunContext'; export { parseBooleanFromStringValue } from './utils/parseBooleanFromStringValue'; export { parseDataFromContentType } from './utils/parseDataFromContentType'; +export { + needsEscaping, + escapePathSegment, + joinVariablePath, + parseVariablePath, +} from './utils/variable-path.util'; export type { LeafType, NodeType, diff --git a/packages/twenty-shared/src/workflow/utils/__tests__/variable-path.util.test.ts b/packages/twenty-shared/src/workflow/utils/__tests__/variable-path.util.test.ts new file mode 100644 index 00000000000..e7f77a50586 --- /dev/null +++ b/packages/twenty-shared/src/workflow/utils/__tests__/variable-path.util.test.ts @@ -0,0 +1,146 @@ +import { + escapePathSegment, + joinVariablePath, + needsEscaping, + parseVariablePath, +} from '../variable-path.util'; + +describe('variable path utility functions', () => { + describe('needsEscaping', () => { + it('should return true for keys with spaces', () => { + expect(needsEscaping('key with space')).toBe(true); + expect(needsEscaping('toto toto')).toBe(true); + }); + + it('should return true for keys with dots', () => { + expect(needsEscaping('key.with.dots')).toBe(true); + }); + + it('should return true for keys with brackets', () => { + expect(needsEscaping('key[0]')).toBe(true); + expect(needsEscaping('[key]')).toBe(true); + }); + + it('should return false for simple keys', () => { + expect(needsEscaping('simpleKey')).toBe(false); + expect(needsEscaping('camelCase')).toBe(false); + expect(needsEscaping('snake_case')).toBe(false); + expect(needsEscaping('kebab-case')).toBe(false); + }); + + describe('escapePathSegment', () => { + it('should wrap keys with spaces in brackets', () => { + expect(escapePathSegment('key with space')).toBe('[key with space]'); + }); + + it('should wrap keys with dots in brackets', () => { + expect(escapePathSegment('key.with.dots')).toBe('[key.with.dots]'); + }); + + it('should not modify simple keys', () => { + expect(escapePathSegment('simpleKey')).toBe('simpleKey'); + }); + }); + + describe('joinVariablePath', () => { + it('should join simple segments with dots', () => { + expect(joinVariablePath(['step', 'field', 'value'])).toBe( + 'step.field.value', + ); + }); + + it('should escape segments with spaces', () => { + expect(joinVariablePath(['step', 'key with space', 'value'])).toBe( + 'step.[key with space].value', + ); + }); + + it('should escape segments with dots', () => { + expect(joinVariablePath(['step', 'key.with.dots'])).toBe( + 'step.[key.with.dots]', + ); + }); + + it('should handle mixed simple and special segments', () => { + expect( + joinVariablePath(['step', 'normal', 'has space', 'another']), + ).toBe('step.normal.[has space].another'); + }); + + it('should handle empty array', () => { + expect(joinVariablePath([])).toBe(''); + }); + + it('should handle single segment', () => { + expect(joinVariablePath(['step'])).toBe('step'); + expect(joinVariablePath(['key with space'])).toBe('[key with space]'); + }); + }); + + describe('parseVariablePath', () => { + it('should parse simple dot-separated path', () => { + expect(parseVariablePath('step.field.value')).toEqual([ + 'step', + 'field', + 'value', + ]); + }); + + it('should parse path with bracketed segments containing spaces', () => { + expect(parseVariablePath('step.[key with space].value')).toEqual([ + 'step', + 'key with space', + 'value', + ]); + }); + + it('should parse path with bracketed segments containing dots', () => { + expect(parseVariablePath('step.[key.with.dots]')).toEqual([ + 'step', + 'key.with.dots', + ]); + }); + + it('should handle multiple bracketed segments', () => { + expect( + parseVariablePath('[first key].[second key].[third key]'), + ).toEqual(['first key', 'second key', 'third key']); + }); + + it('should handle mixed simple and bracketed segments', () => { + expect(parseVariablePath('step.normal.[has space].another')).toEqual([ + 'step', + 'normal', + 'has space', + 'another', + ]); + }); + + it('should handle empty string', () => { + expect(parseVariablePath('')).toEqual([]); + }); + + it('should handle single segment', () => { + expect(parseVariablePath('step')).toEqual(['step']); + expect(parseVariablePath('[key with space]')).toEqual([ + 'key with space', + ]); + }); + + it('should be inverse of joinVariablePath', () => { + const paths = [ + ['step', 'field', 'value'], + ['step', 'key with space', 'value'], + ['step', 'key.with.dots'], + ['step', 'normal', 'has space', 'another'], + ]; + + for (const path of paths) { + const joined = joinVariablePath(path); + const parsed = parseVariablePath(joined); + expect(parsed).toEqual(path); + } + }); + }); + }); +}); diff --git a/packages/twenty-shared/src/workflow/utils/variable-path.util.ts b/packages/twenty-shared/src/workflow/utils/variable-path.util.ts new file mode 100644 index 00000000000..c757811841d --- /dev/null +++ b/packages/twenty-shared/src/workflow/utils/variable-path.util.ts @@ -0,0 +1,70 @@ +// Characters that require bracket escaping in variable paths +// Spaces, dots, and brackets would break the dot-notation parsing +const SPECIAL_CHARS_REGEX = /[\s.[]/; + +export const needsEscaping = (key: string): boolean => + SPECIAL_CHARS_REGEX.test(key); + +export const escapePathSegment = (segment: string): string => + needsEscaping(segment) ? `[${segment}]` : segment; + +export const joinVariablePath = (segments: string[]): string => + segments.map(escapePathSegment).join('.'); + +/** + * Parses a variable path string into segments, handling bracket notation. + * Examples: + * "step.normal.key" => ["step", "normal", "key"] + * "step.[key with space].value" => ["step", "key with space", "value"] + * "step.[key.with.dots]" => ["step", "key.with.dots"] + */ +export const parseVariablePath = (path: string): string[] => { + const segments: string[] = []; + let current = ''; + let inBracket = false; + let segmentIndex = 0; + + while (segmentIndex < path.length) { + const char = path[segmentIndex]; + + if (char === '[' && !inBracket) { + if (current.length > 0) { + segments.push(current); + current = ''; + } + inBracket = true; + segmentIndex++; + continue; + } + + if (char === ']' && inBracket) { + segments.push(current); + current = ''; + inBracket = false; + segmentIndex++; + // Skip the following dot if present + if (segmentIndex < path.length && path[segmentIndex] === '.') { + segmentIndex++; + } + continue; + } + + if (char === '.' && !inBracket) { + if (current.length > 0) { + segments.push(current); + current = ''; + } + segmentIndex++; + continue; + } + + current += char; + segmentIndex++; + } + + if (current.length > 0) { + segments.push(current); + } + + return segments; +};