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:
+4
-7
@@ -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
|
||||
|
||||
+3
-7
@@ -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
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+270
-11
@@ -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>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user