Highlight consumed variables in workflow runs (#10788)

- Create a function to resolve the variables used in the configuration
of a step
- Let the JSON tree components take a prop (`getNodeHighlighting`) to
determine whether an element must be highlighted
- Compute each element's keyPath recursively; the keyPath is passed as
an argument to the `getNodeHighlighting` function

## Demo


https://github.com/user-attachments/assets/8586f43d-53d1-41ba-ab48-08bb8c74e145

Closes https://github.com/twentyhq/core-team-issues/issues/435
This commit is contained in:
Baptiste Devessier
2025-03-13 11:19:12 +01:00
committed by GitHub
parent 7e291f3cff
commit ecf282ad99
11 changed files with 328 additions and 17 deletions

View File

@ -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}
/>
);
};

View File

@ -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 ? (
<StyledEmptyState>{emptyElementsText}</StyledEmptyState>
) : (
elements.map(({ id, label, value }) => (
<JsonNode key={id} label={label} value={value} depth={depth + 1} />
))
elements.map(({ id, label, value }) => {
const nextKeyPath = isNonEmptyString(keyPath)
? `${keyPath}.${id}`
: String(id);
return (
<JsonNode
key={id}
label={label}
value={value}
depth={depth + 1}
keyPath={nextKeyPath}
shouldHighlightNode={shouldHighlightNode}
/>
);
})
)}
</JsonList>
);

View File

@ -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 (
<JsonValueNode
label={label}
valueAsString="[null]"
Icon={IconCircleOff}
isHighlighted={isHighlighted}
/>
);
}
@ -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 <JsonArrayNode label={label} value={value} depth={depth} />;
return (
<JsonArrayNode
label={label}
value={value}
depth={depth}
keyPath={keyPath}
shouldHighlightNode={shouldHighlightNode}
/>
);
}
return <JsonObjectNode label={label} value={value} depth={depth} />;
return (
<JsonObjectNode
label={label}
value={value}
depth={depth}
keyPath={keyPath}
shouldHighlightNode={shouldHighlightNode}
/>
);
};

View File

@ -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}
/>
);
};

View File

@ -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 (
<JsonList depth={0}>
<JsonNode value={value} depth={0} />
<JsonNode
value={value}
depth={0}
keyPath=""
shouldHighlightNode={shouldHighlightNode}
/>
</JsonList>
);
};

View File

@ -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 (
<StyledListItem>
{props.label && <JsonNodeLabel label={props.label} Icon={props.Icon} />}
{props.label && (
<JsonNodeLabel
label={props.label}
Icon={props.Icon}
isHighlighted={props.isHighlighted}
/>
)}
<JsonNodeValue valueAsString={props.valueAsString} />
<JsonNodeValue
valueAsString={props.valueAsString}
isHighlighted={props.isHighlighted}
/>
</StyledListItem>
);
};

View File

@ -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 (
<StyledLabelContainer>
<Icon size={theme.icon.size.md} color={theme.font.color.tertiary} />
<StyledLabelContainer isHighlighted={isHighlighted}>
<Icon
size={theme.icon.size.md}
color={isHighlighted ? theme.color.blue : theme.font.color.tertiary}
/>
<span>{label}</span>
</StyledLabelContainer>

View File

@ -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 <StyledText>{valueAsString}</StyledText>;
export const JsonNodeValue = ({
valueAsString,
isHighlighted,
}: {
valueAsString: string;
isHighlighted?: boolean;
}) => {
return <StyledText isHighlighted={isHighlighted}>{valueAsString}</StyledText>;
};

View File

@ -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)}
/>
</StyledContainer>
);

View File

@ -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",
}
`);
});
});

View File

@ -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<string> {
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<string>();
for (const variable of resolveVariables(step.settings.input)) {
variablesUsedInStep.add(variable);
}
return variablesUsedInStep;
};