Visualize workflow run step input (#10677)

- Compute the context the selected step had access to during its
execution and display it with the `<JsonNestedNode />` component
- Ensure several steps with the same name can be displayed in order
- Prevent access to the input tab in a few cases
- Hide the input tab when the trigger node is selected as this node
takes no input
- Hide the input tab when the selected node has not been executed yet or
is currently executed
- Fallback to the Node tab when the Input tab can't be accessed

## Successful workflow execution


https://github.com/user-attachments/assets/4a2bb5f5-450c-46ed-b2d7-a14d3b1e5c1f

## Failed workflow execution


https://github.com/user-attachments/assets/3be2784e-e76c-48ab-aef5-17f63410898e

Closes https://github.com/twentyhq/core-team-issues/issues/433
This commit is contained in:
Baptiste Devessier
2025-03-06 17:49:10 +01:00
committed by GitHub
parent 9d78dc322d
commit cb5f4820d7
22 changed files with 12943 additions and 74 deletions

View File

@ -10,6 +10,7 @@ import { CardType } from '@/object-record/record-show/types/CardType';
import { ShowPageActivityContainer } from '@/ui/layout/show-page/components/ShowPageActivityContainer';
import { WorkflowRunOutputVisualizer } from '@/workflow/components/WorkflowRunOutputVisualizer';
import { WorkflowRunVisualizer } from '@/workflow/components/WorkflowRunVisualizer';
import { WorkflowRunVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowRunVisualizerEffect';
import { WorkflowVersionVisualizer } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizer';
import { WorkflowVersionVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizerEffect';
import { WorkflowVisualizer } from '@/workflow/workflow-diagram/components/WorkflowVisualizer';
@ -94,7 +95,11 @@ export const CardComponents: Record<CardType, CardComponentType> = {
),
[CardType.WorkflowRunCard]: ({ targetableObject }) => (
<WorkflowRunVisualizer workflowRunId={targetableObject.id} />
<>
<WorkflowRunVisualizerEffect workflowRunId={targetableObject.id} />
<WorkflowRunVisualizer workflowRunId={targetableObject.id} />
</>
),
[CardType.WorkflowRunOutputCard]: ({ targetableObject }) => (
<WorkflowRunOutputVisualizer workflowRunId={targetableObject.id} />

View File

@ -1,7 +1,6 @@
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { WorkflowRun } from '@/workflow/types/Workflow';
import { WorkflowRunDiagramCanvas } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramCanvas';
import { WorkflowRunVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowRunVisualizerEffect';
import { isDefined } from 'twenty-shared';
export const WorkflowRunVisualizerContent = ({
@ -14,11 +13,5 @@ export const WorkflowRunVisualizerContent = ({
return null;
}
return (
<>
<WorkflowRunVisualizerEffect workflowRun={workflowRun} />
<WorkflowRunDiagramCanvas versionStatus={workflowVersion.status} />
</>
);
return <WorkflowRunDiagramCanvas versionStatus={workflowVersion.status} />;
};

View File

@ -14,7 +14,8 @@ export const JsonArrayNode = ({
return (
<JsonNestedNode
elements={[...value.entries()].map(([key, value]) => ({
key: String(key),
id: key,
label: String(key),
value,
}))}
label={label}

View File

@ -28,7 +28,7 @@ export const JsonNestedNode = ({
}: {
label?: string;
Icon: IconComponent;
elements: Array<{ key: string; value: JsonValue }>;
elements: Array<{ id: string | number; label: string; value: JsonValue }>;
depth: number;
}) => {
const hideRoot = !isDefined(label);
@ -37,8 +37,8 @@ export const JsonNestedNode = ({
const renderedChildren = (
<JsonList depth={depth}>
{elements.map(({ key, value }) => (
<JsonNode key={key} label={key} value={value} depth={depth + 1} />
{elements.map(({ id, label, value }) => (
<JsonNode key={id} label={label} value={value} depth={depth + 1} />
))}
</JsonList>
);

View File

@ -14,7 +14,8 @@ export const JsonObjectNode = ({
return (
<JsonNestedNode
elements={Object.entries(value).map(([key, value]) => ({
key,
id: key,
label: key,
value,
}))}
label={label}

View File

@ -0,0 +1,12 @@
import { workflowRunIdState } from '@/workflow/states/workflowRunIdState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const useWorkflowRunIdOrThrow = () => {
const workflowRunId = useRecoilValue(workflowRunIdState);
if (!isDefined(workflowRunId)) {
throw new Error('Expected the workflow run ID to be defined');
}
return workflowRunId;
};

View File

@ -0,0 +1,6 @@
import { createState } from '@ui/utilities/state/utils/createState';
export const workflowRunIdState = createState<string | undefined>({
key: 'workflowRunIdState',
defaultValue: undefined,
});

View File

@ -11,7 +11,9 @@ import {
workflowFindRecordsActionSchema,
workflowFindRecordsActionSettingsSchema,
workflowManualTriggerSchema,
workflowRunContextSchema,
workflowRunOutputSchema,
workflowRunOutputStepsOutputSchema,
workflowRunSchema,
workflowSendEmailActionSchema,
workflowSendEmailActionSettingsSchema,
@ -97,7 +99,13 @@ export type WorkflowVersion = {
};
export type WorkflowRunOutput = z.infer<typeof workflowRunOutputSchema>;
export type WorkflowRunOutputStepsOutput = WorkflowRunOutput['stepsOutput'];
export type WorkflowRunOutputStepsOutput = z.infer<
typeof workflowRunOutputStepsOutputSchema
>;
export type WorkflowRunContext = z.infer<typeof workflowRunContextSchema>;
export type WorkflowRunFlow = WorkflowRunOutput['flow'];
export type WorkflowRun = z.infer<typeof workflowRunSchema>;

View File

@ -181,7 +181,7 @@ const workflowExecutorOutputSchema = z.object({
error: z.string().optional(),
});
const workflowRunOutputStepsOutputSchema = z.record(
export const workflowRunOutputStepsOutputSchema = z.record(
workflowExecutorOutputSchema,
);
@ -195,11 +195,18 @@ export const workflowRunOutputSchema = z.object({
error: z.string().optional(),
});
export const workflowRunSchema = z.object({
__typename: z.literal('WorkflowRun'),
id: z.string(),
workflowVersionId: z.string(),
output: workflowRunOutputSchema.nullable(),
});
export const workflowRunContextSchema = z.record(z.any());
export type WorkflowRunOutput = z.infer<typeof workflowRunOutputSchema>;
export const workflowRunSchema = z
.object({
__typename: z.literal('WorkflowRun'),
id: z.string(),
workflowVersionId: z.string(),
output: workflowRunOutputSchema.nullable(),
context: workflowRunContextSchema.nullable(),
createdAt: z.string(),
deletedAt: z.string().nullable(),
endedAt: z.string().nullable(),
name: z.string(),
})
.passthrough();

View File

@ -2,16 +2,21 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useTabListStates } from '@/ui/layout/tab/hooks/internal/useTabListStates';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import {
WorkflowDiagramNode,
WorkflowDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/constants/WorkflowRunStepSidePanelTabListComponentId';
import { WorkflowRunTabId } from '@/workflow/workflow-steps/types/WorkflowRunTabId';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react';
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { useIcons } from 'twenty-ui';
@ -22,6 +27,26 @@ export const WorkflowRunDiagramCanvasEffect = () => {
const setHotkeyScope = useSetHotkeyScope();
const { closeCommandMenu } = useCommandMenu();
const { activeTabIdState: workflowRunRightDrawerListActiveTabIdState } =
useTabListStates({
tabListScopeId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
});
const goBackToFirstWorkflowRunRightDrawerTabIfNeeded = useRecoilCallback(
({ snapshot, set }) =>
() => {
const activeWorkflowRunRightDrawerTab = getSnapshotValue(
snapshot,
workflowRunRightDrawerListActiveTabIdState,
) as WorkflowRunTabId | null;
if (activeWorkflowRunRightDrawerTab === 'input') {
set(workflowRunRightDrawerListActiveTabIdState, 'node');
}
},
[workflowRunRightDrawerListActiveTabIdState],
);
const handleSelectionChange = useCallback(
({ nodes }: OnSelectionChangeParams) => {
const selectedNode = nodes[0] as WorkflowDiagramNode;
@ -38,6 +63,14 @@ export const WorkflowRunDiagramCanvasEffect = () => {
const selectedNodeData = selectedNode.data as WorkflowDiagramStepNodeData;
if (
selectedNode.id === TRIGGER_STEP_ID ||
selectedNodeData.runStatus === 'not-executed' ||
selectedNodeData.runStatus === 'running'
) {
goBackToFirstWorkflowRunRightDrawerTabIfNeeded();
}
openRightDrawer(RightDrawerPages.WorkflowRunStepView, {
title: selectedNodeData.name,
Icon: getIcon(getWorkflowNodeIconKey(selectedNodeData)),
@ -47,9 +80,10 @@ export const WorkflowRunDiagramCanvasEffect = () => {
setWorkflowSelectedNode,
setHotkeyScope,
openRightDrawer,
getIcon,
closeRightDrawer,
closeCommandMenu,
getIcon,
goBackToFirstWorkflowRunRightDrawerTabIfNeeded,
],
);

View File

@ -1,5 +1,6 @@
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { flowState } from '@/workflow/states/flowState';
import { WorkflowRun } from '@/workflow/types/Workflow';
import { workflowRunIdState } from '@/workflow/states/workflowRunIdState';
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
import { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram';
import { useEffect } from 'react';
@ -7,16 +8,24 @@ import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
export const WorkflowRunVisualizerEffect = ({
workflowRun,
workflowRunId,
}: {
workflowRun: WorkflowRun;
workflowRunId: string;
}) => {
const workflowRun = useWorkflowRun({ workflowRunId });
const setWorkflowRunId = useSetRecoilState(workflowRunIdState);
const setFlow = useSetRecoilState(flowState);
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
useEffect(() => {
if (!isDefined(workflowRun.output)) {
setWorkflowRunId(workflowRunId);
}, [setWorkflowRunId, workflowRunId]);
useEffect(() => {
if (!isDefined(workflowRun?.output)) {
setFlow(undefined);
setWorkflowDiagram(undefined);
return;
}
@ -25,14 +34,6 @@ export const WorkflowRunVisualizerEffect = ({
trigger: workflowRun.output.flow.trigger,
steps: workflowRun.output.flow.steps,
});
}, [setFlow, workflowRun.output]);
useEffect(() => {
if (!isDefined(workflowRun.output)) {
setWorkflowDiagram(undefined);
return;
}
const nextWorkflowDiagram = generateWorkflowRunDiagram({
trigger: workflowRun.output.flow.trigger,
@ -41,7 +42,7 @@ export const WorkflowRunVisualizerEffect = ({
});
setWorkflowDiagram(nextWorkflowDiagram);
}, [setWorkflowDiagram, workflowRun.output]);
}, [setFlow, setWorkflowDiagram, workflowRun?.output]);
return null;
};

View File

@ -2,10 +2,16 @@ import { ShowPageSubContainerTabListContainer } from '@/ui/layout/show-page/comp
import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { useWorkflowSelectedNodeOrThrow } from '@/workflow/workflow-diagram/hooks/useWorkflowSelectedNodeOrThrow';
import { WorkflowRunStepInputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepInputDetail';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/constants/WorkflowRunStepSidePanelTabListComponentId';
import { getWorkflowRunStepExecutionStatus } from '@/workflow/workflow-steps/utils/getWorkflowRunStepExecutionStatus';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared';
import { IconLogin2, IconLogout, IconStepInto } from 'twenty-ui';
const StyledTabListContainer = styled(ShowPageSubContainerTabListContainer)`
@ -17,17 +23,41 @@ type TabId = 'node' | 'input' | 'output';
export const RightDrawerWorkflowRunViewStep = () => {
const flow = useFlowOrThrow();
const workflowSelectedNode = useWorkflowSelectedNodeOrThrow();
const workflowRunId = useWorkflowRunIdOrThrow();
const workflowRun = useWorkflowRun({ workflowRunId });
const { activeTabId } = useTabList<TabId>(
WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
);
const stepExecutionStatus = isDefined(workflowRun)
? getWorkflowRunStepExecutionStatus({
workflowRunOutput: workflowRun.output,
stepId: workflowSelectedNode,
})
: undefined;
const isInputTabDisabled =
workflowSelectedNode === TRIGGER_STEP_ID ||
stepExecutionStatus === 'running' ||
stepExecutionStatus === 'not-executed';
const tabs: SingleTabProps<TabId>[] = [
{ id: 'node', title: 'Node', Icon: IconStepInto },
{ id: 'input', title: 'Input', Icon: IconLogin2 },
{
id: 'input',
title: 'Input',
Icon: IconLogin2,
disabled: isInputTabDisabled,
},
{ id: 'output', title: 'Output', Icon: IconLogout },
];
if (!isDefined(workflowRun)) {
return null;
}
return (
<>
<StyledTabListContainer>
@ -46,6 +76,10 @@ export const RightDrawerWorkflowRunViewStep = () => {
steps={flow.steps}
/>
) : null}
{activeTabId === 'input' ? (
<WorkflowRunStepInputDetail stepId={workflowSelectedNode} />
) : null}
</>
);
};

View File

@ -0,0 +1,47 @@
import { JsonNestedNode } from '@/workflow/components/json-visualizer/components/JsonNestedNode';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { getWorkflowRunStepContext } from '@/workflow/workflow-steps/utils/getWorkflowRunStepContext';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared';
import { IconBrackets } from 'twenty-ui';
const StyledContainer = styled.div`
padding-block: ${({ theme }) => theme.spacing(4)};
padding-inline: ${({ theme }) => theme.spacing(3)};
`;
export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
const workflowRunId = useWorkflowRunIdOrThrow();
const workflowRun = useWorkflowRun({ workflowRunId });
if (
!(
isDefined(workflowRun) &&
isDefined(workflowRun.context) &&
isDefined(workflowRun.output?.flow)
)
) {
return null;
}
const stepContext = getWorkflowRunStepContext({
context: workflowRun.context,
flow: workflowRun.output.flow,
stepId,
});
return (
<StyledContainer>
<JsonNestedNode
elements={stepContext.map(({ id, name, context }) => ({
id,
label: name,
value: context,
}))}
Icon={IconBrackets}
depth={0}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,142 @@
import { flowState } from '@/workflow/states/flowState';
import { workflowRunIdState } from '@/workflow/states/workflowRunIdState';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import styled from '@emotion/styled';
import { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import { graphql, HttpResponse } from 'msw';
import { useSetRecoilState } from 'recoil';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { oneFailedWorkflowRunQueryResult } from '~/testing/mock-data/workflow-run';
import { RightDrawerWorkflowRunViewStep } from '../RightDrawerWorkflowRunViewStep';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 500px;
`;
const meta: Meta<typeof RightDrawerWorkflowRunViewStep> = {
title: 'Modules/Workflow/RightDrawerWorkflowRunViewStep',
component: RightDrawerWorkflowRunViewStep,
decorators: [
(Story) => (
<StyledWrapper>
<Story />
</StyledWrapper>
),
I18nFrontDecorator,
ComponentDecorator,
(Story) => {
const setFlow = useSetRecoilState(flowState);
const setWorkflowSelectedNode = useSetRecoilState(
workflowSelectedNodeState,
);
const setWorkflowRunId = useSetRecoilState(workflowRunIdState);
setFlow(oneFailedWorkflowRunQueryResult.workflowRun.output.flow);
setWorkflowSelectedNode(
oneFailedWorkflowRunQueryResult.workflowRun.output.flow.steps[0].id,
);
setWorkflowRunId(oneFailedWorkflowRunQueryResult.workflowRun.id);
return <Story />;
},
RouterDecorator,
ObjectMetadataItemsDecorator,
WorkspaceDecorator,
],
parameters: {
msw: {
handlers: [
graphql.query('FindOneWorkflowRun', () => {
return HttpResponse.json({
data: oneFailedWorkflowRunQueryResult,
});
}),
...graphqlMocks.handlers,
],
},
},
};
export default meta;
type Story = StoryObj<typeof RightDrawerWorkflowRunViewStep>;
export const NodeTab: Story = {};
export const InputTab: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Input' }));
expect(await canvas.findByText('Trigger')).toBeVisible();
},
};
export const InputTabDisabledForTrigger: Story = {
decorators: [
(Story) => {
const setWorkflowSelectedNode = useSetRecoilState(
workflowSelectedNodeState,
);
setWorkflowSelectedNode(TRIGGER_STEP_ID);
return <Story />;
},
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const inputTab = await canvas.findByRole('button', { name: 'Input' });
expect(inputTab).toBeDisabled();
},
};
export const InputTabNotExecutedStep: Story = {
decorators: [
(Story) => {
const setWorkflowSelectedNode = useSetRecoilState(
workflowSelectedNodeState,
);
setWorkflowSelectedNode(
oneFailedWorkflowRunQueryResult.workflowRun.output.flow.steps.at(-1)!
.id,
);
return <Story />;
},
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const inputTab = await canvas.findByRole('button', { name: 'Input' });
expect(inputTab).toBeDisabled();
},
};
export const OutputTab: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByRole('button', { name: 'Output' }),
);
await waitFor(() => {
expect(canvas.queryByText('Create Record')).not.toBeInTheDocument();
});
},
};

View File

@ -0,0 +1 @@
export type WorkflowRunTabId = 'node' | 'input' | 'output';

View File

@ -0,0 +1,266 @@
import { WorkflowRunFlow } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { getWorkflowRunStepContext } from '../getWorkflowRunStepContext';
describe('getWorkflowRunStepContext', () => {
it('should return an empty array for trigger step', () => {
const flow = {
trigger: {
name: 'Company Created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
},
steps: [],
} satisfies WorkflowRunFlow;
const context = {
[TRIGGER_STEP_ID]: { company: { id: '123' } },
};
const result = getWorkflowRunStepContext({
stepId: TRIGGER_STEP_ID,
flow,
context,
});
expect(result).toEqual([]);
});
it('should include previous steps context', () => {
const flow = {
trigger: {
name: 'Company Created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
},
steps: [
{
id: 'step1',
name: 'Create company',
type: 'CREATE_RECORD',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
},
{
id: 'step2',
name: 'Send Email',
type: 'SEND_EMAIL',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
connectedAccountId: '123',
email: '',
},
outputSchema: {},
},
valid: true,
},
],
} satisfies WorkflowRunFlow;
const context = {
[TRIGGER_STEP_ID]: { company: { id: '123' } },
step1: { taskId: '456' },
};
const result = getWorkflowRunStepContext({
stepId: 'step2',
flow,
context,
});
expect(result).toEqual([
{
id: TRIGGER_STEP_ID,
name: 'Company Created',
context: { company: { id: '123' } },
},
{
id: 'step1',
name: 'Create company',
context: { taskId: '456' },
},
]);
});
it('should not include subsequent steps context', () => {
const flow = {
trigger: {
name: 'Company Created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
},
steps: [
{
id: 'step1',
name: 'Create company',
type: 'CREATE_RECORD',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
},
{
id: 'step2',
name: 'Send Email',
type: 'SEND_EMAIL',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
connectedAccountId: '123',
email: '',
},
outputSchema: {},
},
valid: true,
},
],
} satisfies WorkflowRunFlow;
const context = {
[TRIGGER_STEP_ID]: { company: { id: '123' } },
step1: { taskId: '456' },
step2: { emailId: '789' },
};
const result = getWorkflowRunStepContext({
stepId: 'step1',
flow,
context,
});
expect(result).toEqual([
{
id: TRIGGER_STEP_ID,
name: 'Company Created',
context: { company: { id: '123' } },
},
]);
});
it('should handle multiple steps with the same name', () => {
const flow = {
trigger: {
name: 'Company Created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
},
steps: [
{
id: 'step1',
name: 'Create Note',
type: 'CREATE_RECORD',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Note',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
},
{
id: 'step2',
name: 'Create Note',
type: 'CREATE_RECORD',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Note',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
},
{
id: 'step3',
name: 'Create Note',
type: 'CREATE_RECORD',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Note',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
},
],
} satisfies WorkflowRunFlow;
const context = {
[TRIGGER_STEP_ID]: { company: { id: '123' } },
step1: { noteId: '456' },
step2: { noteId: '789' },
step3: { noteId: '101' },
};
const result = getWorkflowRunStepContext({
stepId: 'step3',
flow,
context,
});
expect(result).toEqual([
{
id: TRIGGER_STEP_ID,
name: 'Company Created',
context: { company: { id: '123' } },
},
{
id: 'step1',
name: 'Create Note',
context: { noteId: '456' },
},
{
id: 'step2',
name: 'Create Note',
context: { noteId: '789' },
},
]);
});
});

View File

@ -0,0 +1,230 @@
import { getWorkflowRunStepExecutionStatus } from '../getWorkflowRunStepExecutionStatus';
describe('getWorkflowRunStepExecutionStatus', () => {
const stepId = '453e0084-aca2-45b9-8d1c-458a2b8ac70a';
it('should return not-executed when the output is null', () => {
expect(
getWorkflowRunStepExecutionStatus({
workflowRunOutput: null,
stepId,
}),
).toBe('not-executed');
});
it('should return success when step has result', () => {
expect(
getWorkflowRunStepExecutionStatus({
workflowRunOutput: {
flow: {
steps: [
{
id: stepId,
name: 'Code - Serverless Function',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId:
'5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c',
serverlessFunctionInput: {
a: null,
b: null,
},
serverlessFunctionVersion: 'draft',
},
outputSchema: {
link: {
tab: 'test',
icon: 'IconVariable',
label: 'Generate Function Output',
isLeaf: true,
},
_outputSchemaType: 'LINK',
},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
},
],
trigger: {
type: 'MANUAL',
settings: {
outputSchema: {},
},
},
},
stepsOutput: {
[stepId]: {
result: {},
},
},
},
stepId,
}),
).toBe('success');
});
it('should return failure when workflow has error', () => {
const error = 'fn(...).then is not a function';
expect(
getWorkflowRunStepExecutionStatus({
workflowRunOutput: {
flow: {
steps: [
{
id: stepId,
name: 'Code - Serverless Function',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId:
'5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c',
serverlessFunctionInput: {
a: null,
b: null,
},
serverlessFunctionVersion: 'draft',
},
outputSchema: {
link: {
tab: 'test',
icon: 'IconVariable',
label: 'Generate Function Output',
isLeaf: true,
},
_outputSchemaType: 'LINK',
},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
},
],
trigger: {
type: 'MANUAL',
settings: {
outputSchema: {},
},
},
},
error,
stepsOutput: {
[stepId]: {
error,
},
},
},
stepId,
}),
).toBe('failure');
});
it('should return not-executed when step has no output', () => {
const secondStepId = '5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c';
expect(
getWorkflowRunStepExecutionStatus({
workflowRunOutput: {
flow: {
steps: [
{
id: stepId,
name: 'Code - Serverless Function',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId:
'5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c',
serverlessFunctionInput: {
a: null,
b: null,
},
serverlessFunctionVersion: 'draft',
},
outputSchema: {
link: {
tab: 'test',
icon: 'IconVariable',
label: 'Generate Function Output',
isLeaf: true,
},
_outputSchemaType: 'LINK',
},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
},
{
id: secondStepId,
name: 'Code - Serverless Function',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId:
'5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c',
serverlessFunctionInput: {
a: null,
b: null,
},
serverlessFunctionVersion: 'draft',
},
outputSchema: {
link: {
tab: 'test',
icon: 'IconVariable',
label: 'Generate Function Output',
isLeaf: true,
},
_outputSchemaType: 'LINK',
},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
},
],
trigger: {
type: 'MANUAL',
settings: {
outputSchema: {},
},
},
},
stepsOutput: {
[stepId]: {
result: {},
},
},
},
stepId: secondStepId,
}),
).toBe('not-executed');
});
});

View File

@ -0,0 +1,38 @@
import { WorkflowRunContext, WorkflowRunFlow } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
export const getWorkflowRunStepContext = ({
stepId,
flow,
context,
}: {
stepId: string;
context: WorkflowRunContext;
flow: WorkflowRunFlow;
}) => {
const stepContext: Array<{ id: string; name: string; context: any }> = [];
if (stepId === TRIGGER_STEP_ID) {
return stepContext;
}
stepContext.push({
id: TRIGGER_STEP_ID,
name: flow.trigger.name ?? 'Trigger',
context: context[TRIGGER_STEP_ID],
});
for (const step of flow.steps) {
if (step.id === stepId) {
break;
}
stepContext.push({
id: step.id,
name: step.name,
context: context[step.id],
});
}
return stepContext;
};

View File

@ -0,0 +1,28 @@
import { WorkflowRunOutput } from '@/workflow/types/Workflow';
import { WorkflowDiagramRunStatus } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { isNull } from '@sniptt/guards';
import { isDefined } from 'twenty-shared';
export const getWorkflowRunStepExecutionStatus = ({
workflowRunOutput,
stepId,
}: {
workflowRunOutput: WorkflowRunOutput | null;
stepId: string;
}): WorkflowDiagramRunStatus => {
if (isNull(workflowRunOutput)) {
return 'not-executed';
}
const stepOutput = workflowRunOutput.stepsOutput?.[stepId];
if (isDefined(stepOutput?.error)) {
return 'failure';
}
if (isDefined(stepOutput?.result)) {
return 'success';
}
return 'not-executed';
};

View File

@ -1,8 +1,13 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import {
WorkflowTriggerType,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { RightDrawerStepListContainer } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepContainer';
import { RightDrawerWorkflowSelectStepTitle } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepTitle';
import { DATABASE_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/DatabaseTriggerTypes';
import { OTHER_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/OtherTriggerTypes';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
@ -10,8 +15,6 @@ import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hoo
import { getTriggerDefaultDefinition } from '@/workflow/workflow-trigger/utils/getTriggerDefaultDefinition';
import { useSetRecoilState } from 'recoil';
import { MenuItemCommand, useIcons } from 'twenty-ui';
import { RightDrawerStepListContainer } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepContainer';
import { RightDrawerWorkflowSelectStepTitle } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepTitle';
export const RightDrawerWorkflowSelectTriggerTypeContent = ({
workflow,
@ -26,6 +29,33 @@ export const RightDrawerWorkflowSelectTriggerTypeContent = ({
const { openRightDrawer } = useRightDrawer();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const handleTriggerTypeClick = ({
type,
defaultLabel,
icon,
}: {
type: WorkflowTriggerType;
defaultLabel: string;
icon: string;
}) => {
return async () => {
await updateTrigger(
getTriggerDefaultDefinition({
defaultLabel,
type,
activeObjectMetadataItems,
}),
);
setWorkflowSelectedNode(TRIGGER_STEP_ID);
openRightDrawer(RightDrawerPages.WorkflowStepEdit, {
title: defaultLabel,
Icon: getIcon(icon),
});
};
};
return (
<RightDrawerStepListContainer>
<RightDrawerWorkflowSelectStepTitle>
@ -36,22 +66,7 @@ export const RightDrawerWorkflowSelectTriggerTypeContent = ({
key={action.defaultLabel}
LeftIcon={getIcon(action.icon)}
text={action.defaultLabel}
onClick={async () => {
await updateTrigger(
getTriggerDefaultDefinition({
defaultLabel: action.defaultLabel,
type: action.type,
activeObjectMetadataItems,
}),
);
setWorkflowSelectedNode(TRIGGER_STEP_ID);
openRightDrawer(RightDrawerPages.WorkflowStepEdit, {
title: action.defaultLabel,
Icon: getIcon(action.icon),
});
}}
onClick={handleTriggerTypeClick(action)}
/>
))}
<RightDrawerWorkflowSelectStepTitle>
@ -62,22 +77,7 @@ export const RightDrawerWorkflowSelectTriggerTypeContent = ({
key={action.defaultLabel}
LeftIcon={getIcon(action.icon)}
text={action.defaultLabel}
onClick={async () => {
await updateTrigger(
getTriggerDefaultDefinition({
defaultLabel: action.defaultLabel,
type: action.type,
activeObjectMetadataItems,
}),
);
setWorkflowSelectedNode(TRIGGER_STEP_ID);
openRightDrawer(RightDrawerPages.WorkflowStepEdit, {
title: action.defaultLabel,
Icon: getIcon(action.icon),
});
}}
onClick={handleTriggerTypeClick(action)}
/>
))}
</RightDrawerStepListContainer>

View File

@ -30,6 +30,7 @@ import {
getWorkflowVersionsMock,
workflowQueryResult,
} from '~/testing/mock-data/workflow';
import { oneSucceededWorkflowRunQueryResult } from '~/testing/mock-data/workflow-run';
import { mockedRemoteServers } from './mock-data/remote-servers';
import { mockedViewFieldsData } from './mock-data/view-fields';
@ -714,6 +715,11 @@ export const graphqlMocks = {
},
});
}),
graphql.query('FindOneWorkflowRun', () => {
return HttpResponse.json({
data: oneSucceededWorkflowRunQueryResult,
});
}),
graphql.query('FindManyWorkflowVersions', () => {
return HttpResponse.json({
data: {

File diff suppressed because it is too large Load Diff