8726 workflow add a test button in workflow code step (#9016)

- add test button to workflow code step
- add test tab to workflow code step


https://github.com/user-attachments/assets/e180a827-7321-49a2-8026-88490c557da2



![image](https://github.com/user-attachments/assets/cacbd756-de3f-4141-a84c-8e1853f6556b)

![image](https://github.com/user-attachments/assets/ee170d81-8a22-4178-bd6d-11a0e8c73365)
This commit is contained in:
martmull
2024-12-13 11:16:29 +01:00
committed by GitHub
parent 07aaf0801c
commit b10d831371
95 changed files with 1537 additions and 1611 deletions

View File

@ -1,11 +1,12 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { OBJECT_EVENT_TRIGGERS } from '@/workflow/constants/ObjectEventTriggers';
import { WorkflowDatabaseEventTrigger } from '@/workflow/types/Workflow';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { useTheme } from '@emotion/react';
import { IconPlaylistAdd, isDefined } from 'twenty-ui';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditTriggerDatabaseEventFormProps = {
trigger: WorkflowDatabaseEventTrigger;
@ -60,88 +61,91 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
: '-';
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
Icon={IconPlaylistAdd}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType={headerType}
>
<Select
dropdownId="workflow-edit-trigger-record-type"
label="Record Type"
fullWidth
disabled={triggerOptions.readonly}
value={triggerEvent?.objectType}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedRecordType) => {
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {
...trigger,
settings: {
...trigger.settings,
eventName: `${updatedRecordType}.${triggerEvent.event}`,
},
}
: {
name: headerTitle,
type: 'DATABASE_EVENT',
settings: {
eventName: `${updatedRecordType}.${OBJECT_EVENT_TRIGGERS[0].value}`,
outputSchema: {},
},
},
);
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
Icon={IconPlaylistAdd}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType={headerType}
/>
<Select
dropdownId="workflow-edit-trigger-event-type"
label="Event type"
fullWidth
value={triggerEvent?.event}
emptyOption={{ label: 'Select an option', value: '' }}
options={OBJECT_EVENT_TRIGGERS}
disabled={triggerOptions.readonly}
onChange={(updatedEvent) => {
if (triggerOptions.readonly === true) {
return;
}
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-trigger-record-type"
label="Record Type"
fullWidth
disabled={triggerOptions.readonly}
value={triggerEvent?.objectType}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedRecordType) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {
...trigger,
settings: {
...trigger.settings,
eventName: `${triggerEvent.objectType}.${updatedEvent}`,
triggerOptions.onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {
...trigger,
settings: {
...trigger.settings,
eventName: `${updatedRecordType}.${triggerEvent.event}`,
},
}
: {
name: headerTitle,
type: 'DATABASE_EVENT',
settings: {
eventName: `${updatedRecordType}.${OBJECT_EVENT_TRIGGERS[0].value}`,
outputSchema: {},
},
},
}
: {
name: headerTitle,
type: 'DATABASE_EVENT',
settings: {
eventName: `${availableMetadata[0].value}.${updatedEvent}`,
outputSchema: {},
);
}}
/>
<Select
dropdownId="workflow-edit-trigger-event-type"
label="Event type"
fullWidth
value={triggerEvent?.event}
emptyOption={{ label: 'Select an option', value: '' }}
options={OBJECT_EVENT_TRIGGERS}
disabled={triggerOptions.readonly}
onChange={(updatedEvent) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {
...trigger,
settings: {
...trigger.settings,
eventName: `${triggerEvent.objectType}.${updatedEvent}`,
},
}
: {
name: headerTitle,
type: 'DATABASE_EVENT',
settings: {
eventName: `${availableMetadata?.[0].value}.${updatedEvent}`,
outputSchema: {},
},
},
},
);
}}
/>
</WorkflowEditGenericFormBase>
);
}}
/>
</WorkflowStepBody>
</>
);
};

View File

@ -1,6 +1,6 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { MANUAL_TRIGGER_AVAILABILITY_OPTIONS } from '@/workflow/constants/ManualTriggerAvailabilityOptions';
import {
WorkflowManualTrigger,
@ -9,6 +9,7 @@ import {
import { getManualTriggerDefaultSettings } from '@/workflow/utils/getManualTriggerDefaultSettings';
import { useTheme } from '@emotion/react';
import { IconHandMove, isDefined, useIcons } from 'twenty-ui';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditTriggerManualFormProps = {
trigger: WorkflowManualTrigger;
@ -47,67 +48,70 @@ export const WorkflowEditTriggerManualForm = ({
const headerTitle = isDefined(trigger.name) ? trigger.name : 'Manual Trigger';
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
Icon={IconHandMove}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Trigger · Manual"
>
<Select
dropdownId="workflow-edit-manual-trigger-availability"
label="Available"
fullWidth
disabled={triggerOptions.readonly}
value={manualTriggerAvailability}
options={MANUAL_TRIGGER_AVAILABILITY_OPTIONS}
onChange={(updatedTriggerType) => {
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: getManualTriggerDefaultSettings({
availability: updatedTriggerType,
activeObjectMetadataItems,
}),
name: newName,
});
}}
Icon={IconHandMove}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Trigger · Manual"
/>
{manualTriggerAvailability === 'WHEN_RECORD_SELECTED' ? (
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-manual-trigger-object"
label="Object"
dropdownId="workflow-edit-manual-trigger-availability"
label="Available"
fullWidth
value={trigger.settings.objectType}
options={availableMetadata}
disabled={triggerOptions.readonly}
onChange={(updatedObject) => {
value={manualTriggerAvailability}
options={MANUAL_TRIGGER_AVAILABILITY_OPTIONS}
onChange={(updatedTriggerType) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
objectType: updatedObject,
outputSchema: {},
},
settings: getManualTriggerDefaultSettings({
availability: updatedTriggerType,
activeObjectMetadataItems,
}),
});
}}
/>
) : null}
</WorkflowEditGenericFormBase>
{manualTriggerAvailability === 'WHEN_RECORD_SELECTED' ? (
<Select
dropdownId="workflow-edit-manual-trigger-object"
label="Object"
fullWidth
value={trigger.settings.objectType}
options={availableMetadata}
disabled={triggerOptions.readonly}
onChange={(updatedObject) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
objectType: updatedObject,
outputSchema: {},
},
});
}}
/>
) : null}
</WorkflowStepBody>
</>
);
};

View File

@ -6,8 +6,8 @@ import {
} from 'twenty-ui';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { StyledFormFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputContainer';
import { StyledFormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/StyledFormFieldInputRowContainer';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { SingleRecordSelect } from '@/object-record/relation-picker/components/SingleRecordSelect';
import { useRecordPicker } from '@/object-record/relation-picker/hooks/useRecordPicker';
import { RecordPickerComponentInstanceContext } from '@/object-record/relation-picker/states/contexts/RecordPickerComponentInstanceContext';
@ -137,9 +137,9 @@ export const WorkflowSingleRecordPicker = ({
};
return (
<StyledFormFieldInputContainer>
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledFormFieldInputRowContainer>
<FormFieldInputRowContainer>
<StyledFormSelectContainer>
<WorkflowSingleRecordFieldChip
draftValue={draftValue}
@ -193,7 +193,7 @@ export const WorkflowSingleRecordPicker = ({
objectNameSingularToSelect={objectNameSingular}
/>
</StyledSearchVariablesDropdownContainer>
</StyledFormFieldInputRowContainer>
</StyledFormFieldInputContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};

View File

@ -0,0 +1,12 @@
import styled from '@emotion/styled';
const StyledWorkflowStepBody = styled.div`
display: flex;
flex-direction: column;
overflow-y: scroll;
padding: ${({ theme }) => theme.spacing(6)};
row-gap: ${({ theme }) => theme.spacing(6)};
flex: 1 1 auto;
`;
export { StyledWorkflowStepBody as WorkflowStepBody };

View File

@ -10,8 +10,8 @@ import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrTh
import { WorkflowEditActionFormCreateRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormCreateRecord';
import { WorkflowEditActionFormDeleteRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormDeleteRecord';
import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-actions/components/WorkflowEditActionFormSendEmail';
import { Suspense, lazy } from 'react';
import { WorkflowEditActionFormUpdateRecord } from '@/workflow/workflow-actions/components/WorkflowEditActionFormUpdateRecord';
import { lazy, Suspense } from 'react';
import { isDefined } from 'twenty-ui';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';

View File

@ -43,27 +43,18 @@ const StyledHeaderIconContainer = styled.div`
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledContentContainer = styled.div`
padding: ${({ theme }) => theme.spacing(6)};
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(4)};
`;
export const WorkflowEditGenericFormBase = ({
export const WorkflowStepHeader = ({
onTitleChange,
Icon,
iconColor,
initialTitle,
headerType,
children,
}: {
onTitleChange: (newTitle: string) => void;
Icon: IconComponent;
iconColor: string;
initialTitle: string;
headerType: string;
children: React.ReactNode;
}) => {
const theme = useTheme();
const [title, setTitle] = useState(initialTitle);
@ -74,33 +65,30 @@ export const WorkflowEditGenericFormBase = ({
};
return (
<>
<StyledHeader>
<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>
</>
<StyledHeader>
<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>
);
};

View File

@ -14,10 +14,7 @@ export const useUpdateStep = ({
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
const updateStep = async <T extends WorkflowStep>(
updatedStep: T,
shouldUpdateStepOutput = true,
) => {
const updateStep = async <T extends WorkflowStep>(updatedStep: T) => {
if (!isDefined(workflow.currentVersion)) {
throw new Error('Can not update an undefined workflow version.');
}
@ -26,7 +23,6 @@ export const useUpdateStep = ({
await updateWorkflowVersionStep({
workflowVersionId: workflowVersion.id,
step: updatedStep,
shouldUpdateStepOutput,
});
};

View File

@ -0,0 +1,30 @@
import { ServerlessFunctionExecutionStatus } from '~/generated-metadata/graphql';
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
export type ServerlessFunctionTestData = {
input: { [field: string]: any };
output: {
data?: string;
duration?: number;
status?: ServerlessFunctionExecutionStatus;
error?: string;
};
language: 'plaintext' | 'json';
height: number;
};
export const DEFAULT_OUTPUT_VALUE =
'Enter an input above then press "run Function"';
export const serverlessFunctionTestDataFamilyState = createFamilyState<
ServerlessFunctionTestData,
string
>({
key: 'serverlessFunctionTestDataFamilyState',
defaultValue: {
language: 'plaintext',
height: 64,
input: {},
output: { data: DEFAULT_OUTPUT_VALUE },
},
});

View File

@ -13,9 +13,11 @@ export type InputSchemaProperty = {
type: InputSchemaPropertyType;
enum?: string[];
items?: InputSchemaProperty;
properties?: InputSchema;
properties?: Properties;
};
export type InputSchema = {
type Properties = {
[name: string]: InputSchemaProperty;
};
export type InputSchema = InputSchemaProperty[];

View File

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

View File

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

View File

@ -1,129 +0,0 @@
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

@ -3,7 +3,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { WorkflowCreateRecordAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
@ -17,6 +17,7 @@ import {
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { FieldMetadataType } from '~/generated/graphql';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormCreateRecordProps = {
action: WorkflowCreateRecordAction;
@ -136,58 +137,61 @@ export const WorkflowEditActionFormCreateRecord = ({
const headerTitle = isDefined(action.name) ? action.name : `Create Record`;
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
>
<Select
dropdownId="workflow-edit-action-record-create-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: CreateRecordFormData = {
objectName: updatedObjectName,
};
setFormData(newFormData);
saveAction(newFormData);
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-record-create-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: CreateRecordFormData = {
objectName: updatedObjectName,
};
<HorizontalSeparator noMargin />
setFormData(newFormData);
{inlineFieldDefinitions.map((field) => {
const currentValue = formData[field.metadata.fieldName] as JsonValue;
saveAction(newFormData);
}}
/>
return (
<FormFieldInput
key={field.metadata.fieldName}
defaultValue={currentValue}
field={field}
onPersist={(value) => {
handleFieldChange(field.metadata.fieldName, value);
}}
VariablePicker={WorkflowVariablePicker}
/>
);
})}
</WorkflowEditGenericFormBase>
<HorizontalSeparator noMargin />
{inlineFieldDefinitions.map((field) => {
const currentValue = formData[field.metadata.fieldName] as JsonValue;
return (
<FormFieldInput
key={field.metadata.fieldName}
defaultValue={currentValue}
field={field}
onPersist={(value) => {
handleFieldChange(field.metadata.fieldName, value);
}}
VariablePicker={WorkflowVariablePicker}
/>
);
})}
</WorkflowStepBody>
</>
);
};

View File

@ -1,6 +1,6 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { WorkflowSingleRecordPicker } from '@/workflow/components/WorkflowSingleRecordPicker';
import { WorkflowDeleteRecordAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
@ -14,6 +14,7 @@ import {
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormDeleteRecordProps = {
action: WorkflowDeleteRecordAction;
@ -118,52 +119,55 @@ export const WorkflowEditActionFormDeleteRecord = ({
const headerTitle = isDefined(action.name) ? action.name : `Delete Record`;
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
>
<Select
dropdownId="workflow-edit-action-record-delete-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(objectName) => {
const newFormData: DeleteRecordFormData = {
objectName,
objectRecordId: '',
};
setFormData(newFormData);
saveAction(newFormData);
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-record-delete-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(objectName) => {
const newFormData: DeleteRecordFormData = {
objectName,
objectRecordId: '',
};
<HorizontalSeparator noMargin />
setFormData(newFormData);
<WorkflowSingleRecordPicker
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
}
objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId}
/>
</WorkflowEditGenericFormBase>
saveAction(newFormData);
}}
/>
<HorizontalSeparator noMargin />
<WorkflowSingleRecordPicker
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
}
objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId}
/>
</WorkflowStepBody>
</>
);
};

View File

@ -5,7 +5,7 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowSendEmailAction } from '@/workflow/types/Workflow';
@ -15,6 +15,7 @@ import { Controller, useForm } from 'react-hook-form';
import { useRecoilValue } from 'recoil';
import { IconMail, IconPlus, isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormSendEmailProps = {
action: WorkflowSendEmailAction;
@ -171,99 +172,104 @@ export const WorkflowEditActionFormSendEmail = ({
return (
!loading && (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconMail}
iconColor={theme.color.blue}
initialTitle={headerTitle}
headerType="Email"
>
<Controller
name="connectedAccountId"
control={form.control}
render={({ field }) => (
<Select
dropdownId="select-connected-account-id"
label="Account"
fullWidth
emptyOption={emptyOption}
value={field.value}
options={connectedAccountOptions}
callToActionButton={{
onClick: () =>
triggerApisOAuth('google', { redirectLocation: redirectUrl }),
Icon: IconPlus,
text: 'Add account',
}}
onChange={(connectedAccountId) => {
field.onChange(connectedAccountId);
handleSave(true);
}}
disabled={actionOptions.readonly}
/>
)}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconMail}
iconColor={theme.color.blue}
initialTitle={headerTitle}
headerType="Email"
/>
<Controller
name="email"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Email"
placeholder="Enter receiver email"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
<Controller
name="subject"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Subject"
placeholder="Enter email subject"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
<Controller
name="body"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Body"
placeholder="Enter email body"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
</WorkflowEditGenericFormBase>
<WorkflowStepBody>
<Controller
name="connectedAccountId"
control={form.control}
render={({ field }) => (
<Select
dropdownId="select-connected-account-id"
label="Account"
fullWidth
emptyOption={emptyOption}
value={field.value}
options={connectedAccountOptions}
callToActionButton={{
onClick: () =>
triggerApisOAuth('google', {
redirectLocation: redirectUrl,
}),
Icon: IconPlus,
text: 'Add account',
}}
onChange={(connectedAccountId) => {
field.onChange(connectedAccountId);
handleSave(true);
}}
disabled={actionOptions.readonly}
/>
)}
/>
<Controller
name="email"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Email"
placeholder="Enter receiver email"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
<Controller
name="subject"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Subject"
placeholder="Enter email subject"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
<Controller
name="body"
control={form.control}
render={({ field }) => (
<FormTextFieldInput
label="Body"
placeholder="Enter email body"
readonly={actionOptions.readonly}
defaultValue={field.value}
onPersist={(value) => {
field.onChange(value);
handleSave();
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
/>
</WorkflowStepBody>
</>
)
);
};

View File

@ -1,48 +1,53 @@
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { StyledFormCompositeFieldInputContainer } from '@/object-record/record-field/form-types/components/StyledFormCompositeFieldInputContainer';
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { WorkflowCodeAction } from '@/workflow/types/Workflow';
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
import { getFunctionInputSchema } from '@/workflow/utils/getFunctionInputSchema';
import { setNestedValue } from '@/workflow/utils/setNestedValue';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/workflow-actions/utils/mergeDefaultFunctionInputAndFunctionInput';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Monaco } from '@monaco-editor/react';
import { editor } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { Fragment, ReactNode, useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import {
CodeEditor,
HorizontalSeparator,
IconCode,
isDefined,
} from 'twenty-ui';
import { useEffect, useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { CodeEditor, IconCode, isDefined, IconPlayerPlay } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
import { usePreventOverlapCallback } from '~/hooks/usePreventOverlapCallback';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { ServerlessFunctionExecutionResult } from '@/serverless-functions/components/ServerlessFunctionExecutionResult';
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton';
import { useTestServerlessFunction } from '@/serverless-functions/hooks/useTestServerlessFunction';
import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctionOutputSchema';
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/serverless-functions/utils/mergeDefaultFunctionInputAndFunctionInput';
import { WorkflowEditActionFormServerlessFunctionFields } from '@/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunctionFields';
const StyledContainer = styled.div`
display: inline-flex;
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledCodeEditorContainer = styled.div`
display: 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 StyledTabList = styled(TabList)`
background: ${({ theme }) => theme.background.secondary};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
`;
type WorkflowEditActionFormServerlessFunctionProps = {
@ -53,10 +58,7 @@ type WorkflowEditActionFormServerlessFunctionProps = {
}
| {
readonly?: false;
onActionUpdate: (
action: WorkflowCodeAction,
shouldUpdateStepOutput?: boolean,
) => void;
onActionUpdate: (action: WorkflowCodeAction) => void;
};
};
@ -64,14 +66,14 @@ type ServerlessFunctionInputFormData = {
[field: string]: string | ServerlessFunctionInputFormData;
};
const INDEX_FILE_PATH = 'src/index.ts';
const TAB_LIST_COMPONENT_ID = 'serverless-function-code-step';
export const WorkflowEditActionFormServerlessFunction = ({
action,
actionOptions,
}: WorkflowEditActionFormServerlessFunctionProps) => {
const theme = useTheme();
const { enqueueSnackBar } = useSnackBar();
const { activeTabId, setActiveTabId } = useTabList(TAB_LIST_COMPONENT_ID);
const { updateOneServerlessFunction } = useUpdateOneServerlessFunction();
const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion();
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
@ -81,6 +83,9 @@ export const WorkflowEditActionFormServerlessFunction = ({
id: serverlessFunctionId,
});
const [serverlessFunctionTestData, setServerlessFunctionTestData] =
useRecoilState(serverlessFunctionTestDataFamilyState(serverlessFunctionId));
const [functionInput, setFunctionInput] =
useState<ServerlessFunctionInputFormData>(
action.settings.input.serverlessFunctionInput,
@ -89,83 +94,80 @@ export const WorkflowEditActionFormServerlessFunction = ({
const { formValues, setFormValues, loading } =
useServerlessFunctionUpdateFormState(serverlessFunctionId);
const headerTitle = action.name || 'Code - Serverless Function';
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 updateOutputSchemaFromTestResult = async (testResult: object) => {
if (actionOptions.readonly === true) {
return;
}
const newOutputSchema = getFunctionOutputSchema(testResult);
updateAction({
...action,
settings: { ...action.settings, outputSchema: newOutputSchema },
});
};
const handleSave = usePreventOverlapCallback(save, 1000);
const { testServerlessFunction } = useTestServerlessFunction(
serverlessFunctionId,
updateOutputSchemaFromTestResult,
);
const onCodeChange = async (value: string) => {
const handleSave = useDebouncedCallback(async () => {
await updateOneServerlessFunction({
id: serverlessFunctionId,
name: formValues.name,
description: formValues.description,
code: formValues.code,
});
}, 1_000);
const onCodeChange = async (newCode: string) => {
if (actionOptions.readonly === true) {
return;
}
setFormValues((prevState) => ({
...prevState,
code: { ...prevState.code, [INDEX_FILE_PATH]: value },
code: { ...prevState.code, [INDEX_FILE_PATH]: newCode },
}));
await handleSave();
await handleUpdateFunctionInputSchema();
};
const updateFunctionInputSchema = async () => {
if (actionOptions.readonly === true) {
return;
}
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);
await handleUpdateFunctionInputSchema(newCode);
};
const handleUpdateFunctionInputSchema = useDebouncedCallback(
updateFunctionInputSchema,
100,
);
const updateFunctionInput = useDebouncedCallback(
async (newFunctionInput: object, shouldUpdateStepOutput = true) => {
async (sourceCode: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate(
{
...action,
settings: {
...action.settings,
input: {
...action.settings.input,
serverlessFunctionInput: newFunctionInput,
},
if (!isDefined(sourceCode)) {
return;
}
const newFunctionInput = getFunctionInputFromSourceCode(sourceCode);
const newMergedInput = mergeDefaultFunctionInputAndFunctionInput({
newInput: newFunctionInput,
oldInput: action.settings.input.serverlessFunctionInput,
});
const newMergedTestInput = mergeDefaultFunctionInputAndFunctionInput({
newInput: newFunctionInput,
oldInput: serverlessFunctionTestData.input,
});
setFunctionInput(newMergedInput);
setServerlessFunctionTestData((prev) => ({
...prev,
input: newMergedTestInput,
}));
updateAction({
...action,
settings: {
...action.settings,
outputSchema: {},
input: {
...action.settings.input,
serverlessFunctionInput: newMergedInput,
},
},
shouldUpdateStepOutput,
);
});
},
1_000,
);
@ -175,63 +177,33 @@ export const WorkflowEditActionFormServerlessFunction = ({
setFunctionInput(updatedFunctionInput);
await updateFunctionInput(updatedFunctionInput, false);
updateAction({
...action,
settings: {
...action.settings,
input: {
...action.settings.input,
serverlessFunctionInput: 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;
};
const handleTestInputChange = async (value: any, path: string[]) => {
const updatedTestFunctionInput = setNestedValue(
serverlessFunctionTestData.input,
path,
value,
);
setServerlessFunctionTestData((prev) => ({
...prev,
input: updatedTestFunctionInput,
}));
};
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>
<StyledFormCompositeFieldInputContainer>
{renderFields(inputValue, currentPath, false)}
</StyledFormCompositeFieldInputContainer>
</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 handleRunFunction = async () => {
await testServerlessFunction();
setActiveTabId('test');
};
const handleEditorDidMount = async (
@ -247,58 +219,103 @@ export const WorkflowEditActionFormServerlessFunction = ({
});
};
const onActionUpdate = (actionUpdate: Partial<WorkflowCodeAction>) => {
if (actionOptions.readonly === true) {
return;
}
const updateAction = useDebouncedCallback(
(actionUpdate: Partial<WorkflowCodeAction>) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions?.onActionUpdate(
{
actionOptions.onActionUpdate({
...action,
...actionUpdate,
},
false,
);
};
});
},
500,
);
const checkWorkflowUpdatable = async () => {
const handleCodeChange = async (value: string) => {
if (actionOptions.readonly === true || !isDefined(workflow)) {
return;
}
await getUpdatableWorkflowVersion(workflow);
await onCodeChange(value);
};
const tabs = [
{ id: 'code', title: 'Code', Icon: IconCode },
{ id: 'test', title: 'Test', Icon: IconPlayerPlay },
];
useEffect(() => {
setFunctionInput(action.settings.input.serverlessFunctionInput);
}, [action]);
return (
!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}
options={{
readOnly: actionOptions.readonly,
domReadOnly: actionOptions.readonly,
}}
<StyledContainer>
<StyledTabList
tabListInstanceId={TAB_LIST_COMPONENT_ID}
tabs={tabs}
behaveAsLinks={false}
/>
{renderFields(functionInput)}
</WorkflowEditGenericFormBase>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
updateAction({ name: newName });
}}
Icon={IconCode}
iconColor={theme.color.orange}
initialTitle={action.name || 'Code - Serverless Function'}
headerType="Code"
/>
<WorkflowStepBody>
{activeTabId === 'code' && (
<>
<WorkflowEditActionFormServerlessFunctionFields
functionInput={functionInput}
VariablePicker={WorkflowVariablePicker}
onInputChange={handleInputChange}
readonly={actionOptions.readonly}
/>
<StyledCodeEditorContainer>
<InputLabel>Code</InputLabel>
<CodeEditor
height={343}
value={formValues.code?.[INDEX_FILE_PATH]}
language={'typescript'}
onChange={handleCodeChange}
onMount={handleEditorDidMount}
options={{
readOnly: actionOptions.readonly,
domReadOnly: actionOptions.readonly,
}}
/>
</StyledCodeEditorContainer>
</>
)}
{activeTabId === 'test' && (
<>
<WorkflowEditActionFormServerlessFunctionFields
functionInput={serverlessFunctionTestData.input}
onInputChange={handleTestInputChange}
readonly={actionOptions.readonly}
/>
<StyledCodeEditorContainer>
<InputLabel>Result</InputLabel>
<ServerlessFunctionExecutionResult
serverlessFunctionTestData={serverlessFunctionTestData}
/>
</StyledCodeEditorContainer>
</>
)}
</WorkflowStepBody>
{activeTabId === 'test' && (
<RightDrawerFooter
actions={[
<CmdEnterActionButton title="Test" onClick={handleRunFunction} />,
]}
/>
)}
</StyledContainer>
)
);
};

View File

@ -0,0 +1,85 @@
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { isObject } from '@sniptt/guards';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import styled from '@emotion/styled';
import { ReactNode } from 'react';
import { FormNestedFieldInputContainer } from '@/object-record/record-field/form-types/components/FormNestedFieldInputContainer';
const StyledContainer = styled.div`
display: inline-flex;
flex-direction: column;
`;
export const WorkflowEditActionFormServerlessFunctionFields = ({
functionInput,
path = [],
VariablePicker,
onInputChange,
readonly = false,
}: {
functionInput: FunctionInput;
path?: string[];
VariablePicker?: VariablePickerComponent;
onInputChange: (value: any, path: string[]) => void;
readonly?: boolean;
}) => {
const renderFields = ({
functionInput,
path = [],
VariablePicker,
onInputChange,
readonly = false,
}: {
functionInput: FunctionInput;
path?: string[];
VariablePicker?: VariablePickerComponent;
onInputChange: (value: any, path: string[]) => void;
readonly?: boolean;
}): ReactNode[] => {
return Object.entries(functionInput).map(([inputKey, inputValue]) => {
const currentPath = [...path, inputKey];
const pathKey = currentPath.join('.');
if (inputValue !== null && isObject(inputValue)) {
return (
<StyledContainer key={pathKey}>
<InputLabel>{inputKey}</InputLabel>
<FormNestedFieldInputContainer>
{renderFields({
functionInput: inputValue,
path: currentPath,
VariablePicker,
onInputChange,
})}
</FormNestedFieldInputContainer>
</StyledContainer>
);
} else {
return (
<FormTextFieldInput
key={pathKey}
label={inputKey}
placeholder="Enter value"
defaultValue={inputValue ? `${inputValue}` : ''}
readonly={readonly}
onPersist={(value) => onInputChange(value, currentPath)}
VariablePicker={VariablePicker}
/>
);
}
});
};
return (
<>
{renderFields({
functionInput,
path,
VariablePicker,
onInputChange,
readonly,
})}
</>
);
};

View File

@ -1,6 +1,6 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader';
import { WorkflowSingleRecordPicker } from '@/workflow/components/WorkflowSingleRecordPicker';
import { WorkflowUpdateRecordAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
@ -14,6 +14,7 @@ import {
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
type WorkflowEditActionFormUpdateRecordProps = {
action: WorkflowUpdateRecordAction;
@ -123,52 +124,55 @@ export const WorkflowEditActionFormUpdateRecord = ({
const headerTitle = isDefined(action.name) ? action.name : `Update Record`;
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
>
<Select
dropdownId="workflow-edit-action-record-update-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: UpdateRecordFormData = {
objectName: updatedObjectName,
objectRecordId: '',
};
setFormData(newFormData);
saveAction(newFormData);
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-record-update-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: UpdateRecordFormData = {
objectName: updatedObjectName,
objectRecordId: '',
};
<HorizontalSeparator noMargin />
setFormData(newFormData);
<WorkflowSingleRecordPicker
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
}
objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId}
/>
</WorkflowEditGenericFormBase>
saveAction(newFormData);
}}
/>
<HorizontalSeparator noMargin />
<WorkflowSingleRecordPicker
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
}
objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId}
/>
</WorkflowStepBody>
</>
);
};

View File

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

View File

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