diff --git a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonArrayNode.tsx b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonArrayNode.tsx index 0e1fde435..9ef496bc3 100644 --- a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonArrayNode.tsx +++ b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonArrayNode.tsx @@ -7,10 +7,14 @@ export const JsonArrayNode = ({ label, value, depth, + keyPath, + shouldHighlightNode, }: { label?: string; value: JsonArray; depth: number; + keyPath: string; + shouldHighlightNode?: (keyPath: string) => boolean; }) => { const { t } = useLingui(); @@ -26,6 +30,8 @@ export const JsonArrayNode = ({ Icon={IconBrackets} depth={depth} emptyElementsText={t`Empty Array`} + keyPath={keyPath} + shouldHighlightNode={shouldHighlightNode} /> ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonNestedNode.tsx b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonNestedNode.tsx index 4e9204181..2ffda032b 100644 --- a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonNestedNode.tsx +++ b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonNestedNode.tsx @@ -3,6 +3,7 @@ import { JsonList } from '@/workflow/components/json-visualizer/components/inter import { JsonNodeLabel } from '@/workflow/components/json-visualizer/components/internal/JsonNodeLabel'; import { JsonNode } from '@/workflow/components/json-visualizer/components/JsonNode'; import styled from '@emotion/styled'; +import { isNonEmptyString } from '@sniptt/guards'; import { useState } from 'react'; import { isDefined } from 'twenty-shared'; import { IconComponent } from 'twenty-ui'; @@ -35,6 +36,8 @@ export const JsonNestedNode = ({ renderElementsCount, emptyElementsText, depth, + keyPath, + shouldHighlightNode, }: { label?: string; Icon: IconComponent; @@ -42,6 +45,8 @@ export const JsonNestedNode = ({ renderElementsCount?: (count: number) => string; emptyElementsText: string; depth: number; + keyPath: string; + shouldHighlightNode?: (keyPath: string) => boolean; }) => { const hideRoot = !isDefined(label); @@ -52,9 +57,22 @@ export const JsonNestedNode = ({ {elements.length === 0 ? ( {emptyElementsText} ) : ( - elements.map(({ id, label, value }) => ( - - )) + elements.map(({ id, label, value }) => { + const nextKeyPath = isNonEmptyString(keyPath) + ? `${keyPath}.${id}` + : String(id); + + return ( + + ); + }) )} ); diff --git a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonNode.tsx b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonNode.tsx index 643382ba2..e326bf4fc 100644 --- a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonNode.tsx +++ b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonNode.tsx @@ -15,17 +15,24 @@ export const JsonNode = ({ label, value, depth, + keyPath, + shouldHighlightNode, }: { label?: string; value: JsonValue; depth: number; + keyPath: string; + shouldHighlightNode?: (keyPath: string) => boolean; }) => { + const isHighlighted = shouldHighlightNode?.(keyPath) ?? false; + if (isNull(value)) { return ( ); } @@ -36,6 +43,7 @@ export const JsonNode = ({ label={label} valueAsString={value} Icon={IconTypography} + isHighlighted={isHighlighted} /> ); } @@ -46,6 +54,7 @@ export const JsonNode = ({ label={label} valueAsString={String(value)} Icon={IconNumber9} + isHighlighted={isHighlighted} /> ); } @@ -56,13 +65,30 @@ export const JsonNode = ({ label={label} valueAsString={String(value)} Icon={IconCheckbox} + isHighlighted={isHighlighted} /> ); } if (isArray(value)) { - return ; + return ( + + ); } - return ; + return ( + + ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonObjectNode.tsx b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonObjectNode.tsx index 5d8535e9f..57bea7d37 100644 --- a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonObjectNode.tsx +++ b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonObjectNode.tsx @@ -7,10 +7,14 @@ export const JsonObjectNode = ({ label, value, depth, + keyPath, + shouldHighlightNode, }: { label?: string; value: JsonObject; depth: number; + keyPath: string; + shouldHighlightNode?: (keyPath: string) => boolean; }) => { const { t } = useLingui(); @@ -26,6 +30,8 @@ export const JsonObjectNode = ({ Icon={IconCube} depth={depth} emptyElementsText={t`Empty Object`} + keyPath={keyPath} + shouldHighlightNode={shouldHighlightNode} /> ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonTree.tsx b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonTree.tsx index 0bc84c6d2..43c474a1f 100644 --- a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonTree.tsx +++ b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonTree.tsx @@ -2,10 +2,21 @@ import { JsonList } from '@/workflow/components/json-visualizer/components/inter import { JsonNode } from '@/workflow/components/json-visualizer/components/JsonNode'; import { JsonValue } from 'type-fest'; -export const JsonTree = ({ value }: { value: JsonValue }) => { +export const JsonTree = ({ + value, + shouldHighlightNode, +}: { + value: JsonValue; + shouldHighlightNode?: (keyPath: string) => boolean; +}) => { return ( - + ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonValueNode.tsx b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonValueNode.tsx index 09a79f1f0..10d5cb927 100644 --- a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonValueNode.tsx +++ b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/JsonValueNode.tsx @@ -10,6 +10,7 @@ const StyledListItem = styled(JsonListItem)` type JsonValueNodeProps = { valueAsString: string; + isHighlighted: boolean; } & ( | { label: string; @@ -24,9 +25,18 @@ type JsonValueNodeProps = { export const JsonValueNode = (props: JsonValueNodeProps) => { return ( - {props.label && } + {props.label && ( + + )} - + ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/internal/JsonNodeLabel.tsx b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/internal/JsonNodeLabel.tsx index a725ee115..7e3a8a5d3 100644 --- a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/internal/JsonNodeLabel.tsx +++ b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/internal/JsonNodeLabel.tsx @@ -2,10 +2,12 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { IconComponent } from 'twenty-ui'; -const StyledLabelContainer = styled.span` +const StyledLabelContainer = styled.span<{ isHighlighted?: boolean }>` align-items: center; background-color: ${({ theme }) => theme.background.transparent.lighter}; border-color: ${({ theme }) => theme.border.color.medium}; + color: ${({ theme, isHighlighted }) => + isHighlighted ? theme.color.blue : theme.font.color.primary}; border-radius: ${({ theme }) => theme.border.radius.sm}; border-style: solid; border-width: 1px; @@ -23,15 +25,20 @@ const StyledLabelContainer = styled.span` export const JsonNodeLabel = ({ label, Icon, + isHighlighted, }: { label: string; Icon: IconComponent; + isHighlighted?: boolean; }) => { const theme = useTheme(); return ( - - + + {label} diff --git a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/internal/JsonNodeValue.tsx b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/internal/JsonNodeValue.tsx index bcf8d8b55..56a33e899 100644 --- a/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/internal/JsonNodeValue.tsx +++ b/packages/twenty-front/src/modules/workflow/components/json-visualizer/components/internal/JsonNodeValue.tsx @@ -1,9 +1,16 @@ import styled from '@emotion/styled'; -const StyledText = styled.span` - color: ${({ theme }) => theme.font.color.tertiary}; +const StyledText = styled.span<{ isHighlighted?: boolean }>` + color: ${({ theme, isHighlighted }) => + isHighlighted ? theme.adaptiveColors.blue4 : theme.font.color.tertiary}; `; -export const JsonNodeValue = ({ valueAsString }: { valueAsString: string }) => { - return {valueAsString}; +export const JsonNodeValue = ({ + valueAsString, + isHighlighted, +}: { + valueAsString: string; + isHighlighted?: boolean; +}) => { + return {valueAsString}; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepInputDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepInputDetail.tsx index cb25aff93..f1bee4ca5 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepInputDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepInputDetail.tsx @@ -2,6 +2,7 @@ import { JsonNestedNode } from '@/workflow/components/json-visualizer/components import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun'; import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow'; import { getWorkflowRunStepContext } from '@/workflow/workflow-steps/utils/getWorkflowRunStepContext'; +import { getWorkflowVariablesUsedInStep } from '@/workflow/workflow-steps/utils/getWorkflowVariablesUsedInStep'; import styled from '@emotion/styled'; import { isDefined } from 'twenty-shared'; import { IconBrackets } from 'twenty-ui'; @@ -16,17 +17,25 @@ const StyledContainer = styled.div` export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => { const workflowRunId = useWorkflowRunIdOrThrow(); const workflowRun = useWorkflowRun({ workflowRunId }); + const step = workflowRun?.output?.flow.steps.find( + (step) => step.id === stepId, + ); if ( !( isDefined(workflowRun) && isDefined(workflowRun.context) && - isDefined(workflowRun.output?.flow) + isDefined(workflowRun.output?.flow) && + isDefined(step) ) ) { return null; } + const variablesUsedInStep = getWorkflowVariablesUsedInStep({ + step, + }); + const stepContext = getWorkflowRunStepContext({ context: workflowRun.context, flow: workflowRun.output.flow, @@ -48,6 +57,8 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => { Icon={IconBrackets} emptyElementsText="" depth={0} + keyPath="" + shouldHighlightNode={(keyPath) => variablesUsedInStep.has(keyPath)} /> ); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowVariablesUsedInStep.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowVariablesUsedInStep.test.ts new file mode 100644 index 000000000..b6a3140bf --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowVariablesUsedInStep.test.ts @@ -0,0 +1,173 @@ +import { WorkflowStep } from '@/workflow/types/Workflow'; +import { getWorkflowVariablesUsedInStep } from '@/workflow/workflow-steps/utils/getWorkflowVariablesUsedInStep'; + +describe('getWorkflowVariablesUsedInStep', () => { + it('returns the variables used in a one-level object', () => { + const step: WorkflowStep = { + id: '42e8b60e-dd44-417a-875f-823d63f16819', + name: 'Code - Serverless Function', + type: 'CODE', + valid: false, + settings: { + input: { + serverlessFunctionId: '5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2d', + serverlessFunctionInput: { + a: '{{5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.a.b.c.d}}', + }, + serverlessFunctionVersion: 'draft', + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { + value: false, + }, + continueOnFailure: { + value: false, + }, + }, + }, + }; + + expect( + getWorkflowVariablesUsedInStep({ + step, + }), + ).toMatchInlineSnapshot(` +Set { + "5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.a.b.c.d", +} +`); + }); + + it('returns the variables used in a computed field', () => { + const step: WorkflowStep = { + id: '42e8b60e-dd44-417a-875f-823d63f16819', + name: 'Create Record', + type: 'CREATE_RECORD', + valid: false, + settings: { + input: { + objectName: 'company', + objectRecord: { + name: 'Test', + address: { + addressLat: null, + addressLng: null, + addressCity: '{{trigger.address.addressCity}}', + addressState: '{{trigger.address.addressState}}', + addressCountry: '{{trigger.address.addressCountry}}', + addressStreet1: '{{trigger.address.addressStreet1}}', + addressStreet2: '{{trigger.address.addressStreet2}}', + addressPostcode: '{{trigger.address.addressPostcode}}', + }, + domainName: { + primaryLinkUrl: '', + primaryLinkLabel: '', + }, + }, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { + value: false, + }, + continueOnFailure: { + value: false, + }, + }, + }, + }; + + expect( + getWorkflowVariablesUsedInStep({ + step, + }), + ).toMatchInlineSnapshot(` +Set { + "trigger.address.addressCity", + "trigger.address.addressState", + "trigger.address.addressCountry", + "trigger.address.addressStreet1", + "trigger.address.addressStreet2", + "trigger.address.addressPostcode", +} +`); + }); + + it('returns all the variables used in a single field', () => { + const step: WorkflowStep = { + id: '42e8b60e-dd44-417a-875f-823d63f16819', + name: 'Code - Serverless Function', + type: 'CODE', + valid: false, + settings: { + input: { + serverlessFunctionId: '5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2d', + serverlessFunctionInput: { + a: '{{5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.a}} {{5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.b}} {{5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.c}} {{5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.d}}', + }, + serverlessFunctionVersion: 'draft', + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { + value: false, + }, + continueOnFailure: { + value: false, + }, + }, + }, + }; + + expect( + getWorkflowVariablesUsedInStep({ + step, + }), + ).toMatchInlineSnapshot(` +Set { + "5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.a", + "5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.b", + "5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.c", + "5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.d", +} +`); + }); + + it('returns the variables used many times only once', () => { + const step: WorkflowStep = { + id: '42e8b60e-dd44-417a-875f-823d63f16819', + name: 'Code - Serverless Function', + type: 'CODE', + valid: false, + settings: { + input: { + serverlessFunctionId: '5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2d', + serverlessFunctionInput: { + a: '{{5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.a}} {{5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.a}} {{5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.a}} {{5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.a}}', + }, + serverlessFunctionVersion: 'draft', + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { + value: false, + }, + continueOnFailure: { + value: false, + }, + }, + }, + }; + + expect( + getWorkflowVariablesUsedInStep({ + step, + }), + ).toMatchInlineSnapshot(` +Set { + "5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c.a", +} +`); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowVariablesUsedInStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowVariablesUsedInStep.ts new file mode 100644 index 000000000..039cc38b7 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/getWorkflowVariablesUsedInStep.ts @@ -0,0 +1,36 @@ +import { WorkflowStep } from '@/workflow/types/Workflow'; +import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex'; +import { isObject, isString } from '@sniptt/guards'; +import { JsonValue } from 'type-fest'; + +function* resolveVariables(value: JsonValue): Generator { + if (isString(value)) { + for (const [, variablePath] of value.matchAll( + CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, + )) { + yield variablePath; + } + + return; + } + + if (isObject(value)) { + for (const nestedValue of Object.values(value)) { + yield* resolveVariables(nestedValue); + } + } +} + +export const getWorkflowVariablesUsedInStep = ({ + step, +}: { + step: WorkflowStep; +}) => { + const variablesUsedInStep = new Set(); + + for (const variable of resolveVariables(step.settings.input)) { + variablesUsedInStep.add(variable); + } + + return variablesUsedInStep; +};