diff --git a/packages/backend/src/apps/filter/actions/continue/index.ts b/packages/backend/src/apps/filter/actions/continue/index.ts
new file mode 100644
index 00000000..39f77afd
--- /dev/null
+++ b/packages/backend/src/apps/filter/actions/continue/index.ts
@@ -0,0 +1,79 @@
+import defineAction from '../../../../helpers/define-action';
+
+type TGroupItem = {
+ key: string;
+ operator: keyof TOperators;
+ value: string;
+ id: string;
+}
+
+type TGroup = Record<'and', TGroupItem[]>;
+
+const isEqual = (a: string, b: string) => a === b;
+const isNotEqual = (a: string, b: string) => !isEqual(a, b)
+const isGreaterThan = (a: string, b: string) => Number(a) > Number(b);
+const isLessThan = (a: string, b: string) => Number(a) < Number(b);
+const isGreaterThanOrEqual = (a: string, b: string) => Number(a) >= Number(b);
+const isLessThanOrEqual = (a: string, b: string) => Number(a) <= Number(b);
+const contains = (a: string, b: string) => a.includes(b);
+const doesNotContain = (a: string, b: string) => !contains(a, b);
+
+type TOperatorFunc = (a: string, b: string) => boolean;
+
+type TOperators = {
+ equal: TOperatorFunc;
+ not_equal: TOperatorFunc;
+ greater_than: TOperatorFunc;
+ less_than: TOperatorFunc;
+ greater_than_or_equal: TOperatorFunc;
+ less_than_or_equal: TOperatorFunc;
+ contains: TOperatorFunc;
+ not_contains: TOperatorFunc;
+};
+
+const operators: TOperators = {
+ 'equal': isEqual,
+ 'not_equal': isNotEqual,
+ 'greater_than': isGreaterThan,
+ 'less_than': isLessThan,
+ 'greater_than_or_equal': isGreaterThanOrEqual,
+ 'less_than_or_equal': isLessThanOrEqual,
+ 'contains': contains,
+ 'not_contains': doesNotContain,
+};
+
+const operate = (operation: keyof TOperators, a: string, b: string) => {
+ return operators[operation](a, b);
+};
+
+export default defineAction({
+ name: 'Continue if conditions match',
+ key: 'continueIfMatches',
+ description: 'Let the execution continue if the conditions match',
+ arguments: [],
+
+ async run($) {
+ const orGroups = $.step.parameters.or as TGroup[];
+
+ const matchingGroups = orGroups.reduce((groups, group) => {
+ const matchingConditions = group.and
+ .filter((condition) => operate(condition.operator, condition.key, condition.value));
+
+ if (matchingConditions.length) {
+ return groups.concat([{ and: matchingConditions }]);
+ }
+
+ return groups;
+ }, []);
+
+ if (matchingGroups.length === 0) {
+ $.execution.exit();
+ }
+
+ $.setActionItem({
+ raw: {
+ or: matchingGroups,
+ }
+ });
+ },
+});
diff --git a/packages/backend/src/apps/filter/actions/index.ts b/packages/backend/src/apps/filter/actions/index.ts
new file mode 100644
index 00000000..0880c5cd
--- /dev/null
+++ b/packages/backend/src/apps/filter/actions/index.ts
@@ -0,0 +1,3 @@
+import continueIfMatches from './continue';
+
+export default [continueIfMatches];
diff --git a/packages/backend/src/apps/filter/assets/favicon.svg b/packages/backend/src/apps/filter/assets/favicon.svg
new file mode 100644
index 00000000..77d3ebe2
--- /dev/null
+++ b/packages/backend/src/apps/filter/assets/favicon.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/packages/backend/src/apps/filter/index.d.ts b/packages/backend/src/apps/filter/index.d.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/packages/backend/src/apps/filter/index.ts b/packages/backend/src/apps/filter/index.ts
new file mode 100644
index 00000000..9b4a81ce
--- /dev/null
+++ b/packages/backend/src/apps/filter/index.ts
@@ -0,0 +1,14 @@
+import defineApp from '../../helpers/define-app';
+import actions from './actions';
+
+export default defineApp({
+ name: 'Filter',
+ key: 'filter',
+ iconUrl: '{BASE_URL}/apps/filter/assets/favicon.svg',
+ authDocUrl: 'https://automatisch.io/docs/apps/filter/connection',
+ supportsConnections: false,
+ baseUrl: '',
+ apiBaseUrl: '',
+ primaryColor: '001F52',
+ actions,
+});
diff --git a/packages/backend/src/errors/already-processed.ts b/packages/backend/src/errors/already-processed.ts
new file mode 100644
index 00000000..2f3ef6a8
--- /dev/null
+++ b/packages/backend/src/errors/already-processed.ts
@@ -0,0 +1,3 @@
+import BaseError from './base';
+
+export default class AlreadyProcessedError extends BaseError { }
diff --git a/packages/backend/src/helpers/compute-parameters.ts b/packages/backend/src/helpers/compute-parameters.ts
index 4e845db8..dbc0762d 100644
--- a/packages/backend/src/helpers/compute-parameters.ts
+++ b/packages/backend/src/helpers/compute-parameters.ts
@@ -38,6 +38,13 @@ export default function computeParameters(
};
}
+ if (Array.isArray(value)) {
+ return {
+ ...result,
+ [key]: value.map(item => computeParameters(item, executionSteps)),
+ };
+ }
+
return {
...result,
[key]: value,
diff --git a/packages/backend/src/helpers/global-variable.ts b/packages/backend/src/helpers/global-variable.ts
index ed4dd968..1cbbcde6 100644
--- a/packages/backend/src/helpers/global-variable.ts
+++ b/packages/backend/src/helpers/global-variable.ts
@@ -13,6 +13,7 @@ import {
IRequest,
} from '@automatisch/types';
import EarlyExitError from '../errors/early-exit';
+import AlreadyProcessedError from '../errors/already-processed';
type GlobalVariableOptions = {
connection?: Connection;
@@ -77,6 +78,9 @@ const globalVariable = async (
execution: {
id: execution?.id,
testRun,
+ exit: () => {
+ throw new EarlyExitError();
+ }
},
lastExecutionStep: (await step?.getLastExecutionStep())?.toJSON(),
triggerOutput: {
@@ -93,7 +97,7 @@ const globalVariable = async (
!$.execution.testRun
) {
// early exit as we do not want to process duplicate items in actual executions
- throw new EarlyExitError();
+ throw new AlreadyProcessedError();
}
$.triggerOutput.data.push(triggerItem);
diff --git a/packages/backend/src/services/action.ts b/packages/backend/src/services/action.ts
index 39fd3987..f1d13373 100644
--- a/packages/backend/src/services/action.ts
+++ b/packages/backend/src/services/action.ts
@@ -5,6 +5,8 @@ import ExecutionStep from '../models/execution-step';
import computeParameters from '../helpers/compute-parameters';
import globalVariable from '../helpers/global-variable';
import HttpError from '../errors/http';
+import EarlyExitError from '../errors/early-exit';
+import AlreadyProcessedError from '../errors/already-processed';
type ProcessActionOptions = {
flowId: string;
@@ -44,13 +46,19 @@ export const processAction = async (options: ProcessActionOptions) => {
try {
await actionCommand.run($);
} catch (error) {
- if (error instanceof HttpError) {
- $.actionOutput.error = error.details;
- } else {
- try {
- $.actionOutput.error = JSON.parse(error.message);
- } catch {
- $.actionOutput.error = { error: error.message };
+ const shouldEarlyExit = error instanceof EarlyExitError;
+ const shouldNotProcess = error instanceof AlreadyProcessedError;
+ const shouldNotConsiderAsError = shouldEarlyExit || shouldNotProcess;
+
+ if (!shouldNotConsiderAsError) {
+ if (error instanceof HttpError) {
+ $.actionOutput.error = error.details;
+ } else {
+ try {
+ $.actionOutput.error = JSON.parse(error.message);
+ } catch {
+ $.actionOutput.error = { error: error.message };
+ }
}
}
}
diff --git a/packages/backend/src/services/flow.ts b/packages/backend/src/services/flow.ts
index 862066f7..9ff6d6fc 100644
--- a/packages/backend/src/services/flow.ts
+++ b/packages/backend/src/services/flow.ts
@@ -1,6 +1,7 @@
import Flow from '../models/flow';
import globalVariable from '../helpers/global-variable';
import EarlyExitError from '../errors/early-exit';
+import AlreadyProcessedError from '../errors/already-processed';
import HttpError from '../errors/http';
type ProcessFlowOptions = {
@@ -29,7 +30,11 @@ export const processFlow = async (options: ProcessFlowOptions) => {
await triggerCommand.run($);
}
} catch (error) {
- if (error instanceof EarlyExitError === false) {
+ const shouldEarlyExit = error instanceof EarlyExitError;
+ const shouldNotProcess = error instanceof AlreadyProcessedError;
+ const shouldNotConsiderAsError = shouldEarlyExit || shouldNotProcess;
+
+ if (!shouldNotConsiderAsError) {
if (error instanceof HttpError) {
$.triggerOutput.error = error.details;
} else {
diff --git a/packages/backend/src/workers/action.ts b/packages/backend/src/workers/action.ts
index 40c2e6c8..22d1169e 100644
--- a/packages/backend/src/workers/action.ts
+++ b/packages/backend/src/workers/action.ts
@@ -21,7 +21,7 @@ const DEFAULT_DELAY_DURATION = 0;
export const worker = new Worker(
'action',
async (job) => {
- const { stepId, flowId, executionId, computedParameters } = await processAction(
+ const { stepId, flowId, executionId, computedParameters, executionStep } = await processAction(
job.data as JobData
);
@@ -48,6 +48,10 @@ export const worker = new Worker(
jobOptions.delay = delayAsMilliseconds(step.key, computedParameters);
}
+ if (step.appKey === 'filter' && !executionStep.dataOut) {
+ return;
+ }
+
await actionQueue.add(jobName, jobPayload, jobOptions);
},
{ connection: redisConfig }
diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts
index 7f56d0e7..e559ca78 100644
--- a/packages/types/index.d.ts
+++ b/packages/types/index.d.ts
@@ -296,6 +296,7 @@ export type IGlobalVariable = {
execution?: {
id: string;
testRun: boolean;
+ exit: () => void;
};
lastExecutionStep?: IExecutionStep;
webhookUrl?: string;
diff --git a/packages/web/package.json b/packages/web/package.json
index ed9fed5e..8d4fa5de 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -9,9 +9,9 @@
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@hookform/resolvers": "^2.8.8",
- "@mui/icons-material": "^5.0.1",
- "@mui/lab": "^5.0.0-alpha.60",
- "@mui/material": "^5.0.2",
+ "@mui/icons-material": "^5.11.9",
+ "@mui/lab": "^5.0.0-alpha.120",
+ "@mui/material": "^5.11.10",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
@@ -21,6 +21,7 @@
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
+ "@types/uuid": "^9.0.0",
"clipboard-copy": "^4.0.1",
"compare-versions": "^4.1.3",
"graphql": "^15.6.0",
@@ -38,6 +39,7 @@
"slate-history": "^0.66.0",
"slate-react": "^0.72.9",
"typescript": "^4.6.3",
+ "uuid": "^9.0.0",
"web-vitals": "^1.0.1",
"yup": "^0.32.11"
},
diff --git a/packages/web/src/components/ControlledAutocomplete/index.tsx b/packages/web/src/components/ControlledAutocomplete/index.tsx
index c209afa7..d0a3b997 100644
--- a/packages/web/src/components/ControlledAutocomplete/index.tsx
+++ b/packages/web/src/components/ControlledAutocomplete/index.tsx
@@ -69,7 +69,7 @@ function ControlledAutocomplete(
},
fieldState,
}) => (
-
+
{/* encapsulated with an element such as div to vertical spacing delegated from parent */}
;
+
+const createGroupItem = (): TGroupItem => ({
+ key: '',
+ operator: operators[0].value,
+ value: '',
+ id: uuidv4(),
+});
+
+const createGroup = (): TGroup => ({
+ and: [createGroupItem()]
+});
+
+const operators = [
+ {
+ label: 'Equal',
+ value: 'equal',
+ },
+ {
+ label: 'Not Equal',
+ value: 'not_equal',
+ },
+ {
+ label: 'Greater Than',
+ value: 'greater_than',
+ },
+ {
+ label: 'Less Than',
+ value: 'less_than',
+ },
+ {
+ label: 'Greater Than Or Equal',
+ value: 'greater_than_or_equal',
+ },
+ {
+ label: 'Less Than Or Equal',
+ value: 'less_than_or_equal',
+ },
+ {
+ label: 'Contains',
+ value: 'contains',
+ },
+ {
+ label: 'Not Contains',
+ value: 'not_contains',
+ },
+];
+
+const createStringArgument = (argumentOptions: Omit): IField => {
+ return {
+ ...argumentOptions,
+ type: 'string',
+ required: true,
+ variables: true,
+ };
+};
+
+const createDropdownArgument = (argumentOptions: Omit): IField => {
+ return {
+ ...argumentOptions,
+ required: true,
+ type: 'dropdown',
+ };
+};
+
+type FilterConditionsProps = {
+ stepId: string;
+};
+
+function FilterConditions(props: FilterConditionsProps): React.ReactElement {
+ const {
+ stepId
+ } = props;
+ const formatMessage = useFormatMessage();
+ const { control, setValue, getValues } = useFormContext();
+ const groups = useWatch({ control, name: 'parameters.or' });
+ const editorContext = React.useContext(EditorContext);
+
+ React.useEffect(function addInitialGroupWhenEmpty() {
+ const groups = getValues('parameters.or');
+
+ if (!groups) {
+ setValue('parameters.or', [createGroup()]);
+ }
+ }, []);
+
+ const appendGroup = React.useCallback(() => {
+ const values = getValues('parameters.or');
+
+ setValue('parameters.or', values.concat(createGroup()))
+ }, []);
+
+ const appendGroupItem = React.useCallback((index) => {
+ const group = getValues(`parameters.or.${index}.and`);
+ setValue(`parameters.or.${index}.and`, group.concat(createGroupItem()));
+ }, []);
+
+ const removeGroupItem = React.useCallback((groupIndex, groupItemIndex) => {
+ const group: TGroupItem[] = getValues(`parameters.or.${groupIndex}.and`);
+
+ if (group.length === 1) {
+ const groups: TGroup[] = getValues('parameters.or');
+
+ setValue('parameters.or', groups.filter((group, index) => index !== groupIndex));
+ } else {
+ setValue(`parameters.or.${groupIndex}.and`, group.filter((groupItem, index) => index !== groupItemIndex));
+ }
+ }, []);
+
+ return (
+
+
+ {groups?.map((group: TGroup, groupIndex: number) => (
+ <>
+ {groupIndex !== 0 && }
+
+
+ {groupIndex === 0 && formatMessage('filterConditions.onlyContinueIf')}
+ {groupIndex !== 0 && formatMessage('filterConditions.orContinueIf')}
+
+
+ {group?.and?.map((groupItem: TGroupItem, groupItemIndex: number) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ removeGroupItem(groupIndex, groupItemIndex)}
+ sx={{ width: 61, height: 61 }}
+ >
+
+
+
+ ))}
+
+
+ appendGroupItem(groupIndex)}
+ >
+ And
+
+
+ {(groups.length - 1) === groupIndex &&
+ Or
+ }
+
+ >
+ ))}
+
+
+ );
+}
+
+export default FilterConditions;
diff --git a/packages/web/src/components/FlowSubstep/index.tsx b/packages/web/src/components/FlowSubstep/index.tsx
index cd9c0c28..00fccdeb 100644
--- a/packages/web/src/components/FlowSubstep/index.tsx
+++ b/packages/web/src/components/FlowSubstep/index.tsx
@@ -4,11 +4,12 @@ import Collapse from '@mui/material/Collapse';
import ListItem from '@mui/material/ListItem';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
+import type { IField, IStep, ISubstep } from '@automatisch/types';
import { EditorContext } from 'contexts/Editor';
import FlowSubstepTitle from 'components/FlowSubstepTitle';
import InputCreator from 'components/InputCreator';
-import type { IField, IStep, ISubstep } from '@automatisch/types';
+import FilterConditions from './FilterConditions';
type FlowSubstepProps = {
substep: ISubstep;
@@ -84,20 +85,25 @@ function FlowSubstep(props: FlowSubstepProps): React.ReactElement {
pb: 3,
flexDirection: 'column',
alignItems: 'flex-start',
+ position: 'relative'
}}
>
-
- {args?.map((argument) => (
-
- ))}
-
+ {!!args?.length && (
+
+ {args.map((argument) => (
+
+ ))}
+
+ )}
+
+ {step.appKey === 'filter' && }