8311 serverless function functions can be executed with any input (#8380)
- remove ts-morph - update inputSchema shape  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:
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
export type FunctionInput =
|
||||
| {
|
||||
[name: string]: FunctionInput;
|
||||
}
|
||||
| any;
|
||||
@ -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;
|
||||
};
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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)
|
||||
: {};
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user