Allow variables with dots and keys (#17361)
Fixes https://github.com/twentyhq/private-issues/issues/410#issuecomment-3781085655 Currently, JSON keys with spaces like { "toto toto": 123 } are rejected with "JSON keys cannot contain spaces" error. This is problematic for HTTP requests and webhook triggers where users cannot control the response structure. We use Handlebars to eval variables, segment-literal bracket notation to escape keys with special characters: Normal: {{step.normalKey}} With spaces: `{{step.[key with space]}}` So we simply need to wrap segments with spaces with brackets. This PR: - Create shared path utilities to wrap the variable segments when needed - Use it in all variable generation places - Remove the restrictions This body is now supported: <img width="609" height="457" alt="Capture d’écran 2026-01-22 à 16 10 13" src="https://github.com/user-attachments/assets/e7653c0a-df1e-49af-9c9a-4b7d59a99726" />
This commit is contained in:
-47
@@ -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 =
|
||||
|
||||
+1
-5
@@ -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,
|
||||
|
||||
+3
-1
@@ -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)}}}`;
|
||||
};
|
||||
|
||||
+6
-1
@@ -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 {
|
||||
|
||||
+5
-2
@@ -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);
|
||||
|
||||
+5
-2
@@ -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);
|
||||
|
||||
+9
-2
@@ -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);
|
||||
|
||||
+6
-3
@@ -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];
|
||||
|
||||
+5
-2
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user