Add first filter step version (#13093)

I rebuilt the advanced filters used in views and workflow search for a
specific filter step.

Components structure remains the same, using `stepFilterGroups` and
`stepFilters`. But those filters are directly sent to backend.

Also re-using the same kind of states we use for advanced filters to
share the current filters used. And a context to share what's coming
from workflow props (function to update step settings and readonly)

⚠️ this PR only focusses on the content of the step. There is still a
lot to do on the filter icon behavior in the workflow



https://github.com/user-attachments/assets/8a6a76f0-11fa-444a-82b9-71fc96b18af4
This commit is contained in:
Thomas Trompette
2025-07-09 13:52:07 +02:00
committed by GitHub
parent 0316c857d8
commit 6e79339e64
67 changed files with 3851 additions and 396 deletions

View File

@ -0,0 +1,152 @@
import { WorkflowStep } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { findStepPosition } from '../findStepPosition';
describe('findStepPosition', () => {
const mockSteps: WorkflowStep[] = [
{
id: 'step-1',
name: 'First Step',
type: 'CREATE_RECORD',
valid: true,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
},
outputSchema: {},
},
} as WorkflowStep,
{
id: 'step-2',
name: 'Second Step',
type: 'UPDATE_RECORD',
valid: true,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
objectRecordId: 'test-id',
fieldsToUpdate: ['name'],
},
outputSchema: {},
},
} as WorkflowStep,
{
id: 'step-3',
name: 'Third Step',
type: 'DELETE_RECORD',
valid: true,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecordId: 'test-id',
},
outputSchema: {},
},
} as WorkflowStep,
];
it('should return index 0 when stepId is undefined', () => {
const result = findStepPosition({
steps: mockSteps,
stepId: undefined,
});
expect(result).toEqual({
steps: mockSteps,
index: 0,
});
});
it('should return index 0 when stepId is TRIGGER_STEP_ID', () => {
const result = findStepPosition({
steps: mockSteps,
stepId: TRIGGER_STEP_ID,
});
expect(result).toEqual({
steps: mockSteps,
index: 0,
});
});
it('should find the correct position for an existing step', () => {
const result = findStepPosition({
steps: mockSteps,
stepId: 'step-2',
});
expect(result).toEqual({
steps: mockSteps,
index: 1,
});
});
it('should find the first step position', () => {
const result = findStepPosition({
steps: mockSteps,
stepId: 'step-1',
});
expect(result).toEqual({
steps: mockSteps,
index: 0,
});
});
it('should find the last step position', () => {
const result = findStepPosition({
steps: mockSteps,
stepId: 'step-3',
});
expect(result).toEqual({
steps: mockSteps,
index: 2,
});
});
it('should return undefined for non-existent stepId', () => {
const result = findStepPosition({
steps: mockSteps,
stepId: 'non-existent-step',
});
expect(result).toBeUndefined();
});
it('should work with empty steps array', () => {
const result = findStepPosition({
steps: [],
stepId: 'step-1',
});
expect(result).toBeUndefined();
});
it('should work with single step array', () => {
const singleStep = [mockSteps[0]];
const result = findStepPosition({
steps: singleStep,
stepId: 'step-1',
});
expect(result).toEqual({
steps: singleStep,
index: 0,
});
});
});

View File

@ -0,0 +1,140 @@
import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { getStepDefinitionOrThrow } from '../getStepDefinitionOrThrow';
describe('getStepDefinitionOrThrow', () => {
const mockTrigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
};
const mockSteps: WorkflowAction[] = [
{
id: 'step-1',
name: 'Create Record',
type: 'CREATE_RECORD',
valid: true,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
},
outputSchema: {},
},
} as WorkflowAction,
{
id: 'step-2',
name: 'Update Record',
type: 'UPDATE_RECORD',
valid: true,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
objectRecordId: 'test-id',
fieldsToUpdate: ['name'],
},
outputSchema: {},
},
} as WorkflowAction,
];
describe('when stepId is TRIGGER_STEP_ID', () => {
it('should return trigger definition when trigger is provided', () => {
const result = getStepDefinitionOrThrow({
stepId: TRIGGER_STEP_ID,
trigger: mockTrigger,
steps: mockSteps,
});
expect(result).toEqual({
type: 'trigger',
definition: mockTrigger,
});
});
it('should return undefined trigger definition when trigger is null', () => {
const result = getStepDefinitionOrThrow({
stepId: TRIGGER_STEP_ID,
trigger: null,
steps: mockSteps,
});
expect(result).toEqual({
type: 'trigger',
definition: undefined,
});
});
});
describe('when stepId is not TRIGGER_STEP_ID', () => {
it('should throw error when steps is null', () => {
expect(() => {
getStepDefinitionOrThrow({
stepId: 'step-1',
trigger: mockTrigger,
steps: null,
});
}).toThrow(
'Malformed workflow version: missing steps information; be sure to create at least one step before trying to edit one',
);
});
it('should return action definition for existing step', () => {
const result = getStepDefinitionOrThrow({
stepId: 'step-1',
trigger: mockTrigger,
steps: mockSteps,
});
expect(result).toEqual({
type: 'action',
definition: mockSteps[0],
});
});
it('should return action definition for second step', () => {
const result = getStepDefinitionOrThrow({
stepId: 'step-2',
trigger: mockTrigger,
steps: mockSteps,
});
expect(result).toEqual({
type: 'action',
definition: mockSteps[1],
});
});
it('should return undefined for non-existent step', () => {
const result = getStepDefinitionOrThrow({
stepId: 'non-existent-step',
trigger: mockTrigger,
steps: mockSteps,
});
expect(result).toBeUndefined();
});
it('should work with empty steps array', () => {
const result = getStepDefinitionOrThrow({
stepId: 'step-1',
trigger: mockTrigger,
steps: [],
});
expect(result).toBeUndefined();
});
});
});

View File

@ -0,0 +1,50 @@
import { getStepOutputSchemaFamilyStateKey } from '../getStepOutputSchemaFamilyStateKey';
describe('getStepOutputSchemaFamilyStateKey', () => {
it('should concatenate workflowVersionId and stepId with a dash', () => {
const workflowVersionId = 'workflow-version-123';
const stepId = 'step-456';
const result = getStepOutputSchemaFamilyStateKey(workflowVersionId, stepId);
expect(result).toBe('workflow-version-123-step-456');
});
it('should handle empty strings', () => {
const workflowVersionId = '';
const stepId = '';
const result = getStepOutputSchemaFamilyStateKey(workflowVersionId, stepId);
expect(result).toBe('-');
});
it('should handle UUID format IDs', () => {
const workflowVersionId = '550e8400-e29b-41d4-a716-446655440000';
const stepId = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
const result = getStepOutputSchemaFamilyStateKey(workflowVersionId, stepId);
expect(result).toBe(
'550e8400-e29b-41d4-a716-446655440000-6ba7b810-9dad-11d1-80b4-00c04fd430c8',
);
});
it('should handle special characters in IDs', () => {
const workflowVersionId = 'workflow_version.123';
const stepId = 'step@456';
const result = getStepOutputSchemaFamilyStateKey(workflowVersionId, stepId);
expect(result).toBe('workflow_version.123-step@456');
});
it('should handle numeric IDs', () => {
const workflowVersionId = '123';
const stepId = '456';
const result = getStepOutputSchemaFamilyStateKey(workflowVersionId, stepId);
expect(result).toBe('123-456');
});
});

View File

@ -0,0 +1,35 @@
import { getWorkflowVisualizerComponentInstanceId } from '../getWorkflowVisualizerComponentInstanceId';
describe('getWorkflowVisualizerComponentInstanceId', () => {
it('should return the same recordId that was passed in', () => {
const recordId = 'test-record-id-123';
const result = getWorkflowVisualizerComponentInstanceId({ recordId });
expect(result).toBe(recordId);
});
it('should return empty string when recordId is empty', () => {
const recordId = '';
const result = getWorkflowVisualizerComponentInstanceId({ recordId });
expect(result).toBe('');
});
it('should handle UUID format recordIds', () => {
const recordId = '550e8400-e29b-41d4-a716-446655440000';
const result = getWorkflowVisualizerComponentInstanceId({ recordId });
expect(result).toBe(recordId);
});
it('should handle special characters in recordId', () => {
const recordId = 'test-record-id_with.special@chars';
const result = getWorkflowVisualizerComponentInstanceId({ recordId });
expect(result).toBe(recordId);
});
});

View File

@ -0,0 +1,102 @@
import { splitWorkflowTriggerEventName } from '../splitWorkflowTriggerEventName';
describe('splitWorkflowTriggerEventName', () => {
it('should split a basic event name into objectType and event', () => {
const eventName = 'company.created';
const result = splitWorkflowTriggerEventName(eventName);
expect(result).toEqual({
objectType: 'company',
event: 'created',
});
});
it('should split event name with updated event', () => {
const eventName = 'person.updated';
const result = splitWorkflowTriggerEventName(eventName);
expect(result).toEqual({
objectType: 'person',
event: 'updated',
});
});
it('should split event name with deleted event', () => {
const eventName = 'opportunity.deleted';
const result = splitWorkflowTriggerEventName(eventName);
expect(result).toEqual({
objectType: 'opportunity',
event: 'deleted',
});
});
it('should handle camelCase object types', () => {
const eventName = 'activityTarget.created';
const result = splitWorkflowTriggerEventName(eventName);
expect(result).toEqual({
objectType: 'activityTarget',
event: 'created',
});
});
it('should handle event names with underscores', () => {
const eventName = 'custom_object.field_updated';
const result = splitWorkflowTriggerEventName(eventName);
expect(result).toEqual({
objectType: 'custom_object',
event: 'field_updated',
});
});
it('should handle event names without dots', () => {
const eventName = 'invalidEventName';
const result = splitWorkflowTriggerEventName(eventName);
expect(result).toEqual({
objectType: 'invalidEventName',
event: undefined,
});
});
it('should handle empty string', () => {
const eventName = '';
const result = splitWorkflowTriggerEventName(eventName);
expect(result).toEqual({
objectType: '',
event: undefined,
});
});
it('should handle event name starting with dot', () => {
const eventName = '.created';
const result = splitWorkflowTriggerEventName(eventName);
expect(result).toEqual({
objectType: '',
event: 'created',
});
});
it('should handle event name ending with dot', () => {
const eventName = 'company.';
const result = splitWorkflowTriggerEventName(eventName);
expect(result).toEqual({
objectType: 'company',
event: '',
});
});
});

View File

@ -141,7 +141,8 @@ export const workflowAiAgentActionSettingsSchema =
export const workflowFilterActionSettingsSchema =
baseWorkflowActionSettingsSchema.extend({
input: z.object({
filter: z.record(z.any()),
stepFilterGroups: z.array(z.any()),
stepFilters: z.array(z.any()),
}),
});

View File

@ -1,3 +1,4 @@
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@ -7,8 +8,10 @@ import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { workflowDiagramPanOnDragComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
@ -99,6 +102,12 @@ export const WorkflowDiagramEdgeV2Content = ({
dropdownId,
);
const workflowVisualizerWorkflowId = useRecoilComponentValueV2(
workflowVisualizerWorkflowIdComponentState,
);
const { openWorkflowEditStepInCommandMenu } = useWorkflowCommandMenu();
const handleCreateFilter = async () => {
await onCreateFilter();
@ -106,6 +115,23 @@ export const WorkflowDiagramEdgeV2Content = ({
setHovered(false);
};
const setWorkflowSelectedNode = useSetRecoilComponentStateV2(
workflowSelectedNodeComponentState,
);
const handleFilterButtonClick = () => {
setWorkflowSelectedNode(stepId);
if (isDefined(filter) && isDefined(workflowVisualizerWorkflowId)) {
openWorkflowEditStepInCommandMenu(
workflowVisualizerWorkflowId,
'Filter',
IconFilter,
);
} else {
handleCreateFilter();
}
};
return (
<StyledContainer
data-click-outside-id={WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}
@ -134,9 +160,7 @@ export const WorkflowDiagramEdgeV2Content = ({
iconButtons={[
{
Icon: IconFilterPlus,
onClick: () => {
handleCreateFilter();
},
onClick: handleFilterButtonClick,
},
{
Icon: IconDotsVertical,

View File

@ -0,0 +1,136 @@
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { addEdgeOptions } from '../addEdgeOptions';
describe('addEdgeOptions', () => {
it('should add shouldDisplayEdgeOptions to all edges', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'trigger',
position: { x: 0, y: 0 },
data: {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Company Created',
icon: 'IconPlus',
},
},
{
id: 'action-1',
position: { x: 0, y: 100 },
data: {
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create Company',
},
},
],
edges: [
{
id: 'edge-1',
source: 'trigger',
target: 'action-1',
data: {
shouldDisplayEdgeOptions: true,
},
},
{
id: 'edge-2',
source: 'action-1',
target: 'action-2',
data: {},
},
],
};
const result = addEdgeOptions(diagram);
expect(result.nodes).toEqual(diagram.nodes);
expect(result.edges).toHaveLength(2);
expect(result.edges[0]).toEqual({
id: 'edge-1',
source: 'trigger',
target: 'action-1',
data: {
shouldDisplayEdgeOptions: true,
},
});
expect(result.edges[1]).toEqual({
id: 'edge-2',
source: 'action-1',
target: 'action-2',
data: {
shouldDisplayEdgeOptions: true,
},
});
});
it('should handle empty edges array', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'trigger',
position: { x: 0, y: 0 },
data: {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Company Created',
icon: 'IconPlus',
},
},
],
edges: [],
};
const result = addEdgeOptions(diagram);
expect(result.nodes).toEqual(diagram.nodes);
expect(result.edges).toEqual([]);
});
it('should handle edges without existing data property', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'trigger',
position: { x: 0, y: 0 },
data: {
nodeType: 'trigger',
triggerType: 'MANUAL',
name: 'Manual Trigger',
icon: 'IconClick',
},
},
{
id: 'action-1',
position: { x: 0, y: 100 },
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Send Email',
},
},
],
edges: [
{
id: 'edge-1',
source: 'trigger',
target: 'action-1',
} as any,
],
};
const result = addEdgeOptions(diagram);
expect(result.edges[0]).toEqual({
id: 'edge-1',
source: 'trigger',
target: 'action-1',
data: {
shouldDisplayEdgeOptions: true,
},
});
});
});

View File

@ -0,0 +1,136 @@
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '../getWorkflowNodeIconKey';
// Mock the getActionIcon function
jest.mock(
'@/workflow/workflow-steps/workflow-actions/utils/getActionIcon',
() => ({
getActionIcon: jest.fn((actionType) => {
const mockIcons = {
CREATE_RECORD: 'IconPlus',
UPDATE_RECORD: 'IconEdit',
DELETE_RECORD: 'IconTrash',
SEND_EMAIL: 'IconMail',
FILTER: 'IconFilter',
};
return mockIcons[actionType as keyof typeof mockIcons] || 'IconQuestion';
}),
}),
);
describe('getWorkflowNodeIconKey', () => {
describe('trigger nodes', () => {
it('should return the icon from trigger node data', () => {
const triggerNodeData: WorkflowDiagramStepNodeData = {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Company Created',
icon: 'IconDatabase',
};
const result = getWorkflowNodeIconKey(triggerNodeData);
expect(result).toBe('IconDatabase');
});
it('should return icon for manual trigger', () => {
const manualTriggerData: WorkflowDiagramStepNodeData = {
nodeType: 'trigger',
triggerType: 'MANUAL',
name: 'Manual Trigger',
icon: 'IconClick',
};
const result = getWorkflowNodeIconKey(manualTriggerData);
expect(result).toBe('IconClick');
});
it('should return icon for webhook trigger', () => {
const webhookTriggerData: WorkflowDiagramStepNodeData = {
nodeType: 'trigger',
triggerType: 'WEBHOOK',
name: 'Webhook Trigger',
icon: 'IconWebhook',
};
const result = getWorkflowNodeIconKey(webhookTriggerData);
expect(result).toBe('IconWebhook');
});
});
describe('action nodes', () => {
it('should return icon for CREATE_RECORD action', () => {
const createActionData: WorkflowDiagramStepNodeData = {
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create Company',
};
const result = getWorkflowNodeIconKey(createActionData);
expect(result).toBe('IconPlus');
});
it('should return icon for UPDATE_RECORD action', () => {
const updateActionData: WorkflowDiagramStepNodeData = {
nodeType: 'action',
actionType: 'UPDATE_RECORD',
name: 'Update Company',
};
const result = getWorkflowNodeIconKey(updateActionData);
expect(result).toBe('IconEdit');
});
it('should return icon for DELETE_RECORD action', () => {
const deleteActionData: WorkflowDiagramStepNodeData = {
nodeType: 'action',
actionType: 'DELETE_RECORD',
name: 'Delete Company',
};
const result = getWorkflowNodeIconKey(deleteActionData);
expect(result).toBe('IconTrash');
});
it('should return icon for SEND_EMAIL action', () => {
const emailActionData: WorkflowDiagramStepNodeData = {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Send Email',
};
const result = getWorkflowNodeIconKey(emailActionData);
expect(result).toBe('IconMail');
});
it('should return icon for FILTER action', () => {
const filterActionData: WorkflowDiagramStepNodeData = {
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter Records',
};
const result = getWorkflowNodeIconKey(filterActionData);
expect(result).toBe('IconFilter');
});
it('should handle unknown action types', () => {
const unknownActionData: WorkflowDiagramStepNodeData = {
nodeType: 'action',
actionType: 'UNKNOWN_ACTION' as any,
name: 'Unknown Action',
};
const result = getWorkflowNodeIconKey(unknownActionData);
expect(result).toBe('IconQuestion');
});
});
});

View File

@ -0,0 +1,132 @@
import { WorkflowDiagramNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { isCreateStepNode } from '../isCreateStepNode';
describe('isCreateStepNode', () => {
it('should return true for create-step node with correct type and nodeType', () => {
const createStepNode: WorkflowDiagramNode = {
id: 'create-step-1',
type: 'create-step',
position: { x: 0, y: 200 },
data: {
nodeType: 'create-step',
parentNodeId: 'action-1',
},
};
const result = isCreateStepNode(createStepNode);
expect(result).toBe(true);
});
it('should return false for node with create-step type but wrong nodeType', () => {
const node: WorkflowDiagramNode = {
id: 'fake-create-step',
type: 'create-step',
position: { x: 0, y: 200 },
data: {
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create Company',
} as any,
};
const result = isCreateStepNode(node);
expect(result).toBe(false);
});
it('should return false for node with correct nodeType but wrong type', () => {
const node: WorkflowDiagramNode = {
id: 'fake-create-step',
type: 'action',
position: { x: 0, y: 200 },
data: {
nodeType: 'create-step',
parentNodeId: 'action-1',
} as any,
};
const result = isCreateStepNode(node);
expect(result).toBe(false);
});
it('should return false for action node', () => {
const actionNode: WorkflowDiagramNode = {
id: 'action-1',
position: { x: 0, y: 100 },
data: {
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create Company',
},
};
const result = isCreateStepNode(actionNode);
expect(result).toBe(false);
});
it('should return false for trigger node', () => {
const triggerNode: WorkflowDiagramNode = {
id: 'trigger',
position: { x: 0, y: 0 },
data: {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Company Created',
icon: 'IconPlus',
},
};
const result = isCreateStepNode(triggerNode);
expect(result).toBe(false);
});
it('should return false for empty-trigger node', () => {
const emptyTriggerNode: WorkflowDiagramNode = {
id: 'empty-trigger',
position: { x: 0, y: 0 },
data: {
nodeType: 'empty-trigger',
},
};
const result = isCreateStepNode(emptyTriggerNode);
expect(result).toBe(false);
});
it('should handle create-step node with additional properties', () => {
const createStepNodeWithExtras: WorkflowDiagramNode = {
id: 'create-step-with-extras',
type: 'create-step',
position: { x: 50, y: 250 },
selected: true,
data: {
nodeType: 'create-step',
parentNodeId: 'action-2',
},
};
const result = isCreateStepNode(createStepNodeWithExtras);
expect(result).toBe(true);
});
it('should return false for node without type property', () => {
const nodeWithoutType: WorkflowDiagramNode = {
id: 'node-without-type',
position: { x: 0, y: 200 },
data: {
nodeType: 'create-step',
parentNodeId: 'action-1',
} as any,
};
const result = isCreateStepNode(nodeWithoutType);
expect(result).toBe(false);
});
});

View File

@ -0,0 +1,84 @@
import { WorkflowDiagramNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { isStepNode } from '../isStepNode';
describe('isStepNode', () => {
it('should return true for trigger node', () => {
const triggerNode: WorkflowDiagramNode = {
id: 'trigger',
position: { x: 0, y: 0 },
data: {
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Company Created',
icon: 'IconPlus',
},
};
const result = isStepNode(triggerNode);
expect(result).toBe(true);
});
it('should return true for action node', () => {
const actionNode: WorkflowDiagramNode = {
id: 'action-1',
position: { x: 0, y: 100 },
data: {
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create Company',
},
};
const result = isStepNode(actionNode);
expect(result).toBe(true);
});
it('should return false for create-step node', () => {
const createStepNode: WorkflowDiagramNode = {
id: 'create-step-1',
position: { x: 0, y: 200 },
data: {
nodeType: 'create-step',
parentNodeId: 'action-1',
},
};
const result = isStepNode(createStepNode);
expect(result).toBe(false);
});
it('should return false for empty-trigger node', () => {
const emptyTriggerNode: WorkflowDiagramNode = {
id: 'empty-trigger',
position: { x: 0, y: 0 },
data: {
nodeType: 'empty-trigger',
},
};
const result = isStepNode(emptyTriggerNode);
expect(result).toBe(false);
});
it('should handle nodes with additional properties', () => {
const nodeWithExtra: WorkflowDiagramNode = {
id: 'trigger-with-extra',
position: { x: 50, y: 50 },
selected: true,
data: {
nodeType: 'trigger',
triggerType: 'MANUAL',
name: 'Manual Trigger',
icon: 'IconClick',
},
};
const result = isStepNode(nodeWithExtra);
expect(result).toBe(true);
});
});

View File

@ -7,6 +7,7 @@ import { WorkflowEditActionCreateRecord } from '@/workflow/workflow-steps/workfl
import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord';
import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail';
import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord';
import { WorkflowEditActionFilter } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords';
import { WorkflowEditActionFormBuilder } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder';
import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest';
@ -185,8 +186,12 @@ export const WorkflowStepDetail = ({
);
}
case 'FILTER': {
throw new Error(
"The Filter action isn't meant to be displayed as a node.",
return (
<WorkflowEditActionFilter
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
/>
);
}

View File

@ -0,0 +1,239 @@
import { WorkflowStep } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { getWorkflowPreviousStepId } from '../getWorkflowPreviousStepId';
describe('getWorkflowPreviousStepId', () => {
const mockSteps: WorkflowStep[] = [
{
id: 'step-1',
name: 'First Step',
type: 'CREATE_RECORD',
valid: true,
nextStepIds: ['step-2'],
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
},
outputSchema: {},
},
} as WorkflowStep,
{
id: 'step-2',
name: 'Second Step',
type: 'UPDATE_RECORD',
valid: true,
nextStepIds: ['step-3'],
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
objectRecordId: 'test-id',
fieldsToUpdate: ['name'],
},
outputSchema: {},
},
} as WorkflowStep,
{
id: 'step-3',
name: 'Third Step',
type: 'SEND_EMAIL',
valid: true,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
connectedAccountId: 'account-id',
email: 'test@example.com',
},
outputSchema: {},
},
} as WorkflowStep,
];
it('should return undefined for TRIGGER_STEP_ID', () => {
const result = getWorkflowPreviousStepId({
stepId: TRIGGER_STEP_ID,
steps: mockSteps,
});
expect(result).toBeUndefined();
});
it('should return TRIGGER_STEP_ID for first step', () => {
const result = getWorkflowPreviousStepId({
stepId: 'step-1',
steps: mockSteps,
});
expect(result).toBe(TRIGGER_STEP_ID);
});
it('should return previous step ID for middle step', () => {
const result = getWorkflowPreviousStepId({
stepId: 'step-2',
steps: mockSteps,
});
expect(result).toBe('step-1');
});
it('should return previous step ID for last step', () => {
const result = getWorkflowPreviousStepId({
stepId: 'step-3',
steps: mockSteps,
});
expect(result).toBe('step-2');
});
it('should return undefined for non-existent step', () => {
const result = getWorkflowPreviousStepId({
stepId: 'non-existent-step',
steps: mockSteps,
});
expect(result).toBeUndefined();
});
it('should work with single step', () => {
const singleStep = [mockSteps[0]];
const result = getWorkflowPreviousStepId({
stepId: 'step-1',
steps: singleStep,
});
expect(result).toBe(TRIGGER_STEP_ID);
});
it('should handle branching workflow with multiple next steps', () => {
const branchingSteps: WorkflowStep[] = [
{
id: 'step-1',
name: 'First Step',
type: 'CREATE_RECORD',
valid: true,
nextStepIds: ['step-2a', 'step-2b'],
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
},
outputSchema: {},
},
} as WorkflowStep,
{
id: 'step-2a',
name: 'Second Step A',
type: 'UPDATE_RECORD',
valid: true,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
objectRecordId: 'test-id',
fieldsToUpdate: ['name'],
},
outputSchema: {},
},
} as WorkflowStep,
{
id: 'step-2b',
name: 'Second Step B',
type: 'SEND_EMAIL',
valid: true,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
connectedAccountId: 'account-id',
email: 'test@example.com',
},
outputSchema: {},
},
} as WorkflowStep,
];
const resultA = getWorkflowPreviousStepId({
stepId: 'step-2a',
steps: branchingSteps,
});
const resultB = getWorkflowPreviousStepId({
stepId: 'step-2b',
steps: branchingSteps,
});
expect(resultA).toBe('step-1');
expect(resultB).toBe('step-1');
});
it('should handle workflow where step is not connected to previous steps', () => {
const disconnectedSteps: WorkflowStep[] = [
{
id: 'step-1',
name: 'First Step',
type: 'CREATE_RECORD',
valid: true,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
},
outputSchema: {},
},
} as WorkflowStep,
{
id: 'step-2',
name: 'Disconnected Step',
type: 'UPDATE_RECORD',
valid: true,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
objectRecordId: 'test-id',
fieldsToUpdate: ['name'],
},
outputSchema: {},
},
} as WorkflowStep,
];
const result = getWorkflowPreviousStepId({
stepId: 'step-2',
steps: disconnectedSteps,
});
expect(result).toBeUndefined();
});
});

View File

@ -0,0 +1,87 @@
import { WorkflowFilterAction } from '@/workflow/types/Workflow';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { WorkflowEditActionFilterBody } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBody';
import { WorkflowEditActionFilterBodyEffect } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBodyEffect';
import { StepFilterGroupsComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFilterGroupsComponentInstanceContext';
import { StepFiltersComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFiltersComponentInstanceContext';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import {
StepFilter,
StepFilterGroup,
} from 'twenty-shared/src/types/StepFilters';
import { useIcons } from 'twenty-ui/display';
type WorkflowEditActionFilterProps = {
action: WorkflowFilterAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowFilterAction) => void;
};
};
export type FilterSettings = {
stepFilterGroups?: StepFilterGroup[];
stepFilters?: StepFilter[];
};
export const WorkflowEditActionFilter = ({
action,
actionOptions,
}: WorkflowEditActionFilterProps) => {
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: 'Filter',
});
const { getIcon } = useIcons();
return (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
/>
<StepFiltersComponentInstanceContext.Provider
value={{
instanceId: action.id,
}}
>
<StepFilterGroupsComponentInstanceContext.Provider
value={{
instanceId: action.id,
}}
>
<WorkflowEditActionFilterBody
action={action}
actionOptions={actionOptions}
/>
<WorkflowEditActionFilterBodyEffect
stepId={action.id}
defaultValue={{
stepFilterGroups: action.settings.input.stepFilterGroups,
stepFilters: action.settings.input.stepFilters,
}}
/>
</StepFilterGroupsComponentInstanceContext.Provider>
</StepFiltersComponentInstanceContext.Provider>
</>
);
};

View File

@ -0,0 +1,117 @@
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { WorkflowFilterAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { FilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
import { WorkflowStepFilterAddFilterRuleSelect } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterAddFilterRuleSelect';
import { WorkflowStepFilterAddRootStepFilterButton } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterAddRootStepFilterButton';
import { WorkflowStepFilterColumn } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterColumn';
import { WorkflowStepFilterGroupColumn } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterGroupColumn';
import { useChildStepFiltersAndChildStepFilterGroups } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useChildStepFiltersAndChildStepFilterGroups';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { rootLevelStepFilterGroupComponentSelector } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/rootLevelStepFilterGroupComponentSelector';
import { isStepFilterGroupChildAStepFilterGroup } from '@/workflow/workflow-steps/workflow-actions/filter-action/utils/isStepFilterGroupChildAStepFilterGroup';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
const StyledContainer = styled.div`
align-items: start;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledChildContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(6)};
width: 100%;
`;
type WorkflowEditActionFilterBodyProps = {
action: WorkflowFilterAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowFilterAction) => void;
};
};
export const WorkflowEditActionFilterBody = ({
action,
actionOptions,
}: WorkflowEditActionFilterBodyProps) => {
const rootStepFilterGroup = useRecoilComponentValueV2(
rootLevelStepFilterGroupComponentSelector,
);
const { childStepFiltersAndChildStepFilterGroups } =
useChildStepFiltersAndChildStepFilterGroups({
stepFilterGroupId: rootStepFilterGroup?.id ?? '',
});
const onFilterSettingsUpdate = (newFilterSettings: FilterSettings) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
stepFilterGroups: newFilterSettings.stepFilterGroups ?? [],
stepFilters: newFilterSettings.stepFilters ?? [],
},
},
});
};
return (
<WorkflowStepFilterContext.Provider
value={{
stepId: action.id,
readonly: actionOptions.readonly,
onFilterSettingsUpdate,
}}
>
<WorkflowStepBody>
{isDefined(rootStepFilterGroup) ? (
<StyledContainer>
<StyledChildContainer>
{childStepFiltersAndChildStepFilterGroups.map(
(stepFilterGroupChild, stepFilterGroupChildIndex) =>
isStepFilterGroupChildAStepFilterGroup(
stepFilterGroupChild,
) ? (
<WorkflowStepFilterGroupColumn
key={stepFilterGroupChild.id}
parentStepFilterGroup={rootStepFilterGroup}
stepFilterGroup={stepFilterGroupChild}
stepFilterGroupIndex={stepFilterGroupChildIndex}
/>
) : (
<WorkflowStepFilterColumn
key={stepFilterGroupChild.id}
stepFilterGroup={rootStepFilterGroup}
stepFilter={stepFilterGroupChild}
stepFilterIndex={stepFilterGroupChildIndex}
/>
),
)}
</StyledChildContainer>
{!actionOptions.readonly && (
<WorkflowStepFilterAddFilterRuleSelect
stepFilterGroup={rootStepFilterGroup}
/>
)}
</StyledContainer>
) : (
<WorkflowStepFilterAddRootStepFilterButton />
)}
</WorkflowStepBody>
</WorkflowStepFilterContext.Provider>
);
};

View File

@ -0,0 +1,74 @@
import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { FilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
import { currentStepFilterGroupsComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFilterGroupsComponentState';
import { currentStepFiltersComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFiltersComponentState';
import { hasInitializedCurrentStepFilterGroupsComponentFamilyState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/hasInitializedCurrentStepFilterGroupsComponentFamilyState';
import { hasInitializedCurrentStepFiltersComponentFamilyState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/hasInitializedCurrentStepFiltersComponentFamilyState';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const WorkflowEditActionFilterBodyEffect = ({
stepId,
defaultValue,
}: {
stepId: string;
defaultValue?: FilterSettings;
}) => {
const [
hasInitializedCurrentStepFilters,
setHasInitializedCurrentStepFilters,
] = useRecoilComponentFamilyStateV2(
hasInitializedCurrentStepFiltersComponentFamilyState,
{ stepId },
);
const [
hasInitializedCurrentStepFilterGroups,
setHasInitializedCurrentStepFilterGroups,
] = useRecoilComponentFamilyStateV2(
hasInitializedCurrentStepFilterGroupsComponentFamilyState,
{ stepId },
);
const setCurrentStepFilters = useSetRecoilComponentStateV2(
currentStepFiltersComponentState,
);
const setCurrentStepFilterGroups = useSetRecoilComponentStateV2(
currentStepFilterGroupsComponentState,
);
useEffect(() => {
if (
!hasInitializedCurrentStepFilters &&
isDefined(defaultValue?.stepFilters)
) {
setCurrentStepFilters(defaultValue.stepFilters ?? []);
setHasInitializedCurrentStepFilters(true);
}
}, [
setCurrentStepFilters,
hasInitializedCurrentStepFilters,
setHasInitializedCurrentStepFilters,
defaultValue?.stepFilters,
]);
useEffect(() => {
if (
!hasInitializedCurrentStepFilterGroups &&
isDefined(defaultValue?.stepFilterGroups) &&
defaultValue.stepFilterGroups.length > 0
) {
setCurrentStepFilterGroups(defaultValue.stepFilterGroups ?? []);
setHasInitializedCurrentStepFilterGroups(true);
}
}, [
setCurrentStepFilterGroups,
hasInitializedCurrentStepFilterGroups,
setHasInitializedCurrentStepFilterGroups,
defaultValue?.stepFilterGroups,
]);
return null;
};

View File

@ -0,0 +1,144 @@
import { ActionButton } from '@/action-menu/actions/display/components/ActionButton';
import { getAdvancedFilterAddFilterRuleSelectDropdownId } from '@/object-record/advanced-filter/utils/getAdvancedFilterAddFilterRuleSelectDropdownId';
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 { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useChildStepFiltersAndChildStepFilterGroups } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useChildStepFiltersAndChildStepFilterGroups';
import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings';
import {
StepFilter,
StepFilterGroup,
StepLogicalOperator,
StepOperand,
} from 'twenty-shared/src/types';
import { isDefined } from 'twenty-shared/utils';
import { IconLibraryPlus, IconPlus } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { v4 } from 'uuid';
type WorkflowStepFilterAddFilterRuleSelectProps = {
stepFilterGroup: StepFilterGroup;
};
export const WorkflowStepFilterAddFilterRuleSelect = ({
stepFilterGroup,
}: WorkflowStepFilterAddFilterRuleSelectProps) => {
const { upsertStepFilterSettings } = useUpsertStepFilterSettings();
const dropdownId = getAdvancedFilterAddFilterRuleSelectDropdownId(
stepFilterGroup.id,
);
const { lastChildPosition } = useChildStepFiltersAndChildStepFilterGroups({
stepFilterGroupId: stepFilterGroup.id,
});
const newPositionInStepFilterGroup = lastChildPosition + 1;
const { closeDropdown } = useCloseDropdown();
const handleAddFilter = () => {
closeDropdown(dropdownId);
const newStepFilter = {
id: v4(),
type: 'text',
label: 'New Filter',
value: '',
operand: StepOperand.EQ,
displayValue: '',
stepFilterGroupId: stepFilterGroup.id,
stepOutputKey: '',
positionInStepFilterGroup: newPositionInStepFilterGroup,
};
upsertStepFilterSettings({
stepFilterToUpsert: newStepFilter,
});
};
const handleAddFilterGroup = () => {
closeDropdown(dropdownId);
const newStepFilterGroupId = v4();
const newStepFilterGroup: StepFilterGroup = {
id: newStepFilterGroupId,
logicalOperator: StepLogicalOperator.AND,
parentStepFilterGroupId: stepFilterGroup.id,
positionInStepFilterGroup: newPositionInStepFilterGroup,
};
const newStepFilter: StepFilter = {
id: v4(),
type: 'text',
operand: StepOperand.EQ,
value: '',
displayValue: '',
stepFilterGroupId: newStepFilterGroupId,
positionInStepFilterGroup: 1,
label: 'New Filter',
stepOutputKey: '',
};
upsertStepFilterSettings({
stepFilterToUpsert: newStepFilter,
stepFilterGroupToUpsert: newStepFilterGroup,
});
};
const isFilterRuleGroupOptionVisible = !isDefined(
stepFilterGroup.parentStepFilterGroupId,
);
if (!isFilterRuleGroupOptionVisible) {
return (
<ActionButton
action={{
Icon: IconPlus,
label: 'Add rule',
shortLabel: 'Add rule',
key: 'add-rule',
}}
onClick={handleAddFilter}
/>
);
}
return (
<Dropdown
dropdownId={dropdownId}
clickableComponent={
<ActionButton
action={{
Icon: IconPlus,
label: 'Add filter rule',
shortLabel: 'Add filter rule',
key: 'add-filter-rule',
}}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconPlus}
text="Add rule"
onClick={handleAddFilter}
/>
{isFilterRuleGroupOptionVisible && (
<MenuItem
LeftIcon={IconLibraryPlus}
text="Add rule group"
onClick={handleAddFilterGroup}
/>
)}
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownOffset={{ y: 8, x: 0 }}
dropdownPlacement="bottom-start"
/>
);
};

View File

@ -0,0 +1,25 @@
import { useAddRootStepFilter } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useAddRootStepFilter';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { IconFilter } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
export const WorkflowStepFilterAddRootStepFilterButton = () => {
const { t } = useLingui();
const { readonly } = useContext(WorkflowStepFilterContext);
const { addRootStepFilter } = useAddRootStepFilter();
return (
<Button
Icon={IconFilter}
size="small"
variant="secondary"
accent="default"
onClick={addRootStepFilter}
ariaLabel={t`Add first filter`}
title={t`Add first filter`}
disabled={readonly}
/>
);
};

View File

@ -0,0 +1,48 @@
import { WorkflowStepFilterFieldSelect } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterFieldSelect';
import { WorkflowStepFilterLogicalOperatorCell } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterLogicalOperatorCell';
import { WorkflowStepFilterOperandSelect } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect';
import { WorkflowStepFilterOptionsDropdown } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOptionsDropdown';
import { WorkflowStepFilterValueInput } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { WorkflowAdvancedFilterDropdownColumn } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowAdvancedFilterDropdownColumn';
import styled from '@emotion/styled';
import { useContext } from 'react';
import { StepFilter, StepFilterGroup } from 'twenty-shared/src/types';
type WorkflowStepFilterColumnProps = {
stepFilterGroup: StepFilterGroup;
stepFilter: StepFilter;
stepFilterIndex: number;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const WorkflowStepFilterColumn = ({
stepFilterGroup,
stepFilter,
stepFilterIndex,
}: WorkflowStepFilterColumnProps) => {
const { readonly } = useContext(WorkflowStepFilterContext);
return (
<WorkflowAdvancedFilterDropdownColumn>
<StyledContainer>
<WorkflowStepFilterLogicalOperatorCell
index={stepFilterIndex}
stepFilterGroup={stepFilterGroup}
/>
{!readonly && (
<WorkflowStepFilterOptionsDropdown stepFilterId={stepFilter.id} />
)}
</StyledContainer>
<WorkflowStepFilterFieldSelect stepFilter={stepFilter} />
<WorkflowStepFilterOperandSelect stepFilter={stepFilter} />
<WorkflowStepFilterValueInput stepFilter={stepFilter} />
</WorkflowAdvancedFilterDropdownColumn>
);
};

View File

@ -0,0 +1,79 @@
import { SelectControl } from '@/ui/input/components/SelectControl';
import { useWorkflowStepContextOrThrow } from '@/workflow/states/context/WorkflowStepContext';
import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/stepsOutputSchemaFamilySelector';
import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { WorkflowVariablesDropdown } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdown';
import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/extractRawVariableNamePart';
import { searchVariableThroughOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughOutputSchema';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { StepFilter } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type WorkflowStepFilterFieldSelectProps = {
stepFilter: StepFilter;
};
export const WorkflowStepFilterFieldSelect = ({
stepFilter,
}: WorkflowStepFilterFieldSelectProps) => {
const { readonly } = useContext(WorkflowStepFilterContext);
const { upsertStepFilterSettings } = useUpsertStepFilterSettings();
const { t } = useLingui();
const { workflowVersionId } = useWorkflowStepContextOrThrow();
const stepId = extractRawVariableNamePart({
rawVariableName: stepFilter.stepOutputKey,
part: 'stepId',
});
const stepsOutputSchema = useRecoilValue(
stepsOutputSchemaFamilySelector({
workflowVersionId,
stepIds: [stepId],
}),
);
if (!isDefined(stepId)) {
return null;
}
const { variableLabel } = searchVariableThroughOutputSchema({
stepOutputSchema: stepsOutputSchema?.[0],
rawVariableName: stepFilter.stepOutputKey,
isFullRecord: false,
});
const isSelectedFieldNotFound = !isDefined(variableLabel);
const label = isSelectedFieldNotFound ? t`No Field Selected` : variableLabel;
const handleChange = (variableName: string) => {
upsertStepFilterSettings({
stepFilterToUpsert: {
...stepFilter,
stepOutputKey: variableName,
displayValue: label,
},
});
};
return (
<WorkflowVariablesDropdown
instanceId={`step-filter-field-${stepFilter.id}`}
onVariableSelect={handleChange}
disabled={readonly}
clickableComponent={
<SelectControl
selectedOption={{
value: stepFilter.stepOutputKey,
label,
}}
isDisabled={readonly}
/>
}
/>
);
};

View File

@ -0,0 +1,61 @@
import { WorkflowStepFilterAddFilterRuleSelect } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterAddFilterRuleSelect';
import { WorkflowStepFilterColumn } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterColumn';
import { useChildStepFiltersAndChildStepFilterGroups } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useChildStepFiltersAndChildStepFilterGroups';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import styled from '@emotion/styled';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
const StyledContainer = styled.div<{ isGrayBackground?: boolean }>`
align-items: start;
background-color: ${({ theme, isGrayBackground }) =>
isGrayBackground ? theme.background.transparent.lighter : 'transparent'};
border: ${({ theme }) => `1px solid ${theme.border.color.medium}`};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex: 1;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(6)};
padding: ${({ theme }) => theme.spacing(2)};
`;
type WorkflowStepFilterGroupChildrenProps = {
stepFilterGroupId: string;
};
export const WorkflowStepFilterGroupChildren = ({
stepFilterGroupId,
}: WorkflowStepFilterGroupChildrenProps) => {
const { readonly } = useContext(WorkflowStepFilterContext);
const { currentStepFilterGroup, childStepFilters } =
useChildStepFiltersAndChildStepFilterGroups({
stepFilterGroupId,
});
if (!currentStepFilterGroup) {
return null;
}
const hasParentStepFilterGroup = isDefined(
currentStepFilterGroup.parentStepFilterGroupId,
);
return (
<StyledContainer isGrayBackground={hasParentStepFilterGroup}>
{(childStepFilters ?? []).map((childStepFilter, childStepFilterIndex) => (
<WorkflowStepFilterColumn
key={childStepFilter.id}
stepFilter={childStepFilter}
stepFilterIndex={childStepFilterIndex}
stepFilterGroup={currentStepFilterGroup}
/>
))}
{!readonly && (
<WorkflowStepFilterAddFilterRuleSelect
stepFilterGroup={currentStepFilterGroup}
/>
)}
</StyledContainer>
);
};

View File

@ -0,0 +1,46 @@
import { WorkflowStepFilterGroupChildren } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterGroupChildren';
import { WorkflowStepFilterGroupOptionsDropdown } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterGroupOptionsDropdown';
import { WorkflowStepFilterLogicalOperatorCell } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterLogicalOperatorCell';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { WorkflowAdvancedFilterDropdownColumn } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowAdvancedFilterDropdownColumn';
import styled from '@emotion/styled';
import { useContext } from 'react';
import { StepFilterGroup } from 'twenty-shared/src/types';
type WorkflowStepFilterGroupColumnProps = {
parentStepFilterGroup: StepFilterGroup;
stepFilterGroup: StepFilterGroup;
stepFilterGroupIndex: number;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const WorkflowStepFilterGroupColumn = ({
parentStepFilterGroup,
stepFilterGroup,
stepFilterGroupIndex,
}: WorkflowStepFilterGroupColumnProps) => {
const { readonly } = useContext(WorkflowStepFilterContext);
return (
<WorkflowAdvancedFilterDropdownColumn>
<StyledContainer>
<WorkflowStepFilterLogicalOperatorCell
index={stepFilterGroupIndex}
stepFilterGroup={parentStepFilterGroup}
/>
{!readonly && (
<WorkflowStepFilterGroupOptionsDropdown
stepFilterGroupId={stepFilterGroup.id}
/>
)}
</StyledContainer>
<WorkflowStepFilterGroupChildren stepFilterGroupId={stepFilterGroup.id} />
</WorkflowAdvancedFilterDropdownColumn>
);
};

View File

@ -0,0 +1,49 @@
import { DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET } from '@/object-record/advanced-filter/constants/DefaultAdvancedFilterDropdownOffset';
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 { useRemoveStepFilterGroup } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useRemoveStepFilterGroup';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { useContext } from 'react';
import { IconDotsVertical, IconTrash } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
type WorkflowStepFilterGroupOptionsDropdownProps = {
stepFilterGroupId: string;
};
export const WorkflowStepFilterGroupOptionsDropdown = ({
stepFilterGroupId,
}: WorkflowStepFilterGroupOptionsDropdownProps) => {
const { readonly } = useContext(WorkflowStepFilterContext);
const { removeStepFilterGroup } = useRemoveStepFilterGroup();
return (
<Dropdown
dropdownId={`step-filter-group-options-${stepFilterGroupId}`}
clickableComponent={
<IconButton
aria-label="Step filter group options"
variant="tertiary"
Icon={IconDotsVertical}
disabled={readonly}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconTrash}
text="Delete group"
onClick={() => removeStepFilterGroup(stepFilterGroupId)}
accent="danger"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownOffset={DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET}
/>
);
};

View File

@ -0,0 +1,93 @@
import { DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET } from '@/object-record/advanced-filter/constants/DefaultAdvancedFilterDropdownOffset';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import styled from '@emotion/styled';
import { useContext } from 'react';
import { StepFilterGroup, StepLogicalOperator } from 'twenty-shared/src/types';
import { capitalize } from 'twenty-shared/utils';
const StyledText = styled.div`
height: ${({ theme }) => theme.spacing(8)};
display: flex;
align-items: center;
`;
const StyledContainer = styled.div`
align-items: start;
display: flex;
min-width: ${({ theme }) => theme.spacing(20)};
color: ${({ theme }) => theme.font.color.tertiary};
`;
type WorkflowStepFilterLogicalOperatorCellProps = {
index: number;
stepFilterGroup: StepFilterGroup;
};
const STEP_FILTER_LOGICAL_OPERATOR_OPTIONS = [
{
value: StepLogicalOperator.AND,
label: 'And',
},
{
value: StepLogicalOperator.OR,
label: 'Or',
},
];
export const WorkflowStepFilterLogicalOperatorCell = ({
index,
stepFilterGroup,
}: WorkflowStepFilterLogicalOperatorCellProps) => {
const { readonly } = useContext(WorkflowStepFilterContext);
const { upsertStepFilterSettings } = useUpsertStepFilterSettings();
const handleChange = (value: StepLogicalOperator) => {
upsertStepFilterSettings({
stepFilterGroupToUpsert: {
id: stepFilterGroup.id,
parentStepFilterGroupId: stepFilterGroup.parentStepFilterGroupId,
positionInStepFilterGroup: stepFilterGroup.positionInStepFilterGroup,
logicalOperator: value,
},
});
};
return (
<StyledContainer>
{index === 0 ? (
<StyledText>Where</StyledText>
) : index === 1 ? (
readonly ? (
<Select
fullWidth
dropdownWidth={GenericDropdownContentWidth.Narrow}
dropdownId={`advanced-filter-logical-operator-${stepFilterGroup.id}`}
value={stepFilterGroup.logicalOperator}
options={STEP_FILTER_LOGICAL_OPERATOR_OPTIONS}
dropdownOffset={DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET}
disabled
/>
) : (
<Select
fullWidth
dropdownWidth={GenericDropdownContentWidth.Narrow}
dropdownId={`advanced-filter-logical-operator-${stepFilterGroup.id}`}
value={stepFilterGroup.logicalOperator}
onChange={handleChange}
options={STEP_FILTER_LOGICAL_OPERATOR_OPTIONS}
dropdownOffset={DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET}
/>
)
) : (
<StyledText>
{capitalize(stepFilterGroup.logicalOperator.toLowerCase())}
</StyledText>
)}
</StyledContainer>
);
};

View File

@ -0,0 +1,54 @@
import { DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET } from '@/object-record/advanced-filter/constants/DefaultAdvancedFilterDropdownOffset';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { useContext } from 'react';
import { StepFilter, StepOperand } from 'twenty-shared/src/types';
type WorkflowStepFilterOperandSelectProps = {
stepFilter: StepFilter;
};
const STEP_OPERAND_OPTIONS = [
{ value: StepOperand.EQ, label: 'Equals' },
{ value: StepOperand.NE, label: 'Not equals' },
{ value: StepOperand.GT, label: 'Greater than' },
{ value: StepOperand.GTE, label: 'Greater than or equal' },
{ value: StepOperand.LT, label: 'Less than' },
{ value: StepOperand.LTE, label: 'Less than or equal' },
{ value: StepOperand.LIKE, label: 'Contains' },
{ value: StepOperand.ILIKE, label: 'Contains (case insensitive)' },
{ value: StepOperand.IN, label: 'In' },
{ value: StepOperand.IS, label: 'Is' },
];
export const WorkflowStepFilterOperandSelect = ({
stepFilter,
}: WorkflowStepFilterOperandSelectProps) => {
const { readonly } = useContext(WorkflowStepFilterContext);
const { upsertStepFilterSettings } = useUpsertStepFilterSettings();
const handleChange = (operand: StepOperand) => {
upsertStepFilterSettings({
stepFilterToUpsert: {
...stepFilter,
operand,
},
});
};
return (
<Select
fullWidth
dropdownWidth={GenericDropdownContentWidth.Medium}
dropdownId={`step-filter-operand-${stepFilter.id}`}
value={stepFilter.operand}
options={STEP_OPERAND_OPTIONS}
onChange={handleChange}
disabled={readonly}
dropdownOffset={DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET}
/>
);
};

View File

@ -0,0 +1,50 @@
import { DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET } from '@/object-record/advanced-filter/constants/DefaultAdvancedFilterDropdownOffset';
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 { useRemoveStepFilter } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useRemoveStepFilter';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { useContext } from 'react';
import { IconDotsVertical, IconTrash } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
type WorkflowStepFilterOptionsDropdownProps = {
stepFilterId: string;
};
export const WorkflowStepFilterOptionsDropdown = ({
stepFilterId,
}: WorkflowStepFilterOptionsDropdownProps) => {
const { readonly } = useContext(WorkflowStepFilterContext);
const { removeStepFilter } = useRemoveStepFilter();
return (
<Dropdown
dropdownId={`step-filter-options-${stepFilterId}`}
clickableComponent={
<IconButton
aria-label="Step filter options"
variant="tertiary"
Icon={IconDotsVertical}
disabled={readonly}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconTrash}
text="Delete"
onClick={() => removeStepFilter(stepFilterId)}
accent="danger"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownOffset={DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET}
dropdownPlacement="bottom-start"
/>
);
};

View File

@ -0,0 +1,39 @@
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { StepFilter } from 'twenty-shared/src/types';
type WorkflowStepFilterValueInputProps = {
stepFilter: StepFilter;
};
export const WorkflowStepFilterValueInput = ({
stepFilter,
}: WorkflowStepFilterValueInputProps) => {
const { t } = useLingui();
const { readonly } = useContext(WorkflowStepFilterContext);
const { upsertStepFilterSettings } = useUpsertStepFilterSettings();
const handleValueChange = (value: string) => {
upsertStepFilterSettings({
stepFilterToUpsert: {
...stepFilter,
value,
},
});
};
return (
<FormTextFieldInput
defaultValue={stepFilter.value}
onChange={handleValueChange}
readonly={readonly}
VariablePicker={WorkflowVariablePicker}
placeholder={t`Enter value`}
/>
);
};

View File

@ -0,0 +1,108 @@
import { WorkflowFilterAction } from '@/workflow/types/Workflow';
import { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
import { WorkflowEditActionFilter } from '../WorkflowEditActionFilter';
const DEFAULT_ACTION: WorkflowFilterAction = {
id: getWorkflowNodeIdMock(),
name: 'Filter Records',
type: 'FILTER',
valid: false,
settings: {
input: {
stepFilterGroups: [],
stepFilters: [],
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
};
const CONFIGURED_ACTION: WorkflowFilterAction = {
id: getWorkflowNodeIdMock(),
name: 'Filter Companies',
type: 'FILTER',
valid: true,
settings: {
input: {
stepFilterGroups: [
{
id: 'filter-group-1',
parentStepFilterGroupId: null,
logicalOperator: 'AND',
stepFilterGroupChildren: [],
},
],
stepFilters: [
{
id: 'filter-1',
stepFilterGroupId: 'filter-group-1',
stepOutputKey: 'company.name',
displayValue: 'Company Name',
operandType: 'LITERAL',
operand: 'contains',
value: 'Acme',
},
],
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
};
const meta: Meta<typeof WorkflowEditActionFilter> = {
title: 'Modules/Workflow/Actions/Filter/WorkflowEditActionFilter',
component: WorkflowEditActionFilter,
parameters: {
msw: graphqlMocks,
},
args: {
action: DEFAULT_ACTION,
actionOptions: {
readonly: false,
onActionUpdate: fn(),
},
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowEditActionFilter>;
export const Default: Story = {};
export const ReadOnly: Story = {
args: {
action: CONFIGURED_ACTION,
actionOptions: {
readonly: true,
},
},
};

View File

@ -0,0 +1,71 @@
import { WorkflowFilterAction } from '@/workflow/types/Workflow';
import { WorkflowStepFilterDecorator } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator';
import { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
import { WorkflowEditActionFilterBody } from '../WorkflowEditActionFilterBody';
const DEFAULT_ACTION: WorkflowFilterAction = {
id: getWorkflowNodeIdMock(),
name: 'Filter Records',
type: 'FILTER',
valid: false,
settings: {
input: {
stepFilterGroups: [],
stepFilters: [],
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
};
const meta: Meta<typeof WorkflowEditActionFilterBody> = {
title: 'Modules/Workflow/Actions/Filter/WorkflowEditActionFilterBody',
component: WorkflowEditActionFilterBody,
parameters: {
msw: graphqlMocks,
},
args: {
action: DEFAULT_ACTION,
actionOptions: {
readonly: false,
onActionUpdate: fn(),
},
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
WorkflowStepFilterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowEditActionFilterBody>;
export const Default: Story = {};
export const ReadOnly: Story = {
args: {
action: DEFAULT_ACTION,
actionOptions: {
readonly: true,
},
},
};

View File

@ -0,0 +1,48 @@
import { WorkflowStepFilterDecorator } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator';
import { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { StepFilterGroup, StepLogicalOperator } from 'twenty-shared/types';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowStepFilterAddFilterRuleSelect } from '../WorkflowStepFilterAddFilterRuleSelect';
const STEP_FILTER_GROUP: StepFilterGroup = {
id: 'filter-group-1',
logicalOperator: StepLogicalOperator.AND,
positionInStepFilterGroup: 0,
};
const meta: Meta<typeof WorkflowStepFilterAddFilterRuleSelect> = {
title:
'Modules/Workflow/Actions/Filter/WorkflowStepFilterAddFilterRuleSelect',
component: WorkflowStepFilterAddFilterRuleSelect,
parameters: {
msw: graphqlMocks,
},
args: {
stepFilterGroup: STEP_FILTER_GROUP,
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
WorkflowStepFilterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowStepFilterAddFilterRuleSelect>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText('Add filter rule')).toBeVisible();
},
};

View File

@ -0,0 +1,39 @@
import { WorkflowStepFilterDecorator } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator';
import { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowStepFilterAddRootStepFilterButton } from '../WorkflowStepFilterAddRootStepFilterButton';
const meta: Meta<typeof WorkflowStepFilterAddRootStepFilterButton> = {
title:
'Modules/Workflow/Actions/Filter/WorkflowStepFilterAddRootStepFilterButton',
component: WorkflowStepFilterAddRootStepFilterButton,
parameters: {
msw: graphqlMocks,
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
WorkflowStepFilterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowStepFilterAddRootStepFilterButton>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const addButton = await canvas.findByRole('button');
await expect(addButton).toBeVisible();
},
};

View File

@ -0,0 +1,58 @@
import { WorkflowStepFilterDecorator } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator';
import { Meta, StoryObj } from '@storybook/react';
import {
StepFilter,
StepFilterGroup,
StepLogicalOperator,
StepOperand,
} from 'twenty-shared/types';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowStepFilterColumn } from '../WorkflowStepFilterColumn';
const STEP_FILTER_GROUP: StepFilterGroup = {
id: 'filter-group-1',
logicalOperator: StepLogicalOperator.AND,
positionInStepFilterGroup: 0,
};
const TEXT_STEP_FILTER: StepFilter = {
id: 'filter-1',
stepFilterGroupId: 'filter-group-1',
stepOutputKey: 'company.name',
displayValue: 'Company Name',
type: 'text',
label: 'Company Name',
value: 'Acme',
operand: StepOperand.LIKE,
};
const meta: Meta<typeof WorkflowStepFilterColumn> = {
title: 'Modules/Workflow/Actions/Filter/WorkflowStepFilterColumn',
component: WorkflowStepFilterColumn,
parameters: {
msw: graphqlMocks,
},
args: {
stepFilterGroup: STEP_FILTER_GROUP,
stepFilter: TEXT_STEP_FILTER,
stepFilterIndex: 0,
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
WorkflowStepFilterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowStepFilterColumn>;
export const Default: Story = {};

View File

@ -0,0 +1,46 @@
import { WorkflowStepFilterDecorator } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator';
import { Meta, StoryObj } from '@storybook/react';
import { StepFilter, StepOperand } from 'twenty-shared/types';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowStepFilterFieldSelect } from '../WorkflowStepFilterFieldSelect';
const DEFAULT_STEP_FILTER: StepFilter = {
id: 'filter-1',
stepFilterGroupId: 'filter-group-1',
stepOutputKey: '',
displayValue: '',
type: 'text',
label: 'New Filter',
operand: StepOperand.EQ,
value: '',
positionInStepFilterGroup: 0,
};
const meta: Meta<typeof WorkflowStepFilterFieldSelect> = {
title: 'Modules/Workflow/Actions/Filter/WorkflowStepFilterFieldSelect',
component: WorkflowStepFilterFieldSelect,
parameters: {
msw: graphqlMocks,
},
args: {
stepFilter: DEFAULT_STEP_FILTER,
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
WorkflowStepFilterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowStepFilterFieldSelect>;
export const Default: Story = {};

View File

@ -0,0 +1,67 @@
import { WorkflowStepFilterDecorator } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator';
import { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { StepFilterGroup, StepLogicalOperator } from 'twenty-shared/types';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowStepFilterLogicalOperatorCell } from '../WorkflowStepFilterLogicalOperatorCell';
const AND_STEP_FILTER_GROUP: StepFilterGroup = {
id: 'filter-group-1',
logicalOperator: StepLogicalOperator.AND,
positionInStepFilterGroup: 0,
};
const OR_STEP_FILTER_GROUP: StepFilterGroup = {
id: 'filter-group-2',
logicalOperator: StepLogicalOperator.OR,
positionInStepFilterGroup: 0,
};
const meta: Meta<typeof WorkflowStepFilterLogicalOperatorCell> = {
title:
'Modules/Workflow/Actions/Filter/WorkflowStepFilterLogicalOperatorCell',
component: WorkflowStepFilterLogicalOperatorCell,
parameters: {
msw: graphqlMocks,
},
args: {
stepFilterGroup: AND_STEP_FILTER_GROUP,
index: 1,
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
WorkflowStepFilterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowStepFilterLogicalOperatorCell>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText('And')).toBeVisible();
},
};
export const OrOperator: Story = {
args: {
stepFilterGroup: OR_STEP_FILTER_GROUP,
index: 1,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText('Or')).toBeVisible();
},
};

View File

@ -0,0 +1,99 @@
import { WorkflowStepFilterDecorator } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator';
import { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { StepFilter, StepOperand } from 'twenty-shared/types';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowStepFilterOperandSelect } from '../WorkflowStepFilterOperandSelect';
const DEFAULT_STEP_FILTER: StepFilter = {
id: 'filter-1',
stepFilterGroupId: 'filter-group-1',
stepOutputKey: 'company.name',
displayValue: 'Company Name',
type: 'text',
label: 'Company Name',
operand: StepOperand.EQ,
value: '',
positionInStepFilterGroup: 0,
};
const LIKE_OPERAND_FILTER: StepFilter = {
id: 'filter-1',
stepFilterGroupId: 'filter-group-1',
stepOutputKey: 'company.name',
displayValue: 'Company Name',
type: 'text',
label: 'Company Name',
operand: StepOperand.LIKE,
value: 'Acme',
positionInStepFilterGroup: 0,
};
const GREATER_THAN_FILTER: StepFilter = {
id: 'filter-1',
stepFilterGroupId: 'filter-group-1',
stepOutputKey: 'company.employees',
displayValue: 'Employee Count',
type: 'number',
label: 'Employee Count',
operand: StepOperand.GT,
value: '100',
positionInStepFilterGroup: 0,
};
const meta: Meta<typeof WorkflowStepFilterOperandSelect> = {
title: 'Modules/Workflow/Actions/Filter/WorkflowStepFilterOperandSelect',
component: WorkflowStepFilterOperandSelect,
parameters: {
msw: graphqlMocks,
},
args: {
stepFilter: DEFAULT_STEP_FILTER,
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
WorkflowStepFilterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowStepFilterOperandSelect>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText('Equals')).toBeVisible();
},
};
export const WithLikeOperand: Story = {
args: {
stepFilter: LIKE_OPERAND_FILTER,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText('Contains')).toBeVisible();
},
};
export const WithGreaterThanOperand: Story = {
args: {
stepFilter: GREATER_THAN_FILTER,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText('Greater than')).toBeVisible();
},
};

View File

@ -0,0 +1,76 @@
import { WorkflowStepFilterDecorator } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator';
import { Meta, StoryObj } from '@storybook/react';
import { expect, within } from '@storybook/test';
import { StepFilter, StepOperand } from 'twenty-shared/types';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowStepFilterValueInput } from '../WorkflowStepFilterValueInput';
const TEXT_FILTER: StepFilter = {
id: 'filter-1',
stepFilterGroupId: 'filter-group-1',
stepOutputKey: 'company.name',
displayValue: 'Company Name',
type: 'text',
label: 'Company Name',
operand: StepOperand.LIKE,
value: 'Acme',
positionInStepFilterGroup: 0,
};
const NUMBER_FILTER: StepFilter = {
id: 'filter-2',
stepFilterGroupId: 'filter-group-1',
stepOutputKey: 'company.employees',
displayValue: 'Employee Count',
type: 'number',
label: 'Employee Count',
operand: StepOperand.GT,
value: '100',
positionInStepFilterGroup: 0,
};
const meta: Meta<typeof WorkflowStepFilterValueInput> = {
title: 'Modules/Workflow/Actions/Filter/WorkflowStepFilterValueInput',
component: WorkflowStepFilterValueInput,
parameters: {
msw: graphqlMocks,
},
args: {
stepFilter: TEXT_FILTER,
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
WorkflowStepFilterDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowStepFilterValueInput>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText('Acme')).toBeVisible();
},
};
export const NumberInput: Story = {
args: {
stepFilter: NUMBER_FILTER,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(await canvas.findByText('100')).toBeVisible();
},
};

View File

@ -0,0 +1,23 @@
import { StepFilterGroupsComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFilterGroupsComponentInstanceContext';
import { StepFiltersComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFiltersComponentInstanceContext';
import { Decorator } from '@storybook/react';
export const WorkflowStepFilterDecorator: Decorator = (Story) => {
const stepId = 'step-id';
return (
<StepFilterGroupsComponentInstanceContext.Provider
value={{
instanceId: stepId,
}}
>
<StepFiltersComponentInstanceContext.Provider
value={{
instanceId: stepId,
}}
>
<Story />
</StepFiltersComponentInstanceContext.Provider>
</StepFilterGroupsComponentInstanceContext.Provider>
);
};

View File

@ -0,0 +1,84 @@
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useSetRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentFamilyStateV2';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { currentStepFilterGroupsComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFilterGroupsComponentState';
import { currentStepFiltersComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFiltersComponentState';
import { hasInitializedCurrentStepFilterGroupsComponentFamilyState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/hasInitializedCurrentStepFilterGroupsComponentFamilyState';
import { hasInitializedCurrentStepFiltersComponentFamilyState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/hasInitializedCurrentStepFiltersComponentFamilyState';
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import {
StepFilter,
StepFilterGroup,
StepLogicalOperator,
StepOperand,
} from 'twenty-shared/types';
import { v4 } from 'uuid';
export const useAddRootStepFilter = () => {
const { stepId, onFilterSettingsUpdate } = useContext(
WorkflowStepFilterContext,
);
const currentStepFilterGroupsCallbackState =
useRecoilComponentCallbackStateV2(currentStepFilterGroupsComponentState);
const currentStepFiltersCallbackState = useRecoilComponentCallbackStateV2(
currentStepFiltersComponentState,
);
const setHasInitializedCurrentStepFilters =
useSetRecoilComponentFamilyStateV2(
hasInitializedCurrentStepFiltersComponentFamilyState,
{ stepId },
);
const setHasInitializedCurrentStepFilterGroups =
useSetRecoilComponentFamilyStateV2(
hasInitializedCurrentStepFilterGroupsComponentFamilyState,
{ stepId },
);
const addRootStepFilterRecoilCallback = useRecoilCallback(
({ set }) =>
() => {
const newStepFilterGroup: StepFilterGroup = {
id: v4(),
logicalOperator: StepLogicalOperator.AND,
};
const newStepFilter: StepFilter = {
id: v4(),
type: 'text',
label: 'New Filter',
value: '',
operand: StepOperand.EQ,
displayValue: '',
stepFilterGroupId: newStepFilterGroup.id,
stepOutputKey: '',
positionInStepFilterGroup: 0,
};
set(currentStepFilterGroupsCallbackState, [newStepFilterGroup]);
set(currentStepFiltersCallbackState, [newStepFilter]);
setHasInitializedCurrentStepFilters(true);
setHasInitializedCurrentStepFilterGroups(true);
onFilterSettingsUpdate({
stepFilterGroups: [newStepFilterGroup],
stepFilters: [newStepFilter],
});
},
[
onFilterSettingsUpdate,
currentStepFilterGroupsCallbackState,
currentStepFiltersCallbackState,
setHasInitializedCurrentStepFilters,
setHasInitializedCurrentStepFilterGroups,
],
);
return {
addRootStepFilter: addRootStepFilterRecoilCallback,
};
};

View File

@ -0,0 +1,66 @@
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { currentStepFilterGroupsComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFilterGroupsComponentState';
import { currentStepFiltersComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFiltersComponentState';
import { StepFilter, StepFilterGroup } from 'twenty-shared/src/types';
import { isDefined } from 'twenty-shared/utils';
export const useChildStepFiltersAndChildStepFilterGroups = ({
stepFilterGroupId,
}: {
stepFilterGroupId: string;
}) => {
const stepFilterGroups = useRecoilComponentValueV2(
currentStepFilterGroupsComponentState,
);
const stepFilters = useRecoilComponentValueV2(
currentStepFiltersComponentState,
);
const currentStepFilterGroup = stepFilterGroups?.find(
(stepFilterGroup) => stepFilterGroup.id === stepFilterGroupId,
);
if (!isDefined(currentStepFilterGroup)) {
return {
currentStepFilterGroup: undefined,
childStepFiltersAndChildStepFilterGroups: [] as Array<
StepFilter | StepFilterGroup
>,
childStepFilters: [] as StepFilter[],
childStepFilterGroups: [] as StepFilterGroup[],
lastChildPosition: 0,
};
}
const childStepFilters = stepFilters?.filter(
(filter) => filter.stepFilterGroupId === currentStepFilterGroup.id,
);
const childStepFilterGroups = stepFilterGroups?.filter(
(filterGroup) =>
filterGroup.parentStepFilterGroupId === currentStepFilterGroup.id,
);
const childStepFiltersAndChildStepFilterGroups = [
...(childStepFilterGroups ?? []),
...(childStepFilters ?? []),
].sort((a, b) => {
const positionA = a.positionInStepFilterGroup ?? 0;
const positionB = b.positionInStepFilterGroup ?? 0;
return positionA - positionB;
});
const lastChildPosition =
childStepFiltersAndChildStepFilterGroups[
childStepFiltersAndChildStepFilterGroups.length - 1
]?.positionInStepFilterGroup ?? 0;
return {
currentStepFilterGroup,
childStepFiltersAndChildStepFilterGroups,
childStepFilters,
childStepFilterGroups,
lastChildPosition,
};
};

View File

@ -0,0 +1,106 @@
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { currentStepFilterGroupsComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFilterGroupsComponentState';
import { currentStepFiltersComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFiltersComponentState';
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { WorkflowStepFilterContext } from '../states/context/WorkflowStepFilterContext';
export const useRemoveStepFilter = () => {
const { onFilterSettingsUpdate } = useContext(WorkflowStepFilterContext);
const currentStepFiltersCallbackState = useRecoilComponentCallbackStateV2(
currentStepFiltersComponentState,
);
const currentStepFilterGroupsCallbackState =
useRecoilComponentCallbackStateV2(currentStepFilterGroupsComponentState);
const removeStepFilterRecoilCallback = useRecoilCallback(
({ set, snapshot }) =>
(stepFilterId: string) => {
const stepFilters = getSnapshotValue(
snapshot,
currentStepFiltersCallbackState,
);
const stepFilterGroups = getSnapshotValue(
snapshot,
currentStepFilterGroupsCallbackState,
);
const rootStepFilterGroup = stepFilterGroups?.find(
(filterGroup) => !isDefined(filterGroup.parentStepFilterGroupId),
);
if (!isDefined(rootStepFilterGroup)) return;
const stepFilterToRemove = stepFilters?.find(
(filter) => filter.id === stepFilterId,
);
if (!isDefined(stepFilterToRemove)) return;
const updatedStepFilters = (stepFilters ?? []).filter(
(filter) => filter.id !== stepFilterId,
);
const parentStepFilterGroup = stepFilterGroups?.find(
(filterGroup) =>
filterGroup.id === stepFilterToRemove.stepFilterGroupId,
);
const stepFiltersInParentStepFilterGroup = updatedStepFilters?.filter(
(filter) => filter.stepFilterGroupId === parentStepFilterGroup?.id,
);
const stepFilterGroupsInParentStepFilterGroup =
stepFilterGroups?.filter(
(g) => g.parentStepFilterGroupId === parentStepFilterGroup?.id,
);
const shouldDeleteParentStepFilterGroup =
stepFiltersInParentStepFilterGroup?.length === 0 &&
stepFilterGroupsInParentStepFilterGroup?.length === 0;
const updatedStepFilterGroups = shouldDeleteParentStepFilterGroup
? (stepFilterGroups ?? []).filter(
(filterGroup) => filterGroup.id !== parentStepFilterGroup?.id,
)
: stepFilterGroups;
const shouldResetStepFilterSettings =
updatedStepFilterGroups.length === 1 &&
updatedStepFilterGroups[0].id === rootStepFilterGroup?.id &&
updatedStepFilters.length === 0;
if (shouldResetStepFilterSettings) {
set(currentStepFilterGroupsCallbackState, []);
set(currentStepFiltersCallbackState, []);
onFilterSettingsUpdate({
stepFilterGroups: [],
stepFilters: [],
});
} else {
set(currentStepFilterGroupsCallbackState, updatedStepFilterGroups);
set(currentStepFiltersCallbackState, updatedStepFilters);
onFilterSettingsUpdate({
stepFilters: updatedStepFilters,
stepFilterGroups: updatedStepFilterGroups,
});
}
},
[
onFilterSettingsUpdate,
currentStepFilterGroupsCallbackState,
currentStepFiltersCallbackState,
],
);
return {
removeStepFilter: removeStepFilterRecoilCallback,
};
};

View File

@ -0,0 +1,78 @@
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { currentStepFilterGroupsComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFilterGroupsComponentState';
import { currentStepFiltersComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFiltersComponentState';
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useRemoveStepFilterGroup = () => {
const { onFilterSettingsUpdate } = useContext(WorkflowStepFilterContext);
const currentStepFilterGroupsCallbackState =
useRecoilComponentCallbackStateV2(currentStepFilterGroupsComponentState);
const currentStepFiltersCallbackState = useRecoilComponentCallbackStateV2(
currentStepFiltersComponentState,
);
const removeStepFilterGroupRecoilCallback = useRecoilCallback(
({ set, snapshot }) =>
(stepFilterGroupId: string) => {
const stepFilterGroups = getSnapshotValue(
snapshot,
currentStepFilterGroupsCallbackState,
);
const stepFilters = getSnapshotValue(
snapshot,
currentStepFiltersCallbackState,
);
const rootStepFilterGroup = stepFilterGroups?.find(
(filterGroup) => !isDefined(filterGroup.parentStepFilterGroupId),
);
const updatedStepFilterGroups = (stepFilterGroups ?? []).filter(
(filterGroup) => filterGroup.id !== stepFilterGroupId,
);
const updatedStepFilters = (stepFilters ?? []).filter(
(filter) => filter.stepFilterGroupId !== stepFilterGroupId,
);
const shouldResetStepFilterSettings =
updatedStepFilterGroups.length === 1 &&
updatedStepFilterGroups[0].id === rootStepFilterGroup?.id &&
updatedStepFilters.length === 0;
if (shouldResetStepFilterSettings) {
set(currentStepFilterGroupsCallbackState, []);
set(currentStepFiltersCallbackState, []);
onFilterSettingsUpdate({
stepFilterGroups: [],
stepFilters: [],
});
} else {
set(currentStepFilterGroupsCallbackState, updatedStepFilterGroups);
set(currentStepFiltersCallbackState, updatedStepFilters);
onFilterSettingsUpdate({
stepFilterGroups: updatedStepFilterGroups,
stepFilters: updatedStepFilters,
});
}
},
[
onFilterSettingsUpdate,
currentStepFilterGroupsCallbackState,
currentStepFiltersCallbackState,
],
);
return {
removeStepFilterGroup: removeStepFilterGroupRecoilCallback,
};
};

View File

@ -0,0 +1,83 @@
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext';
import { currentStepFilterGroupsComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFilterGroupsComponentState';
import { currentStepFiltersComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFiltersComponentState';
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { StepFilter, StepFilterGroup } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const useUpsertStepFilterSettings = () => {
const currentStepFilterGroupsCallbackState =
useRecoilComponentCallbackStateV2(currentStepFilterGroupsComponentState);
const currentStepFiltersCallbackState = useRecoilComponentCallbackStateV2(
currentStepFiltersComponentState,
);
const { onFilterSettingsUpdate } = useContext(WorkflowStepFilterContext);
const upsertStepFilterSettingsRecoilCallback = useRecoilCallback(
({ set, snapshot }) =>
({
stepFilterGroupToUpsert,
stepFilterToUpsert,
}: {
stepFilterGroupToUpsert?: StepFilterGroup;
stepFilterToUpsert?: StepFilter;
}) => {
const stepFilterGroups = getSnapshotValue(
snapshot,
currentStepFilterGroupsCallbackState,
);
const stepFilters = getSnapshotValue(
snapshot,
currentStepFiltersCallbackState,
);
const updatedStepFilterGroups = [...(stepFilterGroups ?? [])];
const updatedStepFilters = [...(stepFilters ?? [])];
if (isDefined(stepFilterGroupToUpsert)) {
const existingIndex = updatedStepFilterGroups.findIndex(
(filterGroup) => filterGroup.id === stepFilterGroupToUpsert.id,
);
if (existingIndex >= 0) {
updatedStepFilterGroups[existingIndex] = stepFilterGroupToUpsert;
} else {
updatedStepFilterGroups.push(stepFilterGroupToUpsert);
}
set(currentStepFilterGroupsCallbackState, updatedStepFilterGroups);
}
if (isDefined(stepFilterToUpsert)) {
const existingIndex = updatedStepFilters.findIndex(
(filter) => filter.id === stepFilterToUpsert.id,
);
if (existingIndex >= 0) {
updatedStepFilters[existingIndex] = stepFilterToUpsert;
} else {
updatedStepFilters.push(stepFilterToUpsert);
}
set(currentStepFiltersCallbackState, updatedStepFilters);
}
onFilterSettingsUpdate({
stepFilterGroups: updatedStepFilterGroups,
stepFilters: updatedStepFilters,
});
},
[
onFilterSettingsUpdate,
currentStepFilterGroupsCallbackState,
currentStepFiltersCallbackState,
],
);
return {
upsertStepFilterSettings: upsertStepFilterSettingsRecoilCallback,
};
};

View File

@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const StepFilterGroupsComponentInstanceContext =
createComponentInstanceContext();

View File

@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const StepFiltersComponentInstanceContext =
createComponentInstanceContext();

View File

@ -0,0 +1,13 @@
import { FilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter';
import { createContext } from 'react';
type WorkflowStepFilterContextType = {
stepId: string;
onFilterSettingsUpdate: (filterSettings: FilterSettings) => void;
readonly?: boolean;
};
export const WorkflowStepFilterContext =
createContext<WorkflowStepFilterContextType>(
{} as WorkflowStepFilterContextType,
);

View File

@ -0,0 +1,11 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { StepFilterGroupsComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFilterGroupsComponentInstanceContext';
import { StepFilterGroup } from 'twenty-shared/types';
export const currentStepFilterGroupsComponentState = createComponentStateV2<
StepFilterGroup[]
>({
key: 'currentStepFilterGroupsComponentState',
defaultValue: [],
componentInstanceContext: StepFilterGroupsComponentInstanceContext,
});

View File

@ -0,0 +1,11 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { StepFiltersComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFiltersComponentInstanceContext';
import { StepFilter } from 'twenty-shared/types';
export const currentStepFiltersComponentState = createComponentStateV2<
StepFilter[]
>({
key: 'currentStepFiltersComponentState',
defaultValue: [],
componentInstanceContext: StepFiltersComponentInstanceContext,
});

View File

@ -0,0 +1,9 @@
import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2';
import { StepFilterGroupsComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFilterGroupsComponentInstanceContext';
export const hasInitializedCurrentStepFilterGroupsComponentFamilyState =
createComponentFamilyStateV2<boolean, { stepId: string }>({
key: 'hasInitializedCurrentStepFilterGroupsComponentFamilyState',
defaultValue: false,
componentInstanceContext: StepFilterGroupsComponentInstanceContext,
});

View File

@ -0,0 +1,9 @@
import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2';
import { StepFiltersComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFiltersComponentInstanceContext';
export const hasInitializedCurrentStepFiltersComponentFamilyState =
createComponentFamilyStateV2<boolean, { stepId: string }>({
key: 'hasInitializedCurrentStepFiltersComponentFamilyState',
defaultValue: false,
componentInstanceContext: StepFiltersComponentInstanceContext,
});

View File

@ -0,0 +1,24 @@
import { createComponentSelectorV2 } from '@/ui/utilities/state/component-state/utils/createComponentSelectorV2';
import { StepFilterGroupsComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFilterGroupsComponentInstanceContext';
import { currentStepFilterGroupsComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFilterGroupsComponentState';
import { isDefined } from 'twenty-shared/utils';
export const rootLevelStepFilterGroupComponentSelector =
createComponentSelectorV2({
key: 'rootLevelStepFilterGroupComponentSelector',
get:
({ instanceId }) =>
({ get }) => {
const currentStepFilterGroups = get(
currentStepFilterGroupsComponentState.atomFamily({ instanceId }),
);
const rootLevelStepFilterGroup = currentStepFilterGroups.find(
(stepFilterGroup) =>
!isDefined(stepFilterGroup.parentStepFilterGroupId),
);
return rootLevelStepFilterGroup;
},
componentInstanceContext: StepFilterGroupsComponentInstanceContext,
});

View File

@ -0,0 +1,10 @@
import {
StepFilter,
StepFilterGroup,
} from 'twenty-shared/src/types/StepFilters';
export const isStepFilterGroupChildAStepFilterGroup = (
child: StepFilter | StepFilterGroup,
): child is StepFilterGroup => {
return ('logicalOperator' satisfies keyof StepFilterGroup) in child;
};

View File

@ -1,3 +1,4 @@
import { WorkflowActionType } from '@/workflow/types/Workflow';
import { Theme } from '@emotion/react';
import { COLOR, GRAY_SCALE } from 'twenty-ui/theme';
import { getActionIconColorOrThrow } from '../getActionIconColorOrThrow';
@ -22,106 +23,277 @@ describe('getActionIconColorOrThrow', () => {
).toBe(mockTheme.color.orange);
});
it('should return tertiary font color for CREATE_RECORD action type', () => {
expect(
getActionIconColorOrThrow({
describe('action types that return tertiary font color', () => {
const recordActionTypes: WorkflowActionType[] = [
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'FIND_RECORDS',
'FORM',
];
recordActionTypes.forEach((actionType) => {
it(`should return tertiary font color for ${actionType} action type`, () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
expect(result).toBe(mockTheme.font.color.tertiary);
});
});
});
describe('action types that return blue color', () => {
it('should return blue color for SEND_EMAIL action type', () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'SEND_EMAIL',
});
expect(result).toBe(mockTheme.color.blue);
});
});
describe('action types that return pink color', () => {
it('should return pink color for AI_AGENT action type', () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'AI_AGENT',
});
expect(result).toBe(mockTheme.color.pink);
});
});
describe('FILTER action type', () => {
it('should throw an error for FILTER action type', () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'FILTER',
});
expect(result).toBe(mockTheme.font.color.tertiary);
});
});
describe('theme object handling', () => {
it('should use the provided theme colors correctly', () => {
const customTheme: Theme = {
color: {
orange: COLOR.red,
blue: COLOR.purple,
pink: COLOR.turquoise,
},
font: {
color: {
tertiary: GRAY_SCALE.gray50,
},
},
} as Theme;
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'CODE',
}),
).toBe(COLOR.red);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'SEND_EMAIL',
}),
).toBe(COLOR.purple);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'AI_AGENT',
}),
).toBe(COLOR.turquoise);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'CREATE_RECORD',
}),
).toBe(GRAY_SCALE.gray50);
});
});
describe('type safety and exhaustive checking', () => {
it('should handle all valid action types without throwing unreachable errors', () => {
const validActionTypes: WorkflowActionType[] = [
'CODE',
'HTTP_REQUEST',
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'FIND_RECORDS',
'FORM',
'SEND_EMAIL',
'AI_AGENT',
];
validActionTypes.forEach((actionType) => {
expect(() => {
getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
}).not.toThrow();
});
});
it('should return consistent color values for the same action type', () => {
const actionType: WorkflowActionType = 'CODE';
const result1 = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
const result2 = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
expect(result1).toBe(result2);
expect(result1).toBe(mockTheme.color.orange);
});
});
describe('color grouping logic', () => {
it('should group CODE and HTTP_REQUEST actions with orange color', () => {
const orangeActions: WorkflowActionType[] = ['CODE', 'HTTP_REQUEST'];
orangeActions.forEach((actionType) => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
expect(result).toBe(mockTheme.color.orange);
});
});
it('should group record-related actions with tertiary font color', () => {
const recordActions: WorkflowActionType[] = [
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'FIND_RECORDS',
'FORM',
];
recordActions.forEach((actionType) => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
expect(result).toBe(mockTheme.font.color.tertiary);
});
});
it('should have unique colors for different action categories', () => {
const tertiaryResult = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'CREATE_RECORD',
}),
).toBe(mockTheme.font.color.tertiary);
});
});
it('should return blue color for SEND_EMAIL action type', () => {
expect(
getActionIconColorOrThrow({ theme: mockTheme, actionType: 'SEND_EMAIL' }),
).toBe(mockTheme.color.blue);
});
expect(tertiaryResult).toBe(mockTheme.font.color.tertiary);
});
it('should return pink color for AI_AGENT action type', () => {
expect(
getActionIconColorOrThrow({ theme: mockTheme, actionType: 'AI_AGENT' }),
).toBe(mockTheme.color.pink);
});
it('should return blue color for SEND_EMAIL action type', () => {
expect(
getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'SEND_EMAIL',
}),
).toBe(mockTheme.color.blue);
});
it('should throw an error for FILTER action type', () => {
expect(() => {
getActionIconColorOrThrow({ theme: mockTheme, actionType: 'FILTER' });
}).toThrow("The Filter action isn't meant to be displayed as a node.");
});
it('should return pink color for AI_AGENT action type', () => {
expect(
getActionIconColorOrThrow({ theme: mockTheme, actionType: 'AI_AGENT' }),
).toBe(mockTheme.color.pink);
});
it('should use the provided theme colors correctly', () => {
const customTheme: Theme = {
color: {
orange: COLOR.red,
blue: COLOR.purple,
pink: COLOR.turquoise,
},
font: {
it('should use the provided theme colors correctly', () => {
const customTheme: Theme = {
color: {
tertiary: GRAY_SCALE.gray50,
orange: COLOR.red,
blue: COLOR.purple,
pink: COLOR.turquoise,
},
},
} as Theme;
font: {
color: {
tertiary: GRAY_SCALE.gray50,
},
},
} as Theme;
expect(
getActionIconColorOrThrow({ theme: customTheme, actionType: 'CODE' }),
).toBe(COLOR.red);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'SEND_EMAIL',
}),
).toBe(COLOR.purple);
expect(
getActionIconColorOrThrow({ theme: customTheme, actionType: 'AI_AGENT' }),
).toBe(COLOR.turquoise);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'CREATE_RECORD',
}),
).toBe(GRAY_SCALE.gray50);
});
expect(
getActionIconColorOrThrow({ theme: customTheme, actionType: 'CODE' }),
).toBe(COLOR.red);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'SEND_EMAIL',
}),
).toBe(COLOR.purple);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'AI_AGENT',
}),
).toBe(COLOR.turquoise);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'CREATE_RECORD',
}),
).toBe(GRAY_SCALE.gray50);
});
it('should return undefined when blue color is missing for SEND_EMAIL action', () => {
const themeWithoutBlue: Theme = {
color: {
orange: COLOR.orange,
pink: COLOR.pink,
},
font: {
it('should return undefined when blue color is missing for SEND_EMAIL action', () => {
const themeWithoutBlue: Theme = {
color: {
tertiary: GRAY_SCALE.gray40,
orange: COLOR.orange,
pink: COLOR.pink,
},
},
} as Theme;
font: {
color: {
tertiary: GRAY_SCALE.gray40,
},
},
} as Theme;
expect(
getActionIconColorOrThrow({
theme: themeWithoutBlue,
actionType: 'SEND_EMAIL',
}),
).toBeUndefined();
});
expect(
getActionIconColorOrThrow({
theme: themeWithoutBlue,
actionType: 'SEND_EMAIL',
}),
).toBeUndefined();
});
it('should handle null theme gracefully', () => {
expect(() => {
getActionIconColorOrThrow({
theme: null as unknown as Theme,
it('should handle null theme gracefully', () => {
expect(() => {
getActionIconColorOrThrow({
theme: null as unknown as Theme,
actionType: 'CODE',
});
}).toThrow();
});
it('should return the same color for the same action type', () => {
const result1 = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'CODE',
});
}).toThrow();
});
it('should return the same color for the same action type', () => {
const result1 = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'CODE',
const result2 = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'CODE',
});
expect(result1).toBe(result2);
});
const result2 = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'CODE',
});
expect(result1).toBe(result2);
});
});

View File

@ -18,9 +18,7 @@ export const getActionHeaderTypeOrThrow = (actionType: WorkflowActionType) => {
case 'AI_AGENT':
return msg`AI Agent`;
case 'FILTER': {
throw new Error(
"The Filter action isn't meant to be displayed as a node.",
);
return msg`Filter`;
}
default:

View File

@ -9,6 +9,8 @@ export const getActionIcon = (actionType: WorkflowActionType) => {
case 'DELETE_RECORD':
case 'FIND_RECORDS':
return RECORD_ACTIONS.find((item) => item.type === actionType)?.icon;
case 'FILTER':
return 'IconFilter';
default:
return OTHER_ACTIONS.find((item) => item.type === actionType)?.icon;
}

View File

@ -18,16 +18,12 @@ export const getActionIconColorOrThrow = ({
case 'DELETE_RECORD':
case 'FIND_RECORDS':
case 'FORM':
case 'FILTER':
return theme.font.color.tertiary;
case 'SEND_EMAIL':
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

@ -37,12 +37,14 @@ export const WorkflowVariablesDropdown = ({
disabled,
objectNameSingularToSelect,
multiline,
clickableComponent,
}: {
instanceId: string;
onVariableSelect: (variableName: string) => void;
disabled?: boolean;
objectNameSingularToSelect?: string;
multiline?: boolean;
clickableComponent?: React.ReactNode;
}) => {
const theme = useTheme();
@ -102,12 +104,14 @@ export const WorkflowVariablesDropdown = ({
<Dropdown
dropdownId={dropdownId}
clickableComponent={
<StyledDropdownVariableButtonContainer
isUnfolded={isDropdownOpen}
transparentBackground
>
<IconVariablePlus size={theme.icon.size.sm} />
</StyledDropdownVariableButtonContainer>
clickableComponent ?? (
<StyledDropdownVariableButtonContainer
isUnfolded={isDropdownOpen}
transparentBackground
>
<IconVariablePlus size={theme.icon.size.sm} />
</StyledDropdownVariableButtonContainer>
)
}
dropdownComponents={
!isDefined(selectedStep) ? (

View File

@ -592,8 +592,8 @@ export class WorkflowVersionStepWorkspaceService {
settings: {
...BASE_STEP_DEFINITION,
input: {
filterGroups: [],
filters: [],
stepFilterGroups: [],
stepFilters: [],
},
},
};

View File

@ -2,4 +2,5 @@ export type WorkflowActionOutput = {
result?: object;
error?: string;
pendingEvent?: boolean;
shouldEndWorkflowRun?: boolean;
};

View File

@ -33,23 +33,24 @@ export class FilterWorkflowAction implements WorkflowAction {
);
}
const { filterGroups, filters } = step.settings.input;
const { stepFilterGroups, stepFilters } = step.settings.input;
if (!filterGroups || !filters) {
throw new WorkflowStepExecutorException(
'Filter is not defined',
WorkflowStepExecutorExceptionCode.INVALID_STEP_SETTINGS,
);
if (!stepFilterGroups || !stepFilters) {
return {
result: {
shouldEndWorkflowRun: false,
},
};
}
const resolvedFilters = filters.map((filter) => ({
const resolvedFilters = stepFilters.map((filter) => ({
...filter,
rightOperand: resolveInput(filter.value, context),
leftOperand: resolveInput(filter.stepOutputKey, context),
}));
const matchesFilter = evaluateFilterConditions({
filterGroups,
filterGroups: stepFilterGroups,
filters: resolvedFilters,
});
@ -57,6 +58,7 @@ export class FilterWorkflowAction implements WorkflowAction {
result: {
matchesFilter,
},
shouldEndWorkflowRun: !matchesFilter,
};
}
}

View File

@ -1,44 +1,10 @@
import { StepFilter, StepFilterGroup } from 'twenty-shared/types';
import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
export enum LogicalOperator {
AND = 'AND',
OR = 'OR',
}
export enum Operand {
EQ = 'eq',
NE = 'ne',
GT = 'gt',
GTE = 'gte',
LT = 'lt',
LTE = 'lte',
LIKE = 'like',
ILIKE = 'ilike',
IN = 'in',
IS = 'is',
}
export type FilterGroup = {
id: string;
logicalOperator: LogicalOperator;
parentRecordFilterGroupId?: string;
positionInRecordFilterGroup?: number;
};
export type Filter = {
id: string;
type: string;
label: string;
value: string;
operand: Operand;
displayValue: string;
recordFilterGroupId: string;
stepOutputKey: string;
};
export type WorkflowFilterActionSettings = BaseWorkflowActionSettings & {
input: {
filterGroups?: FilterGroup[];
filters?: Filter[];
stepFilterGroups?: StepFilterGroup[];
stepFilters?: StepFilter[];
};
};

View File

@ -1,21 +1,11 @@
import {
FilterGroup,
LogicalOperator,
Operand,
} from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type';
import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util';
StepFilter,
StepFilterGroup,
StepLogicalOperator,
StepOperand,
} from 'twenty-shared/types';
type ResolvedFilter = {
id: string;
type: string;
label: string;
rightOperand: unknown;
operand: Operand;
displayValue: string;
fieldMetadataId: string;
recordFilterGroupId: string;
leftOperand: unknown;
};
import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util';
describe('evaluateFilterConditions', () => {
describe('empty inputs', () => {
@ -36,8 +26,13 @@ describe('evaluateFilterConditions', () => {
});
describe('single filter operands', () => {
type ResolvedFilter = Omit<StepFilter, 'value' | 'stepOutputKey'> & {
rightOperand: unknown;
leftOperand: unknown;
};
const createFilter = (
operand: Operand,
operand: StepOperand,
leftOperand: unknown,
rightOperand: unknown,
): ResolvedFilter => ({
@ -47,28 +42,27 @@ describe('evaluateFilterConditions', () => {
rightOperand,
operand,
displayValue: String(rightOperand),
fieldMetadataId: 'field1',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand,
});
describe('eq operand', () => {
it('should return true when values are equal', () => {
const filter = createFilter(Operand.EQ, 'John', 'John');
const filter = createFilter(StepOperand.EQ, 'John', 'John');
const result = evaluateFilterConditions({ filters: [filter] });
expect(result).toBe(true);
});
it('should return true when values are loosely equal', () => {
const filter = createFilter(Operand.EQ, '123', 123);
const filter = createFilter(StepOperand.EQ, '123', 123);
const result = evaluateFilterConditions({ filters: [filter] });
expect(result).toBe(true);
});
it('should return false when values are not equal', () => {
const filter = createFilter(Operand.EQ, 'John', 'Jane');
const filter = createFilter(StepOperand.EQ, 'John', 'Jane');
const result = evaluateFilterConditions({ filters: [filter] });
expect(result).toBe(false);
@ -77,14 +71,14 @@ describe('evaluateFilterConditions', () => {
describe('ne operand', () => {
it('should return false when values are equal', () => {
const filter = createFilter(Operand.NE, 'John', 'John');
const filter = createFilter(StepOperand.NE, 'John', 'John');
const result = evaluateFilterConditions({ filters: [filter] });
expect(result).toBe(false);
});
it('should return true when values are not equal', () => {
const filter = createFilter(Operand.NE, 'John', 'Jane');
const filter = createFilter(StepOperand.NE, 'John', 'Jane');
const result = evaluateFilterConditions({ filters: [filter] });
expect(result).toBe(true);
@ -93,17 +87,17 @@ describe('evaluateFilterConditions', () => {
describe('numeric operands', () => {
it('should handle gt operand correctly', () => {
const filter1 = createFilter(Operand.GT, 30, 25);
const filter2 = createFilter(Operand.GT, 20, 25);
const filter1 = createFilter(StepOperand.GT, 30, 25);
const filter2 = createFilter(StepOperand.GT, 20, 25);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
});
it('should handle gte operand correctly', () => {
const filter1 = createFilter(Operand.GTE, 25, 25);
const filter2 = createFilter(Operand.GTE, 30, 25);
const filter3 = createFilter(Operand.GTE, 20, 25);
const filter1 = createFilter(StepOperand.GTE, 25, 25);
const filter2 = createFilter(StepOperand.GTE, 30, 25);
const filter3 = createFilter(StepOperand.GTE, 20, 25);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
@ -111,17 +105,17 @@ describe('evaluateFilterConditions', () => {
});
it('should handle lt operand correctly', () => {
const filter1 = createFilter(Operand.LT, 20, 25);
const filter2 = createFilter(Operand.LT, 30, 25);
const filter1 = createFilter(StepOperand.LT, 20, 25);
const filter2 = createFilter(StepOperand.LT, 30, 25);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
});
it('should handle lte operand correctly', () => {
const filter1 = createFilter(Operand.LTE, 25, 25);
const filter2 = createFilter(Operand.LTE, 20, 25);
const filter3 = createFilter(Operand.LTE, 30, 25);
const filter1 = createFilter(StepOperand.LTE, 25, 25);
const filter2 = createFilter(StepOperand.LTE, 20, 25);
const filter3 = createFilter(StepOperand.LTE, 30, 25);
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
@ -129,8 +123,8 @@ describe('evaluateFilterConditions', () => {
});
it('should convert string numbers for numeric comparisons', () => {
const filter1 = createFilter(Operand.GT, '30', '25');
const filter2 = createFilter(Operand.LT, '20', '25');
const filter1 = createFilter(StepOperand.GT, '30', '25');
const filter2 = createFilter(StepOperand.LT, '20', '25');
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
@ -139,17 +133,17 @@ describe('evaluateFilterConditions', () => {
describe('string operands', () => {
it('should handle like operand correctly', () => {
const filter1 = createFilter(Operand.LIKE, 'Hello World', 'World');
const filter2 = createFilter(Operand.LIKE, 'Hello', 'World');
const filter1 = createFilter(StepOperand.LIKE, 'Hello World', 'World');
const filter2 = createFilter(StepOperand.LIKE, 'Hello', 'World');
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
});
it('should handle ilike operand correctly (case insensitive)', () => {
const filter1 = createFilter(Operand.ILIKE, 'Hello World', 'WORLD');
const filter2 = createFilter(Operand.ILIKE, 'Hello World', 'world');
const filter3 = createFilter(Operand.ILIKE, 'Hello', 'WORLD');
const filter1 = createFilter(StepOperand.ILIKE, 'Hello World', 'WORLD');
const filter2 = createFilter(StepOperand.ILIKE, 'Hello World', 'world');
const filter3 = createFilter(StepOperand.ILIKE, 'Hello', 'WORLD');
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
@ -160,12 +154,12 @@ describe('evaluateFilterConditions', () => {
describe('in operand', () => {
it('should handle JSON array values', () => {
const filter1 = createFilter(
Operand.IN,
StepOperand.IN,
'apple',
'["apple", "banana", "cherry"]',
);
const filter2 = createFilter(
Operand.IN,
StepOperand.IN,
'grape',
'["apple", "banana", "cherry"]',
);
@ -176,12 +170,12 @@ describe('evaluateFilterConditions', () => {
it('should handle comma-separated string values when JSON parsing fails', () => {
const filter1 = createFilter(
Operand.IN,
StepOperand.IN,
'apple',
'apple, banana, cherry',
);
const filter2 = createFilter(
Operand.IN,
StepOperand.IN,
'grape',
'apple, banana, cherry',
);
@ -192,7 +186,7 @@ describe('evaluateFilterConditions', () => {
it('should handle comma-separated values with whitespace', () => {
const filter = createFilter(
Operand.IN,
StepOperand.IN,
'apple',
' apple , banana , cherry ',
);
@ -201,7 +195,11 @@ describe('evaluateFilterConditions', () => {
});
it('should return false for non-array JSON values', () => {
const filter = createFilter(Operand.IN, 'apple', '{"key": "value"}');
const filter = createFilter(
StepOperand.IN,
'apple',
'{"key": "value"}',
);
expect(evaluateFilterConditions({ filters: [filter] })).toBe(false);
});
@ -209,9 +207,9 @@ describe('evaluateFilterConditions', () => {
describe('is operand', () => {
it('should handle null checks', () => {
const filter1 = createFilter(Operand.IS, null, 'null');
const filter2 = createFilter(Operand.IS, undefined, 'NULL');
const filter3 = createFilter(Operand.IS, 'value', 'null');
const filter1 = createFilter(StepOperand.IS, null, 'null');
const filter2 = createFilter(StepOperand.IS, undefined, 'NULL');
const filter3 = createFilter(StepOperand.IS, 'value', 'null');
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
@ -219,10 +217,10 @@ describe('evaluateFilterConditions', () => {
});
it('should handle not null checks', () => {
const filter1 = createFilter(Operand.IS, 'value', 'not null');
const filter2 = createFilter(Operand.IS, 'value', 'NOT NULL');
const filter3 = createFilter(Operand.IS, null, 'not null');
const filter4 = createFilter(Operand.IS, undefined, 'not null');
const filter1 = createFilter(StepOperand.IS, 'value', 'not null');
const filter2 = createFilter(StepOperand.IS, 'value', 'NOT NULL');
const filter3 = createFilter(StepOperand.IS, null, 'not null');
const filter4 = createFilter(StepOperand.IS, undefined, 'not null');
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true);
@ -231,8 +229,8 @@ describe('evaluateFilterConditions', () => {
});
it('should handle exact value comparisons for non-null/not-null cases', () => {
const filter1 = createFilter(Operand.IS, 'exact', 'exact');
const filter2 = createFilter(Operand.IS, 'value', 'different');
const filter1 = createFilter(StepOperand.IS, 'exact', 'exact');
const filter2 = createFilter(StepOperand.IS, 'value', 'different');
expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true);
expect(evaluateFilterConditions({ filters: [filter2] })).toBe(false);
@ -241,7 +239,7 @@ describe('evaluateFilterConditions', () => {
describe('error cases', () => {
it('should throw error for unknown operand', () => {
const filter = createFilter('unknown' as Operand, 'value', 'value');
const filter = createFilter('unknown' as StepOperand, 'value', 'value');
expect(() => evaluateFilterConditions({ filters: [filter] })).toThrow(
'Unknown operand: unknown',
@ -251,6 +249,11 @@ describe('evaluateFilterConditions', () => {
});
describe('multiple filters without groups', () => {
type ResolvedFilter = Omit<StepFilter, 'value' | 'stepOutputKey'> & {
rightOperand: unknown;
leftOperand: unknown;
};
it('should apply AND logic by default for multiple filters', () => {
const filters: ResolvedFilter[] = [
{
@ -258,10 +261,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'John',
fieldMetadataId: 'field1',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 'John',
},
{
@ -269,10 +271,9 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: Operand.GT,
operand: StepOperand.GT,
displayValue: '25',
fieldMetadataId: 'field2',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 30,
},
];
@ -289,10 +290,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'John',
fieldMetadataId: 'field1',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 'John',
},
{
@ -300,10 +300,9 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: Operand.GT,
operand: StepOperand.GT,
displayValue: '25',
fieldMetadataId: 'field2',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 20, // This will fail
},
];
@ -315,12 +314,17 @@ describe('evaluateFilterConditions', () => {
});
describe('filter groups', () => {
type ResolvedFilter = Omit<StepFilter, 'value' | 'stepOutputKey'> & {
rightOperand: unknown;
leftOperand: unknown;
};
describe('single group with AND logic', () => {
it('should return true when all filters pass', () => {
const filterGroups: FilterGroup[] = [
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: LogicalOperator.AND,
logicalOperator: StepLogicalOperator.AND,
},
];
@ -330,10 +334,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'John',
fieldMetadataId: 'field1',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 'John',
},
{
@ -341,10 +344,9 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: Operand.GT,
operand: StepOperand.GT,
displayValue: '25',
fieldMetadataId: 'field2',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 30,
},
];
@ -355,10 +357,10 @@ describe('evaluateFilterConditions', () => {
});
it('should return false when one filter fails', () => {
const filterGroups: FilterGroup[] = [
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: LogicalOperator.AND,
logicalOperator: StepLogicalOperator.AND,
},
];
@ -368,10 +370,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'John',
fieldMetadataId: 'field1',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 'Jane', // This will fail
},
{
@ -379,10 +380,9 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: Operand.GT,
operand: StepOperand.GT,
displayValue: '25',
fieldMetadataId: 'field2',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 30,
},
];
@ -395,10 +395,10 @@ describe('evaluateFilterConditions', () => {
describe('single group with OR logic', () => {
it('should return true when at least one filter passes', () => {
const filterGroups: FilterGroup[] = [
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: LogicalOperator.OR,
logicalOperator: StepLogicalOperator.OR,
},
];
@ -408,10 +408,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'John',
fieldMetadataId: 'field1',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 'Jane', // This will fail
},
{
@ -419,10 +418,9 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: Operand.GT,
operand: StepOperand.GT,
displayValue: '25',
fieldMetadataId: 'field2',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 30, // This will pass
},
];
@ -433,10 +431,10 @@ describe('evaluateFilterConditions', () => {
});
it('should return false when all filters fail', () => {
const filterGroups: FilterGroup[] = [
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: LogicalOperator.OR,
logicalOperator: StepLogicalOperator.OR,
},
];
@ -446,10 +444,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'John',
fieldMetadataId: 'field1',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 'Jane', // This will fail
},
{
@ -457,10 +454,9 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: Operand.GT,
operand: StepOperand.GT,
displayValue: '25',
fieldMetadataId: 'field2',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 20, // This will fail
},
];
@ -473,10 +469,10 @@ describe('evaluateFilterConditions', () => {
describe('empty groups', () => {
it('should return true for empty group', () => {
const filterGroups: FilterGroup[] = [
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: LogicalOperator.AND,
logicalOperator: StepLogicalOperator.AND,
},
];
@ -488,16 +484,16 @@ describe('evaluateFilterConditions', () => {
describe('nested groups', () => {
it('should handle nested groups correctly', () => {
const filterGroups: FilterGroup[] = [
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: LogicalOperator.AND,
logicalOperator: StepLogicalOperator.AND,
},
{
id: 'group2',
logicalOperator: LogicalOperator.OR,
parentRecordFilterGroupId: 'group1',
positionInRecordFilterGroup: 1,
logicalOperator: StepLogicalOperator.OR,
parentStepFilterGroupId: 'group1',
positionInStepFilterGroup: 1,
},
];
@ -508,10 +504,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'John',
fieldMetadataId: 'field1',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 'John', // This will pass
},
// Filters in child group with OR logic
@ -520,10 +515,9 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 25,
operand: Operand.GT,
operand: StepOperand.GT,
displayValue: '25',
fieldMetadataId: 'field2',
recordFilterGroupId: 'group2',
stepFilterGroupId: 'group2',
leftOperand: 20, // This will fail
},
{
@ -531,10 +525,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'City Filter',
rightOperand: 'NYC',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'NYC',
fieldMetadataId: 'field3',
recordFilterGroupId: 'group2',
stepFilterGroupId: 'group2',
leftOperand: 'NYC', // This will pass
},
];
@ -546,22 +539,22 @@ describe('evaluateFilterConditions', () => {
});
it('should handle multiple child groups with correct positioning', () => {
const filterGroups: FilterGroup[] = [
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: LogicalOperator.AND,
logicalOperator: StepLogicalOperator.AND,
},
{
id: 'group2',
logicalOperator: LogicalOperator.OR,
parentRecordFilterGroupId: 'group1',
positionInRecordFilterGroup: 1,
logicalOperator: StepLogicalOperator.OR,
parentStepFilterGroupId: 'group1',
positionInStepFilterGroup: 1,
},
{
id: 'group3',
logicalOperator: LogicalOperator.AND,
parentRecordFilterGroupId: 'group1',
positionInRecordFilterGroup: 2,
logicalOperator: StepLogicalOperator.AND,
parentStepFilterGroupId: 'group1',
positionInStepFilterGroup: 2,
},
];
@ -572,10 +565,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'John',
fieldMetadataId: 'field1',
recordFilterGroupId: 'group2',
stepFilterGroupId: 'group2',
leftOperand: 'Jane', // This will fail
},
{
@ -583,10 +575,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Status Filter',
rightOperand: 'active',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'active',
fieldMetadataId: 'field2',
recordFilterGroupId: 'group2',
stepFilterGroupId: 'group2',
leftOperand: 'active', // This will pass
},
// Group3 filters (AND logic)
@ -595,10 +586,9 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 18,
operand: Operand.GTE,
operand: StepOperand.GTE,
displayValue: '18',
fieldMetadataId: 'field3',
recordFilterGroupId: 'group3',
stepFilterGroupId: 'group3',
leftOperand: 25, // This will pass
},
{
@ -606,10 +596,9 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Score Filter',
rightOperand: 80,
operand: Operand.GT,
operand: StepOperand.GT,
displayValue: '80',
fieldMetadataId: 'field4',
recordFilterGroupId: 'group3',
stepFilterGroupId: 'group3',
leftOperand: 85, // This will pass
},
];
@ -623,14 +612,14 @@ describe('evaluateFilterConditions', () => {
describe('multiple root groups', () => {
it('should combine multiple root groups with AND logic', () => {
const filterGroups: FilterGroup[] = [
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: LogicalOperator.OR,
logicalOperator: StepLogicalOperator.OR,
},
{
id: 'group2',
logicalOperator: LogicalOperator.AND,
logicalOperator: StepLogicalOperator.AND,
},
];
@ -641,10 +630,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'John',
fieldMetadataId: 'field1',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 'John', // This will pass
},
{
@ -652,10 +640,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Status Filter',
rightOperand: 'inactive',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'inactive',
fieldMetadataId: 'field2',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 'active', // This will fail
},
// Group2 filters (AND logic)
@ -664,10 +651,9 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 18,
operand: Operand.GTE,
operand: StepOperand.GTE,
displayValue: '18',
fieldMetadataId: 'field3',
recordFilterGroupId: 'group2',
stepFilterGroupId: 'group2',
leftOperand: 25, // This will pass
},
{
@ -675,10 +661,9 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Score Filter',
rightOperand: 80,
operand: Operand.GT,
operand: StepOperand.GT,
displayValue: '80',
fieldMetadataId: 'field4',
recordFilterGroupId: 'group2',
stepFilterGroupId: 'group2',
leftOperand: 85, // This will pass
},
];
@ -690,14 +675,14 @@ describe('evaluateFilterConditions', () => {
});
it('should return false when one root group fails', () => {
const filterGroups: FilterGroup[] = [
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: LogicalOperator.OR,
logicalOperator: StepLogicalOperator.OR,
},
{
id: 'group2',
logicalOperator: LogicalOperator.AND,
logicalOperator: StepLogicalOperator.AND,
},
];
@ -708,10 +693,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'John',
fieldMetadataId: 'field1',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 'John', // This will pass
},
// Group2 filters (AND logic) - will fail
@ -720,10 +704,9 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Age Filter',
rightOperand: 30,
operand: Operand.GT,
operand: StepOperand.GT,
displayValue: '30',
fieldMetadataId: 'field2',
recordFilterGroupId: 'group2',
stepFilterGroupId: 'group2',
leftOperand: 25, // This will fail
},
{
@ -731,10 +714,9 @@ describe('evaluateFilterConditions', () => {
type: 'number',
label: 'Score Filter',
rightOperand: 80,
operand: Operand.GT,
operand: StepOperand.GT,
displayValue: '80',
fieldMetadataId: 'field3',
recordFilterGroupId: 'group2',
stepFilterGroupId: 'group2',
leftOperand: 85, // This will pass
},
];
@ -748,10 +730,10 @@ describe('evaluateFilterConditions', () => {
describe('error cases', () => {
it('should throw error when filter group is not found', () => {
const filterGroups: FilterGroup[] = [
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: LogicalOperator.AND,
logicalOperator: StepLogicalOperator.AND,
},
];
@ -761,10 +743,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'John',
fieldMetadataId: 'field1',
recordFilterGroupId: 'nonexistent-group',
stepFilterGroupId: 'nonexistent-group',
leftOperand: 'John',
},
];
@ -775,10 +756,10 @@ describe('evaluateFilterConditions', () => {
});
it('should throw error for unknown logical operator', () => {
const filterGroups: FilterGroup[] = [
const filterGroups: StepFilterGroup[] = [
{
id: 'group1',
logicalOperator: 'UNKNOWN' as LogicalOperator,
logicalOperator: 'UNKNOWN' as StepLogicalOperator,
},
];
@ -788,10 +769,9 @@ describe('evaluateFilterConditions', () => {
type: 'text',
label: 'Name Filter',
rightOperand: 'John',
operand: Operand.EQ,
operand: StepOperand.EQ,
displayValue: 'John',
fieldMetadataId: 'field1',
recordFilterGroupId: 'group1',
stepFilterGroupId: 'group1',
leftOperand: 'John',
},
];

View File

@ -1,9 +1,6 @@
import {
Filter,
FilterGroup,
} from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type';
import { StepFilter, StepFilterGroup } from 'twenty-shared/types';
type ResolvedFilter = Omit<Filter, 'value' | 'stepOutputKey'> & {
type ResolvedFilter = Omit<StepFilter, 'value' | 'stepOutputKey'> & {
rightOperand: unknown;
leftOperand: unknown;
};
@ -72,7 +69,7 @@ function evaluateFilter(filter: ResolvedFilter): boolean {
*/
function evaluateFilterGroup(
groupId: string,
filterGroups: FilterGroup[],
filterGroups: StepFilterGroup[],
filters: ResolvedFilter[],
): boolean {
const group = filterGroups.find((g) => g.id === groupId);
@ -83,14 +80,13 @@ function evaluateFilterGroup(
// Get all direct child groups
const childGroups = filterGroups
.filter((g) => g.parentRecordFilterGroupId === groupId)
.filter((g) => g.parentStepFilterGroupId === groupId)
.sort(
(a, b) =>
(a.positionInRecordFilterGroup || 0) -
(b.positionInRecordFilterGroup || 0),
(a.positionInStepFilterGroup || 0) - (b.positionInStepFilterGroup || 0),
);
const groupFilters = filters.filter((f) => f.recordFilterGroupId === groupId);
const groupFilters = filters.filter((f) => f.stepFilterGroupId === groupId);
const filterResults = groupFilters.map((filter) => evaluateFilter(filter));
@ -120,7 +116,7 @@ export function evaluateFilterConditions({
filterGroups = [],
filters = [],
}: {
filterGroups?: FilterGroup[];
filterGroups?: StepFilterGroup[];
filters?: ResolvedFilter[];
}): boolean {
if (filterGroups.length === 0 && filters.length === 0) {
@ -131,15 +127,15 @@ export function evaluateFilterConditions({
const groupIds = new Set(filterGroups.map((g) => g.id));
for (const filter of filters) {
if (!groupIds.has(filter.recordFilterGroupId)) {
if (!groupIds.has(filter.stepFilterGroupId)) {
throw new Error(
`Filter group with id ${filter.recordFilterGroupId} not found`,
`Filter group with id ${filter.stepFilterGroupId} not found`,
);
}
}
}
const rootGroups = filterGroups.filter((g) => !g.parentRecordFilterGroupId);
const rootGroups = filterGroups.filter((g) => !g.parentStepFilterGroupId);
if (rootGroups.length === 0 && filters.length > 0) {
const filterResults = filters.map((filter) => evaluateFilter(filter));

View File

@ -1,50 +0,0 @@
import { t } from '@lingui/core/macro';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const getPreviousStepOutput = (
steps: WorkflowAction[],
currentStepId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: Record<string, any>,
) => {
const previousSteps = steps.filter((step) =>
step?.nextStepIds?.includes(currentStepId),
);
if (previousSteps.length === 0) {
throw new WorkflowStepExecutorException(
'Filter action must have a previous step',
WorkflowStepExecutorExceptionCode.FAILED_TO_EXECUTE_STEP,
{
userFriendlyMessage: t`Filter action must have a previous step`,
},
);
}
if (previousSteps.length > 1) {
throw new WorkflowStepExecutorException(
'Filter action must have only one previous step',
WorkflowStepExecutorExceptionCode.FAILED_TO_EXECUTE_STEP,
{
userFriendlyMessage: t`Filter action must have only one previous step`,
},
);
}
const previousStep = previousSteps[0];
const previousStepOutput = context[previousStep.id];
if (!previousStepOutput) {
throw new WorkflowStepExecutorException(
'Previous step output not found',
WorkflowStepExecutorExceptionCode.FAILED_TO_EXECUTE_STEP,
);
}
return previousStepOutput;
};

View File

@ -15,13 +15,13 @@ import {
} from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity';
import { WorkflowActionFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-action.factory';
import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
import { StepStatus } from 'src/modules/workflow/workflow-executor/types/workflow-run-step-info.type';
import {
WorkflowBranchExecutorInput,
WorkflowExecutorInput,
} from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { StepStatus } from 'src/modules/workflow/workflow-executor/types/workflow-run-step-info.type';
import { canExecuteStep } from 'src/modules/workflow/workflow-executor/utils/can-execute-step.utils';
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
const MAX_RETRIES_ON_FAILURE = 3;
@ -128,11 +128,11 @@ export class WorkflowExecutorWorkspaceService {
const actionOutputSuccess = isDefined(actionOutput.result);
const shouldContinue =
const isValidActionOutput =
actionOutputSuccess ||
stepToExecute.settings.errorHandlingOptions.continueOnFailure.value;
if (shouldContinue) {
if (isValidActionOutput) {
await this.workflowRunWorkspaceService.saveWorkflowRunState({
workflowRunId,
stepOutput,
@ -144,7 +144,8 @@ export class WorkflowExecutorWorkspaceService {
if (
!isDefined(stepToExecute.nextStepIds) ||
stepToExecute.nextStepIds.length === 0
stepToExecute.nextStepIds.length === 0 ||
actionOutput.shouldEndWorkflowRun === true
) {
await this.workflowRunWorkspaceService.endWorkflowRun({
workflowRunId,

View File

@ -1,4 +1,4 @@
import { ObjectRecordsPermissions } from '@/types/ObjectRecordsPermissions';
import { ObjectRecordsPermissions } from './ObjectRecordsPermissions';
type RoleId = string;
export type ObjectRecordsPermissionsByRoleId = Record<

View File

@ -0,0 +1,36 @@
export enum StepLogicalOperator {
AND = 'AND',
OR = 'OR',
}
export enum StepOperand {
EQ = 'eq',
NE = 'ne',
GT = 'gt',
GTE = 'gte',
LT = 'lt',
LTE = 'lte',
LIKE = 'like',
ILIKE = 'ilike',
IN = 'in',
IS = 'is',
}
export type StepFilterGroup = {
id: string;
logicalOperator: StepLogicalOperator;
parentStepFilterGroupId?: string;
positionInStepFilterGroup?: number;
};
export type StepFilter = {
id: string;
type: string;
label: string;
value: string;
operand: StepOperand;
displayValue: string;
stepFilterGroupId: string;
stepOutputKey: string;
positionInStepFilterGroup?: number;
};

View File

@ -13,3 +13,5 @@ export { FieldMetadataType } from './FieldMetadataType';
export type { IsExactly } from './IsExactly';
export type { ObjectRecordsPermissions } from './ObjectRecordsPermissions';
export type { ObjectRecordsPermissionsByRoleId } from './ObjectRecordsPermissionsByRoleId';
export type { StepFilterGroup, StepFilter } from './StepFilters';
export { StepLogicalOperator, StepOperand } from './StepFilters';