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' && }