Scaffold filters creation and deletion (#12990)

When the feature flag is activated, we can now create filters and delete
them. This PR mainly updates how we generate workflow diagrams.


https://github.com/user-attachments/assets/1a4aef46-7c3c-45fa-953f-0bd1908b9be7
This commit is contained in:
Baptiste Devessier
2025-07-02 17:01:44 +02:00
committed by GitHub
parent ba67e0d5f4
commit e8a2d71844
21 changed files with 818 additions and 204 deletions

View File

@ -1,9 +1,15 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import {
WorkflowActionType,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { RightDrawerStepListContainer } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepContainer';
import { RightDrawerWorkflowSelectStepTitle } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepTitle';
import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { RECORD_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/RecordActions';
import { useFilteredOtherActions } from '@/workflow/workflow-steps/workflow-actions/hooks/useFilteredOtherActions';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { MenuItemCommand } from 'twenty-ui/navigation';
@ -13,11 +19,36 @@ export const CommandMenuWorkflowSelectActionContent = ({
workflow: WorkflowWithCurrentVersion;
}) => {
const { getIcon } = useIcons();
const { createStep } = useCreateStep({
workflow,
});
const filteredOtherActions = useFilteredOtherActions();
const [workflowInsertStepIds, setWorkflowInsertStepIds] =
useRecoilComponentStateV2(workflowInsertStepIdsComponentState);
const handleCreateStep = async (actionType: WorkflowActionType) => {
const { parentStepId, nextStepId } = workflowInsertStepIds;
if (!isDefined(parentStepId)) {
throw new Error(
'No parentStepId. Please select a parent step to create from.',
);
}
await createStep({
newStepType: actionType,
parentStepId,
nextStepId,
});
setWorkflowInsertStepIds({
parentStepId: undefined,
nextStepId: undefined,
});
};
return (
<RightDrawerStepListContainer>
<RightDrawerWorkflowSelectStepTitle>
@ -28,7 +59,7 @@ export const CommandMenuWorkflowSelectActionContent = ({
key={action.type}
LeftIcon={getIcon(action.icon)}
text={action.label}
onClick={() => createStep(action.type)}
onClick={() => handleCreateStep(action.type)}
/>
))}
<RightDrawerWorkflowSelectStepTitle>
@ -39,7 +70,7 @@ export const CommandMenuWorkflowSelectActionContent = ({
key={action.type}
LeftIcon={getIcon(action.icon)}
text={action.label}
onClick={() => createStep(action.type)}
onClick={() => handleCreateStep(action.type)}
/>
))}
</RightDrawerStepListContainer>

View File

@ -10,6 +10,8 @@ import {
workflowDeleteRecordActionSchema,
workflowDeleteRecordActionSettingsSchema,
workflowExecutorOutputSchema,
workflowFilterActionSchema,
workflowFilterActionSettingsSchema,
workflowFindRecordsActionSchema,
workflowFindRecordsActionSettingsSchema,
workflowFormActionSchema,
@ -48,6 +50,9 @@ export type WorkflowDeleteRecordActionSettings = z.infer<
export type WorkflowFindRecordsActionSettings = z.infer<
typeof workflowFindRecordsActionSettingsSchema
>;
export type WorkflowFilterActionSettings = z.infer<
typeof workflowFilterActionSettingsSchema
>;
export type WorkflowFormActionSettings = z.infer<
typeof workflowFormActionSettingsSchema
>;
@ -68,6 +73,7 @@ export type WorkflowDeleteRecordAction = z.infer<
export type WorkflowFindRecordsAction = z.infer<
typeof workflowFindRecordsActionSchema
>;
export type WorkflowFilterAction = z.infer<typeof workflowFilterActionSchema>;
export type WorkflowFormAction = z.infer<typeof workflowFormActionSchema>;
export type WorkflowHttpRequestAction = z.infer<
typeof workflowHttpRequestActionSchema
@ -86,6 +92,7 @@ export type WorkflowAction =
| WorkflowUpdateRecordAction
| WorkflowDeleteRecordAction
| WorkflowFindRecordsAction
| WorkflowFilterAction
| WorkflowFormAction
| WorkflowHttpRequestAction
| WorkflowAiAgentAction;

View File

@ -138,6 +138,13 @@ export const workflowAiAgentActionSettingsSchema =
}),
});
export const workflowFilterActionSettingsSchema =
baseWorkflowActionSettingsSchema.extend({
input: z.object({
filter: z.record(z.any()),
}),
});
// Action schemas
export const workflowCodeActionSchema = baseWorkflowActionSchema.extend({
type: z.literal('CODE'),
@ -190,6 +197,11 @@ export const workflowAiAgentActionSchema = baseWorkflowActionSchema.extend({
settings: workflowAiAgentActionSettingsSchema,
});
export const workflowFilterActionSchema = baseWorkflowActionSchema.extend({
type: z.literal('FILTER'),
settings: workflowFilterActionSettingsSchema,
});
// Combined action schema
export const workflowActionSchema = z.discriminatedUnion('type', [
workflowCodeActionSchema,
@ -201,6 +213,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [
workflowFormActionSchema,
workflowHttpRequestActionSchema,
workflowAiAgentActionSchema,
workflowFilterActionSchema,
]);
// Trigger schemas

View File

@ -50,8 +50,10 @@ export const WorkflowDiagramDefaultEdge = ({
<WorkflowDiagramEdgeV2
labelX={labelX}
labelY={labelY}
stepId={data.stepId}
parentStepId={source}
nextStepId={target}
filter={data.filter}
/>
) : (
<WorkflowDiagramEdgeV1

View File

@ -1,164 +1,71 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { isDropdownOpenComponentStateV2 } from '@/ui/layout/dropdown/states/isDropdownOpenComponentStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { assertWorkflowWithCurrentVersionIsDefined } from '@/workflow/utils/assertWorkflowWithCurrentVersionIsDefined';
import { WorkflowDiagramEdgeV2Content } from '@/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Content';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { workflowDiagramPanOnDragComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import {
IconDotsVertical,
IconFilter,
IconFilterPlus,
IconFilterX,
IconGitBranchDeleted,
IconPlus,
} from 'twenty-ui/display';
import { IconButtonGroup } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
const StyledIconButtonGroup = styled(IconButtonGroup)`
pointer-events: all;
`;
const StyledContainer = styled.div<{ labelX: number; labelY: number }>`
padding: ${({ theme }) => theme.spacing(1)};
pointer-events: all;
${({ labelX, labelY }) => css`
transform: translate(-50%, -50%) translate(${labelX}px, ${labelY}px);
`}
position: absolute;
`;
const StyledOpacityOverlay = styled.div<{ shouldDisplay: boolean }>`
opacity: ${({ shouldDisplay }) => (shouldDisplay ? 1 : 0)};
position: relative;
`;
import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep';
import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep';
import { isDefined } from 'twenty-shared/utils';
type WorkflowDiagramEdgeV2Props = {
labelX: number;
labelY: number;
stepId: string | undefined;
parentStepId: string;
nextStepId: string;
filter: Record<string, any> | undefined;
};
export const WorkflowDiagramEdgeV2 = ({
labelX,
labelY,
stepId,
parentStepId,
nextStepId,
filter,
}: WorkflowDiagramEdgeV2Props) => {
const { openDropdown } = useOpenDropdown();
const { closeDropdown } = useCloseDropdown();
const workflowVisualizerWorkflowId = useRecoilComponentValueV2(
workflowVisualizerWorkflowIdComponentState,
);
const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId);
assertWorkflowWithCurrentVersionIsDefined(workflow);
const { createStep } = useCreateStep({ workflow });
const { deleteStep } = useDeleteStep({ workflow });
const { startNodeCreation } = useStartNodeCreation();
const [hovered, setHovered] = useState(false);
const setWorkflowDiagramPanOnDrag = useSetRecoilComponentStateV2(
workflowDiagramPanOnDragComponentState,
);
const workflowInsertStepIds = useRecoilComponentValueV2(
workflowInsertStepIdsComponentState,
);
const isSelected =
workflowInsertStepIds.parentStepId === parentStepId &&
workflowInsertStepIds.nextStepId === nextStepId;
const dropdownId = `${WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}-${parentStepId}-${nextStepId}`;
const isDropdownOpen = useRecoilComponentValueV2(
isDropdownOpenComponentStateV2,
dropdownId,
);
return (
<StyledContainer
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
<WorkflowDiagramEdgeV2Content
labelX={labelX}
labelY={labelY}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<StyledOpacityOverlay
shouldDisplay={isSelected || hovered || isDropdownOpen}
>
<StyledIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconFilterPlus,
onClick: () => {},
},
{
Icon: IconDotsVertical,
onClick: () => {
openDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
},
},
]}
/>
stepId={stepId}
parentStepId={parentStepId}
nextStepId={nextStepId}
filter={filter}
onCreateFilter={() => {
return createStep({
newStepType: 'FILTER',
parentStepId,
nextStepId,
});
}}
onDeleteFilter={() => {
if (!isDefined(stepId)) {
throw new Error(
'Step ID must be configured for the edge when rendering a filter',
);
}
<Dropdown
dropdownId={dropdownId}
clickableComponent={<div></div>}
data-select-disable
dropdownPlacement="bottom-start"
dropdownStrategy="absolute"
dropdownOffset={{
x: 0,
y: 4,
}}
onOpen={() => {
setWorkflowDiagramPanOnDrag(false);
}}
onClose={() => {
setWorkflowDiagramPanOnDrag(true);
}}
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Narrow}>
<DropdownMenuItemsContainer>
<MenuItem
text="Filter"
LeftIcon={IconFilter}
onClick={() => {}}
/>
<MenuItem
text="Remove Filter"
LeftIcon={IconFilterX}
onClick={() => {}}
/>
<MenuItem
text="Add Node"
LeftIcon={IconPlus}
onClick={() => {
closeDropdown(dropdownId);
setHovered(false);
startNodeCreation({ parentStepId, nextStepId });
}}
/>
<MenuItem
text="Delete branch"
LeftIcon={IconGitBranchDeleted}
onClick={() => {}}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</StyledOpacityOverlay>
</StyledContainer>
return deleteStep(stepId);
}}
onCreateNode={() => {
if (isDefined(filter)) {
startNodeCreation({ parentStepId: stepId, nextStepId });
} else {
startNodeCreation({ parentStepId, nextStepId });
}
}}
/>
);
};

View File

@ -0,0 +1,209 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { isDropdownOpenComponentStateV2 } from '@/ui/layout/dropdown/states/isDropdownOpenComponentStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { workflowDiagramPanOnDragComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
IconDotsVertical,
IconFilter,
IconFilterPlus,
IconFilterX,
IconGitBranchDeleted,
IconPlus,
} from 'twenty-ui/display';
import { IconButtonGroup } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
const StyledIconButtonGroup = styled(IconButtonGroup)`
pointer-events: all;
`;
const StyledRoundedIconButtonGroup = styled(IconButtonGroup)`
border-radius: 50px;
overflow: hidden;
pointer-events: all;
`;
const StyledContainer = styled.div<{ labelX: number; labelY: number }>`
padding: ${({ theme }) => theme.spacing(1)};
pointer-events: all;
${({ labelX, labelY }) => css`
transform: translate(-50%, -50%) translate(${labelX}px, ${labelY}px);
`}
position: absolute;
`;
const StyledOpacityOverlay = styled.div<{ shouldDisplay: boolean }>`
opacity: ${({ shouldDisplay }) => (shouldDisplay ? 1 : 0)};
position: relative;
`;
type WorkflowDiagramEdgeV2ContentProps = {
labelX: number;
labelY: number;
stepId: string | undefined;
parentStepId: string;
nextStepId: string;
filter: Record<string, any> | undefined;
onCreateFilter: () => Promise<void>;
onDeleteFilter: () => Promise<void>;
onCreateNode: () => void;
};
export const WorkflowDiagramEdgeV2Content = ({
labelX,
labelY,
stepId,
parentStepId,
nextStepId,
filter,
onCreateFilter,
onDeleteFilter,
onCreateNode,
}: WorkflowDiagramEdgeV2ContentProps) => {
const { openDropdown } = useOpenDropdown();
const { closeDropdown } = useCloseDropdown();
const [hovered, setHovered] = useState(false);
const setWorkflowDiagramPanOnDrag = useSetRecoilComponentStateV2(
workflowDiagramPanOnDragComponentState,
);
const workflowInsertStepIds = useRecoilComponentValueV2(
workflowInsertStepIdsComponentState,
);
const isSelected =
workflowInsertStepIds.nextStepId === nextStepId &&
(workflowInsertStepIds.parentStepId === parentStepId ||
(isNonEmptyString(stepId) &&
workflowInsertStepIds.parentStepId === stepId));
const dropdownId = `${WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}-${parentStepId}-${nextStepId}`;
const isDropdownOpen = useRecoilComponentValueV2(
isDropdownOpenComponentStateV2,
dropdownId,
);
const handleCreateFilter = async () => {
await onCreateFilter();
closeDropdown(dropdownId);
setHovered(false);
};
return (
<StyledContainer
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
labelX={labelX}
labelY={labelY}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<StyledOpacityOverlay
shouldDisplay={
isSelected || hovered || isDropdownOpen || isDefined(filter)
}
>
{isDefined(filter) && !hovered && !isDropdownOpen && !isSelected ? (
<StyledRoundedIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconFilterPlus,
},
]}
/>
) : (
<StyledIconButtonGroup
className="nodrag nopan"
iconButtons={[
{
Icon: IconFilterPlus,
onClick: () => {
handleCreateFilter();
},
},
{
Icon: IconDotsVertical,
onClick: () => {
openDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
},
},
]}
/>
)}
<Dropdown
dropdownId={dropdownId}
clickableComponent={<div></div>}
data-select-disable
dropdownPlacement="bottom-start"
dropdownStrategy="absolute"
dropdownOffset={{
x: 0,
y: 4,
}}
onOpen={() => {
setWorkflowDiagramPanOnDrag(false);
}}
onClose={() => {
setWorkflowDiagramPanOnDrag(true);
}}
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Narrow}>
<DropdownMenuItemsContainer>
<MenuItem
text="Filter"
LeftIcon={IconFilter}
onClick={() => {}}
/>
<MenuItem
text="Remove Filter"
LeftIcon={IconFilterX}
onClick={() => {
closeDropdown(dropdownId);
setHovered(false);
onDeleteFilter();
}}
/>
<MenuItem
text="Add Node"
LeftIcon={IconPlus}
onClick={() => {
closeDropdown(dropdownId);
setHovered(false);
onCreateNode();
}}
/>
<MenuItem
text="Delete branch"
LeftIcon={IconGitBranchDeleted}
onClick={() => {}}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</StyledOpacityOverlay>
</StyledContainer>
);
};

View File

@ -1,17 +1,17 @@
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
import { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
import '@xyflow/react/dist/style.css';
import {
ComponentDecorator,
getCanvasElementForDropdownTesting,
} from 'twenty-ui/testing';
import { ReactflowDecorator } from '~/testing/decorators/ReactflowDecorator';
import { WorkflowDiagramEdgeV2 } from '../WorkflowDiagramEdgeV2';
import { WorkflowDiagramEdgeV2Content } from '../WorkflowDiagramEdgeV2Content';
const meta: Meta<typeof WorkflowDiagramEdgeV2> = {
title: 'Modules/Workflow/WorkflowDiagramEdgeV2',
component: WorkflowDiagramEdgeV2,
const meta: Meta<typeof WorkflowDiagramEdgeV2Content> = {
title: 'Modules/Workflow/WorkflowDiagramEdgeV2Content',
component: WorkflowDiagramEdgeV2Content,
decorators: [
ComponentDecorator,
ReactflowDecorator,
@ -35,11 +35,14 @@ const meta: Meta<typeof WorkflowDiagramEdgeV2> = {
labelY: 0,
parentStepId: 'parent-step-id',
nextStepId: 'next-step-id',
onCreateFilter: fn(),
onDeleteFilter: fn(),
onCreateNode: fn(),
},
};
export default meta;
type Story = StoryObj<typeof WorkflowDiagramEdgeV2>;
type Story = StoryObj<typeof WorkflowDiagramEdgeV2Content>;
export const ButtonsAppearOnHover: Story = {
play: async ({ canvasElement }) => {
@ -57,7 +60,7 @@ export const ButtonsAppearOnHover: Story = {
};
export const CreateFilter: Story = {
play: async ({ canvasElement }) => {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const buttons = await canvas.findAllByRole('button');
@ -71,12 +74,14 @@ export const CreateFilter: Story = {
userEvent.click(filterButton);
// TODO: Assert we created a filter
await waitFor(() => {
expect(args.onCreateFilter).toHaveBeenCalledTimes(1);
});
},
};
export const AddNodeAction: Story = {
play: async ({ canvasElement }) => {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const buttons = await canvas.findAllByRole('button');
@ -99,6 +104,10 @@ export const AddNodeAction: Story = {
await waitFor(() => {
expect(canvas.queryByText('Add Node')).not.toBeInTheDocument();
});
await waitFor(() => {
expect(args.onCreateNode).toHaveBeenCalledTimes(1);
});
},
};

View File

@ -69,6 +69,8 @@ export type WorkflowRunDiagramNodeData = Exclude<
> & { runStatus: WorkflowDiagramRunStatus };
export type EdgeData = {
stepId?: string;
filter?: Record<string, any>;
shouldDisplayEdgeOptions?: boolean;
};

View File

@ -0,0 +1,333 @@
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { transformFilterNodesAsEdges } from '../transformFilterNodesAsEdges';
describe('transformFilterNodesAsEdges', () => {
it('should return the original diagram when there are no filter nodes', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'A',
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
position: { x: 0, y: 0 },
},
{
id: 'C',
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
},
position: { x: 0, y: 300 },
},
],
edges: [
{
id: 'A-C',
source: 'A',
target: 'C',
data: { stepId: 'A', shouldDisplayEdgeOptions: true },
},
],
};
const result = transformFilterNodesAsEdges(diagram);
expect(result.nodes).toEqual(diagram.nodes);
expect(result.edges).toEqual(diagram.edges);
});
it('should transform A->B->C where B is a FILTER step', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'A',
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
position: { x: 0, y: 0 },
},
{
id: 'B',
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' },
position: { x: 0, y: 150 },
},
{
id: 'C',
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
},
position: { x: 0, y: 300 },
},
],
edges: [
{
id: 'A-B',
source: 'A',
target: 'B',
data: { stepId: 'A', shouldDisplayEdgeOptions: true },
},
{
id: 'B-C',
source: 'B',
target: 'C',
data: { stepId: 'B', shouldDisplayEdgeOptions: true },
},
],
};
const result = transformFilterNodesAsEdges(diagram);
// Should only have nodes A and C
expect(result.nodes).toEqual([
{
id: 'A',
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
position: { x: 0, y: 0 },
},
{
id: 'C',
data: { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C' },
position: { x: 0, y: 300 },
},
]);
// Should have one edge with filter data
expect(result.edges).toHaveLength(1);
expect(result.edges[0]).toEqual({
id: 'A-C-filter-B',
source: 'A',
target: 'C',
data: {
shouldDisplayEdgeOptions: true,
stepId: 'B',
filter: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' },
},
});
});
it('should handle multiple filter nodes', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'A',
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
position: { x: 0, y: 0 },
},
{
id: 'B1',
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B1' },
position: { x: 0, y: 150 },
},
{
id: 'C',
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
},
position: { x: 0, y: 300 },
},
{
id: 'B2',
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B2' },
position: { x: 0, y: 450 },
},
{
id: 'D',
data: {
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Step D',
},
position: { x: 0, y: 600 },
},
],
edges: [
{
id: 'A-B1',
source: 'A',
target: 'B1',
data: { stepId: 'A', shouldDisplayEdgeOptions: true },
},
{
id: 'B1-C',
source: 'B1',
target: 'C',
data: { stepId: 'B1', shouldDisplayEdgeOptions: true },
},
{
id: 'C-B2',
source: 'C',
target: 'B2',
data: { stepId: 'C', shouldDisplayEdgeOptions: true },
},
{
id: 'B2-D',
source: 'B2',
target: 'D',
data: { stepId: 'B2', shouldDisplayEdgeOptions: true },
},
],
};
const result = transformFilterNodesAsEdges(diagram);
// Should only have nodes A, C, and D
expect(result.nodes).toHaveLength(3);
expect(result.nodes.map((n) => n.id)).toEqual(
expect.arrayContaining(['A', 'C', 'D']),
);
// Should have two edges with filter data
expect(result.edges).toHaveLength(2);
const edgeAC = result.edges.find(
(e) => e.source === 'A' && e.target === 'C',
);
expect(edgeAC).toEqual({
id: 'A-C-filter-B1',
source: 'A',
target: 'C',
data: {
stepId: 'B1',
shouldDisplayEdgeOptions: true,
filter: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B1' },
},
});
const edgeCD = result.edges.find(
(e) => e.source === 'C' && e.target === 'D',
);
expect(edgeCD).toEqual({
id: 'C-D-filter-B2',
source: 'C',
target: 'D',
data: {
stepId: 'B2',
shouldDisplayEdgeOptions: true,
filter: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B2' },
},
});
});
it('should handle filter nodes that are not part of a chain', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'A',
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
position: { x: 0, y: 0 },
},
{
id: 'B',
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' },
position: { x: 0, y: 150 },
},
],
edges: [
{
id: 'A-B',
source: 'A',
target: 'B',
data: { stepId: 'A', shouldDisplayEdgeOptions: true },
},
],
};
const result = transformFilterNodesAsEdges(diagram);
// Should only have node A (filter node B is removed)
expect(result.nodes).toEqual([
{
id: 'A',
data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' },
position: { x: 0, y: 0 },
},
]);
// Should have no edges (original edge A-B is removed, no new edges created)
expect(result.edges).toEqual([]);
});
it('should preserve trigger nodes', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'trigger',
data: {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Trigger',
},
position: { x: 0, y: 0 },
},
{
id: 'B',
data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' },
position: { x: 0, y: 150 },
},
{
id: 'C',
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
},
position: { x: 0, y: 300 },
},
],
edges: [
{
id: 'trigger-B',
source: 'trigger',
target: 'B',
data: { stepId: 'trigger', shouldDisplayEdgeOptions: true },
},
{
id: 'B-C',
source: 'B',
target: 'C',
data: { stepId: 'B', shouldDisplayEdgeOptions: true },
},
],
};
const result = transformFilterNodesAsEdges(diagram);
// Should have trigger and C nodes
expect(result.nodes).toEqual([
{
id: 'trigger',
data: {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Trigger',
},
position: { x: 0, y: 0 },
},
{
id: 'C',
data: { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C' },
position: { x: 0, y: 300 },
},
]);
// Should have one edge with filter data
expect(result.edges).toEqual([
{
id: 'trigger-C-filter-B',
source: 'trigger',
target: 'C',
data: {
stepId: 'B',
shouldDisplayEdgeOptions: true,
filter: {
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter B',
},
},
},
]);
});
});

View File

@ -6,9 +6,14 @@ export const addEdgeOptions = ({
}: WorkflowDiagram): WorkflowDiagram => {
return {
nodes,
edges: edges.map((edge) => ({
...edge,
data: { shouldDisplayEdgeOptions: true },
})),
edges: edges.map((edge) => {
return {
...edge,
data: {
...edge.data,
shouldDisplayEdgeOptions: true,
},
};
}),
};
};

View File

@ -10,9 +10,10 @@ import {
WorkflowRunDiagramNode,
WorkflowRunDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { isDefined } from 'twenty-shared/utils';
import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
import { isStepNode } from '@/workflow/workflow-diagram/utils/isStepNode';
import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges';
import { isDefined } from 'twenty-shared/utils';
export const generateWorkflowRunDiagram = ({
trigger,
@ -38,7 +39,9 @@ export const generateWorkflowRunDiagram = ({
}
| undefined = undefined;
const workflowDiagram = generateWorkflowDiagram({ trigger, steps });
const workflowDiagram = transformFilterNodesAsEdges(
generateWorkflowDiagram({ trigger, steps }),
);
let skippedExecution = false;

View File

@ -1,6 +1,7 @@
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram';
import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges';
import { isDefined } from 'twenty-shared/utils';
const EMPTY_DIAGRAM: WorkflowDiagram = {
@ -15,8 +16,10 @@ export const getWorkflowVersionDiagram = (
return EMPTY_DIAGRAM;
}
return generateWorkflowDiagram({
trigger: workflowVersion.trigger ?? undefined,
steps: workflowVersion.steps ?? [],
});
return transformFilterNodesAsEdges(
generateWorkflowDiagram({
trigger: workflowVersion.trigger ?? undefined,
steps: workflowVersion.steps ?? [],
}),
);
};

View File

@ -0,0 +1,66 @@
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { isDefined } from 'twenty-shared/utils';
export const transformFilterNodesAsEdges = ({
nodes,
edges,
}: WorkflowDiagram): WorkflowDiagram => {
const filterNodes = nodes.filter(
(node) =>
node.data.nodeType === 'action' &&
'actionType' in node.data &&
node.data.actionType === 'FILTER',
);
if (filterNodes.length === 0) {
return { nodes, edges };
}
const resultNodes = nodes.filter(
(node) => !filterNodes.some((filterNode) => filterNode.id === node.id),
);
const resultEdges = [...edges];
const edgesToRemove = new Set<string>();
const edgesToAdd: typeof edges = [];
for (const filterNode of filterNodes) {
const incomingEdge = edges.find((edge) => edge.target === filterNode.id);
const outgoingEdge = edges.find((edge) => edge.source === filterNode.id);
if (isDefined(incomingEdge) && isDefined(outgoingEdge)) {
const newEdge = {
...incomingEdge,
id: `${incomingEdge.source}-${outgoingEdge.target}-filter-${filterNode.id}`,
target: outgoingEdge.target,
data: {
...incomingEdge.data,
stepId: filterNode.id,
filter: filterNode.data,
},
};
edgesToAdd.push(newEdge);
edgesToRemove.add(incomingEdge.id);
edgesToRemove.add(outgoingEdge.id);
} else {
if (isDefined(incomingEdge)) {
edgesToRemove.add(incomingEdge.id);
}
if (isDefined(outgoingEdge)) {
edgesToRemove.add(outgoingEdge.id);
}
}
}
const finalEdges = [
...resultEdges.filter((edge) => !edgesToRemove.has(edge.id)),
...edgesToAdd,
];
return {
nodes: resultNodes,
edges: finalEdges,
};
};

View File

@ -200,6 +200,11 @@ export const WorkflowRunStepNodeDetail = ({
/>
);
}
case 'FILTER': {
throw new Error(
"The Filter action isn't meant to be displayed as a node.",
);
}
}
}
}

View File

@ -184,6 +184,11 @@ export const WorkflowStepDetail = ({
/>
);
}
case 'FILTER': {
throw new Error(
"The Filter action isn't meant to be displayed as a node.",
);
}
default:
return assertUnreachable(

View File

@ -1,5 +1,4 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { WorkflowVisualizerComponentInstanceContext } from '../../../workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
@ -30,16 +29,7 @@ const wrapper = ({ children }: { children: React.ReactNode }) => {
'workflow-visualizer-instance-id';
return (
<RecoilRoot
initializeState={({ set }) => {
set(
workflowInsertStepIdsComponentState.atomFamily({
instanceId: workflowVisualizerComponentInstanceId,
}),
{ parentStepId: 'parent-step-id', nextStepId: undefined },
);
}}
>
<RecoilRoot>
<WorkflowVisualizerComponentInstanceContext.Provider
value={{
instanceId: workflowVisualizerComponentInstanceId,
@ -73,7 +63,11 @@ describe('useCreateStep', () => {
wrapper,
},
);
await result.current.createStep('CODE');
await result.current.createStep({
newStepType: 'CODE',
parentStepId: 'parent-step-id',
nextStepId: undefined,
});
expect(mockCreateWorkflowVersionStep).toHaveBeenCalled();
});

View File

@ -7,10 +7,7 @@ import {
} from '@/workflow/types/Workflow';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { useCreateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { isDefined } from 'twenty-shared/utils';
import { useState } from 'react';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
export const useCreateStep = ({
workflow,
@ -26,12 +23,17 @@ export const useCreateStep = ({
workflowLastCreatedStepIdComponentState,
);
const [workflowInsertStepIds, setWorkflowInsertStepIds] =
useRecoilComponentStateV2(workflowInsertStepIdsComponentState);
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const createStep = async (newStepType: WorkflowStepType) => {
const createStep = async ({
newStepType,
parentStepId,
nextStepId,
}: {
newStepType: WorkflowStepType;
parentStepId: string;
nextStepId: string | undefined;
}) => {
if (isLoading === true) {
return;
}
@ -39,20 +41,14 @@ export const useCreateStep = ({
setIsLoading(true);
try {
if (!isDefined(workflowInsertStepIds.parentStepId)) {
throw new Error(
'No parentStepId. Please select a parent step to create from.',
);
}
const workflowVersionId = await getUpdatableWorkflowVersion(workflow);
const createdStep = (
await createWorkflowVersionStep({
workflowVersionId,
stepType: newStepType,
parentStepId: workflowInsertStepIds.parentStepId,
nextStepId: workflowInsertStepIds.nextStepId,
parentStepId,
nextStepId,
})
)?.data?.createWorkflowVersionStep;
@ -62,10 +58,6 @@ export const useCreateStep = ({
setWorkflowSelectedNode(createdStep.id);
setWorkflowLastCreatedStepId(createdStep.id);
setWorkflowInsertStepIds({
parentStepId: undefined,
nextStepId: undefined,
});
} finally {
setIsLoading(false);
}

View File

@ -1,5 +1,9 @@
import {
workflowActionSchema,
WorkflowFormAction,
WorkflowHttpRequestAction,
WorkflowSendEmailAction,
} from '@/workflow/types/Workflow';
import {
workflowFormActionSettingsSchema,
workflowHttpRequestActionSettingsSchema,
workflowSendEmailActionSettingsSchema,
@ -34,7 +38,7 @@ describe('useWorkflowActionHeader', () => {
describe('when action name is not defined', () => {
it('should return default title', () => {
const action = workflowActionSchema.parse({
const action = {
id: '1',
name: '',
type: 'HTTP_REQUEST',
@ -52,7 +56,7 @@ describe('useWorkflowActionHeader', () => {
},
}),
valid: true,
});
} satisfies WorkflowHttpRequestAction;
const { result } = renderHook(() =>
useWorkflowActionHeader({
@ -71,7 +75,7 @@ describe('useWorkflowActionHeader', () => {
describe('when action name is defined', () => {
it('should return the action name', () => {
const action = workflowActionSchema.parse({
const action = {
id: '1',
name: 'Test Action',
type: 'HTTP_REQUEST',
@ -89,7 +93,7 @@ describe('useWorkflowActionHeader', () => {
},
}),
valid: true,
});
} satisfies WorkflowHttpRequestAction;
const { result } = renderHook(() =>
useWorkflowActionHeader({
@ -108,7 +112,7 @@ describe('useWorkflowActionHeader', () => {
describe('when action type is defined', () => {
it('should return default title for HTTP request action', () => {
const action = workflowActionSchema.parse({
const action = {
id: '1',
name: '',
type: 'HTTP_REQUEST',
@ -126,7 +130,7 @@ describe('useWorkflowActionHeader', () => {
},
}),
valid: true,
});
} satisfies WorkflowHttpRequestAction;
const { result } = renderHook(() =>
useWorkflowActionHeader({
@ -143,7 +147,7 @@ describe('useWorkflowActionHeader', () => {
});
it('should return default title for form action', () => {
const action = workflowActionSchema.parse({
const action = {
id: '1',
name: '',
type: 'FORM',
@ -165,7 +169,7 @@ describe('useWorkflowActionHeader', () => {
},
}),
valid: true,
});
} satisfies WorkflowFormAction;
const { result } = renderHook(() =>
useWorkflowActionHeader({
@ -182,7 +186,7 @@ describe('useWorkflowActionHeader', () => {
});
it('should return default title for email action', () => {
const action = workflowActionSchema.parse({
const action = {
id: '1',
name: '',
type: 'SEND_EMAIL',
@ -200,7 +204,7 @@ describe('useWorkflowActionHeader', () => {
},
}),
valid: true,
});
} satisfies WorkflowSendEmailAction;
const { result } = renderHook(() =>
useWorkflowActionHeader({

View File

@ -17,6 +17,11 @@ export const getActionHeaderTypeOrThrow = (actionType: WorkflowActionType) => {
return msg`HTTP Request`;
case 'AI_AGENT':
return msg`AI Agent`;
case 'FILTER': {
throw new Error(
"The Filter action isn't meant to be displayed as a node.",
);
}
default:
assertUnreachable(actionType, `Unsupported action type: ${actionType}`);

View File

@ -23,6 +23,11 @@ export const getActionIconColorOrThrow = ({
return theme.color.blue;
case 'AI_AGENT':
return theme.color.pink;
case 'FILTER': {
throw new Error(
"The Filter action isn't meant to be displayed as a node.",
);
}
default:
assertUnreachable(actionType, `Unsupported action type: ${actionType}`);
}

View File

@ -576,6 +576,20 @@ export class WorkflowVersionStepWorkspaceService {
},
};
}
case WorkflowActionType.FILTER: {
return {
id: newStepId,
name: 'Filter',
type: WorkflowActionType.FILTER,
valid: false,
settings: {
...BASE_STEP_DEFINITION,
input: {
filter: {},
},
},
};
}
case WorkflowActionType.HTTP_REQUEST: {
return {
id: newStepId,