diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord.tsx index 7b1d749aa94..be75b234062 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord.tsx @@ -268,7 +268,7 @@ export const WorkflowEditActionUpsertRecord = ({ { handleFieldChange('id', recordId); }} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/utils/resolve-rich-text-fields-in-record.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/resolve-rich-text-fields-in-record.util.ts new file mode 100644 index 00000000000..455b4e6f24f --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/utils/resolve-rich-text-fields-in-record.util.ts @@ -0,0 +1,40 @@ +import { isString } from 'class-validator'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined, resolveRichTextVariables } from 'twenty-shared/utils'; + +import { type ObjectMetadataInfo } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; + +export const resolveRichTextFieldsInRecord = ( + objectRecord: Record, + objectMetadataInfo: ObjectMetadataInfo, + context: Record, +): Record => { + const { flatObjectMetadata, flatFieldMetadataMaps } = objectMetadataInfo; + + const richTextFieldNames = flatObjectMetadata.fieldMetadataIds + .map((fieldId) => flatFieldMetadataMaps.byId[fieldId]) + .filter((field) => field?.type === FieldMetadataType.RICH_TEXT_V2) + .map((field) => field?.name) + .filter(isDefined); + + const resolvedRecord = { ...objectRecord }; + + for (const fieldName of richTextFieldNames) { + const fieldValue = resolvedRecord[fieldName]; + + if ( + isDefined(fieldValue) && + 'blocknote' in fieldValue && + isString(fieldValue.blocknote) + ) { + const richTextValue = fieldValue as { blocknote: string }; + + resolvedRecord[fieldName] = { + ...richTextValue, + blocknote: resolveRichTextVariables(richTextValue.blocknote, context), + }; + } + } + + return resolvedRecord; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts index 42850d915a9..703ef7c072a 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/create-record.workflow-action.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { resolveInput } from 'twenty-shared/utils'; import { type ActorMetadata, FieldActorSource } from 'twenty-shared/types'; +import { resolveInput } from 'twenty-shared/utils'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; @@ -10,11 +10,13 @@ import { RecordCrudExceptionCode, } from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-executor/services/workflow-execution-context.service'; import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input'; import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type'; import { type WorkflowExecutionContext } from 'src/modules/workflow/workflow-executor/types/workflow-execution-context.type'; import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util'; +import { resolveRichTextFieldsInRecord } from 'src/modules/workflow/workflow-executor/utils/resolve-rich-text-fields-in-record.util'; import { type WorkflowCreateRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type'; @Injectable() @@ -22,6 +24,7 @@ export class CreateRecordWorkflowAction implements WorkflowAction { constructor( private readonly createRecordService: CreateRecordService, private readonly workflowExecutionContextService: WorkflowExecutionContextService, + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, ) {} async execute({ @@ -37,8 +40,25 @@ export class CreateRecordWorkflowAction implements WorkflowAction { const { workspaceId } = runInfo; + const rawInput = step.settings.input as WorkflowCreateRecordActionInput; + + const objectMetadataInfo = + await this.workflowCommonWorkspaceService.getObjectMetadataInfo( + rawInput.objectName, + workspaceId, + ); + + const inputWithResolvedRichText = { + ...rawInput, + objectRecord: resolveRichTextFieldsInRecord( + rawInput.objectRecord, + objectMetadataInfo, + context, + ), + }; + const workflowActionInput = resolveInput( - step.settings.input, + inputWithResolvedRichText, context, ) as WorkflowCreateRecordActionInput; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts index de4b7d24891..6f9297d93d4 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/update-record.workflow-action.ts @@ -9,6 +9,7 @@ import { RecordCrudExceptionCode, } from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { WorkflowStepExecutorException, WorkflowStepExecutorExceptionCode, @@ -17,6 +18,7 @@ import { WorkflowExecutionContextService } from 'src/modules/workflow/workflow-e import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input'; import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type'; import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util'; +import { resolveRichTextFieldsInRecord } from 'src/modules/workflow/workflow-executor/utils/resolve-rich-text-fields-in-record.util'; import { isWorkflowUpdateRecordAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-update-record-action.guard'; import { type WorkflowUpdateRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type'; @@ -25,6 +27,7 @@ export class UpdateRecordWorkflowAction implements WorkflowAction { constructor( private readonly updateRecordService: UpdateRecordService, private readonly workflowExecutionContextService: WorkflowExecutionContextService, + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, ) {} async execute({ @@ -45,8 +48,27 @@ export class UpdateRecordWorkflowAction implements WorkflowAction { ); } + const { workspaceId } = runInfo; + + const rawInput = step.settings.input as WorkflowUpdateRecordActionInput; + + const objectMetadataInfo = + await this.workflowCommonWorkspaceService.getObjectMetadataInfo( + rawInput.objectName, + workspaceId, + ); + + const inputWithResolvedRichText = { + ...rawInput, + objectRecord: resolveRichTextFieldsInRecord( + rawInput.objectRecord, + objectMetadataInfo, + context, + ), + }; + const workflowActionInput = resolveInput( - step.settings.input, + inputWithResolvedRichText, context, ) as WorkflowUpdateRecordActionInput; @@ -61,8 +83,6 @@ export class UpdateRecordWorkflowAction implements WorkflowAction { ); } - const { workspaceId } = runInfo; - const executionContext = await this.workflowExecutionContextService.getExecutionContext(runInfo); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts index b4367c0f129..6b7971ecad1 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { resolveInput } from 'twenty-shared/utils'; +import { resolveInput, resolveRichTextVariables } from 'twenty-shared/utils'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; @@ -10,6 +10,7 @@ import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.ty import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input'; import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type'; +import { type WorkflowSendEmailActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-input.type'; import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; @Injectable() @@ -44,7 +45,23 @@ export class ToolExecutorWorkflowAction implements WorkflowAction { throw new Error(`No tool found for workflow action type: ${step.type}`); } - const toolInput = resolveInput(step.settings.input, context) as ToolInput; + let toolInput = step.settings.input; + + if (step.type === WorkflowActionType.SEND_EMAIL) { + const sendEmailInput = toolInput as WorkflowSendEmailActionInput; + + if (sendEmailInput.body) { + toolInput = { + ...sendEmailInput, + body: resolveRichTextVariables( + sendEmailInput.body, + context, + ), + }; + } + } + + toolInput = resolveInput(toolInput, context) as ToolInput; const toolOutput = await tool.execute(toolInput, { workspaceId: runInfo.workspaceId, diff --git a/packages/twenty-shared/src/utils/__tests__/rich-text-variable-resolver.test.ts b/packages/twenty-shared/src/utils/__tests__/rich-text-variable-resolver.test.ts new file mode 100644 index 00000000000..1cffeb53544 --- /dev/null +++ b/packages/twenty-shared/src/utils/__tests__/rich-text-variable-resolver.test.ts @@ -0,0 +1,225 @@ +import { resolveRichTextVariables } from '../rich-text-variable-resolver'; + +describe('resolveRichTextVariables', () => { + const context = { + step1: { + message: 'Hello World', + name: 'John', + }, + user: { + email: 'john@example.com', + }, + }; + + it('should resolve a single variableTag node', () => { + const input = + '[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}}]}]'; + + const result = resolveRichTextVariables(input, context); + + expect(result).toBe( + '[{"type":"paragraph","content":[{"type":"text","text":"Hello World"}]}]', + ); + }); + + it('should resolve variableTag nodes mixed with text', () => { + const input = + '[{"type":"paragraph","content":[{"type":"text","text":"Message: "},{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}},{"type":"text","text":" from user"}]}]'; + + const result = resolveRichTextVariables(input, context); + + expect(result).toBe( + '[{"type":"paragraph","content":[{"type":"text","text":"Message: "},{"type":"text","text":"Hello World"},{"type":"text","text":" from user"}]}]', + ); + }); + + it('should resolve multiple variableTag nodes', () => { + const input = + '[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.name}}"}},{"type":"text","text":" - "},{"type":"variableTag","attrs":{"variable":"{{user.email}}"}}]}]'; + + const result = resolveRichTextVariables(input, context); + + expect(result).toBe( + '[{"type":"paragraph","content":[{"type":"text","text":"John"},{"type":"text","text":" - "},{"type":"text","text":"john@example.com"}]}]', + ); + }); + + it('should handle undefined variables by replacing with empty string', () => { + const input = + '[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{nonexistent.field}}"}}]}]'; + + const result = resolveRichTextVariables(input, context); + + expect(result).toBe( + '[{"type":"paragraph","content":[{"type":"text","text":""}]}]', + ); + }); + + it('should escape special characters in resolved values', () => { + const contextWithSpecialChars = { + step1: { + message: 'Hello "World" with \\ backslash', + }, + }; + + const input = + '[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}}]}]'; + + const result = resolveRichTextVariables(input, contextWithSpecialChars); + + expect(result).toBe( + '[{"type":"paragraph","content":[{"type":"text","text":"Hello \\"World\\" with \\\\ backslash"}]}]', + ); + }); + + it('should not modify strings without variableTag nodes', () => { + const input = + '[{"type":"paragraph","content":[{"type":"text","text":"Plain text content"}]}]'; + + const result = resolveRichTextVariables(input, context); + + expect(result).toBe(input); + }); + + it('should handle doc type structure', () => { + const input = + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}}]}]}'; + + const result = resolveRichTextVariables(input, context); + + expect(result).toBe( + '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello World"}]}]}', + ); + }); + + it('should handle null context values by replacing with empty string', () => { + const contextWithNull = { + step1: { + value: null, + }, + }; + + const input = + '[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.value}}"}}]}]'; + + const result = resolveRichTextVariables(input, contextWithNull); + + expect(result).toBe( + '[{"type":"paragraph","content":[{"type":"text","text":""}]}]', + ); + }); + + it('should handle numeric values', () => { + const contextWithNumber = { + step1: { + count: 42, + }, + }; + + const input = + '[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.count}}"}}]}]'; + + const result = resolveRichTextVariables(input, contextWithNumber); + + expect(result).toBe( + '[{"type":"paragraph","content":[{"type":"text","text":"42"}]}]', + ); + }); + + it('should preserve regular {{variable}} patterns in non-variableTag contexts', () => { + const input = + '[{"type":"paragraph","content":[{"type":"text","text":"Regular {{step1.message}} pattern"}]}]'; + + const result = resolveRichTextVariables(input, context); + + expect(result).toBe(input); + }); + + it('should return null for null input', () => { + const result = resolveRichTextVariables(null, context); + + expect(result).toBeNull(); + }); + + it('should return undefined for undefined input', () => { + const result = resolveRichTextVariables(undefined, context); + + expect(result).toBeUndefined(); + }); + + it('should resolve variableTag nodes with attrs before type (alternate JSON order)', () => { + const input = + '[{"type":"paragraph","content":[{"attrs":{"variable":"{{step1.message}}"},"type":"variableTag"}]}]'; + + const result = resolveRichTextVariables(input, context); + + expect(result).toBe( + '[{"type":"paragraph","content":[{"type":"text","text":"Hello World"}]}]', + ); + }); + + it('should resolve mixed property order variableTag nodes', () => { + const input = + '[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.name}}"}},{"attrs":{"variable":"{{user.email}}"},"type":"variableTag"}]}]'; + + const result = resolveRichTextVariables(input, context); + + expect(result).toBe( + '[{"type":"paragraph","content":[{"type":"text","text":"John"},{"type":"text","text":"john@example.com"}]}]', + ); + }); + + it('should convert newlines to hardBreak nodes', () => { + const contextWithNewlines = { + step1: { + message: 'Hello\nWorld', + }, + }; + + const input = + '[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}}]}]'; + + const result = resolveRichTextVariables(input, contextWithNewlines); + + expect(result).toBe( + '[{"type":"paragraph","content":[{"type":"text","text":"Hello"},{"type":"hardBreak"},{"type":"text","text":"World"}]}]', + ); + }); + + it('should handle multiple newlines', () => { + const contextWithMultipleNewlines = { + step1: { + message: 'Line 1\nLine 2\nLine 3', + }, + }; + + const input = + '[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}}]}]'; + + const result = resolveRichTextVariables(input, contextWithMultipleNewlines); + + expect(result).toBe( + '[{"type":"paragraph","content":[{"type":"text","text":"Line 1"},{"type":"hardBreak"},{"type":"text","text":"Line 2"},{"type":"hardBreak"},{"type":"text","text":"Line 3"}]}]', + ); + }); + + it('should handle newlines with special characters', () => { + const contextWithNewlinesAndSpecialChars = { + step1: { + message: 'Hello "World"\nGoodbye', + }, + }; + + const input = + '[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}}]}]'; + + const result = resolveRichTextVariables( + input, + contextWithNewlinesAndSpecialChars, + ); + + expect(result).toBe( + '[{"type":"paragraph","content":[{"type":"text","text":"Hello \\"World\\""},{"type":"hardBreak"},{"type":"text","text":"Goodbye"}]}]', + ); + }); +}); diff --git a/packages/twenty-shared/src/utils/variable-resolver.test.ts b/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts similarity index 98% rename from packages/twenty-shared/src/utils/variable-resolver.test.ts rename to packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts index 377f1d1f724..c7e9402b7a3 100644 --- a/packages/twenty-shared/src/utils/variable-resolver.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts @@ -1,4 +1,4 @@ -import { resolveInput } from './variable-resolver'; +import { resolveInput } from '../variable-resolver'; describe('resolveInput', () => { const context = { diff --git a/packages/twenty-shared/src/utils/evalFromContext.ts b/packages/twenty-shared/src/utils/evalFromContext.ts new file mode 100644 index 00000000000..9759022eb2d --- /dev/null +++ b/packages/twenty-shared/src/utils/evalFromContext.ts @@ -0,0 +1,24 @@ +import Handlebars from 'handlebars'; + +export const evalFromContext = ( + input: string, + context: Record, +) => { + try { + Handlebars.registerHelper('json', (input: string) => JSON.stringify(input)); + + const inputWithHelper = input + .replace('{{', '{{{ json ') + .replace('}}', ' }}}'); + + const inferredInput = Handlebars.compile(inputWithHelper)(context, { + helpers: { + json: (input: string) => JSON.stringify(input), + }, + }); + + return JSON.parse(inferredInput); + } catch { + return undefined; + } +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 7fe777613b3..552f765fe9b 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -22,6 +22,7 @@ export { assertUnreachable } from './assertUnreachable'; export { computeDiffBetweenObjects } from './compute-diff-between-objects'; export { deepMerge } from './deepMerge'; export { CustomError } from './errors/CustomError'; +export { evalFromContext } from './evalFromContext'; export { extractAndSanitizeObjectStringFields } from './extractAndSanitizeObjectStringFields'; export { computeMorphRelationFieldName } from './fieldMetadata/compute-morph-relation-field-name'; export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind'; @@ -33,25 +34,25 @@ export { computeEmptyGqlOperationFilterForEmails } from './filter/computeEmptyGq export { computeEmptyGqlOperationFilterForLinks } from './filter/computeEmptyGqlOperationFilterForLinks'; export { computeRecordGqlOperationFilter } from './filter/computeRecordGqlOperationFilter'; export { addUnitToDateTime } from './filter/dates/utils/addUnitToDateTime'; -export type { FirstDayOfTheWeek } from './filter/dates/utils/firstDayOfWeekSchema'; export { firstDayOfWeekSchema } from './filter/dates/utils/firstDayOfWeekSchema'; +export type { FirstDayOfTheWeek } from './filter/dates/utils/firstDayOfWeekSchema'; export { getDateFromPlainDate } from './filter/dates/utils/getDateFromPlainDate'; export { getEndUnitOfDateTime } from './filter/dates/utils/getEndUnitOfDateTime'; export { getFirstDayOfTheWeekAsANumberForDateFNS } from './filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS'; export { getPlainDateFromDate } from './filter/dates/utils/getPlainDateFromDate'; export { getStartUnitOfDateTime } from './filter/dates/utils/getStartUnitOfDateTime'; export { relativeDateFilterAmountSchema } from './filter/dates/utils/relativeDateFilterAmountSchema'; -export type { RelativeDateFilterDirection } from './filter/dates/utils/relativeDateFilterDirectionSchema'; export { relativeDateFilterDirectionSchema } from './filter/dates/utils/relativeDateFilterDirectionSchema'; -export type { RelativeDateFilter } from './filter/dates/utils/relativeDateFilterSchema'; +export type { RelativeDateFilterDirection } from './filter/dates/utils/relativeDateFilterDirectionSchema'; export { relativeDateFilterSchema } from './filter/dates/utils/relativeDateFilterSchema'; +export type { RelativeDateFilter } from './filter/dates/utils/relativeDateFilterSchema'; export { relativeDateFilterStringifiedSchema } from './filter/dates/utils/relativeDateFilterStringifiedSchema'; -export type { RelativeDateFilterUnit } from './filter/dates/utils/relativeDateFilterUnitSchema'; export { relativeDateFilterUnitSchema } from './filter/dates/utils/relativeDateFilterUnitSchema'; -export type { ResolvedDateFilterValue } from './filter/dates/utils/resolveDateFilter'; +export type { RelativeDateFilterUnit } from './filter/dates/utils/relativeDateFilterUnitSchema'; export { resolveDateFilter } from './filter/dates/utils/resolveDateFilter'; -export type { ResolvedDateTimeFilterValue } from './filter/dates/utils/resolveDateTimeFilter'; +export type { ResolvedDateFilterValue } from './filter/dates/utils/resolveDateFilter'; export { resolveDateTimeFilter } from './filter/dates/utils/resolveDateTimeFilter'; +export type { ResolvedDateTimeFilterValue } from './filter/dates/utils/resolveDateTimeFilter'; export { resolveRelativeDateFilter } from './filter/dates/utils/resolveRelativeDateFilter'; export { resolveRelativeDateFilterStringified } from './filter/dates/utils/resolveRelativeDateFilterStringified'; export { resolveRelativeDateTimeFilter } from './filter/dates/utils/resolveRelativeDateTimeFilter'; @@ -60,11 +61,11 @@ export { shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone } fro export { subUnitFromDateTime } from './filter/dates/utils/subUnitFromDateTime'; export { isEmptinessOperand } from './filter/isEmptinessOperand'; export { turnAnyFieldFilterIntoRecordGqlFilter } from './filter/turnAnyFieldFilterIntoRecordGqlFilter'; +export { turnRecordFilterGroupsIntoGqlOperationFilter } from './filter/turnRecordFilterGroupIntoGqlOperationFilter'; export type { RecordFilter, - RecordFilterGroup, + RecordFilterGroup } from './filter/turnRecordFilterGroupIntoGqlOperationFilter'; -export { turnRecordFilterGroupsIntoGqlOperationFilter } from './filter/turnRecordFilterGroupIntoGqlOperationFilter'; export { turnRecordFilterIntoRecordGqlOperationFilter } from './filter/turnRecordFilterIntoGqlOperationFilter'; export { combineFilters } from './filter/utils/combineFilters'; export { computeTimezoneDifferenceInMinutes } from './filter/utils/computeTimezoneDifferenceInMinutes'; @@ -74,7 +75,7 @@ export { createAnyFieldRecordFilterBaseProperties } from './filter/utils/createA export { convertGreaterThanOrEqualRatingToArrayOfRatingValues, convertLessThanOrEqualRatingToArrayOfRatingValues, - convertRatingToRatingValue, + convertRatingToRatingValue } from './filter/utils/fieldRatingConvertors'; export { filterSelectOptionsOfFieldMetadataItem } from './filter/utils/filterSelectOptionsOfFieldMetadataItem'; export { generateILikeFiltersForCompositeFields } from './filter/utils/generateILikeFiltersForCompositeFields'; @@ -84,16 +85,16 @@ export { isExpectedSubFieldName } from './filter/utils/isExpectedSubFieldName'; export { arrayOfStringsOrVariablesSchema } from './filter/utils/validation-schemas/arrayOfStringsOrVariablesSchema'; export { arrayOfUuidOrVariableSchema } from './filter/utils/validation-schemas/arrayOfUuidsOrVariablesSchema'; export { - relationFilterValueSchemaObject, jsonRelationFilterValueSchema, + relationFilterValueSchemaObject } from './filter/utils/validation-schemas/jsonRelationFilterValueSchema'; export { fromArrayToUniqueKeyRecord } from './from-array-to-unique-key-record.util'; export { fromArrayToValuesByKeyRecord } from './fromArrayToValuesByKeyRecord.util'; export { getURLSafely } from './getURLSafely'; export { getImageAbsoluteURI } from './image/getImageAbsoluteURI'; export { - sanitizeURL, getLogoUrlFromDomainName, + sanitizeURL } from './image/getLogoUrlFromDomainName'; export { getUniqueConstraintsFields } from './indexMetadata/getUniqueConstraintsFields'; export { fastDeepEqual } from './json/fast-deep-equal'; @@ -102,25 +103,26 @@ export { getSettingsPath } from './navigation/getSettingsPath'; export { parseJson } from './parseJson'; export { removePropertiesFromRecord } from './removePropertiesFromRecord'; export { removeUndefinedFields } from './removeUndefinedFields'; +export { resolveRichTextVariables } from './rich-text-variable-resolver'; export { safeParseRelativeDateFilterJSONStringified } from './safeParseRelativeDateFilterJSONStringified'; export { getGenericOperationName } from './sentry/getGenericOperationName'; export { getHumanReadableNameFromCode } from './sentry/getHumanReadableNameFromCode'; export { appendCopySuffix } from './strings/appendCopySuffix'; export { capitalize } from './strings/capitalize'; export { uncapitalize } from './strings/uncapitalize'; -export type { - TipTapMarkType, - TipTapNodeType, - LinkMarkAttributes, - TipTapMark, -} from './tiptap/tiptap-marks'; export { TIPTAP_MARK_TYPES, - TIPTAP_NODE_TYPES, TIPTAP_MARKS_RENDER_ORDER, + TIPTAP_NODE_TYPES +} from './tiptap/tiptap-marks'; +export type { + LinkMarkAttributes, + TipTapMark, + TipTapMarkType, + TipTapNodeType } from './tiptap/tiptap-marks'; -export type { StringPropertyKeys } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties'; export { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties'; +export type { StringPropertyKeys } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties'; export { trimAndRemoveDuplicatedWhitespacesFromString } from './trim-and-remove-duplicated-whitespaces-from-string'; export { throwIfNotDefined } from './typeguard/throwIfNotDefined'; export { absoluteUrlSchema } from './url/absoluteUrlSchema'; diff --git a/packages/twenty-shared/src/utils/rich-text-variable-resolver.ts b/packages/twenty-shared/src/utils/rich-text-variable-resolver.ts new file mode 100644 index 00000000000..40f80fcf9de --- /dev/null +++ b/packages/twenty-shared/src/utils/rich-text-variable-resolver.ts @@ -0,0 +1,49 @@ +import { isDefined } from '@/utils/validation'; +import { evalFromContext } from './evalFromContext'; + +const VARIABLE_TAG_PATTERN = + /\{"type":"variableTag","attrs":\{"variable":"(\{\{[^{}]+\}\})"\}\}|\{"attrs":\{"variable":"(\{\{[^{}]+\}\})"\},"type":"variableTag"\}/g; + +const escapeJsonString = (text: string): string => { + return JSON.stringify(text).slice(1, -1); +}; + +const buildTextNodesWithLineBreaks = (text: string): string => { + const lines = text.split('\n'); + + if (lines.length === 1) { + return `{"type":"text","text":"${escapeJsonString(text)}"}`; + } + + return lines + .map((line, index) => { + const textNode = `{"type":"text","text":"${escapeJsonString(line)}"}`; + + if (index < lines.length - 1) { + return `${textNode},{"type":"hardBreak"}`; + } + + return textNode; + }) + .join(','); +}; + +export const resolveRichTextVariables = ( + input: string | null | undefined, + context: Record, +): string | null | undefined => { + if (!isDefined(input)) { + return input; + } + + return input.replace( + VARIABLE_TAG_PATTERN, + (_, variableTypeFirst: string, variableAttrsFirst: string) => { + const variable = variableTypeFirst ?? variableAttrsFirst; + const resolvedValue = evalFromContext(variable, context); + const textValue = isDefined(resolvedValue) ? String(resolvedValue) : ''; + + return buildTextNodesWithLineBreaks(textValue); + }, + ); +}; diff --git a/packages/twenty-shared/src/utils/variable-resolver.ts b/packages/twenty-shared/src/utils/variable-resolver.ts index 7e7b1a8fde1..b4dec1aa626 100644 --- a/packages/twenty-shared/src/utils/variable-resolver.ts +++ b/packages/twenty-shared/src/utils/variable-resolver.ts @@ -1,10 +1,7 @@ -import Handlebars from 'handlebars'; +import { evalFromContext } from '@/utils/evalFromContext'; +import { isDefined } from '@/utils/validation'; -const isNil = (value: any): value is null | undefined => { - return value === null || value === undefined; -}; - -const isString = (value: any): value is string => { +const isString = (value: unknown): value is string => { return typeof value === 'string'; }; @@ -14,7 +11,7 @@ export const resolveInput = ( unresolvedInput: unknown, context: Record, ): unknown => { - if (isNil(unresolvedInput)) { + if (!isDefined(unresolvedInput)) { return unresolvedInput; } @@ -84,23 +81,3 @@ const resolveString = ( return processedToken; }); }; - -const evalFromContext = (input: string, context: Record) => { - try { - Handlebars.registerHelper('json', (input: string) => JSON.stringify(input)); - - const inputWithHelper = input - .replace('{{', '{{{ json ') - .replace('}}', ' }}}'); - - const inferredInput = Handlebars.compile(inputWithHelper)(context, { - helpers: { - json: (input: string) => JSON.stringify(input), - }, - }); - - return JSON.parse(inferredInput); - } catch { - return undefined; - } -};