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

@ -57,11 +57,12 @@ export const useWorkflowRunRecordActions = ({
index,
activeWorkflowVersion,
] of activeWorkflowVersions.entries()) {
const name = capitalize(activeWorkflowVersion.workflow.name);
addActionMenuEntry({
type: ActionMenuEntryType.WorkflowRun,
key: `workflow-run-${activeWorkflowVersion.id}`,
scope: ActionMenuEntryScope.RecordSelection,
label: capitalize(activeWorkflowVersion.workflow.name),
label: name,
position: index,
Icon: IconSettingsAutomation,
onClick: async () => {
@ -76,7 +77,7 @@ export const useWorkflowRunRecordActions = ({
enqueueSnackBar('', {
variant: SnackBarVariant.Success,
title: `${capitalize(activeWorkflowVersion.workflow.name)} starting...`,
title: `${name} starting...`,
icon: (
<IconSettingsAutomation
size={16}

View File

@ -37,11 +37,12 @@ export const useWorkflowRunActions = () => {
index,
activeWorkflowVersion,
] of activeWorkflowVersions.entries()) {
const name = capitalize(activeWorkflowVersion.workflow.name);
addActionMenuEntry({
type: ActionMenuEntryType.WorkflowRun,
key: `workflow-run-${activeWorkflowVersion.id}`,
scope: ActionMenuEntryScope.Global,
label: capitalize(activeWorkflowVersion.workflow.name),
label: name,
position: index,
Icon: IconSettingsAutomation,
onClick: async () => {
@ -51,7 +52,7 @@ export const useWorkflowRunActions = () => {
enqueueSnackBar('', {
variant: SnackBarVariant.Success,
title: `${capitalize(activeWorkflowVersion.workflow.name)} starting...`,
title: `${name} starting...`,
icon: (
<IconSettingsAutomation
size={16}

View File

@ -1,4 +1,4 @@
import { useLazyQuery } from '@apollo/client';
import { useLazyQuery, WatchQueryFetchPolicy } from '@apollo/client';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
@ -10,6 +10,8 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord';
type UseLazyFindOneRecordParams = ObjectMetadataItemIdentifier & {
recordGqlFields?: RecordGqlOperationGqlRecordFields;
withSoftDeleted?: boolean;
fetchPolicy?: WatchQueryFetchPolicy;
};
type FindOneRecordParams<T extends ObjectRecord> = {
@ -20,6 +22,8 @@ type FindOneRecordParams<T extends ObjectRecord> = {
export const useLazyFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
recordGqlFields,
withSoftDeleted = false,
fetchPolicy = 'cache-first',
}: UseLazyFindOneRecordParams) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
@ -30,22 +34,28 @@ export const useLazyFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
recordGqlFields:
recordGqlFields ??
generateDepthOneRecordGqlFields({ objectMetadataItem }),
withSoftDeleted,
});
const [findOneRecord, { loading, error, data, called }] =
useLazyQuery(findOneRecordQuery);
return {
findOneRecord: ({ objectRecordId, onCompleted }: FindOneRecordParams<T>) =>
findOneRecord({
findOneRecord: async ({
objectRecordId,
onCompleted,
}: FindOneRecordParams<T>) => {
await findOneRecord({
variables: { objectRecordId },
fetchPolicy,
onCompleted: (data) => {
const record = getRecordFromRecordNode<T>({
recordNode: data[objectNameSingular],
});
onCompleted?.(record);
},
}),
});
},
called,
error,
loading,

View File

@ -1,4 +1,3 @@
import { SettingsServerlessFunctionCodeEditorContainer } from '@/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditorContainer';
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { EditorProps, Monaco } from '@monaco-editor/react';
import dotenv from 'dotenv';
@ -6,6 +5,7 @@ import { editor, MarkerSeverity } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { CodeEditor } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
import { useParams } from 'react-router-dom';
export type File = {
language: string;
@ -31,7 +31,10 @@ export const SettingsServerlessFunctionCodeEditor = ({
height = 450,
options = undefined,
}: SettingsServerlessFunctionCodeEditorProps) => {
const { availablePackages } = useGetAvailablePackages();
const { serverlessFunctionId = '' } = useParams();
const { availablePackages } = useGetAvailablePackages({
id: serverlessFunctionId,
});
const currentFile = files.find((file) => file.path === currentFilePath);
const environmentVariablesFile = files.find((file) => file.path === '.env');
@ -116,17 +119,16 @@ export const SettingsServerlessFunctionCodeEditor = ({
return (
isDefined(currentFile) &&
isDefined(availablePackages) && (
<SettingsServerlessFunctionCodeEditorContainer>
<CodeEditor
height={height}
value={currentFile.content}
language={currentFile.language}
onMount={handleEditorDidMount}
onChange={onChange}
onValidate={handleEditorValidation}
options={options}
/>
</SettingsServerlessFunctionCodeEditorContainer>
<CodeEditor
height={height}
value={currentFile.content}
language={currentFile.language}
onMount={handleEditorDidMount}
onChange={onChange}
onValidate={handleEditorValidation}
options={options}
withHeader
/>
)
);
};

View File

@ -1,11 +0,0 @@
import styled from '@emotion/styled';
const StyledEditorContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-top: none;
border-radius: 0 0 ${({ theme }) => theme.border.radius.sm}
${({ theme }) => theme.border.radius.sm};
`;
export const SettingsServerlessFunctionCodeEditorContainer =
StyledEditorContainer;

View File

@ -8,7 +8,6 @@ import {
} from 'twenty-ui';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { SettingsServerlessFunctionCodeEditorContainer } from '@/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditorContainer';
import { SettingsServerlessFunctionsOutputMetadataInfo } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsOutputMetadataInfo';
import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
@ -82,30 +81,26 @@ export const SettingsServerlessFunctionTestTab = ({
/>,
]}
/>
<SettingsServerlessFunctionCodeEditorContainer>
<CodeEditor
value={settingsServerlessFunctionInput}
language="json"
height={200}
onChange={setSettingsServerlessFunctionInput}
/>
</SettingsServerlessFunctionCodeEditorContainer>
<CodeEditor
value={settingsServerlessFunctionInput}
language="json"
height={200}
onChange={setSettingsServerlessFunctionInput}
withHeader
/>
</div>
<div>
<CoreEditorHeader
leftNodes={[<SettingsServerlessFunctionsOutputMetadataInfo />]}
rightNodes={[<LightCopyIconButton copyText={result} />]}
/>
<SettingsServerlessFunctionCodeEditorContainer>
<CodeEditor
value={result}
language={
settingsServerlessFunctionCodeEditorOutputParams.language
}
height={settingsServerlessFunctionCodeEditorOutputParams.height}
options={{ readOnly: true, domReadOnly: true }}
/>
</SettingsServerlessFunctionCodeEditorContainer>
<CodeEditor
value={result}
language={settingsServerlessFunctionCodeEditorOutputParams.language}
height={settingsServerlessFunctionCodeEditorOutputParams.height}
options={{ readOnly: true, domReadOnly: true }}
withHeader
/>
</div>
</StyledInputsContainer>
</Section>

View File

@ -1,7 +1,7 @@
import { gql } from '@apollo/client';
export const FIND_MANY_AVAILABLE_PACKAGES = gql`
query FindManyAvailablePackages {
getAvailablePackages
query FindManyAvailablePackages($input: ServerlessFunctionIdInput!) {
getAvailablePackages(input: $input)
}
`;

View File

@ -4,15 +4,19 @@ import { FIND_MANY_AVAILABLE_PACKAGES } from '@/settings/serverless-functions/gr
import {
FindManyAvailablePackagesQuery,
FindManyAvailablePackagesQueryVariables,
ServerlessFunctionIdInput,
} from '~/generated-metadata/graphql';
export const useGetAvailablePackages = () => {
export const useGetAvailablePackages = (input: ServerlessFunctionIdInput) => {
const apolloMetadataClient = useApolloMetadataClient();
const { data } = useQuery<
FindManyAvailablePackagesQuery,
FindManyAvailablePackagesQueryVariables
>(FIND_MANY_AVAILABLE_PACKAGES, {
client: apolloMetadataClient ?? undefined,
variables: {
input,
},
});
return {
availablePackages: data?.getAvailablePackages || null,

View File

@ -10,7 +10,6 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation';
import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { GraphQLView } from '@/views/types/GraphQLView';
import { ViewField } from '@/views/types/ViewField';
@ -94,7 +93,7 @@ export const usePersistViewFieldRecords = () => {
update: (cache, { data }) => {
const record = data?.['updateViewField'];
if (!record) return;
const cachedRecord = getRecordFromCache<ObjectRecord>(record.id);
const cachedRecord = getRecordFromCache<ViewField>(record.id);
if (!cachedRecord) return;

View File

@ -11,7 +11,6 @@ import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordF
import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation';
import { useDestroyOneRecordMutation } from '@/object-record/hooks/useDestroyOneRecordMutation';
import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { GraphQLView } from '@/views/types/GraphQLView';
import { ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { isDefined } from 'twenty-ui';
@ -142,7 +141,9 @@ export const usePersistViewFilterGroupRecords = () => {
update: (cache, { data }) => {
const record = data?.updateViewFilterGroup;
if (!record) return;
const cachedRecord = getRecordFromCache<ObjectRecord>(record.id);
const cachedRecord = getRecordFromCache<ViewFilterGroup>(
record.id,
);
if (!cachedRecord) return;

View File

@ -11,7 +11,6 @@ import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordF
import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation';
import { useDestroyOneRecordMutation } from '@/object-record/hooks/useDestroyOneRecordMutation';
import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { GraphQLView } from '@/views/types/GraphQLView';
import { ViewFilter } from '@/views/types/ViewFilter';
@ -100,7 +99,7 @@ export const usePersistViewFilterRecords = () => {
update: (cache, { data }) => {
const record = data?.['updateViewFilter'];
if (!record) return;
const cachedRecord = getRecordFromCache<ObjectRecord>(record.id);
const cachedRecord = getRecordFromCache<ViewFilter>(record.id);
if (!cachedRecord) return;

View File

@ -11,7 +11,6 @@ import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordF
import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation';
import { useDestroyOneRecordMutation } from '@/object-record/hooks/useDestroyOneRecordMutation';
import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { GraphQLView } from '@/views/types/GraphQLView';
import { ViewSort } from '@/views/types/ViewSort';
@ -93,7 +92,7 @@ export const usePersistViewSortRecords = () => {
update: (cache, { data }) => {
const record = data?.['updateViewSort'];
if (!record) return;
const cachedRecord = getRecordFromCache<ObjectRecord>(record.id);
const cachedRecord = getRecordFromCache<ViewSort>(record.id);
if (!cachedRecord) return;

View File

@ -1,5 +1,5 @@
import { WorkflowStepDetail } from '@/workflow/components/WorkflowStepDetail';
import { useUpdateWorkflowVersionStep } from '@/workflow/hooks/useUpdateWorkflowVersionStep';
import { useUpdateStep } from '@/workflow/hooks/useUpdateStep';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/hooks/useUpdateWorkflowVersionTrigger';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
@ -19,9 +19,8 @@ export const RightDrawerWorkflowEditStepContent = ({
}
const { updateTrigger } = useUpdateWorkflowVersionTrigger({ workflow });
const { updateStep } = useUpdateWorkflowVersionStep({
const { updateStep } = useUpdateStep({
workflow,
stepId: workflowSelectedNode,
});
return (

View File

@ -1,5 +1,5 @@
import { WorkflowDiagramStepNodeBase } from '@/workflow/components/WorkflowDiagramStepNodeBase';
import { useDeleteOneStep } from '@/workflow/hooks/useDeleteOneStep';
import { useDeleteStep } from '@/workflow/hooks/useDeleteStep';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
@ -21,9 +21,8 @@ export const WorkflowDiagramStepNodeEditable = ({
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
assertWorkflowWithCurrentVersionIsDefined(workflowWithCurrentVersion);
const { deleteOneStep } = useDeleteOneStep({
const { deleteStep } = useDeleteStep({
workflow: workflowWithCurrentVersion,
stepId: id,
});
return (
@ -35,7 +34,7 @@ export const WorkflowDiagramStepNodeEditable = ({
size="medium"
Icon={IconTrash}
onClick={() => {
return deleteOneStep();
deleteStep(id);
}}
/>
) : undefined

View File

@ -1,256 +0,0 @@
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { WorkflowCodeAction } from '@/workflow/types/Workflow';
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
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 { HorizontalSeparator, IconCode, isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
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 WorkflowEditActionFormServerlessFunctionInnerProps = {
action: WorkflowCodeAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowCodeAction) => void;
};
};
type ServerlessFunctionInputFormData = {
[field: string]: string | ServerlessFunctionInputFormData;
};
export const WorkflowEditActionFormServerlessFunctionInner = ({
action,
actionOptions,
}: WorkflowEditActionFormServerlessFunctionInnerProps) => {
const theme = useTheme();
const { serverlessFunctions } = useGetManyServerlessFunctions();
const getFunctionInput = (serverlessFunctionId: string) => {
if (!serverlessFunctionId) {
return {};
}
const serverlessFunction = serverlessFunctions.find(
(f) => f.id === serverlessFunctionId,
);
const inputSchema = serverlessFunction?.latestVersionInputSchema;
const defaultFunctionInput =
getDefaultFunctionInputFromInputSchema(inputSchema);
return defaultFunctionInput;
};
const [selectedFunctionId, setSelectedFunctionId] = useState(
action.settings.input.serverlessFunctionId,
);
const [functionInput, setFunctionInput] =
useState<ServerlessFunctionInputFormData>(
mergeDefaultFunctionInputAndFunctionInput({
defaultFunctionInput: getFunctionInput(selectedFunctionId),
functionInput: 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 = (value: any, path: string[]) => {
const updatedFunctionInput = setNestedValue(functionInput, path, value);
setFunctionInput(updatedFunctionInput);
updateFunctionInput(updatedFunctionInput);
};
const availableFunctions: Array<SelectOption<string>> = [
...serverlessFunctions
.filter((serverlessFunction) =>
isDefined(serverlessFunction.latestVersion),
)
.map((serverlessFunction) => ({
label: serverlessFunction.name,
value: serverlessFunction.id,
latestVersionInputSchema: serverlessFunction.latestVersionInputSchema,
})),
];
const handleFunctionChange = (newServerlessFunctionId: string) => {
if (actionOptions.readonly === true) {
return;
}
updateFunctionInput.cancel();
setSelectedFunctionId(newServerlessFunctionId);
const serverlessFunction = serverlessFunctions.find(
(f) => f.id === newServerlessFunctionId,
);
const newFunctionInput = getFunctionInput(newServerlessFunctionId);
const newProps = {
...action,
settings: {
...action.settings,
input: {
serverlessFunctionId: newServerlessFunctionId,
serverlessFunctionVersion:
serverlessFunction?.latestVersion || 'latest',
serverlessFunctionInput: newFunctionInput,
},
},
};
setFunctionInput(newFunctionInput);
actionOptions.onActionUpdate(newProps);
};
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 ? String(inputValue) : ''}
readonly={actionOptions.readonly}
onPersist={(value) => {
handleInputChange(value, currentPath);
}}
VariablePicker={WorkflowVariablePicker}
/>
);
}
});
};
const headerTitle = isDefined(action.name)
? action.name
: 'Code - Serverless Function';
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions?.onActionUpdate({
...action,
name: newName,
});
}}
HeaderIcon={<IconCode color={theme.color.orange} />}
headerTitle={headerTitle}
headerType="Code"
>
<Select
dropdownId="select-serverless-function-id"
label="Function"
fullWidth
value={selectedFunctionId}
options={availableFunctions}
emptyOption={{ label: 'None', value: '' }}
disabled={actionOptions.readonly}
onChange={handleFunctionChange}
/>
{renderFields(functionInput)}
</WorkflowEditGenericFormBase>
);
};

View File

@ -1,27 +1,36 @@
import { TextInput } from '@/ui/field/input/components/TextInput';
import styled from '@emotion/styled';
import React from 'react';
import React, { useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { IconComponent } from 'packages/twenty-ui';
import { useTheme } from '@emotion/react';
const StyledHeader = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(6)};
flex-direction: row;
padding: ${({ theme }) => theme.spacing(4)};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderTitle = styled.p`
const StyledHeaderInfo = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xl};
margin: ${({ theme }) => theme.spacing(3)} 0;
width: 420px;
overflow: hidden;
`;
const StyledHeaderType = styled.p`
const StyledHeaderType = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
margin: 0;
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderIconContainer = styled.div`
@ -30,8 +39,8 @@ const StyledHeaderIconContainer = styled.div`
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.xs};
padding: ${({ theme }) => theme.spacing(1)};
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledContentContainer = styled.div`
@ -43,39 +52,54 @@ const StyledContentContainer = styled.div`
export const WorkflowEditGenericFormBase = ({
onTitleChange,
HeaderIcon,
headerTitle,
Icon,
iconColor,
initialTitle,
headerType,
children,
}: {
onTitleChange: (newTitle: string) => void;
HeaderIcon: React.ReactNode;
headerTitle: string;
Icon: IconComponent;
iconColor: string;
initialTitle: string;
headerType: string;
children: React.ReactNode;
}) => {
const theme = useTheme();
const [title, setTitle] = useState(initialTitle);
const debouncedOnTitleChange = useDebouncedCallback(onTitleChange, 100);
const handleChange = (newTitle: string) => {
setTitle(newTitle);
debouncedOnTitleChange(newTitle);
};
return (
<>
<StyledHeader>
<StyledHeaderIconContainer>{HeaderIcon}</StyledHeaderIconContainer>
<StyledHeaderTitle>
<TextInput
value={headerTitle}
copyButton={false}
hotkeyScope="workflow-step-title"
onEnter={onTitleChange}
onEscape={onTitleChange}
onChange={debouncedOnTitleChange}
shouldTrim={false}
/>
</StyledHeaderTitle>
<StyledHeaderType>{headerType}</StyledHeaderType>
<StyledHeaderIconContainer>
{
<Icon
color={iconColor}
stroke={theme.icon.stroke.sm}
size={theme.icon.size.lg}
/>
}
</StyledHeaderIconContainer>
<StyledHeaderInfo>
<StyledHeaderTitle>
<TextInput
value={title}
copyButton={false}
hotkeyScope="workflow-step-title"
onEnter={onTitleChange}
onEscape={onTitleChange}
onChange={handleChange}
shouldTrim={false}
/>
</StyledHeaderTitle>
<StyledHeaderType>{headerType}</StyledHeaderType>
</StyledHeaderInfo>
</StyledHeader>
<StyledContentContainer>{children}</StyledContentContainer>
</>
);

View File

@ -71,8 +71,9 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
name: newName,
});
}}
HeaderIcon={<IconPlaylistAdd color={theme.font.color.tertiary} />}
headerTitle={headerTitle}
Icon={IconPlaylistAdd}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType={headerType}
>
<Select

View File

@ -58,8 +58,9 @@ export const WorkflowEditTriggerManualForm = ({
name: newName,
});
}}
HeaderIcon={<IconHandMove color={theme.font.color.tertiary} />}
headerTitle={headerTitle}
Icon={IconHandMove}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Trigger · Manual"
>
<Select

View File

@ -3,10 +3,7 @@ import styled from '@emotion/styled';
import { CodeEditor, isDefined } from 'twenty-ui';
const StyledSourceCodeContainer = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
margin: ${({ theme }) => theme.spacing(4)};
overflow: hidden;
`;
export const WorkflowRunOutputVisualizer = ({

View File

@ -1,3 +1,4 @@
import { lazy, Suspense } from 'react';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/components/WorkflowEditTriggerManualForm';
import {
@ -12,8 +13,16 @@ import { isWorkflowRecordUpdateAction } from '@/workflow/utils/isWorkflowRecordU
import { WorkflowEditActionFormRecordCreate } from '@/workflow/workflow-actions/components/WorkflowEditActionFormRecordCreate';
import { WorkflowEditActionFormRecordUpdate } from '@/workflow/workflow-actions/components/WorkflowEditActionFormRecordUpdate';
import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-actions/components/WorkflowEditActionFormSendEmail';
import { WorkflowEditActionFormServerlessFunction } from '@/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunction';
import { isDefined } from 'twenty-ui';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
const WorkflowEditActionFormServerlessFunction = lazy(() =>
import(
'@/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunction'
).then((module) => ({
default: module.WorkflowEditActionFormServerlessFunction,
})),
);
type WorkflowStepDetailProps =
| {
@ -80,10 +89,12 @@ export const WorkflowStepDetail = ({
switch (stepDefinition.definition.type) {
case 'CODE': {
return (
<WorkflowEditActionFormServerlessFunction
action={stepDefinition.definition}
actionOptions={props}
/>
<Suspense fallback={<RightDrawerSkeletonLoader />}>
<WorkflowEditActionFormServerlessFunction
action={stepDefinition.definition}
actionOptions={props}
/>
</Suspense>
);
}
case 'SEND_EMAIL': {

View File

@ -33,36 +33,6 @@ export const BlankInitialVersion: Story = {
parameters: {
msw: {
handlers: [
graphql.query('FindManyWorkflows', () => {
return HttpResponse.json({
data: {
workflows: {
__typename: 'WorkflowConnection',
totalCount: 1,
pageInfo: {
__typename: 'PageInfo',
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'eyJpZCI6IjIwMGMxNTA4LWYxMDItNGJiOS1hZjMyLWVkYTU1MjM5YWU2MSJ9',
endCursor:
'eyJpZCI6IjIwMGMxNTA4LWYxMDItNGJiOS1hZjMyLWVkYTU1MjM5YWU2MSJ9',
},
edges: [
{
__typename: 'WorkflowEdge',
cursor:
'eyJpZCI6IjIwMGMxNTA4LWYxMDItNGJiOS1hZjMyLWVkYTU1MjM5YWU2MSJ9',
node: {
__typename: 'Workflow',
id: blankInitialVersionWorkflowId,
},
},
],
},
},
});
}),
graphql.query('FindOneWorkflow', () => {
return HttpResponse.json({
data: {
@ -112,44 +82,6 @@ export const BlankInitialVersion: Story = {
},
});
}),
graphql.query('FindManyWorkflowVersions', () => {
return HttpResponse.json({
data: {
workflowVersions: {
__typename: 'WorkflowVersionConnection',
totalCount: 1,
pageInfo: {
__typename: 'PageInfo',
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'eyJjcmVhdGVkQXQiOiIyMDI0LTA5LTE5VDEwOjEwOjA0LjcyNVoiLCJpZCI6ImY2MTg4NDNhLTI2YmUtNGE1NC1hNjBmLWY0Y2U4OGE1OTRmMCJ9',
endCursor:
'eyJjcmVhdGVkQXQiOiIyMDI0LTA5LTE5VDEwOjEwOjA0LjcyNVoiLCJpZCI6ImY2MTg4NDNhLTI2YmUtNGE1NC1hNjBmLWY0Y2U4OGE1OTRmMCJ9',
},
edges: [
{
__typename: 'WorkflowVersionEdge',
cursor:
'eyJjcmVhdGVkQXQiOiIyMDI0LTA5LTE5VDEwOjEwOjA0LjcyNVoiLCJpZCI6ImY2MTg4NDNhLTI2YmUtNGE1NC1hNjBmLWY0Y2U4OGE1OTRmMCJ9',
node: {
__typename: 'WorkflowVersion',
updatedAt: '2024-09-19T10:13:12.075Z',
steps: null,
createdAt: '2024-09-19T10:10:04.725Z',
status: 'DRAFT',
name: 'v1',
id: 'f618843a-26be-4a54-a60f-f4ce88a594f0',
trigger: null,
deletedAt: null,
workflowId: blankInitialVersionWorkflowId,
},
},
],
},
},
});
}),
...graphqlMocks.handlers,
],
},
@ -172,53 +104,19 @@ export const ActiveVersion: Story = {
parameters: {
msw: {
handlers: [
graphql.query('FindManyWorkflows', () => {
return HttpResponse.json({
data: {
workflows: {
__typename: 'WorkflowConnection',
totalCount: 1,
pageInfo: {
__typename: 'PageInfo',
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'eyJwb3NpdGlvbiI6LTEsImlkIjoiN2JlM2E4MmMtNDRiNy00MTUwLWEyZTgtNDA4ODcxNDZmNGQ0In0=',
endCursor:
'eyJwb3NpdGlvbiI6LTEsImlkIjoiN2JlM2E4MmMtNDRiNy00MTUwLWEyZTgtNDA4ODcxNDZmNGQ0In0=',
},
edges: [
{
__typename: 'WorkflowEdge',
cursor:
'eyJwb3NpdGlvbiI6LTEsImlkIjoiN2JlM2E4MmMtNDRiNy00MTUwLWEyZTgtNDA4ODcxNDZmNGQ0In0=',
node: {
__typename: 'Workflow',
id: activeVersionWorkflowId,
},
},
],
},
},
});
}),
graphql.query('FindOneWorkflow', () => {
return HttpResponse.json({
data: {
workflow: {
__typename: 'Workflow',
name: 'test qqqq',
lastPublishedVersionId: 'b57e577a-ae55-4de2-ba08-fe361dcc1a57',
id: activeVersionWorkflowId,
deletedAt: null,
id: blankInitialVersionWorkflowId,
name: '1231 qqerrt',
statuses: null,
createdAt: '2024-09-20T10:18:59.977Z',
updatedAt: '2024-09-20T16:59:37.212Z',
position: -1,
runs: {
__typename: 'WorkflowRunConnection',
edges: [],
},
lastPublishedVersionId: '',
deletedAt: null,
updatedAt: '2024-09-19T10:10:04.505Z',
position: 0,
createdAt: '2024-09-19T10:10:04.505Z',
favorites: {
__typename: 'FavoriteConnection',
edges: [],
@ -227,6 +125,10 @@ export const ActiveVersion: Story = {
__typename: 'WorkflowEventListenerConnection',
edges: [],
},
runs: {
__typename: 'WorkflowRunConnection',
edges: [],
},
versions: {
__typename: 'WorkflowVersionConnection',
edges: [
@ -234,165 +136,15 @@ export const ActiveVersion: Story = {
__typename: 'WorkflowVersionEdge',
node: {
__typename: 'WorkflowVersion',
updatedAt: '2024-09-20T16:59:37.212Z',
status: 'ARCHIVED',
deletedAt: null,
steps: [
{
id: '93c41c1d-eff3-4c91-ac61-f56cc1a0df8a',
name: 'Code',
type: 'CODE',
valid: false,
settings: {
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
serverlessFunctionId: '',
},
},
],
workflowId: activeVersionWorkflowId,
trigger: {
type: 'DATABASE_EVENT',
settings: {
eventName: 'note.created',
},
},
name: 'v1',
id: '394cd0b5-bd48-41d7-a110-a92cafaf171d',
createdAt: '2024-09-20T10:19:00.141Z',
},
},
{
__typename: 'WorkflowVersionEdge',
node: {
__typename: 'WorkflowVersion',
updatedAt: '2024-09-20T17:01:15.637Z',
status: 'DRAFT',
deletedAt: null,
steps: [
{
id: '93c41c1d-eff3-4c91-ac61-f56cc1a0df8a',
name: 'Code',
type: 'CODE',
valid: false,
settings: {
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
serverlessFunctionId: '',
},
},
{
id: '4177d57d-35dc-4eb1-a467-07e25cb31da0',
name: 'Code',
type: 'CODE',
valid: false,
settings: {
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
serverlessFunctionId: '',
},
},
{
id: '0cc392d9-5f28-4d92-90a0-08180f264e68',
name: 'Code',
type: 'CODE',
valid: false,
settings: {
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
serverlessFunctionId: '',
},
},
],
workflowId: activeVersionWorkflowId,
trigger: {
type: 'DATABASE_EVENT',
settings: {
eventName: 'note.created',
},
},
name: 'v3',
id: '5eae34ef-9d62-4a9e-b827-3eb927481728',
createdAt: '2024-09-20T17:01:15.637Z',
},
},
{
__typename: 'WorkflowVersionEdge',
node: {
__typename: 'WorkflowVersion',
updatedAt: '2024-09-20T17:00:16.097Z',
updatedAt: '2024-09-19T10:13:12.075Z',
steps: null,
createdAt: '2024-09-19T10:10:04.725Z',
status: 'ACTIVE',
name: 'v1',
id: 'f618843a-26be-4a54-a60f-f4ce88a594f0',
trigger: null,
deletedAt: null,
steps: [
{
id: '93c41c1d-eff3-4c91-ac61-f56cc1a0df8a',
name: 'Code',
type: 'CODE',
valid: false,
settings: {
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
serverlessFunctionId: '',
},
},
{
id: '4177d57d-35dc-4eb1-a467-07e25cb31da0',
name: 'Code',
type: 'CODE',
valid: false,
settings: {
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
serverlessFunctionId: '',
},
},
],
workflowId: activeVersionWorkflowId,
trigger: {
type: 'DATABASE_EVENT',
settings: {
eventName: 'note.created',
},
},
name: 'v2',
id: 'b57e577a-ae55-4de2-ba08-fe361dcc1a57',
createdAt: '2024-09-20T16:59:35.755Z',
workflowId: blankInitialVersionWorkflowId,
},
},
],
@ -401,101 +153,6 @@ export const ActiveVersion: Story = {
},
});
}),
graphql.query('FindManyWorkflowVersions', () => {
return HttpResponse.json({
data: {
workflowVersions: {
__typename: 'WorkflowVersionConnection',
totalCount: 3,
pageInfo: {
__typename: 'PageInfo',
hasNextPage: true,
hasPreviousPage: false,
startCursor:
'eyJjcmVhdGVkQXQiOiIyMDI0LTA5LTIwVDE3OjAxOjE1LjYzN1oiLCJpZCI6IjVlYWUzNGVmLTlkNjItNGE5ZS1iODI3LTNlYjkyNzQ4MTcyOCJ9',
endCursor:
'eyJjcmVhdGVkQXQiOiIyMDI0LTA5LTIwVDE3OjAxOjE1LjYzN1oiLCJpZCI6IjVlYWUzNGVmLTlkNjItNGE5ZS1iODI3LTNlYjkyNzQ4MTcyOCJ9',
},
edges: [
{
__typename: 'WorkflowVersionEdge',
cursor:
'eyJjcmVhdGVkQXQiOiIyMDI0LTA5LTIwVDE3OjAxOjE1LjYzN1oiLCJpZCI6IjVlYWUzNGVmLTlkNjItNGE5ZS1iODI3LTNlYjkyNzQ4MTcyOCJ9',
node: {
__typename: 'WorkflowVersion',
updatedAt: '2024-09-20T17:01:15.637Z',
status: 'ACTIVE',
deletedAt: null,
steps: [
{
id: '93c41c1d-eff3-4c91-ac61-f56cc1a0df8a',
name: 'Code',
type: 'CODE',
valid: false,
settings: {
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
serverlessFunctionId: '',
},
},
{
id: '4177d57d-35dc-4eb1-a467-07e25cb31da0',
name: 'Code',
type: 'CODE',
valid: false,
settings: {
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
serverlessFunctionId: '',
},
},
{
id: '0cc392d9-5f28-4d92-90a0-08180f264e68',
name: 'Code',
type: 'CODE',
valid: false,
settings: {
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
serverlessFunctionId: '',
},
},
],
workflowId: activeVersionWorkflowId,
trigger: {
type: 'DATABASE_EVENT',
settings: {
eventName: 'note.created',
},
},
name: 'v3',
id: '5eae34ef-9d62-4a9e-b827-3eb927481728',
createdAt: '2024-09-20T17:01:15.637Z',
},
},
],
},
},
});
}),
...graphqlMocks.handlers,
],
},
@ -518,36 +175,6 @@ export const DraftVersionWithPreviousActiveVersion: Story = {
parameters: {
msw: {
handlers: [
graphql.query('FindManyWorkflows', () => {
return HttpResponse.json({
data: {
workflows: {
__typename: 'WorkflowConnection',
totalCount: 1,
pageInfo: {
__typename: 'PageInfo',
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'eyJpZCI6IjIwMGMxNTA4LWYxMDItNGJiOS1hZjMyLWVkYTU1MjM5YWU2MSJ9',
endCursor:
'eyJpZCI6IjIwMGMxNTA4LWYxMDItNGJiOS1hZjMyLWVkYTU1MjM5YWU2MSJ9',
},
edges: [
{
__typename: 'WorkflowEdge',
cursor:
'eyJpZCI6IjIwMGMxNTA4LWYxMDItNGJiOS1hZjMyLWVkYTU1MjM5YWU2MSJ9',
node: {
__typename: 'Workflow',
id: draftVersionWithPreviousActiveVersionWorkflowId,
},
},
],
},
},
});
}),
graphql.query('FindOneWorkflow', () => {
return HttpResponse.json({
data: {
@ -614,45 +241,6 @@ export const DraftVersionWithPreviousActiveVersion: Story = {
},
});
}),
graphql.query('FindManyWorkflowVersions', () => {
return HttpResponse.json({
data: {
workflowVersions: {
__typename: 'WorkflowVersionConnection',
totalCount: 1,
pageInfo: {
__typename: 'PageInfo',
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'eyJjcmVhdGVkQXQiOiIyMDI0LTA5LTE5VDEwOjEwOjA0LjcyNVoiLCJpZCI6ImY2MTg4NDNhLTI2YmUtNGE1NC1hNjBmLWY0Y2U4OGE1OTRmMCJ9',
endCursor:
'eyJjcmVhdGVkQXQiOiIyMDI0LTA5LTE5VDEwOjEwOjA0LjcyNVoiLCJpZCI6ImY2MTg4NDNhLTI2YmUtNGE1NC1hNjBmLWY0Y2U4OGE1OTRmMCJ9',
},
edges: [
{
__typename: 'WorkflowVersionEdge',
cursor:
'eyJjcmVhdGVkQXQiOiIyMDI0LTA5LTE5VDEwOjEwOjA0LjcyNVoiLCJpZCI6ImY2MTg4NDNhLTI2YmUtNGE1NC1hNjBmLWY0Y2U4OGE1OTRmMCJ9',
node: {
__typename: 'WorkflowVersion',
updatedAt: '2024-09-19T10:13:12.075Z',
steps: null,
createdAt: '2024-09-19T10:10:05.725Z',
status: 'DRAFT',
name: 'v2',
id: 'f618843a-26be-4a54-a60f-f4ce88a594f1',
trigger: null,
deletedAt: null,
workflowId:
draftVersionWithPreviousActiveVersionWorkflowId,
},
},
],
},
},
});
}),
...graphqlMocks.handlers,
],
},

View File

@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const CREATE_WORKFLOW_VERSION_STEP = gql`
mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) {
createWorkflowVersionStep(input: $input) {
id
name
type
settings
valid
}
}
`;

View File

@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const DELETE_WORKFLOW_VERSION_STEP = gql`
mutation DeleteWorkflowVersionStep($input: DeleteWorkflowVersionStepInput!) {
deleteWorkflowVersionStep(input: $input) {
id
name
type
settings
valid
}
}
`;

View File

@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const UPDATE_WORKFLOW_VERSION_STEP = gql`
mutation UpdateWorkflowVersionStep($input: UpdateWorkflowVersionStepInput!) {
updateWorkflowVersionStep(input: $input) {
id
name
type
settings
valid
}
}
`;

View File

@ -3,10 +3,9 @@ import { renderHook } from '@testing-library/react';
import { useCreateStep } from '../useCreateStep';
const mockOpenRightDrawer = jest.fn();
const mockUpdateOneWorkflowVersion = jest.fn();
const mockCreateNewWorkflowVersion = jest.fn();
const mockComputeStepOutputSchema = jest.fn().mockResolvedValue({
data: { computeStepOutputSchema: { type: 'object' } },
const mockCreateWorkflowVersionStep = jest.fn().mockResolvedValue({
data: { createWorkflowVersionStep: { id: '1' } },
});
jest.mock('recoil', () => ({
@ -15,19 +14,15 @@ jest.mock('recoil', () => ({
atom: (params: any) => params,
}));
jest.mock('@/workflow/states/workflowCreateStepFromParentStepIdState', () => ({
workflowCreateStepFromParentStepIdState: 'mockAtomState',
}));
jest.mock('@/ui/layout/right-drawer/hooks/useRightDrawer', () => ({
useRightDrawer: () => ({
openRightDrawer: mockOpenRightDrawer,
}),
}));
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
useUpdateOneRecord: () => ({
updateOneRecord: mockUpdateOneWorkflowVersion,
jest.mock('@/workflow/hooks/useCreateWorkflowVersionStep', () => ({
useCreateWorkflowVersionStep: () => ({
createWorkflowVersionStep: mockCreateWorkflowVersionStep,
}),
}));
@ -37,24 +32,6 @@ jest.mock('@/workflow/hooks/useCreateNewWorkflowVersion', () => ({
}),
}));
jest.mock('@/workflow/hooks/useComputeStepOutputSchema', () => ({
useComputeStepOutputSchema: () => ({
computeStepOutputSchema: mockComputeStepOutputSchema,
}),
}));
jest.mock('@/object-metadata/hooks/useFilteredObjectMetadataItems', () => ({
useFilteredObjectMetadataItems: () => ({
activeObjectMetadataItems: [],
}),
}));
jest.mock('@/workflow/utils/insertStep', () => ({
insertStep: jest
.fn()
.mockImplementation(({ steps, stepToAdd }) => [...steps, stepToAdd]),
}));
describe('useCreateStep', () => {
const mockWorkflow = {
id: '123',
@ -75,7 +52,7 @@ describe('useCreateStep', () => {
);
await result.current.createStep('CODE');
expect(mockUpdateOneWorkflowVersion).toHaveBeenCalled();
expect(mockCreateWorkflowVersionStep).toHaveBeenCalled();
expect(mockOpenRightDrawer).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,62 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { renderHook } from '@testing-library/react';
import { useDeleteStep } from '@/workflow/hooks/useDeleteStep';
const mockCloseRightDrawer = jest.fn();
const mockCreateNewWorkflowVersion = jest.fn();
const mockDeleteWorkflowVersionStep = jest.fn();
const updateOneRecordMock = jest.fn();
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
useUpdateOneRecord: () => ({
updateOneRecord: updateOneRecordMock,
}),
}));
jest.mock('recoil', () => ({
useRecoilValue: () => 'parent-step-id',
useSetRecoilState: () => jest.fn(),
atom: (params: any) => params,
}));
jest.mock('@/ui/layout/right-drawer/hooks/useRightDrawer', () => ({
useRightDrawer: () => ({
closeRightDrawer: mockCloseRightDrawer,
}),
}));
jest.mock('@/workflow/hooks/useDeleteWorkflowVersionStep', () => ({
useDeleteWorkflowVersionStep: () => ({
deleteWorkflowVersionStep: mockDeleteWorkflowVersionStep,
}),
}));
jest.mock('@/workflow/hooks/useCreateNewWorkflowVersion', () => ({
useCreateNewWorkflowVersion: () => ({
createNewWorkflowVersion: mockCreateNewWorkflowVersion,
}),
}));
describe('useDeleteStep', () => {
const mockWorkflow = {
id: '123',
currentVersion: {
id: '456',
status: 'DRAFT',
steps: [],
trigger: { type: 'manual' },
},
versions: [],
};
it('should delete step in draft version', async () => {
const { result } = renderHook(() =>
useDeleteStep({
workflow: mockWorkflow as unknown as WorkflowWithCurrentVersion,
}),
);
await result.current.deleteStep('1');
expect(mockDeleteWorkflowVersionStep).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,62 @@
import { renderHook } from '@testing-library/react';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
const mockCreateNewWorkflowVersion = jest.fn().mockResolvedValue({
id: '457',
name: 'toto',
createdAt: '2024-07-03T20:03:35.064Z',
updatedAt: '2024-07-03T20:03:35.064Z',
workflowId: '123',
__typename: 'WorkflowVersion',
status: 'DRAFT',
steps: [],
trigger: null,
});
jest.mock('@/workflow/hooks/useCreateNewWorkflowVersion', () => ({
useCreateNewWorkflowVersion: () => ({
createNewWorkflowVersion: mockCreateNewWorkflowVersion,
}),
}));
describe('useGetUpdatableWorkflowVersion', () => {
const mockWorkflow = (status: 'ACTIVE' | 'DRAFT') =>
({
id: '123',
__typename: 'Workflow',
statuses: [],
lastPublishedVersionId: '1',
name: 'toto',
versions: [],
currentVersion: {
id: '456',
name: 'toto',
createdAt: '2024-07-03T20:03:35.064Z',
updatedAt: '2024-07-03T20:03:35.064Z',
workflowId: '123',
__typename: 'WorkflowVersion',
status,
steps: [],
trigger: null,
},
}) as WorkflowWithCurrentVersion;
it('should not create workflow version if draft version exists', async () => {
const { result } = renderHook(() => useGetUpdatableWorkflowVersion());
const workflowVersion = await result.current.getUpdatableWorkflowVersion(
mockWorkflow('DRAFT'),
);
expect(mockCreateNewWorkflowVersion).not.toHaveBeenCalled();
expect(workflowVersion.id === '456');
});
it('should create workflow version if no draft version exists', async () => {
const { result } = renderHook(() => useGetUpdatableWorkflowVersion());
const workflowVersion = await result.current.getUpdatableWorkflowVersion(
mockWorkflow('ACTIVE'),
);
expect(mockCreateNewWorkflowVersion).toHaveBeenCalled();
expect(workflowVersion.id === '457');
});
});

View File

@ -0,0 +1,66 @@
import { renderHook, act } from '@testing-library/react';
import { useTriggerNodeSelection } from '@/workflow/hooks/useTriggerNodeSelection';
import { RecoilRoot, useRecoilState } from 'recoil';
import { useReactFlow } from '@xyflow/react';
import { workflowDiagramTriggerNodeSelectionState } from '@/workflow/states/workflowDiagramTriggerNodeSelectionState';
jest.mock('@xyflow/react', () => ({
useReactFlow: jest.fn(),
}));
const wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot>{children}</RecoilRoot>
);
describe('useTriggerNodeSelection', () => {
const mockUpdateNode = jest.fn();
beforeEach(() => {
(useReactFlow as jest.Mock).mockReturnValue({
updateNode: mockUpdateNode,
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('should trigger node selection', () => {
const { result } = renderHook(
() => {
const [
workflowDiagramTriggerNodeSelection,
setWorkflowDiagramTriggerNodeSelection,
] = useRecoilState(workflowDiagramTriggerNodeSelectionState);
useTriggerNodeSelection();
return {
workflowDiagramTriggerNodeSelection,
setWorkflowDiagramTriggerNodeSelection,
};
},
{
wrapper,
},
);
const mockNodeId = 'test-node-id';
act(() => {
result.current.setWorkflowDiagramTriggerNodeSelection(mockNodeId);
});
expect(mockUpdateNode).toHaveBeenCalledWith(mockNodeId, { selected: true });
expect(result.current.workflowDiagramTriggerNodeSelection).toBeUndefined();
});
it('should not trigger update if state is not defined', () => {
renderHook(() => useTriggerNodeSelection(), {
wrapper,
});
// Ensure updateNode is not called when state is undefined
expect(mockUpdateNode).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,69 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { renderHook } from '@testing-library/react';
import { useUpdateStep } from '@/workflow/hooks/useUpdateStep';
const mockCreateNewWorkflowVersion = jest.fn();
const mockUpdateWorkflowVersionStep = jest.fn();
jest.mock('recoil', () => ({
useRecoilValue: () => 'parent-step-id',
useSetRecoilState: () => jest.fn(),
atom: (params: any) => params,
}));
jest.mock('@/workflow/hooks/useUpdateWorkflowVersionStep', () => ({
useUpdateWorkflowVersionStep: () => ({
updateWorkflowVersionStep: mockUpdateWorkflowVersionStep,
}),
}));
jest.mock('@/workflow/hooks/useCreateNewWorkflowVersion', () => ({
useCreateNewWorkflowVersion: () => ({
createNewWorkflowVersion: mockCreateNewWorkflowVersion,
}),
}));
describe('useUpdateStep', () => {
const mockWorkflow = {
id: '123',
currentVersion: {
id: '456',
status: 'DRAFT',
steps: [],
trigger: { type: 'manual' },
},
versions: [],
};
it('should update step in draft version', async () => {
const { result } = renderHook(() =>
useUpdateStep({
workflow: mockWorkflow as unknown as WorkflowWithCurrentVersion,
}),
);
await result.current.updateStep({
id: '1',
name: 'name',
valid: true,
type: 'CODE',
settings: {
input: {
serverlessFunctionId: 'id',
serverlessFunctionVersion: '1',
serverlessFunctionInput: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: true,
},
continueOnFailure: {
value: true,
},
},
},
});
expect(mockUpdateWorkflowVersionStep).toHaveBeenCalled();
});
});

View File

@ -67,5 +67,5 @@ export const useAllActiveWorkflowVersions = ({
};
}
return { records };
return { records: records.filter((record) => isDefined(record.workflow)) };
};

View File

@ -8,13 +8,13 @@ export const useCreateNewWorkflowVersion = () => {
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const createNewWorkflowVersion = (
const createNewWorkflowVersion = async (
workflowVersionData: Pick<
WorkflowVersion,
'workflowId' | 'name' | 'status' | 'trigger' | 'steps'
>,
) => {
return createOneWorkflowVersion(workflowVersionData);
return await createOneWorkflowVersion(workflowVersionData);
};
return {

View File

@ -1,23 +1,16 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useComputeStepOutputSchema } from '@/workflow/hooks/useComputeStepOutputSchema';
import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion';
import { workflowCreateStepFromParentStepIdState } from '@/workflow/states/workflowCreateStepFromParentStepIdState';
import { workflowDiagramTriggerNodeSelectionState } from '@/workflow/states/workflowDiagramTriggerNodeSelectionState';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import {
WorkflowStep,
WorkflowStepType,
WorkflowVersion,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { getStepDefaultDefinition } from '@/workflow/utils/getStepDefaultDefinition';
import { insertStep } from '@/workflow/utils/insertStep';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
import { useCreateWorkflowVersionStep } from '@/workflow/hooks/useCreateWorkflowVersionStep';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
export const useCreateStep = ({
workflow,
@ -25,6 +18,7 @@ export const useCreateStep = ({
workflow: WorkflowWithCurrentVersion;
}) => {
const { openRightDrawer } = useRightDrawer();
const { createWorkflowVersionStep } = useCreateWorkflowVersionStep();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const workflowCreateStepFromParentStepId = useRecoilValue(
@ -35,82 +29,27 @@ export const useCreateStep = ({
workflowDiagramTriggerNodeSelectionState,
);
const { updateOneRecord: updateOneWorkflowVersion } =
useUpdateOneRecord<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion();
const { computeStepOutputSchema } = useComputeStepOutputSchema();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const insertNodeAndSave = async ({
parentNodeId,
nodeToAdd,
}: {
parentNodeId: string;
nodeToAdd: WorkflowStep;
}) => {
const currentVersion = workflow.currentVersion;
if (!isDefined(currentVersion)) {
throw new Error("Can't add a node when there is no current version.");
}
const updatedSteps = insertStep({
steps: currentVersion.steps ?? [],
parentStepId: parentNodeId,
stepToAdd: nodeToAdd,
});
if (workflow.currentVersion.status === 'DRAFT') {
await updateOneWorkflowVersion({
idToUpdate: currentVersion.id,
updateOneRecordInput: {
steps: updatedSteps,
},
});
return;
}
await createNewWorkflowVersion({
workflowId: workflow.id,
name: `v${workflow.versions.length + 1}`,
status: 'DRAFT',
trigger: workflow.currentVersion.trigger,
steps: updatedSteps,
});
};
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const createStep = async (newStepType: WorkflowStepType) => {
if (!isDefined(workflowCreateStepFromParentStepId)) {
throw new Error('Select a step to create a new step from first.');
}
const newStep = getStepDefaultDefinition({
type: newStepType,
activeObjectMetadataItems,
});
const workflowVersion = await getUpdatableWorkflowVersion(workflow);
const outputSchema = (
await computeStepOutputSchema({
step: newStep,
const createdStep = (
await createWorkflowVersionStep({
workflowVersionId: workflowVersion.id,
stepType: newStepType,
})
)?.data?.computeStepOutputSchema;
)?.data?.createWorkflowVersionStep;
newStep.settings = {
...newStep.settings,
outputSchema: outputSchema || {},
};
if (!createdStep) {
return;
}
await insertNodeAndSave({
parentNodeId: workflowCreateStepFromParentStepId,
nodeToAdd: newStep,
});
setWorkflowSelectedNode(newStep.id);
setWorkflowSelectedNode(createdStep.id);
openRightDrawer(RightDrawerPages.WorkflowStepEdit);
/**
@ -120,7 +59,7 @@ export const useCreateStep = ({
*
* Selecting the node will cause a right drawer to open in order to edit the step.
*/
setWorkflowDiagramTriggerNodeSelection(newStep.id);
setWorkflowDiagramTriggerNodeSelection(createdStep.id);
};
return {

View File

@ -0,0 +1,64 @@
import { useApolloClient, useMutation } from '@apollo/client';
import {
CreateWorkflowVersionStepMutation,
CreateWorkflowVersionStepMutationVariables,
CreateWorkflowVersionStepInput,
} from '~/generated/graphql';
import { CREATE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/createWorkflowVersionStep';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { isDefined } from 'twenty-ui';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { WorkflowVersion } from '@/workflow/types/Workflow';
export const useCreateWorkflowVersionStep = () => {
const apolloClient = useApolloClient();
const { objectMetadataItems } = useObjectMetadataItems();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const [mutate] = useMutation<
CreateWorkflowVersionStepMutation,
CreateWorkflowVersionStepMutationVariables
>(CREATE_WORKFLOW_VERSION_STEP, {
client: apolloClient,
});
const createWorkflowVersionStep = async (
input: CreateWorkflowVersionStepInput,
) => {
const result = await mutate({
variables: { input },
});
const createdStep = result?.data?.createWorkflowVersionStep;
if (!isDefined(createdStep)) {
return;
}
const cachedRecord = getRecordFromCache<WorkflowVersion>(
input.workflowVersionId,
);
if (!cachedRecord) {
return;
}
const newCachedRecord = {
...cachedRecord,
steps: [...(cachedRecord.steps || []), createdStep],
};
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloClient.cache,
record: newCachedRecord,
});
return result;
};
return { createWorkflowVersionStep };
};

View File

@ -1,81 +0,0 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion';
import {
WorkflowVersion,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { removeStep } from '@/workflow/utils/removeStep';
export const useDeleteOneStep = ({
stepId,
workflow,
}: {
stepId: string;
workflow: WorkflowWithCurrentVersion;
}) => {
const { closeRightDrawer } = useRightDrawer();
const { updateOneRecord: updateOneWorkflowVersion } =
useUpdateOneRecord<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion();
const deleteOneStep = async () => {
closeRightDrawer();
if (workflow.currentVersion.status !== 'DRAFT') {
const newVersionName = `v${workflow.versions.length + 1}`;
if (stepId === TRIGGER_STEP_ID) {
await createNewWorkflowVersion({
workflowId: workflow.id,
name: newVersionName,
status: 'DRAFT',
trigger: null,
steps: workflow.currentVersion.steps,
});
} else {
await createNewWorkflowVersion({
workflowId: workflow.id,
name: newVersionName,
status: 'DRAFT',
trigger: workflow.currentVersion.trigger,
steps: removeStep({
steps: workflow.currentVersion.steps ?? [],
stepId,
}),
});
}
return;
}
if (stepId === TRIGGER_STEP_ID) {
await updateOneWorkflowVersion({
idToUpdate: workflow.currentVersion.id,
updateOneRecordInput: {
trigger: null,
},
});
} else {
await updateOneWorkflowVersion({
idToUpdate: workflow.currentVersion.id,
updateOneRecordInput: {
steps: removeStep({
steps: workflow.currentVersion.steps ?? [],
stepId,
}),
},
});
}
};
return {
deleteOneStep,
};
};

View File

@ -0,0 +1,47 @@
import {
WorkflowWithCurrentVersion,
WorkflowVersion,
} from '@/workflow/types/Workflow';
import { useDeleteWorkflowVersionStep } from '@/workflow/hooks/useDeleteWorkflowVersionStep';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
export const useDeleteStep = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const { deleteWorkflowVersionStep } = useDeleteWorkflowVersionStep();
const { updateOneRecord: updateOneWorkflowVersion } =
useUpdateOneRecord<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const { closeRightDrawer } = useRightDrawer();
const deleteStep = async (stepId: string) => {
closeRightDrawer();
const workflowVersion = await getUpdatableWorkflowVersion(workflow);
if (stepId === TRIGGER_STEP_ID) {
await updateOneWorkflowVersion({
idToUpdate: workflow.currentVersion.id,
updateOneRecordInput: {
trigger: null,
},
});
return;
}
await deleteWorkflowVersionStep({
workflowVersionId: workflowVersion.id,
stepId,
});
};
return {
deleteStep,
};
};

View File

@ -0,0 +1,64 @@
import { useApolloClient, useMutation } from '@apollo/client';
import {
DeleteWorkflowVersionStepMutation,
DeleteWorkflowVersionStepMutationVariables,
DeleteWorkflowVersionStepInput,
WorkflowAction,
} from '~/generated/graphql';
import { DELETE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/deleteWorkflowVersionStep';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { isDefined } from 'twenty-ui';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { WorkflowVersion } from '@/workflow/types/Workflow';
export const useDeleteWorkflowVersionStep = () => {
const apolloClient = useApolloClient();
const { objectMetadataItems } = useObjectMetadataItems();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const [mutate] = useMutation<
DeleteWorkflowVersionStepMutation,
DeleteWorkflowVersionStepMutationVariables
>(DELETE_WORKFLOW_VERSION_STEP, {
client: apolloClient,
});
const deleteWorkflowVersionStep = async (
input: DeleteWorkflowVersionStepInput,
) => {
const result = await mutate({ variables: { input } });
const deletedStep = result?.data?.deleteWorkflowVersionStep;
if (!isDefined(deletedStep)) {
return;
}
const cachedRecord = getRecordFromCache<WorkflowVersion>(
input.workflowVersionId,
);
if (!cachedRecord) {
return;
}
const newCachedRecord = {
...cachedRecord,
steps: (cachedRecord.steps || []).filter(
(step: WorkflowAction) => step.id !== deletedStep.id,
),
};
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloClient.cache,
record: newCachedRecord,
});
};
return { deleteWorkflowVersionStep };
};

View File

@ -0,0 +1,23 @@
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion';
export const useGetUpdatableWorkflowVersion = () => {
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion();
const getUpdatableWorkflowVersion = async (
workflow: WorkflowWithCurrentVersion,
) => {
if (workflow.currentVersion.status === 'DRAFT') {
return workflow.currentVersion;
}
return await createNewWorkflowVersion({
workflowId: workflow.id,
name: `v${workflow.versions.length + 1}`,
status: 'DRAFT',
trigger: workflow.currentVersion.trigger,
steps: workflow.currentVersion.steps,
});
};
return { getUpdatableWorkflowVersion };
};

View File

@ -5,18 +5,16 @@ import {
} from '@/workflow/types/WorkflowDiagram';
import { useReactFlow } from '@xyflow/react';
import { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const useTriggerNodeSelection = () => {
const reactflow = useReactFlow<WorkflowDiagramNode, WorkflowDiagramEdge>();
const workflowDiagramTriggerNodeSelection = useRecoilValue(
workflowDiagramTriggerNodeSelectionState,
);
const setWorkflowDiagramTriggerNodeSelection = useSetRecoilState(
workflowDiagramTriggerNodeSelectionState,
);
const [
workflowDiagramTriggerNodeSelection,
setWorkflowDiagramTriggerNodeSelection,
] = useRecoilState(workflowDiagramTriggerNodeSelectionState);
useEffect(() => {
if (!isDefined(workflowDiagramTriggerNodeSelection)) {

View File

@ -0,0 +1,32 @@
import {
WorkflowStep,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { isDefined } from 'twenty-ui';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
import { useUpdateWorkflowVersionStep } from '@/workflow/hooks/useUpdateWorkflowVersionStep';
export const useUpdateStep = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
const updateStep = async <T extends WorkflowStep>(updatedStep: T) => {
if (!isDefined(workflow.currentVersion)) {
throw new Error('Can not update an undefined workflow version.');
}
const workflowVersion = await getUpdatableWorkflowVersion(workflow);
await updateWorkflowVersionStep({
workflowVersionId: workflowVersion.id,
step: updatedStep,
});
};
return {
updateStep,
};
};

View File

@ -1,73 +1,69 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion';
import { useApolloClient, useMutation } from '@apollo/client';
import {
WorkflowStep,
WorkflowVersion,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { replaceStep } from '@/workflow/utils/replaceStep';
UpdateWorkflowVersionStepInput,
UpdateWorkflowVersionStepMutation,
UpdateWorkflowVersionStepMutationVariables,
WorkflowAction,
} from '~/generated/graphql';
import { UPDATE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/updateWorkflowVersionStep';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { isDefined } from 'twenty-ui';
import { useComputeStepOutputSchema } from '@/workflow/hooks/useComputeStepOutputSchema';
import { WorkflowVersion } from '@/workflow/types/Workflow';
export const useUpdateWorkflowVersionStep = ({
workflow,
stepId,
}: {
workflow: WorkflowWithCurrentVersion;
stepId: string;
}) => {
const { updateOneRecord: updateOneWorkflowVersion } =
useUpdateOneRecord<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion();
const { computeStepOutputSchema } = useComputeStepOutputSchema();
const updateStep = async <T extends WorkflowStep>(updatedStep: T) => {
if (!isDefined(workflow.currentVersion)) {
throw new Error('Can not update an undefined workflow version.');
}
const outputSchema = (
await computeStepOutputSchema({
step: updatedStep,
})
)?.data?.computeStepOutputSchema;
updatedStep.settings = {
...updatedStep.settings,
outputSchema: outputSchema || {},
};
const updatedSteps = replaceStep({
steps: workflow.currentVersion.steps ?? [],
stepId,
stepToReplace: updatedStep,
});
if (workflow.currentVersion.status === 'DRAFT') {
await updateOneWorkflowVersion({
idToUpdate: workflow.currentVersion.id,
updateOneRecordInput: {
steps: updatedSteps,
},
});
export const useUpdateWorkflowVersionStep = () => {
const apolloClient = useApolloClient();
const { objectMetadataItems } = useObjectMetadataItems();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const [mutate] = useMutation<
UpdateWorkflowVersionStepMutation,
UpdateWorkflowVersionStepMutationVariables
>(UPDATE_WORKFLOW_VERSION_STEP, {
client: apolloClient,
});
const updateWorkflowVersionStep = async (
input: UpdateWorkflowVersionStepInput,
) => {
const result = await mutate({ variables: { input } });
const updatedStep = result?.data?.updateWorkflowVersionStep;
if (!isDefined(updatedStep)) {
return;
}
await createNewWorkflowVersion({
workflowId: workflow.id,
name: `v${workflow.versions.length + 1}`,
status: 'DRAFT',
trigger: workflow.currentVersion.trigger,
steps: updatedSteps,
const cachedRecord = getRecordFromCache<WorkflowVersion>(
input.workflowVersionId,
);
if (!cachedRecord) {
return;
}
const newCachedRecord = {
...cachedRecord,
steps: (cachedRecord.steps || []).map((step: WorkflowAction) => {
if (step.id === updatedStep.id) {
return updatedStep;
}
return step;
}),
};
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloClient.cache,
record: newCachedRecord,
});
return result;
};
return {
updateStep,
};
return { updateWorkflowVersionStep };
};

View File

@ -1,6 +1,5 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion';
import {
WorkflowTrigger,
WorkflowVersion,
@ -8,6 +7,7 @@ import {
} from '@/workflow/types/Workflow';
import { isDefined } from 'twenty-ui';
import { useComputeStepOutputSchema } from '@/workflow/hooks/useComputeStepOutputSchema';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
export const useUpdateWorkflowVersionTrigger = ({
workflow,
@ -19,7 +19,7 @@ export const useUpdateWorkflowVersionTrigger = ({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion();
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const { computeStepOutputSchema } = useComputeStepOutputSchema();
@ -28,34 +28,24 @@ export const useUpdateWorkflowVersionTrigger = ({
throw new Error('Can not update an undefined workflow version.');
}
if (workflow.currentVersion.status === 'DRAFT') {
const outputSchema = (
await computeStepOutputSchema({
step: updatedTrigger,
})
)?.data?.computeStepOutputSchema;
const workflowVersion = await getUpdatableWorkflowVersion(workflow);
updatedTrigger.settings = {
...updatedTrigger.settings,
outputSchema: outputSchema || {},
};
const outputSchema = (
await computeStepOutputSchema({
step: updatedTrigger,
})
)?.data?.computeStepOutputSchema;
await updateOneWorkflowVersion({
idToUpdate: workflow.currentVersion.id,
updateOneRecordInput: {
trigger: updatedTrigger,
},
});
updatedTrigger.settings = {
...updatedTrigger.settings,
outputSchema: outputSchema || {},
};
return;
}
await createNewWorkflowVersion({
workflowId: workflow.id,
name: `v${workflow.versions.length + 1}`,
status: 'DRAFT',
trigger: updatedTrigger,
steps: workflow.currentVersion.steps,
await updateOneWorkflowVersion({
idToUpdate: workflowVersion.id,
updateOneRecordInput: {
trigger: updatedTrigger,
},
});
};

View File

@ -1,9 +1,7 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import {
Workflow,
WorkflowVersion,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { useMemo } from 'react';
@ -19,40 +17,25 @@ export const useWorkflowWithCurrentVersion = (
id: true,
name: true,
statuses: true,
versions: {
totalCount: true,
},
versions: true,
},
skip: !isDefined(workflowId),
});
const {
records: mostRecentWorkflowVersions,
loading: loadingMostRecentWorkflowVersions,
} = useFindManyRecords<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
filter: {
workflowId: {
eq: workflowId,
},
},
orderBy: [
{
createdAt: 'DescNullsLast',
},
],
skip: !isDefined(workflowId),
});
const workflowWithCurrentVersion = useMemo(() => {
if (!isDefined(workflow) || loadingMostRecentWorkflowVersions) {
return useMemo(() => {
if (!isDefined(workflow)) {
return undefined;
}
const draftVersion = mostRecentWorkflowVersions.find(
const draftVersion = workflow.versions.find(
(workflowVersion) => workflowVersion.status === 'DRAFT',
);
const latestVersion = mostRecentWorkflowVersions[0];
const workflowVersions = [...workflow.versions];
workflowVersions.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1));
const latestVersion = workflowVersions[0];
const currentVersion = draftVersion ?? latestVersion;
@ -64,7 +47,5 @@ export const useWorkflowWithCurrentVersion = (
...workflow,
currentVersion,
};
}, [loadingMostRecentWorkflowVersions, mostRecentWorkflowVersions, workflow]);
return workflowWithCurrentVersion;
}, [workflow]);
};

View File

@ -9,7 +9,7 @@ export type InputSchemaPropertyType =
| 'unknown'
| FieldMetadataType;
type InputSchemaProperty = {
export type InputSchemaProperty = {
type: InputSchemaPropertyType;
enum?: string[];
items?: InputSchemaProperty;

View File

@ -1,142 +0,0 @@
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { getStepDefaultDefinition } from '../getStepDefaultDefinition';
it('returns a valid definition for CODE actions', () => {
expect(
getStepDefaultDefinition({
type: 'CODE',
activeObjectMetadataItems: generatedMockObjectMetadataItems,
}),
).toStrictEqual({
id: expect.any(String),
name: 'Code',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId: '',
serverlessFunctionVersion: '',
serverlessFunctionInput: {},
},
outputSchema: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
});
});
it('returns a valid definition for SEND_EMAIL actions', () => {
expect(
getStepDefaultDefinition({
type: 'SEND_EMAIL',
activeObjectMetadataItems: generatedMockObjectMetadataItems,
}),
).toStrictEqual({
id: expect.any(String),
name: 'Send Email',
type: 'SEND_EMAIL',
valid: false,
settings: {
input: {
connectedAccountId: '',
email: '',
subject: '',
body: '',
},
outputSchema: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
});
});
it('returns a valid definition for RECORD_CRUD.CREATE actions', () => {
expect(
getStepDefaultDefinition({
type: 'RECORD_CRUD.CREATE',
activeObjectMetadataItems: generatedMockObjectMetadataItems,
}),
).toStrictEqual({
id: expect.any(String),
name: 'Create Record',
type: 'RECORD_CRUD',
valid: false,
settings: {
input: {
type: 'CREATE',
objectName: generatedMockObjectMetadataItems[0].nameSingular,
objectRecord: {},
},
outputSchema: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
});
});
it('returns a valid definition for RECORD_CRUD.UPDATE actions', () => {
expect(
getStepDefaultDefinition({
type: 'RECORD_CRUD.UPDATE',
activeObjectMetadataItems: generatedMockObjectMetadataItems,
}),
).toStrictEqual({
id: expect.any(String),
name: 'Update Record',
type: 'RECORD_CRUD',
valid: false,
settings: {
input: {
type: 'UPDATE',
objectName: generatedMockObjectMetadataItems[0].nameSingular,
objectRecord: {},
objectRecordId: '',
},
outputSchema: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
});
});
it("throws for RECORD_CRUD.DELETE actions as it's not implemented yet", () => {
expect(() => {
getStepDefaultDefinition({
type: 'RECORD_CRUD.DELETE',
activeObjectMetadataItems: generatedMockObjectMetadataItems,
});
}).toThrow('Not implemented yet');
});
it('throws when providing an unknown type', () => {
expect(() => {
getStepDefaultDefinition({
type: 'unknown' as any,
activeObjectMetadataItems: generatedMockObjectMetadataItems,
});
}).toThrow('Unknown type: unknown');
});

View File

@ -0,0 +1,129 @@
import {
ArrayTypeNode,
ArrowFunction,
createSourceFile,
FunctionDeclaration,
LiteralTypeNode,
PropertySignature,
ScriptTarget,
StringLiteral,
SyntaxKind,
TypeNode,
UnionTypeNode,
VariableStatement,
} from 'typescript';
import { InputSchema, InputSchemaProperty } from '@/workflow/types/InputSchema';
import { isDefined } from 'twenty-ui';
const getTypeString = (typeNode: TypeNode): InputSchemaProperty => {
switch (typeNode.kind) {
case SyntaxKind.NumberKeyword:
return { type: 'number' };
case SyntaxKind.StringKeyword:
return { type: 'string' };
case SyntaxKind.BooleanKeyword:
return { type: 'boolean' };
case SyntaxKind.ArrayType:
return {
type: 'array',
items: getTypeString((typeNode as ArrayTypeNode).elementType),
};
case SyntaxKind.ObjectKeyword:
return { type: 'object' };
case SyntaxKind.TypeLiteral: {
const properties: InputSchema = {};
(typeNode as any).members.forEach((member: PropertySignature) => {
if (isDefined(member.name) && isDefined(member.type)) {
const memberName = (member.name as any).text;
properties[memberName] = getTypeString(member.type);
}
});
return { type: 'object', properties };
}
case SyntaxKind.UnionType: {
const unionNode = typeNode as UnionTypeNode;
const enumValues: string[] = [];
let isEnum = true;
unionNode.types.forEach((subType) => {
if (subType.kind === SyntaxKind.LiteralType) {
const literal = (subType as LiteralTypeNode).literal;
if (literal.kind === SyntaxKind.StringLiteral) {
enumValues.push((literal as StringLiteral).text);
} else {
isEnum = false;
}
} else {
isEnum = false;
}
});
if (isEnum) {
return { type: 'string', enum: enumValues };
}
return { type: 'unknown' };
}
default:
return { type: 'unknown' };
}
};
export const getFunctionInputSchema = (fileContent: string): InputSchema => {
const sourceFile = createSourceFile(
'temp.ts',
fileContent,
ScriptTarget.ESNext,
true,
);
const schema: InputSchema = {};
sourceFile.forEachChild((node) => {
if (node.kind === SyntaxKind.FunctionDeclaration) {
const funcNode = node as FunctionDeclaration;
const params = funcNode.parameters;
params.forEach((param) => {
const paramName = param.name.getText();
const typeNode = param.type;
if (isDefined(typeNode)) {
schema[paramName] = getTypeString(typeNode);
} else {
schema[paramName] = { type: 'unknown' };
}
});
} else if (node.kind === SyntaxKind.VariableStatement) {
const varStatement = node as VariableStatement;
varStatement.declarationList.declarations.forEach((declaration) => {
if (
isDefined(declaration.initializer) &&
declaration.initializer.kind === SyntaxKind.ArrowFunction
) {
const arrowFunction = declaration.initializer as ArrowFunction;
const params = arrowFunction.parameters;
params.forEach((param: any) => {
const paramName = param.name.text;
const typeNode = param.type;
if (isDefined(typeNode)) {
schema[paramName] = getTypeString(typeNode);
} else {
schema[paramName] = { type: 'unknown' };
}
});
}
});
}
});
return schema;
};

View File

@ -1,100 +0,0 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { WorkflowStep, WorkflowStepType } from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { v4 } from 'uuid';
const BASE_DEFAULT_STEP_SETTINGS = {
outputSchema: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
};
export const getStepDefaultDefinition = ({
type,
activeObjectMetadataItems,
}: {
type: WorkflowStepType;
activeObjectMetadataItems: ObjectMetadataItem[];
}): WorkflowStep => {
const newStepId = v4();
switch (type) {
case 'CODE': {
return {
id: newStepId,
name: 'Code',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId: '',
serverlessFunctionVersion: '',
serverlessFunctionInput: {},
},
...BASE_DEFAULT_STEP_SETTINGS,
},
};
}
case 'SEND_EMAIL': {
return {
id: newStepId,
name: 'Send Email',
type: 'SEND_EMAIL',
valid: false,
settings: {
input: {
connectedAccountId: '',
email: '',
subject: '',
body: '',
},
...BASE_DEFAULT_STEP_SETTINGS,
},
};
}
case 'RECORD_CRUD.CREATE': {
return {
id: newStepId,
name: 'Create Record',
type: 'RECORD_CRUD',
valid: false,
settings: {
input: {
type: 'CREATE',
objectName: activeObjectMetadataItems[0].nameSingular,
objectRecord: {},
},
...BASE_DEFAULT_STEP_SETTINGS,
},
};
}
case 'RECORD_CRUD.UPDATE':
return {
id: newStepId,
name: 'Update Record',
type: 'RECORD_CRUD',
valid: false,
settings: {
input: {
type: 'UPDATE',
objectName: activeObjectMetadataItems[0].nameSingular,
objectRecordId: '',
objectRecord: {},
},
...BASE_DEFAULT_STEP_SETTINGS,
},
};
case 'RECORD_CRUD.DELETE': {
throw new Error('Not implemented yet');
}
default: {
return assertUnreachable(type, `Unknown type: ${type}`);
}
}
};

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