8723 workflow add editor in serverless function code step (#8805)

- create a serverless function when creating a new workflow code step
- add code editor in workflow code step
- move workflowVersion steps management from frontend to backend
  - add a custom resolver for workflow-version management
  - fix optimistic rendering on frontend
- fix css
- delete serverless function when deleting workflow code step

TODO
- Don't update serverlessFunction if no code change
- Factorize what can be between crud trigger and crud step
- Publish serverless version when activating workflow
- delete serverless functions when deleting workflow or workflowVersion
- fix optimistic rendering for code updates
- Unify CRUD types

<img width="1279" alt="image"
src="https://github.com/user-attachments/assets/3d97ee9f-4b96-4abc-9d36-5c0280058be4">
This commit is contained in:
martmull
2024-12-03 09:41:13 +01:00
committed by GitHub
parent 9d7632cb4f
commit d0ff1ffd5f
75 changed files with 2192 additions and 1527 deletions

View File

@ -81,6 +81,7 @@ export const WorkflowEditActionFormRecordCreate = ({
const selectedObjectMetadataItem = activeObjectMetadataItems.find(
(item) => item.nameSingular === selectedObjectMetadataItemNameSingular,
);
if (!isDefined(selectedObjectMetadataItem)) {
throw new Error('Should have found the metadata item');
}
@ -135,13 +136,9 @@ export const WorkflowEditActionFormRecordCreate = ({
name: newName,
});
}}
HeaderIcon={
<IconAddressBook
color={theme.font.color.tertiary}
stroke={theme.icon.stroke.sm}
/>
}
headerTitle={headerTitle}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
>
<Select

View File

@ -135,13 +135,9 @@ export const WorkflowEditActionFormRecordUpdate = ({
name: newName,
});
}}
HeaderIcon={
<IconAddressBook
color={theme.font.color.tertiary}
stroke={theme.icon.stroke.sm}
/>
}
headerTitle={headerTitle}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
>
<Select

View File

@ -182,8 +182,9 @@ export const WorkflowEditActionFormSendEmail = ({
name: newName,
});
}}
HeaderIcon={<IconMail color={theme.color.blue} />}
headerTitle={headerTitle}
Icon={IconMail}
iconColor={theme.color.blue}
initialTitle={headerTitle}
headerType="Email"
>
<Controller

View File

@ -1,6 +1,59 @@
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { WorkflowEditActionFormServerlessFunctionInner } from '@/workflow/components/WorkflowEditActionFormServerlessFunctionInner';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { WorkflowCodeAction } from '@/workflow/types/Workflow';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/utils/mergeDefaultFunctionInputAndFunctionInput';
import { setNestedValue } from '@/workflow/utils/setNestedValue';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Fragment, ReactNode, useState } from 'react';
import {
CodeEditor,
HorizontalSeparator,
IconCode,
isDefined,
} from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { usePreventOverlapCallback } from '~/hooks/usePreventOverlapCallback';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { editor } from 'monaco-editor';
import { Monaco } from '@monaco-editor/react';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { getFunctionInputSchema } from '@/workflow/utils/getFunctionInputSchema';
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { useRecoilValue } from 'recoil';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
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(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledInputContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
position: relative;
`;
type WorkflowEditActionFormServerlessFunctionProps = {
action: WorkflowCodeAction;
@ -14,21 +67,227 @@ type WorkflowEditActionFormServerlessFunctionProps = {
};
};
type ServerlessFunctionInputFormData = {
[field: string]: string | ServerlessFunctionInputFormData;
};
const INDEX_FILE_PATH = 'src/index.ts';
export const WorkflowEditActionFormServerlessFunction = ({
action,
actionOptions,
}: WorkflowEditActionFormServerlessFunctionProps) => {
const { loading: isLoadingServerlessFunctions } =
useGetManyServerlessFunctions();
const theme = useTheme();
const { enqueueSnackBar } = useSnackBar();
const { updateOneServerlessFunction } = useUpdateOneServerlessFunction();
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
const { availablePackages } = useGetAvailablePackages({
id: serverlessFunctionId,
});
if (isLoadingServerlessFunctions) {
return null;
}
const { formValues, setFormValues, loading } =
useServerlessFunctionUpdateFormState(serverlessFunctionId);
const save = async () => {
try {
await updateOneServerlessFunction({
id: serverlessFunctionId,
name: formValues.name,
description: formValues.description,
code: formValues.code,
});
} catch (err) {
enqueueSnackBar(
(err as Error)?.message || 'An error occurred while updating function',
{
variant: SnackBarVariant.Error,
},
);
}
};
const handleSave = usePreventOverlapCallback(save, 1000);
const onCodeChange = async (value: string) => {
setFormValues((prevState) => ({
...prevState,
code: { ...prevState.code, [INDEX_FILE_PATH]: value },
}));
await handleSave();
await handleUpdateFunctionInputSchema();
};
const updateFunctionInputSchema = async () => {
const sourceCode = formValues.code?.[INDEX_FILE_PATH];
if (!isDefined(sourceCode)) {
return;
}
const functionInputSchema = getFunctionInputSchema(sourceCode);
const newMergedInputSchema = mergeDefaultFunctionInputAndFunctionInput({
defaultFunctionInput:
getDefaultFunctionInputFromInputSchema(functionInputSchema),
functionInput: action.settings.input.serverlessFunctionInput,
});
setFunctionInput(newMergedInputSchema);
await updateFunctionInput(newMergedInputSchema);
};
const handleUpdateFunctionInputSchema = useDebouncedCallback(
updateFunctionInputSchema,
100,
);
const [functionInput, setFunctionInput] =
useState<ServerlessFunctionInputFormData>(
action.settings.input.serverlessFunctionInput,
);
const updateFunctionInput = useDebouncedCallback(
async (newFunctionInput: object) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
...action.settings.input,
serverlessFunctionInput: newFunctionInput,
},
},
});
},
1_000,
);
const handleInputChange = async (value: any, path: string[]) => {
const updatedFunctionInput = setNestedValue(functionInput, path, value);
setFunctionInput(updatedFunctionInput);
await updateFunctionInput(updatedFunctionInput);
};
const renderFields = (
functionInput: FunctionInput,
path: string[] = [],
isRoot = true,
): ReactNode[] => {
const displaySeparator = (functionInput: FunctionInput) => {
const keys = Object.keys(functionInput);
if (keys.length > 1) {
return true;
}
if (keys.length === 1) {
const subKeys = Object.keys(functionInput[keys[0]]);
return subKeys.length > 0;
}
return false;
};
return Object.entries(functionInput).map(([inputKey, inputValue]) => {
const currentPath = [...path, inputKey];
const pathKey = currentPath.join('.');
if (inputValue !== null && typeof inputValue === 'object') {
if (isRoot) {
return (
<Fragment key={pathKey}>
{displaySeparator(functionInput) && (
<HorizontalSeparator noMargin />
)}
{renderFields(inputValue, currentPath, false)}
</Fragment>
);
}
return (
<StyledContainer key={pathKey}>
<StyledLabel>{inputKey}</StyledLabel>
<StyledInputContainer>
{renderFields(inputValue, currentPath, false)}
</StyledInputContainer>
</StyledContainer>
);
} else {
return (
<FormTextFieldInput
key={pathKey}
label={inputKey}
placeholder="Enter value"
defaultValue={inputValue ? `${inputValue}` : ''}
readonly={actionOptions.readonly}
onPersist={(value) => handleInputChange(value, currentPath)}
VariablePicker={WorkflowVariablePicker}
/>
);
}
});
};
const headerTitle = isDefined(action.name)
? action.name
: 'Code - Serverless Function';
const handleEditorDidMount = async (
editor: editor.IStandaloneCodeEditor,
monaco: Monaco,
) => {
await AutoTypings.create(editor, {
monaco,
preloadPackages: true,
onlySpecifiedPackages: true,
versions: availablePackages,
debounceDuration: 0,
});
};
const onActionUpdate = (actionUpdate: Partial<WorkflowCodeAction>) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions?.onActionUpdate({
...action,
...actionUpdate,
});
};
const checkWorkflowUpdatable = async () => {
if (actionOptions.readonly === true || !isDefined(workflow)) {
return;
}
await getUpdatableWorkflowVersion(workflow);
};
return (
<WorkflowEditActionFormServerlessFunctionInner
action={action}
actionOptions={actionOptions}
/>
!loading && (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
onActionUpdate({ name: newName });
}}
Icon={IconCode}
iconColor={theme.color.orange}
initialTitle={headerTitle}
headerType="Code"
>
<CodeEditor
height={340}
value={formValues.code?.[INDEX_FILE_PATH]}
language={'typescript'}
onChange={async (value) => {
await checkWorkflowUpdatable();
await onCodeChange(value);
}}
onMount={handleEditorDidMount}
/>
{renderFields(functionInput)}
</WorkflowEditGenericFormBase>
)
);
};