feat(workflow): condition filter on database-event triggers (#21868)
## Problem
Connecting a mailbox bulk-creates contacts via the email/calendar sync,
and each `person.upserted` fires the seeded **"Create company when
adding a new person"** workflow. The trigger enqueues one run per record
(no batching) and each run bills several `WORKFLOW_NODE_RUN` events — so
a single mailbox connect can rack up tens of thousands of runs and
exhaust credits on a brand-new workspace. The workflow is also redundant
on that path: the sync already creates the company from the email domain
and links the person to it.
## What this does
Adds an optional, user-defined **filter** to database-event (listener)
triggers, evaluated in the listener **before a run is enqueued**.
Non-matching events never create a run, so they consume zero execution
credits. This is the Filter node's capability, lifted to the trigger
level, and available for all event types (created / updated / upserted /
deleted).
The seeded "Create company when adding a new person" workflow now
carries a visible trigger filter — `Created by → Source is not Email`
**and** `is not Calendar` — so it no longer runs for sync-created
contacts, while still running for manually / API / CSV-added people.
## How (reuse)
- **Backend:** extracted `evaluateStepFilters()`, shared by the Filter
action and the trigger listener's new `eventMatchesRecordFilter` gate.
The record is exposed under the `trigger` key so filters reference it
exactly like steps do (`{{trigger.properties.after.…}}`).
- **Shared:** one optional `filter` added to the database-event trigger
zod schema; the front-end type derives from it (settings stay JSON — no
codegen).
- **Frontend:** extracted `WorkflowStepFilterBuilder` from the Filter
action's body; both the Filter action and the trigger editor render it.
The field picker needed no changes — at the trigger it already resolves
to the record's own fields via `TRIGGER_STEP_ID`.
## Scope / decisions
- **No migration for existing workspaces** (by request) — only newly
created workspaces get the filtered default; already-created workspaces
keep the always-on workflow.
- Deliberately did **not** add relation-enrichment to the upsert path
(it would add a DB lookup to the very bulk-sync path we're relieving).
Trigger filters work on the record's own scalar/composite fields (e.g.
`createdBy.source`); relation-based filters work on created/updated
where enrichment already runs.
## Verification
- Typecheck: `twenty-shared`, `twenty-server`, `twenty-front` all green.
- Lint (diff, autofix): 0 warnings / 0 errors across all three.
- Unit tests: a new `evaluate-step-filters` spec exercising the exact
`createdBy.source IS_NOT` seed mechanism, plus new listener specs
proving non-matching events are not enqueued. All backend
filter/listener suites pass.
- Not run here: integration tests (need a DB) and Storybook.
https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De
---
_Generated by [Claude
Code](https://claude.ai/code/session_013k36vfekDppwRCgM6Ha7De)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21868?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+1
-9
@@ -5,22 +5,14 @@ import { currentStepFilterGroupsComponentState } from '@/workflow/workflow-steps
|
||||
import { currentStepFiltersComponentState } from '@/workflow/workflow-steps/filters/states/currentStepFiltersComponentState';
|
||||
import { hasInitializedCurrentStepFilterGroupsComponentFamilyState } from '@/workflow/workflow-steps/filters/states/hasInitializedCurrentStepFilterGroupsComponentFamilyState';
|
||||
import { hasInitializedCurrentStepFiltersComponentFamilyState } from '@/workflow/workflow-steps/filters/states/hasInitializedCurrentStepFiltersComponentFamilyState';
|
||||
import { type FilterSettingsWithPotentiallyDeprecatedOperand } from '@/workflow/workflow-steps/filters/types/FilterSettings';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import {
|
||||
type StepFilterGroup,
|
||||
type StepFilterWithPotentiallyDeprecatedOperand,
|
||||
} from 'twenty-shared/types';
|
||||
import {
|
||||
convertViewFilterOperandToCoreOperand,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||
|
||||
type FilterSettingsWithPotentiallyDeprecatedOperand = {
|
||||
stepFilterGroups?: StepFilterGroup[];
|
||||
stepFilters?: StepFilterWithPotentiallyDeprecatedOperand[];
|
||||
};
|
||||
|
||||
export const WorkflowEditActionFilterBodyEffect = ({
|
||||
stepId,
|
||||
defaultValue,
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { WorkflowEditActionFilterBodyEffect } from '@/workflow/workflow-steps/filters/components/WorkflowEditActionFilterBodyEffect';
|
||||
import { WorkflowStepFilterAddFilterRuleSelect } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterAddFilterRuleSelect';
|
||||
import { WorkflowStepFilterAddRootStepFilterButton } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterAddRootStepFilterButton';
|
||||
import { WorkflowStepFilterColumn } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterColumn';
|
||||
import { WorkflowStepFilterGroupColumn } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterGroupColumn';
|
||||
import { useChildStepFiltersAndChildStepFilterGroups } from '@/workflow/workflow-steps/filters/hooks/useChildStepFiltersAndChildStepFilterGroups';
|
||||
import { StepFilterGroupsComponentInstanceContext } from '@/workflow/workflow-steps/filters/states/context/StepFilterGroupsComponentInstanceContext';
|
||||
import { StepFiltersComponentInstanceContext } from '@/workflow/workflow-steps/filters/states/context/StepFiltersComponentInstanceContext';
|
||||
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/filters/states/context/WorkflowStepFilterContext';
|
||||
import { rootLevelStepFilterGroupComponentSelector } from '@/workflow/workflow-steps/filters/states/rootLevelStepFilterGroupComponentSelector';
|
||||
import {
|
||||
type FilterSettings,
|
||||
type FilterSettingsWithPotentiallyDeprecatedOperand,
|
||||
} from '@/workflow/workflow-steps/filters/types/FilterSettings';
|
||||
import { isStepFilterGroupChildAStepFilterGroup } from '@/workflow/workflow-steps/filters/utils/isStepFilterGroupChildAStepFilterGroup';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledChildContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[6]};
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledConditions = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: ${themeCssVariables.spacing[0]};
|
||||
`;
|
||||
|
||||
type WorkflowStepFilterBuilderProps = {
|
||||
instanceId: string;
|
||||
defaultValue?: FilterSettingsWithPotentiallyDeprecatedOperand;
|
||||
readonly?: boolean;
|
||||
onFilterSettingsUpdate: (filterSettings: FilterSettings) => void;
|
||||
};
|
||||
|
||||
const WorkflowStepFilterBuilderConditions = ({
|
||||
readonly,
|
||||
}: {
|
||||
readonly?: boolean;
|
||||
}) => {
|
||||
const rootStepFilterGroup = useAtomComponentSelectorValue(
|
||||
rootLevelStepFilterGroupComponentSelector,
|
||||
);
|
||||
|
||||
const { childStepFiltersAndChildStepFilterGroups } =
|
||||
useChildStepFiltersAndChildStepFilterGroups({
|
||||
stepFilterGroupId: rootStepFilterGroup?.id ?? '',
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledConditions>
|
||||
<InputLabel>{t`Conditions`}</InputLabel>
|
||||
{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>
|
||||
{!readonly && (
|
||||
<WorkflowStepFilterAddFilterRuleSelect
|
||||
stepFilterGroup={rootStepFilterGroup}
|
||||
/>
|
||||
)}
|
||||
</StyledContainer>
|
||||
) : (
|
||||
<WorkflowStepFilterAddRootStepFilterButton />
|
||||
)}
|
||||
</StyledConditions>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorkflowStepFilterBuilder = ({
|
||||
instanceId,
|
||||
defaultValue,
|
||||
readonly,
|
||||
onFilterSettingsUpdate,
|
||||
}: WorkflowStepFilterBuilderProps) => {
|
||||
return (
|
||||
<StepFiltersComponentInstanceContext.Provider value={{ instanceId }}>
|
||||
<StepFilterGroupsComponentInstanceContext.Provider value={{ instanceId }}>
|
||||
<WorkflowStepFilterContext.Provider
|
||||
value={{
|
||||
stepId: instanceId,
|
||||
readonly,
|
||||
onFilterSettingsUpdate,
|
||||
}}
|
||||
>
|
||||
<WorkflowStepFilterBuilderConditions readonly={readonly} />
|
||||
</WorkflowStepFilterContext.Provider>
|
||||
<WorkflowEditActionFilterBodyEffect
|
||||
stepId={instanceId}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
</StepFilterGroupsComponentInstanceContext.Provider>
|
||||
</StepFiltersComponentInstanceContext.Provider>
|
||||
);
|
||||
};
|
||||
+10
-3
@@ -14,7 +14,10 @@ import { useLingui } from '@lingui/react/macro';
|
||||
import { useContext, useState } from 'react';
|
||||
import { type StepFilter } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { extractRawVariableNamePart } from 'twenty-shared/workflow';
|
||||
import {
|
||||
extractRawVariableNamePart,
|
||||
TRIGGER_STEP_ID,
|
||||
} from 'twenty-shared/workflow';
|
||||
import { useIcons } from 'twenty-ui/icon';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
@@ -30,7 +33,9 @@ const NON_SELECTABLE_FIELD_TYPES = [
|
||||
export const WorkflowStepFilterFieldSelect = ({
|
||||
stepFilter,
|
||||
}: WorkflowStepFilterFieldSelectProps) => {
|
||||
const { readonly } = useContext(WorkflowStepFilterContext);
|
||||
const { readonly, stepId: currentStepId } = useContext(
|
||||
WorkflowStepFilterContext,
|
||||
);
|
||||
const { t } = useLingui();
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
const { getIcon } = useIcons();
|
||||
@@ -88,7 +93,9 @@ export const WorkflowStepFilterFieldSelect = ({
|
||||
|
||||
const isSelectedFieldNotFound = !isDefined(variableLabel);
|
||||
const label = isSelectedFieldNotFound
|
||||
? t`Select a field from a previous step`
|
||||
? currentStepId === TRIGGER_STEP_ID
|
||||
? t`Select a field`
|
||||
: t`Select a field from a previous step`
|
||||
: variableLabel;
|
||||
|
||||
const fullRecordIconProps = stepFilter.isFullRecord
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowStepFilterBuilder } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterBuilder';
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { fn } from 'storybook/test';
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
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';
|
||||
|
||||
const meta: Meta<typeof WorkflowStepFilterBuilder> = {
|
||||
title: 'Modules/Workflow/Filters/WorkflowStepFilterBuilder',
|
||||
component: WorkflowStepFilterBuilder,
|
||||
parameters: {
|
||||
msw: graphqlMocks,
|
||||
},
|
||||
args: {
|
||||
instanceId: getWorkflowNodeIdMock(),
|
||||
defaultValue: {
|
||||
stepFilterGroups: [],
|
||||
stepFilters: [],
|
||||
},
|
||||
readonly: false,
|
||||
onFilterSettingsUpdate: fn(),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<WorkflowStepBody rowGap={themeCssVariables.spacing[0]}>
|
||||
<Story />
|
||||
</WorkflowStepBody>
|
||||
),
|
||||
WorkflowStepActionDrawerDecorator,
|
||||
WorkflowStepDecorator,
|
||||
ComponentDecorator,
|
||||
WorkspaceDecorator,
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WorkflowStepFilterBuilder>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
readonly: true,
|
||||
},
|
||||
};
|
||||
+10
-1
@@ -1,6 +1,15 @@
|
||||
import { type StepFilter, type StepFilterGroup } from 'twenty-shared/types';
|
||||
import {
|
||||
type StepFilter,
|
||||
type StepFilterGroup,
|
||||
type StepFilterWithPotentiallyDeprecatedOperand,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
export type FilterSettings = {
|
||||
stepFilterGroups?: StepFilterGroup[];
|
||||
stepFilters?: StepFilter[];
|
||||
};
|
||||
|
||||
export type FilterSettingsWithPotentiallyDeprecatedOperand = {
|
||||
stepFilterGroups?: StepFilterGroup[];
|
||||
stepFilters?: StepFilterWithPotentiallyDeprecatedOperand[];
|
||||
};
|
||||
|
||||
+29
-27
@@ -1,9 +1,9 @@
|
||||
import { type WorkflowFilterAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
|
||||
import { WorkflowEditActionFilterBodyEffect } from '@/workflow/workflow-steps/filters/components/WorkflowEditActionFilterBodyEffect';
|
||||
import { StepFilterGroupsComponentInstanceContext } from '@/workflow/workflow-steps/filters/states/context/StepFilterGroupsComponentInstanceContext';
|
||||
import { StepFiltersComponentInstanceContext } from '@/workflow/workflow-steps/filters/states/context/StepFiltersComponentInstanceContext';
|
||||
import { WorkflowEditActionFilterBody } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBody';
|
||||
import { WorkflowStepFilterBuilder } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterBuilder';
|
||||
import { type FilterSettings } from '@/workflow/workflow-steps/filters/types/FilterSettings';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
type WorkflowEditActionFilterProps = {
|
||||
action: WorkflowFilterAction;
|
||||
@@ -21,31 +21,33 @@ export const WorkflowEditActionFilter = ({
|
||||
action,
|
||||
actionOptions,
|
||||
}: WorkflowEditActionFilterProps) => {
|
||||
const handleFilterSettingsUpdate = (filterSettings: FilterSettings) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionOptions.onActionUpdate({
|
||||
...action,
|
||||
settings: {
|
||||
...action.settings,
|
||||
input: {
|
||||
stepFilterGroups: filterSettings.stepFilterGroups ?? [],
|
||||
stepFilters: filterSettings.stepFilters ?? [],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<WorkflowStepBody rowGap={themeCssVariables.spacing[0]}>
|
||||
<WorkflowStepFilterBuilder
|
||||
instanceId={action.id}
|
||||
defaultValue={action.settings.input}
|
||||
readonly={actionOptions.readonly}
|
||||
onFilterSettingsUpdate={handleFilterSettingsUpdate}
|
||||
/>
|
||||
</WorkflowStepBody>
|
||||
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
|
||||
</>
|
||||
);
|
||||
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { type WorkflowFilterAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowStepFilterAddFilterRuleSelect } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterAddFilterRuleSelect';
|
||||
import { WorkflowStepFilterAddRootStepFilterButton } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterAddRootStepFilterButton';
|
||||
import { WorkflowStepFilterColumn } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterColumn';
|
||||
import { WorkflowStepFilterGroupColumn } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterGroupColumn';
|
||||
import { useChildStepFiltersAndChildStepFilterGroups } from '@/workflow/workflow-steps/filters/hooks/useChildStepFiltersAndChildStepFilterGroups';
|
||||
import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/filters/states/context/WorkflowStepFilterContext';
|
||||
import { rootLevelStepFilterGroupComponentSelector } from '@/workflow/workflow-steps/filters/states/rootLevelStepFilterGroupComponentSelector';
|
||||
import { type FilterSettings } from '@/workflow/workflow-steps/filters/types/FilterSettings';
|
||||
import { isStepFilterGroupChildAStepFilterGroup } from '@/workflow/workflow-steps/filters/utils/isStepFilterGroupChildAStepFilterGroup';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledChildContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.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 = useAtomComponentSelectorValue(
|
||||
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 rowGap={themeCssVariables.spacing[0]}>
|
||||
<InputLabel>{t`Conditions`}</InputLabel>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import { type WorkflowFilterAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowStepFilterDecorator } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator';
|
||||
import { WorkflowEditActionFilterBody } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBody';
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { fn } from 'storybook/test';
|
||||
import { ComponentDecorator } from 'twenty-ui/testing';
|
||||
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';
|
||||
|
||||
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,
|
||||
WorkflowStepFilterDecorator,
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WorkflowEditActionFilterBody>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
action: DEFAULT_ACTION,
|
||||
actionOptions: {
|
||||
readonly: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
+32
@@ -16,6 +16,8 @@ import { type WorkflowDatabaseEventTrigger } from '@/workflow/types/Workflow';
|
||||
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
|
||||
import { WorkflowStepFilterBuilder } from '@/workflow/workflow-steps/filters/components/WorkflowStepFilterBuilder';
|
||||
import { type FilterSettings } from '@/workflow/workflow-steps/filters/types/FilterSettings';
|
||||
import { styled } from '@linaria/react';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
@@ -155,6 +157,23 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleFilterSettingsUpdate = (filterSettings: FilterSettings) => {
|
||||
if (triggerOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
triggerOptions.onTriggerUpdate({
|
||||
...trigger,
|
||||
settings: {
|
||||
...trigger.settings,
|
||||
filter: {
|
||||
stepFilterGroups: filterSettings.stepFilterGroups ?? [],
|
||||
stepFilters: filterSettings.stepFilters ?? [],
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSystemObjectsClick = () => {
|
||||
setIsSystemObjectsOpen(true);
|
||||
setSearchInputValue('');
|
||||
@@ -269,6 +288,19 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
|
||||
actionType="DATABASE_EVENT"
|
||||
/>
|
||||
)}
|
||||
{isDefined(selectedObjectMetadataItem) && (
|
||||
<WorkflowStepFilterBuilder
|
||||
instanceId={TRIGGER_STEP_ID}
|
||||
defaultValue={
|
||||
trigger.settings.filter ?? {
|
||||
stepFilterGroups: [],
|
||||
stepFilters: [],
|
||||
}
|
||||
}
|
||||
readonly={triggerOptions.readonly ?? false}
|
||||
onFilterSettingsUpdate={handleFilterSettingsUpdate}
|
||||
/>
|
||||
)}
|
||||
</WorkflowStepBody>
|
||||
{!triggerOptions.readonly && (
|
||||
<WorkflowStepFooter stepId={TRIGGER_STEP_ID} />
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { buildPersonSyncSourceFilter } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/build-person-sync-source-filter.util';
|
||||
import { evaluateStepFilters } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-step-filters.util';
|
||||
|
||||
describe('buildPersonSyncSourceFilter', () => {
|
||||
const filter = buildPersonSyncSourceFilter({
|
||||
createdByFieldMetadataId: 'created-by-field-id',
|
||||
});
|
||||
|
||||
const evaluateForSource = (source?: string) =>
|
||||
evaluateStepFilters({
|
||||
stepFilters: filter.stepFilters,
|
||||
stepFilterGroups: filter.stepFilterGroups,
|
||||
context: {
|
||||
trigger: {
|
||||
properties: {
|
||||
after: {
|
||||
createdBy: source === undefined ? {} : { source },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('suppresses people auto-created by the email sync', () => {
|
||||
expect(evaluateForSource('EMAIL')).toBe(false);
|
||||
});
|
||||
|
||||
it('suppresses people auto-created by the calendar sync', () => {
|
||||
expect(evaluateForSource('CALENDAR')).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['MANUAL', 'API', 'IMPORT', 'WORKFLOW', 'SYSTEM', 'WEBHOOK'])(
|
||||
'runs the workflow for people created via %s',
|
||||
(source) => {
|
||||
expect(evaluateForSource(source)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('runs the workflow when the createdBy source is missing (fails open)', () => {
|
||||
expect(evaluateForSource(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('builds two ANDed source filters that reference the given field', () => {
|
||||
expect(filter.stepFilterGroups).toHaveLength(1);
|
||||
expect(filter.stepFilterGroups[0].logicalOperator).toBe('AND');
|
||||
|
||||
expect(filter.stepFilters).toHaveLength(2);
|
||||
expect(
|
||||
filter.stepFilters.every(
|
||||
(stepFilter) =>
|
||||
stepFilter.fieldMetadataId === 'created-by-field-id' &&
|
||||
stepFilter.operand === 'IS_NOT' &&
|
||||
stepFilter.compositeFieldSubFieldName === 'source' &&
|
||||
stepFilter.stepFilterGroupId === filter.stepFilterGroups[0].id,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
FieldActorSource,
|
||||
type StepFilter,
|
||||
type StepFilterGroup,
|
||||
StepLogicalOperator,
|
||||
ViewFilterOperand,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
const PERSON_SYNC_SOURCE_FILTER_GROUP_ID =
|
||||
'2d9c1f3a-6b4e-4c8a-9f12-7a3b5c6d8e90';
|
||||
|
||||
const PERSON_SYNC_SOURCE_EMAIL_FILTER_ID =
|
||||
'3e8b2a4c-7c5f-4d9b-8a23-6b4c5d7e9f01';
|
||||
|
||||
const PERSON_SYNC_SOURCE_CALENDAR_FILTER_ID =
|
||||
'4f9c3b5d-8d6a-4e0c-9b34-7c5d6e8f0a12';
|
||||
|
||||
export const buildPersonSyncSourceFilter = ({
|
||||
createdByFieldMetadataId,
|
||||
}: {
|
||||
createdByFieldMetadataId: string;
|
||||
}): { stepFilterGroups: StepFilterGroup[]; stepFilters: StepFilter[] } => {
|
||||
const baseFilter = {
|
||||
type: 'ACTOR',
|
||||
operand: ViewFilterOperand.IS_NOT,
|
||||
stepOutputKey: '{{trigger.properties.after.createdBy.source}}',
|
||||
stepFilterGroupId: PERSON_SYNC_SOURCE_FILTER_GROUP_ID,
|
||||
compositeFieldSubFieldName: 'source',
|
||||
fieldMetadataId: createdByFieldMetadataId,
|
||||
};
|
||||
|
||||
return {
|
||||
stepFilterGroups: [
|
||||
{
|
||||
id: PERSON_SYNC_SOURCE_FILTER_GROUP_ID,
|
||||
logicalOperator: StepLogicalOperator.AND,
|
||||
},
|
||||
],
|
||||
stepFilters: [
|
||||
{
|
||||
...baseFilter,
|
||||
id: PERSON_SYNC_SOURCE_EMAIL_FILTER_ID,
|
||||
value: JSON.stringify([FieldActorSource.EMAIL]),
|
||||
positionInStepFilterGroup: 0,
|
||||
},
|
||||
{
|
||||
...baseFilter,
|
||||
id: PERSON_SYNC_SOURCE_CALENDAR_FILTER_ID,
|
||||
value: JSON.stringify([FieldActorSource.CALENDAR]),
|
||||
positionInStepFilterGroup: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
+19
@@ -8,6 +8,7 @@ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
|
||||
import { buildPersonSyncSourceFilter } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/build-person-sync-source-filter.util';
|
||||
import { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record-event';
|
||||
import { generateObjectRecordFields } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-object-record-fields';
|
||||
import { getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionIds } from 'src/engine/workspace-manager/standard-objects-prefill-data/utils/prefill-workflow-code-step-logic-functions.util';
|
||||
@@ -75,6 +76,22 @@ export const prefillWorkflows = async (
|
||||
throw new Error('Company domainName field metadata not found');
|
||||
}
|
||||
|
||||
const personCreatedByFieldMetadata = Object.values(
|
||||
flatFieldMetadataMaps.byUniversalIdentifier,
|
||||
).find(
|
||||
(fieldMetadata) =>
|
||||
fieldMetadata?.objectMetadataId === personObjectMetadataId &&
|
||||
fieldMetadata?.name === 'createdBy',
|
||||
);
|
||||
|
||||
if (!isDefined(personCreatedByFieldMetadata)) {
|
||||
throw new Error('Person createdBy field metadata not found');
|
||||
}
|
||||
|
||||
const personSyncSourceFilter = buildPersonSyncSourceFilter({
|
||||
createdByFieldMetadataId: personCreatedByFieldMetadata.id,
|
||||
});
|
||||
|
||||
await entityManager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
@@ -356,6 +373,7 @@ export const prefillWorkflows = async (
|
||||
},
|
||||
DatabaseEventAction.UPSERTED,
|
||||
),
|
||||
filter: personSyncSourceFilter,
|
||||
},
|
||||
nextStepIds: ['c30d7cbe-00e0-4966-bc1a-99b0a11a2cca'],
|
||||
}),
|
||||
@@ -755,6 +773,7 @@ export const prefillWorkflows = async (
|
||||
settings: {
|
||||
eventName: 'person.upserted',
|
||||
fields: ['emails'],
|
||||
filter: personSyncSourceFilter,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
+5
-12
@@ -1,7 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { resolveInput } from 'twenty-shared/utils';
|
||||
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
|
||||
|
||||
import {
|
||||
@@ -12,7 +10,7 @@ import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor
|
||||
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
|
||||
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
|
||||
import { isWorkflowFilterAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/guards/is-workflow-filter-action.guard';
|
||||
import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util';
|
||||
import { evaluateStepFilters } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-step-filters.util';
|
||||
|
||||
@Injectable()
|
||||
export class FilterWorkflowAction implements WorkflowAction {
|
||||
@@ -41,15 +39,10 @@ export class FilterWorkflowAction implements WorkflowAction {
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedFilters = stepFilters.map((filter) => ({
|
||||
...filter,
|
||||
rightOperand: resolveInput(filter.value, context),
|
||||
leftOperand: resolveInput(filter.stepOutputKey, context),
|
||||
}));
|
||||
|
||||
const matchesFilter = evaluateFilterConditions({
|
||||
filterGroups: stepFilterGroups,
|
||||
filters: resolvedFilters,
|
||||
const matchesFilter = evaluateStepFilters({
|
||||
stepFilters,
|
||||
stepFilterGroups,
|
||||
context,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
type StepFilter,
|
||||
type StepFilterGroup,
|
||||
StepLogicalOperator,
|
||||
ViewFilterOperand,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { evaluateStepFilters } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-step-filters.util';
|
||||
|
||||
describe('evaluateStepFilters', () => {
|
||||
const context = {
|
||||
trigger: {
|
||||
properties: {
|
||||
after: {
|
||||
createdBy: { source: 'EMAIL' },
|
||||
name: 'Acme',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const group: StepFilterGroup = {
|
||||
id: 'group-1',
|
||||
logicalOperator: StepLogicalOperator.AND,
|
||||
};
|
||||
|
||||
const sourceFilter = (operand: ViewFilterOperand): StepFilter => ({
|
||||
id: 'filter-1',
|
||||
type: 'ACTOR',
|
||||
operand,
|
||||
value: JSON.stringify(['EMAIL']),
|
||||
stepOutputKey: '{{trigger.properties.after.createdBy.source}}',
|
||||
stepFilterGroupId: group.id,
|
||||
compositeFieldSubFieldName: 'source',
|
||||
});
|
||||
|
||||
it('returns true when there are no filters', () => {
|
||||
expect(
|
||||
evaluateStepFilters({
|
||||
stepFilters: [],
|
||||
stepFilterGroups: [],
|
||||
context,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves operands from the context and matches the record', () => {
|
||||
expect(
|
||||
evaluateStepFilters({
|
||||
stepFilterGroups: [group],
|
||||
stepFilters: [sourceFilter(ViewFilterOperand.IS)],
|
||||
context,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the record source is excluded (IS_NOT)', () => {
|
||||
expect(
|
||||
evaluateStepFilters({
|
||||
stepFilterGroups: [group],
|
||||
stepFilters: [sourceFilter(ViewFilterOperand.IS_NOT)],
|
||||
context,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when a different source is excluded (IS_NOT)', () => {
|
||||
const calendarFilter: StepFilter = {
|
||||
...sourceFilter(ViewFilterOperand.IS_NOT),
|
||||
value: JSON.stringify(['CALENDAR']),
|
||||
};
|
||||
|
||||
expect(
|
||||
evaluateStepFilters({
|
||||
stepFilterGroups: [group],
|
||||
stepFilters: [calendarFilter],
|
||||
context,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('evaluates IS_NOT_EMPTY against a present field when no value is set', () => {
|
||||
const filter: StepFilter = {
|
||||
id: 'filter-present',
|
||||
type: 'TEXT',
|
||||
operand: ViewFilterOperand.IS_NOT_EMPTY,
|
||||
value: '',
|
||||
stepOutputKey: '{{trigger.properties.after.name}}',
|
||||
stepFilterGroupId: group.id,
|
||||
};
|
||||
|
||||
expect(
|
||||
evaluateStepFilters({
|
||||
stepFilterGroups: [group],
|
||||
stepFilters: [filter],
|
||||
context,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves a missing field path to empty (IS_EMPTY is true)', () => {
|
||||
const filter: StepFilter = {
|
||||
id: 'filter-missing',
|
||||
type: 'TEXT',
|
||||
operand: ViewFilterOperand.IS_EMPTY,
|
||||
value: '',
|
||||
stepOutputKey: '{{trigger.properties.after.missingField}}',
|
||||
stepFilterGroupId: group.id,
|
||||
};
|
||||
|
||||
expect(
|
||||
evaluateStepFilters({
|
||||
stepFilterGroups: [group],
|
||||
stepFilters: [filter],
|
||||
context,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('applies implicit AND across flat filters without groups', () => {
|
||||
const nameContains: StepFilter = {
|
||||
id: 'name-contains',
|
||||
type: 'TEXT',
|
||||
operand: ViewFilterOperand.CONTAINS,
|
||||
value: 'Acme',
|
||||
stepOutputKey: '{{trigger.properties.after.name}}',
|
||||
stepFilterGroupId: 'unused',
|
||||
};
|
||||
const sourceIsCalendar: StepFilter = {
|
||||
...sourceFilter(ViewFilterOperand.IS),
|
||||
value: JSON.stringify(['CALENDAR']),
|
||||
stepFilterGroupId: 'unused',
|
||||
};
|
||||
|
||||
expect(
|
||||
evaluateStepFilters({
|
||||
stepFilterGroups: [],
|
||||
stepFilters: [nameContains, sourceIsCalendar],
|
||||
context,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { type StepFilter, type StepFilterGroup } from 'twenty-shared/types';
|
||||
import { resolveInput } from 'twenty-shared/utils';
|
||||
|
||||
import { evaluateFilterConditions } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util';
|
||||
|
||||
export const evaluateStepFilters = ({
|
||||
stepFilters,
|
||||
stepFilterGroups,
|
||||
context,
|
||||
}: {
|
||||
stepFilters: StepFilter[];
|
||||
stepFilterGroups: StepFilterGroup[];
|
||||
context: Record<string, unknown>;
|
||||
}): boolean => {
|
||||
const resolvedFilters = stepFilters.map((filter) => ({
|
||||
...filter,
|
||||
rightOperand: resolveInput(filter.value, context),
|
||||
leftOperand: resolveInput(filter.stepOutputKey, context),
|
||||
}));
|
||||
|
||||
return evaluateFilterConditions({
|
||||
filterGroups: stepFilterGroups,
|
||||
filters: resolvedFilters,
|
||||
});
|
||||
};
|
||||
+8
@@ -1,5 +1,13 @@
|
||||
import { type StepFilter, type StepFilterGroup } from 'twenty-shared/types';
|
||||
|
||||
export type DatabaseEventTriggerFilterSettings = {
|
||||
stepFilters: StepFilter[];
|
||||
stepFilterGroups: StepFilterGroup[];
|
||||
};
|
||||
|
||||
export type BaseDatabaseEventTriggerSettings = {
|
||||
eventName: string;
|
||||
filter?: DatabaseEventTriggerFilterSettings;
|
||||
};
|
||||
|
||||
export type DatabaseEventTriggerSettings =
|
||||
|
||||
+57
-22
@@ -9,7 +9,8 @@ import {
|
||||
type ObjectRecordUpsertEvent,
|
||||
} from 'twenty-shared/database-events';
|
||||
import { type ObjectRecord } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
|
||||
import { In, Raw } from 'typeorm';
|
||||
|
||||
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
|
||||
@@ -31,15 +32,22 @@ import {
|
||||
type WorkflowAutomatedTriggerWorkspaceEntity,
|
||||
} from 'src/modules/workflow/common/standard-objects/workflow-automated-trigger.workspace-entity';
|
||||
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
import { evaluateStepFilters } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-step-filters.util';
|
||||
import {
|
||||
type BaseDatabaseEventTriggerSettings,
|
||||
type UpdateEventTriggerSettings,
|
||||
type UpsertEventTriggerSettings,
|
||||
} from 'src/modules/workflow/workflow-trigger/automated-trigger/constants/automated-trigger-settings';
|
||||
import {
|
||||
WorkflowTriggerJob,
|
||||
type WorkflowTriggerJobData,
|
||||
} from 'src/modules/workflow/workflow-trigger/jobs/workflow-trigger.job';
|
||||
|
||||
type TriggerEvaluationArgs = {
|
||||
eventPayload: ObjectRecordEvent;
|
||||
eventListener: WorkflowAutomatedTriggerWorkspaceEntity;
|
||||
action: DatabaseEventAction;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowDatabaseEventTriggerListener {
|
||||
private readonly logger = new Logger(
|
||||
@@ -386,27 +394,26 @@ export class WorkflowDatabaseEventTriggerListener {
|
||||
eventPayload,
|
||||
eventListener,
|
||||
action,
|
||||
}: {
|
||||
eventPayload: ObjectRecordEvent;
|
||||
eventListener: WorkflowAutomatedTriggerWorkspaceEntity;
|
||||
action: DatabaseEventAction;
|
||||
}) {
|
||||
if (action === DatabaseEventAction.UPDATED) {
|
||||
}: TriggerEvaluationArgs) {
|
||||
return (
|
||||
this.eventMatchesWatchedFields({ eventPayload, eventListener, action }) &&
|
||||
this.eventMatchesRecordFilter({ eventPayload, eventListener })
|
||||
);
|
||||
}
|
||||
|
||||
private eventMatchesWatchedFields({
|
||||
eventPayload,
|
||||
eventListener,
|
||||
action,
|
||||
}: TriggerEvaluationArgs) {
|
||||
if (
|
||||
action === DatabaseEventAction.UPDATED ||
|
||||
action === DatabaseEventAction.UPSERTED
|
||||
) {
|
||||
const settings = eventListener.settings as UpdateEventTriggerSettings;
|
||||
const updateEventPayload = eventPayload as ObjectRecordUpdateEvent;
|
||||
const updatedFields = updateEventPayload?.properties?.updatedFields ?? [];
|
||||
|
||||
return (
|
||||
!settings.fields ||
|
||||
settings.fields.length === 0 ||
|
||||
settings.fields.some((field) => updatedFields.includes(field))
|
||||
);
|
||||
}
|
||||
|
||||
if (action === DatabaseEventAction.UPSERTED) {
|
||||
const settings = eventListener.settings as UpsertEventTriggerSettings;
|
||||
const upsertEventPayload = eventPayload as ObjectRecordUpsertEvent;
|
||||
const updatedFields = upsertEventPayload?.properties?.updatedFields ?? [];
|
||||
const updatedFields =
|
||||
(eventPayload as ObjectRecordUpdateEvent)?.properties?.updatedFields ??
|
||||
[];
|
||||
|
||||
return (
|
||||
!settings.fields ||
|
||||
@@ -417,4 +424,32 @@ export class WorkflowDatabaseEventTriggerListener {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private eventMatchesRecordFilter({
|
||||
eventPayload,
|
||||
eventListener,
|
||||
}: Pick<TriggerEvaluationArgs, 'eventPayload' | 'eventListener'>) {
|
||||
const { filter } =
|
||||
eventListener.settings as BaseDatabaseEventTriggerSettings;
|
||||
|
||||
if (!isDefined(filter) || !isNonEmptyArray(filter.stepFilters)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return evaluateStepFilters({
|
||||
stepFilters: filter.stepFilters,
|
||||
stepFilterGroups: filter.stepFilterGroups,
|
||||
context: { [TRIGGER_STEP_ID]: eventPayload },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to evaluate database-event trigger filter for workflow ${eventListener.workflowId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import request from 'supertest';
|
||||
|
||||
const client = request(`http://localhost:${APP_PORT}`);
|
||||
|
||||
const STEP_FILTER_GROUP_ID = 'a1b2c3d4-1111-4a2b-8c3d-000000000001';
|
||||
const STEP_FILTER_ID = 'a1b2c3d4-2222-4a2b-8c3d-000000000002';
|
||||
const FILTER_VALUE = 'trigger-me-co';
|
||||
|
||||
type AutomatedTriggerNode = {
|
||||
type: string;
|
||||
workflowId: string;
|
||||
settings: {
|
||||
eventName?: string;
|
||||
filter?: {
|
||||
stepFilters: Array<Record<string, unknown>>;
|
||||
stepFilterGroups: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const graphql = (query: string, variables?: object) =>
|
||||
client
|
||||
.post('/graphql')
|
||||
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
|
||||
.send({ query, variables });
|
||||
|
||||
describe('Database event trigger filter (e2e)', () => {
|
||||
let createdWorkflowId: string | null = null;
|
||||
let createdWorkflowVersionId: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const createWorkflowResponse = await graphql(`
|
||||
mutation CreateWorkflow {
|
||||
createWorkflow(data: { name: "DB Event Trigger Filter Test" }) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(createWorkflowResponse.body.errors).toBeUndefined();
|
||||
createdWorkflowId = createWorkflowResponse.body.data.createWorkflow.id;
|
||||
|
||||
const getWorkflowResponse = await graphql(
|
||||
`
|
||||
query GetWorkflow($id: UUID!) {
|
||||
workflow(filter: { id: { eq: $id } }) {
|
||||
id
|
||||
versions {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: createdWorkflowId },
|
||||
);
|
||||
|
||||
expect(getWorkflowResponse.body.errors).toBeUndefined();
|
||||
createdWorkflowVersionId =
|
||||
getWorkflowResponse.body.data.workflow.versions.edges[0].node.id;
|
||||
|
||||
const databaseEventTrigger = {
|
||||
name: 'Company is created',
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: {
|
||||
eventName: 'company.created',
|
||||
outputSchema: {},
|
||||
filter: {
|
||||
stepFilterGroups: [
|
||||
{ id: STEP_FILTER_GROUP_ID, logicalOperator: 'AND' },
|
||||
],
|
||||
stepFilters: [
|
||||
{
|
||||
id: STEP_FILTER_ID,
|
||||
type: 'TEXT',
|
||||
operand: 'CONTAINS',
|
||||
value: FILTER_VALUE,
|
||||
stepOutputKey: '{{trigger.properties.after.name}}',
|
||||
stepFilterGroupId: STEP_FILTER_GROUP_ID,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
nextStepIds: [],
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
const updateTriggerResponse = await graphql(
|
||||
`
|
||||
mutation UpdateWorkflowVersion(
|
||||
$id: UUID!
|
||||
$data: WorkflowVersionUpdateInput!
|
||||
) {
|
||||
updateWorkflowVersion(id: $id, data: $data) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
id: createdWorkflowVersionId,
|
||||
data: { trigger: databaseEventTrigger },
|
||||
},
|
||||
);
|
||||
|
||||
expect(updateTriggerResponse.body.errors).toBeUndefined();
|
||||
|
||||
const createStepResponse = await graphql(
|
||||
`
|
||||
mutation CreateWorkflowVersionStep(
|
||||
$input: CreateWorkflowVersionStepInput!
|
||||
) {
|
||||
createWorkflowVersionStep(input: $input) {
|
||||
stepsDiff
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
input: {
|
||||
workflowVersionId: createdWorkflowVersionId,
|
||||
stepType: 'CODE',
|
||||
parentStepId: 'trigger',
|
||||
position: { x: 200, y: 0 },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(createStepResponse.body.errors).toBeUndefined();
|
||||
|
||||
const activateResponse = await graphql(
|
||||
`
|
||||
mutation ActivateWorkflowVersion($workflowVersionId: UUID!) {
|
||||
activateWorkflowVersion(workflowVersionId: $workflowVersionId)
|
||||
}
|
||||
`,
|
||||
{ workflowVersionId: createdWorkflowVersionId },
|
||||
);
|
||||
|
||||
expect(activateResponse.body.errors).toBeUndefined();
|
||||
expect(activateResponse.body.data.activateWorkflowVersion).toBe(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdWorkflowId) {
|
||||
await graphql(
|
||||
`
|
||||
mutation DestroyWorkflow($id: ID!) {
|
||||
destroyWorkflow(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id: createdWorkflowId },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('syncs the trigger filter onto the workflowAutomatedTrigger row read by the listener', async () => {
|
||||
const response = await graphql(
|
||||
`
|
||||
query WorkflowAutomatedTriggers($workflowId: UUID!) {
|
||||
workflowAutomatedTriggers(
|
||||
filter: { workflowId: { eq: $workflowId } }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
type
|
||||
settings
|
||||
workflowId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ workflowId: createdWorkflowId },
|
||||
);
|
||||
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const automatedTriggers: AutomatedTriggerNode[] =
|
||||
response.body.data.workflowAutomatedTriggers.edges.map(
|
||||
(edge: { node: AutomatedTriggerNode }) => edge.node,
|
||||
);
|
||||
|
||||
expect(automatedTriggers).toHaveLength(1);
|
||||
|
||||
const automatedTrigger = automatedTriggers[0];
|
||||
|
||||
expect(automatedTrigger.type).toBe('DATABASE_EVENT');
|
||||
expect(automatedTrigger.settings.eventName).toBe('company.created');
|
||||
|
||||
const filter = automatedTrigger.settings.filter;
|
||||
|
||||
expect(filter).toBeDefined();
|
||||
expect(filter?.stepFilterGroups).toHaveLength(1);
|
||||
expect(filter?.stepFilters).toHaveLength(1);
|
||||
expect(filter?.stepFilters[0]).toMatchObject({
|
||||
operand: 'CONTAINS',
|
||||
value: FILTER_VALUE,
|
||||
stepOutputKey: '{{trigger.properties.after.name}}',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { baseTriggerSchema } from './base-trigger-schema';
|
||||
import { stepFilterGroupSchema } from './step-filter-group-schema';
|
||||
import { stepFilterSchema } from './step-filter-schema';
|
||||
|
||||
export const workflowDatabaseEventTriggerSchema = baseTriggerSchema
|
||||
.extend({
|
||||
@@ -22,6 +24,15 @@ export const workflowDatabaseEventTriggerSchema = baseTriggerSchema
|
||||
),
|
||||
objectType: z.string().optional(),
|
||||
fields: z.array(z.string()).optional().nullable(),
|
||||
filter: z
|
||||
.object({
|
||||
stepFilterGroups: z.array(stepFilterGroupSchema),
|
||||
stepFilters: z.array(stepFilterSchema),
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional condition evaluated against the triggering record. The workflow only runs when the record matches; non-matching events are skipped before a run is created.',
|
||||
),
|
||||
}),
|
||||
})
|
||||
.describe(
|
||||
|
||||
Reference in New Issue
Block a user