feat(filter): add filter app

This commit is contained in:
Ali BARIN
2023-01-15 16:10:53 +01:00
parent 1f7228f95a
commit 5ee7b85cc4
22 changed files with 570 additions and 10993 deletions

View File

@@ -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,
}
});
},
});

View File

@@ -0,0 +1,3 @@
import continueIfMatches from './continue';
export default [continueIfMatches];

View File

@@ -0,0 +1,8 @@
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Shape" fill="#000000" transform="translate(42.666667, 85.333333)">
<path d="M3.55271368e-14,1.42108547e-14 L191.565013,234.666667 L192,234.666667 L192,384 L234.666667,384 L234.666667,234.666667 L426.666667,1.42108547e-14 L3.55271368e-14,1.42108547e-14 Z M214.448,192 L211.81248,192 L89.9076267,42.6666667 L336.630187,42.6666667 L214.448,192 Z">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 628 B

View File

View File

@@ -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,
});

View File

@@ -0,0 +1,3 @@
import BaseError from './base';
export default class AlreadyProcessedError extends BaseError { }

View File

@@ -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,

View File

@@ -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);

View File

@@ -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 };
}
}
}
}

View File

@@ -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 {

View File

@@ -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 }

View File

@@ -296,6 +296,7 @@ export type IGlobalVariable = {
execution?: {
id: string;
testRun: boolean;
exit: () => void;
};
lastExecutionStep?: IExecutionStep;
webhookUrl?: string;

View File

@@ -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"
},

View File

@@ -69,7 +69,7 @@ function ControlledAutocomplete(
},
fieldState,
}) => (
<div>
<div style={{ width:'100%' }}>
{/* encapsulated with an element such as div to vertical spacing delegated from parent */}
<Autocomplete
{...autocompleteProps}

View File

@@ -0,0 +1,213 @@
import * as React from 'react';
import { v4 as uuidv4 } from 'uuid';
import { useFormContext, useWatch } from 'react-hook-form';
import Stack from '@mui/material/Stack';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import RemoveIcon from '@mui/icons-material/Remove';
import AddIcon from '@mui/icons-material/Add';
import type { IField, IFieldText, IFieldDropdown } from '@automatisch/types';
import useFormatMessage from 'hooks/useFormatMessage';
import InputCreator from 'components/InputCreator';
import { EditorContext } from 'contexts/Editor';
type TGroupItem = {
key: string;
operator: string;
value: string;
id: string;
}
type TGroup = Record<'and', TGroupItem[]>;
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<IFieldText, 'type' | 'required' | 'variables'>): IField => {
return {
...argumentOptions,
type: 'string',
required: true,
variables: true,
};
};
const createDropdownArgument = (argumentOptions: Omit<IFieldDropdown, 'type' | 'required'>): 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 (
<React.Fragment>
<Stack sx={{ width: "100%" }} direction="column" spacing={2} mt={2}>
{groups?.map((group: TGroup, groupIndex: number) => (
<>
{groupIndex !== 0 && <Divider />}
<Typography variant="subtitle2" gutterBottom>
{groupIndex === 0 && formatMessage('filterConditions.onlyContinueIf')}
{groupIndex !== 0 && formatMessage('filterConditions.orContinueIf')}
</Typography>
{group?.and?.map((groupItem: TGroupItem, groupItemIndex: number) => (
<Stack direction="row" spacing={2} key={`item-${groupItem.id}`}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 2}} sx={{ display: 'flex', flex: 1 }}>
<Box sx={{ display: 'flex', flex: '1 0 0px', maxWidth: ['100%', '33%'] }}>
<InputCreator
schema={createStringArgument({ key: `or.${groupIndex}.and.${groupItemIndex}.key`, label: 'Choose field' })}
namePrefix="parameters"
stepId={stepId}
disabled={editorContext.readOnly}
/>
</Box>
<Box sx={{ display: 'flex', flex: '1 0 0px', maxWidth: ['100%', '33%'] }}>
<InputCreator
schema={createDropdownArgument({ key: `or.${groupIndex}.and.${groupItemIndex}.operator`, options: operators, label: 'Choose condition' })}
namePrefix="parameters"
stepId={stepId}
disabled={editorContext.readOnly}
/>
</Box>
<Box sx={{ display: 'flex', flex: '1 0 0px', maxWidth: ['100%', '33%'] }}>
<InputCreator
schema={createStringArgument({ key: `or.${groupIndex}.and.${groupItemIndex}.value`, label: 'Enter text' })}
namePrefix="parameters"
stepId={stepId}
disabled={editorContext.readOnly}
/>
</Box>
</Stack>
<IconButton
size="small"
edge="start"
onClick={() => removeGroupItem(groupIndex, groupItemIndex)}
sx={{ width: 61, height: 61 }}
>
<RemoveIcon />
</IconButton>
</Stack>
))}
<Stack spacing={1} direction="row">
<IconButton
size="small"
edge="start"
sx={{ width: 61, height: 61 }}
onClick={() => appendGroupItem(groupIndex)}
>
<AddIcon /> And
</IconButton>
{(groups.length - 1) === groupIndex && <IconButton
size="small"
edge="start"
onClick={appendGroup}
sx={{ width: 61, height: 61 }}
>
<AddIcon /> Or
</IconButton>}
</Stack>
</>
))}
</Stack>
</React.Fragment>
);
}
export default FilterConditions;

View File

@@ -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'
}}
>
<Stack width="100%" spacing={2}>
{args?.map((argument) => (
<InputCreator
key={argument.key}
schema={argument}
namePrefix="parameters"
stepId={step.id}
disabled={editorContext.readOnly}
showOptionValue={true}
/>
))}
</Stack>
{!!args?.length && (
<Stack width="100%" spacing={2}>
{args.map((argument) => (
<InputCreator
key={argument.key}
schema={argument}
namePrefix="parameters"
stepId={step.id}
disabled={editorContext.readOnly}
showOptionValue={true}
/>
))}
</Stack>
)}
{step.appKey === 'filter' && <FilterConditions stepId={step.id} />}
<Button
fullWidth

View File

@@ -67,7 +67,6 @@ export default function InputCreator(
options={preparedOptions}
renderInput={(params) => <MuiTextField {...params} label={label} />}
defaultValue={value as string}
onChange={console.log}
description={description}
loading={loading}
disabled={disabled}
@@ -98,7 +97,6 @@ export default function InputCreator(
onChange={onChange}
onBlur={onBlur}
name={computedName}
size="small"
label={label}
fullWidth
helperText={description}

View File

@@ -130,7 +130,7 @@ const PowerInput = (props: PowerInputProps) => {
/>
</FakeInput>
{/* ghost placer for the variables popover */}
<div ref={editorRef} style={{ width: '100%' }} />
<div ref={editorRef} style={{ position: 'absolute', right: 16, left: 16 }} />
<FormHelperText variant="outlined">{description}</FormHelperText>

View File

@@ -58,6 +58,8 @@
"flowEditor.triggerEvent": "Trigger event",
"flowEditor.actionEvent": "Action event",
"flowEditor.instantTriggerType": "Instant",
"filterConditions.onlyContinueIf": "Only continue if…",
"filterConditions.orContinueIf": "OR continue if…",
"chooseConnectionSubstep.continue": "Continue",
"chooseConnectionSubstep.addNewConnection": "Add new connection",
"chooseConnectionSubstep.chooseConnection": "Choose connection",

View File

@@ -251,14 +251,6 @@ const extendedTheme = createTheme({
}),
},
},
MuiOutlinedInput: {
styleOverrides: {
inputSizeSmall: ({ theme }) => ({
// 1.5625 = 12.5px based on 1 = 8px
padding: theme.spacing(1.5625, 1.75),
}),
},
},
MuiTab: {
styleOverrides: {
root: ({ theme }) => ({

File diff suppressed because it is too large Load Diff