8311 serverless function functions can be executed with any input (#8380)

- remove ts-morph
- update inputSchema shape

![image](https://github.com/user-attachments/assets/e62f3fdb-5be8-4666-8172-44f73a1981b9)


https://github.com/user-attachments/assets/913cd305-9e7c-48da-b20f-c974a8ac7cea

## TODO
- have inputTypes to match the inputSchema type (string, number,
boolean, etc...), only string for now
- handle required/optional inputs
- handle case when inputSchema changes, fix data reset when switching
function
This commit is contained in:
martmull
2024-11-08 17:15:27 +01:00
committed by GitHub
parent 0381996fb9
commit 354ee86cb9
26 changed files with 534 additions and 296 deletions

View File

@ -1,13 +1,39 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { setNestedValue } from '@/workflow/utils/setNestedValue';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput';
import { WorkflowCodeStep } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import { useState } from 'react';
import { IconCode, isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
import { capitalize } from '~/utils/string/capitalize';
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/utils/mergeDefaultFunctionInputAndFunctionInput';
const StyledContainer = styled.div`
display: inline-flex;
flex-direction: column;
`;
const StyledLabel = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-top: ${({ theme }) => theme.spacing(3)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: column;
position: relative;
gap: ${({ theme }) => theme.spacing(4)};
padding-left: ${({ theme }) => theme.spacing(4)};
`;
type WorkflowEditActionFormServerlessFunctionProps =
| {
@ -24,16 +50,30 @@ export const WorkflowEditActionFormServerlessFunction = (
props: WorkflowEditActionFormServerlessFunctionProps,
) => {
const theme = useTheme();
const { serverlessFunctions } = useGetManyServerlessFunctions();
const defaultFunctionInput =
props.action.settings.input.serverlessFunctionInput;
const getFunctionInput = (serverlessFunctionId: string) => {
if (!serverlessFunctionId) {
return {};
}
const [functionInput, setFunctionInput] =
useState<Record<string, any>>(defaultFunctionInput);
const serverlessFunction = serverlessFunctions.find(
(f) => f.id === serverlessFunctionId,
);
const inputSchema = serverlessFunction?.latestVersionInputSchema;
const defaultFunctionInput =
getDefaultFunctionInputFromInputSchema(inputSchema);
const [serverlessFunctionId, setServerlessFunctionId] = useState<string>(
const existingFunctionInput =
props.action.settings.input.serverlessFunctionInput;
return mergeDefaultFunctionInputAndFunctionInput({
defaultFunctionInput,
functionInput: existingFunctionInput,
});
};
const functionInput = getFunctionInput(
props.action.settings.input.serverlessFunctionId,
);
@ -48,14 +88,8 @@ export const WorkflowEditActionFormServerlessFunction = (
settings: {
...props.action.settings,
input: {
serverlessFunctionId:
props.action.settings.input.serverlessFunctionId,
serverlessFunctionVersion:
props.action.settings.input.serverlessFunctionVersion,
serverlessFunctionInput: {
...props.action.settings.input.serverlessFunctionInput,
...newFunctionInput,
},
...props.action.settings.input,
serverlessFunctionInput: newFunctionInput,
},
},
});
@ -63,14 +97,11 @@ export const WorkflowEditActionFormServerlessFunction = (
1_000,
);
const handleInputChange = (key: string, value: any) => {
const newFunctionInput = { ...functionInput, [key]: value };
setFunctionInput(newFunctionInput);
updateFunctionInput(newFunctionInput);
const handleInputChange = (value: any, path: string[]) => {
updateFunctionInput(setNestedValue(functionInput, path, value));
};
const availableFunctions: Array<SelectOption<string>> = [
{ label: 'None', value: '' },
...serverlessFunctions
.filter((serverlessFunction) =>
isDefined(serverlessFunction.latestVersion),
@ -83,36 +114,58 @@ export const WorkflowEditActionFormServerlessFunction = (
];
const handleFunctionChange = (newServerlessFunctionId: string) => {
setServerlessFunctionId(newServerlessFunctionId);
const serverlessFunction = serverlessFunctions.find(
(f) => f.id === newServerlessFunctionId,
);
const serverlessFunctionVersion =
serverlessFunction?.latestVersion || 'latest';
const defaultFunctionInput = serverlessFunction?.latestVersionInputSchema
? serverlessFunction.latestVersionInputSchema
.map((parameter) => parameter.name)
.reduce((acc, name) => ({ ...acc, [name]: null }), {})
: {};
const newProps = {
...props.action,
settings: {
...props.action.settings,
input: {
serverlessFunctionId: newServerlessFunctionId,
serverlessFunctionVersion:
serverlessFunction?.latestVersion || 'latest',
serverlessFunctionInput: getFunctionInput(newServerlessFunctionId),
},
},
};
if (!props.readonly) {
props.onActionUpdate({
...props.action,
settings: {
...props.action.settings,
input: {
serverlessFunctionId: newServerlessFunctionId,
serverlessFunctionVersion,
serverlessFunctionInput: defaultFunctionInput,
},
},
});
props.onActionUpdate(newProps);
}
};
setFunctionInput(defaultFunctionInput);
const renderFields = (
functionInput: FunctionInput,
path: string[] = [],
): ReactNode | undefined => {
return Object.entries(functionInput).map(([inputKey, inputValue]) => {
const currentPath = [...path, inputKey];
const pathKey = currentPath.join('.');
if (inputValue !== null && typeof inputValue === 'object') {
return (
<StyledContainer key={pathKey}>
<StyledLabel>{inputKey}</StyledLabel>
<StyledInputContainer>
{renderFields(inputValue, currentPath)}
</StyledInputContainer>
</StyledContainer>
);
} else {
return (
<VariableTagInput
key={pathKey}
inputId={`input-${inputKey}`}
label={inputKey}
placeholder="Enter value (use {{variable}} for dynamic content)"
value={`${inputValue || ''}`}
onChange={(value) => handleInputChange(value, currentPath)}
/>
);
}
});
};
return (
@ -125,21 +178,13 @@ export const WorkflowEditActionFormServerlessFunction = (
dropdownId="select-serverless-function-id"
label="Function"
fullWidth
value={serverlessFunctionId}
value={props.action.settings.input.serverlessFunctionId}
options={availableFunctions}
emptyOption={{ label: 'None', value: '' }}
disabled={props.readonly}
onChange={handleFunctionChange}
/>
{functionInput &&
Object.entries(functionInput).map(([inputKey, inputValue]) => (
<VariableTagInput
inputId={`input-${inputKey}`}
label={capitalize(inputKey)}
placeholder="Enter value (use {{variable}} for dynamic content)"
value={inputValue ?? ''}
onChange={(value) => handleInputChange(inputKey, value)}
/>
))}
{functionInput && renderFields(functionInput)}
</WorkflowEditGenericFormBase>
);
};

View File

@ -0,0 +1,5 @@
export type FunctionInput =
| {
[name: string]: FunctionInput;
}
| any;

View File

@ -0,0 +1,18 @@
type InputSchemaPropertyType =
| 'string'
| 'number'
| 'boolean'
| 'object'
| 'array'
| 'unknown';
type InputSchemaProperty = {
type: InputSchemaPropertyType;
enum?: string[];
items?: InputSchemaProperty;
properties?: InputSchema;
};
export type InputSchema = {
[name: string]: InputSchemaProperty;
};

View File

@ -0,0 +1,29 @@
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
import { InputSchema } from '@/workflow/types/InputSchema';
describe('getDefaultFunctionInputFromInputSchema', () => {
it('should init function input properly', () => {
const inputSchema = {
params: {
type: 'object',
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
},
} as InputSchema;
const expectedResult = {
params: {
a: null,
b: null,
},
};
expect(getDefaultFunctionInputFromInputSchema(inputSchema)).toEqual(
expectedResult,
);
});
});

View File

@ -0,0 +1,21 @@
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/utils/mergeDefaultFunctionInputAndFunctionInput';
describe('mergeDefaultFunctionInputAndFunctionInput', () => {
it('should merge properly', () => {
const defaultFunctionInput = {
params: { a: null, b: null, c: { cc: null } },
};
const functionInput = {
params: { a: 'a', c: 'c' },
};
const expectedResult = {
params: { a: 'a', b: null, c: { cc: null } },
};
expect(
mergeDefaultFunctionInputAndFunctionInput({
defaultFunctionInput,
functionInput,
}),
).toEqual(expectedResult);
});
});

View File

@ -0,0 +1,11 @@
import { setNestedValue } from '@/workflow/utils/setNestedValue';
describe('setNestedValue', () => {
it('should set nested value properly', () => {
const obj = { a: { b: 'b' } };
const path = ['a', 'b'];
const newValue = 'bb';
const expectedResult = { a: { b: newValue } };
expect(setNestedValue(obj, path, newValue)).toEqual(expectedResult);
});
});

View File

@ -0,0 +1,22 @@
import { InputSchema } from '@/workflow/types/InputSchema';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { isDefined } from '~/utils/isDefined';
export const getDefaultFunctionInputFromInputSchema = (
inputSchema: InputSchema | undefined,
): FunctionInput => {
return isDefined(inputSchema)
? Object.entries(inputSchema).reduce((acc, [key, value]) => {
if (['string', 'number', 'boolean'].includes(value.type)) {
acc[key] = null;
} else if (value.type === 'object') {
acc[key] = isDefined(value.properties)
? getDefaultFunctionInputFromInputSchema(value.properties)
: {};
} else if (value.type === 'array' && isDefined(value.items)) {
acc[key] = [];
}
return acc;
}, {} as FunctionInput)
: {};
};

View File

@ -0,0 +1,32 @@
import { FunctionInput } from '@/workflow/types/FunctionInput';
export const mergeDefaultFunctionInputAndFunctionInput = ({
defaultFunctionInput,
functionInput,
}: {
defaultFunctionInput: FunctionInput;
functionInput: FunctionInput;
}): FunctionInput => {
const result: FunctionInput = {};
for (const key of Object.keys(defaultFunctionInput)) {
if (!(key in functionInput)) {
result[key] = defaultFunctionInput[key];
} else {
if (
defaultFunctionInput[key] !== null &&
typeof defaultFunctionInput[key] === 'object'
) {
result[key] = mergeDefaultFunctionInputAndFunctionInput({
defaultFunctionInput: defaultFunctionInput[key],
functionInput:
typeof functionInput[key] === 'object' ? functionInput[key] : {},
});
} else {
result[key] = functionInput[key];
}
}
}
return result;
};

View File

@ -0,0 +1,10 @@
export const setNestedValue = (obj: any, path: string[], value: any) => {
const newObj = { ...obj };
path.reduce((o, key, index) => {
if (index === path.length - 1) {
o[key] = value;
}
return o[key] || {};
}, newObj);
return newObj;
};