Add Record Create action in the frontend (#8514)

In this PR:

- Updated the front-end types for workflows to include CRUD actions and
global naming changes
- Allow users to create a Record Create action
- Scaffold the edit for Record Create action; for now, I render a
`<VariableTagInput />` component for every editable field; it's likely
we'll change it soon

Closes https://github.com/twentyhq/private-issues/issues/142

Demo:


https://github.com/user-attachments/assets/6f0b207a-b7d2-46d9-b5ab-9e32bde55d76
This commit is contained in:
Baptiste Devessier 2024-11-18 18:23:46 +01:00 committed by GitHub
parent 316537a68a
commit c17e18b1e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 503 additions and 132 deletions

View File

@ -0,0 +1,26 @@
import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput';
type FormFieldInputProps = {
recordFieldInputdId: string;
label: string;
value: string;
onChange: (value: string) => void;
isReadOnly?: boolean;
};
export const FormFieldInput = ({
recordFieldInputdId,
label,
onChange,
value,
}: FormFieldInputProps) => {
return (
<VariableTagInput
inputId={recordFieldInputdId}
label={label}
placeholder="Enter value (use {{variable}} for dynamic content)"
value={value}
onChange={onChange}
/>
);
};

View File

@ -3,7 +3,13 @@ import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { IconCode, IconHandMove, IconMail, IconPlaylistAdd } from 'twenty-ui'; import {
IconAddressBook,
IconCode,
IconHandMove,
IconMail,
IconPlaylistAdd,
} from 'twenty-ui';
const StyledStepNodeLabelIconContainer = styled.div` const StyledStepNodeLabelIconContainer = styled.div`
align-items: center; align-items: center;
@ -70,6 +76,21 @@ export const WorkflowDiagramStepNodeBase = ({
</StyledStepNodeLabelIconContainer> </StyledStepNodeLabelIconContainer>
); );
} }
case 'RECORD_CRUD.CREATE': {
return (
<StyledStepNodeLabelIconContainer>
<IconAddressBook
size={theme.icon.size.lg}
color={theme.font.color.tertiary}
stroke={theme.icon.stroke.sm}
/>
</StyledStepNodeLabelIconContainer>
);
}
case 'RECORD_CRUD.DELETE':
case 'RECORD_CRUD.UPDATE': {
return null;
}
} }
} }
} }

View File

@ -0,0 +1,167 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowRecordCreateAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
import {
HorizontalSeparator,
IconAddressBook,
isDefined,
useIcons,
} from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
import { FieldMetadataType } from '~/generated/graphql';
type WorkflowEditActionFormRecordCreateProps = {
action: WorkflowRecordCreateAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowRecordCreateAction) => void;
};
};
type SendEmailFormData = {
objectName: string;
[field: string]: unknown;
};
export const WorkflowEditActionFormRecordCreate = ({
action,
actionOptions,
}: WorkflowEditActionFormRecordCreateProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const availableMetadata: Array<SelectOption<string>> =
activeObjectMetadataItems.map((item) => ({
Icon: getIcon(item.icon),
label: item.labelPlural,
value: item.nameSingular,
}));
const [formData, setFormData] = useState<SendEmailFormData>({
objectName: action.settings.input.objectName,
...action.settings.input.objectRecord,
});
const isFormDisabled = actionOptions.readonly;
const handleFieldChange = (
fieldName: keyof SendEmailFormData,
updatedValue: string,
) => {
const newFormData: SendEmailFormData = {
...formData,
[fieldName]: updatedValue,
};
setFormData(newFormData);
saveAction(newFormData);
};
useEffect(() => {
setFormData({
objectName: action.settings.input.objectName,
...action.settings.input.objectRecord,
});
}, [action.settings.input]);
const selectedObjectMetadataItemNameSingular = formData.objectName;
const selectedObjectMetadataItem = activeObjectMetadataItems.find(
(item) => item.nameSingular === selectedObjectMetadataItemNameSingular,
);
if (!isDefined(selectedObjectMetadataItem)) {
throw new Error('Should have found the metadata item');
}
const editableFields = selectedObjectMetadataItem.fields.filter(
(field) =>
field.type !== FieldMetadataType.Relation &&
!field.isSystem &&
field.isActive,
);
const saveAction = useDebouncedCallback(
async (formData: SendEmailFormData) => {
if (actionOptions.readonly === true) {
return;
}
const { objectName: updatedObjectName, ...updatedOtherFields } = formData;
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
type: 'CREATE',
objectName: updatedObjectName,
objectRecord: updatedOtherFields,
},
},
});
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
return (
<WorkflowEditGenericFormBase
HeaderIcon={
<IconAddressBook
color={theme.font.color.tertiary}
stroke={theme.icon.stroke.sm}
/>
}
headerTitle="Record Create"
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: SendEmailFormData = {
objectName: updatedObjectName,
};
setFormData(newFormData);
saveAction(newFormData);
}}
/>
<HorizontalSeparator noMargin />
{editableFields.map((field) => (
<FormFieldInput
key={field.id}
recordFieldInputdId={field.id}
label={field.label}
value={formData[field.name] as string}
onChange={(value) => {
handleFieldChange(field.name, value);
}}
/>
))}
</WorkflowEditGenericFormBase>
);
};

View File

@ -7,7 +7,7 @@ import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase'; import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { VariableTagInput } from '@/workflow/search-variables/components/VariableTagInput'; import { VariableTagInput } from '@/workflow/search-variables/components/VariableTagInput';
import { workflowIdState } from '@/workflow/states/workflowIdState'; import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowSendEmailStep } from '@/workflow/types/Workflow'; import { WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
@ -15,16 +15,17 @@ import { useRecoilValue } from 'recoil';
import { IconMail, IconPlus, isDefined } from 'twenty-ui'; import { IconMail, IconPlus, isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
type WorkflowEditActionFormSendEmailProps = type WorkflowEditActionFormSendEmailProps = {
| { action: WorkflowSendEmailAction;
action: WorkflowSendEmailStep; actionOptions:
readonly: true; | {
} readonly: true;
| { }
action: WorkflowSendEmailStep; | {
readonly?: false; readonly?: false;
onActionUpdate: (action: WorkflowSendEmailStep) => void; onActionUpdate: (action: WorkflowSendEmailAction) => void;
}; };
};
type SendEmailFormData = { type SendEmailFormData = {
connectedAccountId: string; connectedAccountId: string;
@ -33,9 +34,10 @@ type SendEmailFormData = {
body: string; body: string;
}; };
export const WorkflowEditActionFormSendEmail = ( export const WorkflowEditActionFormSendEmail = ({
props: WorkflowEditActionFormSendEmailProps, action,
) => { actionOptions,
}: WorkflowEditActionFormSendEmailProps) => {
const theme = useTheme(); const theme = useTheme();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { triggerApisOAuth } = useTriggerApisOAuth(); const { triggerApisOAuth } = useTriggerApisOAuth();
@ -50,7 +52,7 @@ export const WorkflowEditActionFormSendEmail = (
subject: '', subject: '',
body: '', body: '',
}, },
disabled: props.readonly, disabled: actionOptions.readonly,
}); });
const checkConnectedAccountScopes = async ( const checkConnectedAccountScopes = async (
@ -77,23 +79,23 @@ export const WorkflowEditActionFormSendEmail = (
useEffect(() => { useEffect(() => {
form.setValue( form.setValue(
'connectedAccountId', 'connectedAccountId',
props.action.settings.input.connectedAccountId ?? '', action.settings.input.connectedAccountId ?? '',
); );
form.setValue('email', props.action.settings.input.email ?? ''); form.setValue('email', action.settings.input.email ?? '');
form.setValue('subject', props.action.settings.input.subject ?? ''); form.setValue('subject', action.settings.input.subject ?? '');
form.setValue('body', props.action.settings.input.body ?? ''); form.setValue('body', action.settings.input.body ?? '');
}, [props.action.settings, form]); }, [action.settings, form]);
const saveAction = useDebouncedCallback( const saveAction = useDebouncedCallback(
async (formData: SendEmailFormData, checkScopes = false) => { async (formData: SendEmailFormData, checkScopes = false) => {
if (props.readonly === true) { if (actionOptions.readonly === true) {
return; return;
} }
props.onActionUpdate({ actionOptions.onActionUpdate({
...props.action, ...action,
settings: { settings: {
...props.action.settings, ...action.settings,
input: { input: {
connectedAccountId: formData.connectedAccountId, connectedAccountId: formData.connectedAccountId,
email: formData.email, email: formData.email,
@ -132,12 +134,12 @@ export const WorkflowEditActionFormSendEmail = (
}; };
if ( if (
isDefined(props.action.settings.input.connectedAccountId) && isDefined(action.settings.input.connectedAccountId) &&
props.action.settings.input.connectedAccountId !== '' action.settings.input.connectedAccountId !== ''
) { ) {
filter.or.push({ filter.or.push({
id: { id: {
eq: props.action.settings.input.connectedAccountId, eq: action.settings.input.connectedAccountId,
}, },
}); });
} }

View File

@ -1,17 +1,17 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions'; import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { setNestedValue } from '@/workflow/utils/setNestedValue';
import { Select, SelectOption } from '@/ui/input/components/Select'; import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase'; import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput'; import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput';
import { WorkflowCodeStep } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import { IconCode, isDefined, HorizontalSeparator } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
import { FunctionInput } from '@/workflow/types/FunctionInput'; 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 { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/utils/mergeDefaultFunctionInputAndFunctionInput';
import { setNestedValue } from '@/workflow/utils/setNestedValue';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ReactNode } from 'react';
import { HorizontalSeparator, IconCode, isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
const StyledContainer = styled.div` const StyledContainer = styled.div`
display: inline-flex; display: inline-flex;
@ -37,20 +37,22 @@ const StyledInputContainer = styled.div`
position: relative; position: relative;
`; `;
type WorkflowEditActionFormServerlessFunctionProps = type WorkflowEditActionFormServerlessFunctionProps = {
| { action: WorkflowCodeAction;
action: WorkflowCodeStep; actionOptions:
readonly: true; | {
} readonly: true;
| { }
action: WorkflowCodeStep; | {
readonly?: false; readonly?: false;
onActionUpdate: (action: WorkflowCodeStep) => void; onActionUpdate: (action: WorkflowCodeAction) => void;
}; };
};
export const WorkflowEditActionFormServerlessFunction = ( export const WorkflowEditActionFormServerlessFunction = ({
props: WorkflowEditActionFormServerlessFunctionProps, action,
) => { actionOptions,
}: WorkflowEditActionFormServerlessFunctionProps) => {
const theme = useTheme(); const theme = useTheme();
const { serverlessFunctions } = useGetManyServerlessFunctions(); const { serverlessFunctions } = useGetManyServerlessFunctions();
@ -66,8 +68,7 @@ export const WorkflowEditActionFormServerlessFunction = (
const defaultFunctionInput = const defaultFunctionInput =
getDefaultFunctionInputFromInputSchema(inputSchema); getDefaultFunctionInputFromInputSchema(inputSchema);
const existingFunctionInput = const existingFunctionInput = action.settings.input.serverlessFunctionInput;
props.action.settings.input.serverlessFunctionInput;
return mergeDefaultFunctionInputAndFunctionInput({ return mergeDefaultFunctionInputAndFunctionInput({
defaultFunctionInput, defaultFunctionInput,
@ -76,21 +77,21 @@ export const WorkflowEditActionFormServerlessFunction = (
}; };
const functionInput = getFunctionInput( const functionInput = getFunctionInput(
props.action.settings.input.serverlessFunctionId, action.settings.input.serverlessFunctionId,
); );
const updateFunctionInput = useDebouncedCallback( const updateFunctionInput = useDebouncedCallback(
async (newFunctionInput: object) => { async (newFunctionInput: object) => {
if (props.readonly === true) { if (actionOptions.readonly === true) {
return; return;
} }
props.onActionUpdate({ actionOptions.onActionUpdate({
...props.action, ...action,
settings: { settings: {
...props.action.settings, ...action.settings,
input: { input: {
...props.action.settings.input, ...action.settings.input,
serverlessFunctionInput: newFunctionInput, serverlessFunctionInput: newFunctionInput,
}, },
}, },
@ -116,14 +117,18 @@ export const WorkflowEditActionFormServerlessFunction = (
]; ];
const handleFunctionChange = (newServerlessFunctionId: string) => { const handleFunctionChange = (newServerlessFunctionId: string) => {
if (actionOptions.readonly === true) {
return;
}
const serverlessFunction = serverlessFunctions.find( const serverlessFunction = serverlessFunctions.find(
(f) => f.id === newServerlessFunctionId, (f) => f.id === newServerlessFunctionId,
); );
const newProps = { const newProps = {
...props.action, ...action,
settings: { settings: {
...props.action.settings, ...action.settings,
input: { input: {
serverlessFunctionId: newServerlessFunctionId, serverlessFunctionId: newServerlessFunctionId,
serverlessFunctionVersion: serverlessFunctionVersion:
@ -133,9 +138,7 @@ export const WorkflowEditActionFormServerlessFunction = (
}, },
}; };
if (!props.readonly) { actionOptions.onActionUpdate(newProps);
props.onActionUpdate(newProps);
}
}; };
const renderFields = ( const renderFields = (
@ -203,10 +206,10 @@ export const WorkflowEditActionFormServerlessFunction = (
dropdownId="select-serverless-function-id" dropdownId="select-serverless-function-id"
label="Function" label="Function"
fullWidth fullWidth
value={props.action.settings.input.serverlessFunctionId} value={action.settings.input.serverlessFunctionId}
options={availableFunctions} options={availableFunctions}
emptyOption={{ label: 'None', value: '' }} emptyOption={{ label: 'None', value: '' }}
disabled={props.readonly} disabled={actionOptions.readonly}
onChange={handleFunctionChange} onChange={handleFunctionChange}
/> />
{renderFields(functionInput)} {renderFields(functionInput)}

View File

@ -45,22 +45,22 @@ const StyledTriggerSettings = styled.div`
row-gap: ${({ theme }) => theme.spacing(4)}; row-gap: ${({ theme }) => theme.spacing(4)};
`; `;
type WorkflowEditTriggerDatabaseEventFormProps = type WorkflowEditTriggerDatabaseEventFormProps = {
| { trigger: WorkflowDatabaseEventTrigger;
trigger: WorkflowDatabaseEventTrigger; triggerOptions:
readonly: true; | {
onTriggerUpdate?: undefined; readonly: true;
} onTriggerUpdate?: undefined;
| { }
trigger: WorkflowDatabaseEventTrigger; | {
readonly?: false; readonly?: false;
onTriggerUpdate: (trigger: WorkflowDatabaseEventTrigger) => void; onTriggerUpdate: (trigger: WorkflowDatabaseEventTrigger) => void;
}; };
};
export const WorkflowEditTriggerDatabaseEventForm = ({ export const WorkflowEditTriggerDatabaseEventForm = ({
trigger, trigger,
readonly, triggerOptions,
onTriggerUpdate,
}: WorkflowEditTriggerDatabaseEventFormProps) => { }: WorkflowEditTriggerDatabaseEventFormProps) => {
const theme = useTheme(); const theme = useTheme();
@ -112,16 +112,16 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
dropdownId="workflow-edit-trigger-record-type" dropdownId="workflow-edit-trigger-record-type"
label="Record Type" label="Record Type"
fullWidth fullWidth
disabled={readonly} disabled={triggerOptions.readonly}
value={triggerEvent?.objectType} value={triggerEvent?.objectType}
emptyOption={{ label: 'Select an option', value: '' }} emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata} options={availableMetadata}
onChange={(updatedRecordType) => { onChange={(updatedRecordType) => {
if (readonly === true) { if (triggerOptions.readonly === true) {
return; return;
} }
onTriggerUpdate( triggerOptions.onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent) isDefined(trigger) && isDefined(triggerEvent)
? { ? {
...trigger, ...trigger,
@ -147,13 +147,13 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
value={triggerEvent?.event} value={triggerEvent?.event}
emptyOption={{ label: 'Select an option', value: '' }} emptyOption={{ label: 'Select an option', value: '' }}
options={OBJECT_EVENT_TRIGGERS} options={OBJECT_EVENT_TRIGGERS}
disabled={readonly} disabled={triggerOptions.readonly}
onChange={(updatedEvent) => { onChange={(updatedEvent) => {
if (readonly === true) { if (triggerOptions.readonly === true) {
return; return;
} }
onTriggerUpdate( triggerOptions.onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent) isDefined(trigger) && isDefined(triggerEvent)
? { ? {
...trigger, ...trigger,

View File

@ -10,22 +10,22 @@ import { getManualTriggerDefaultSettings } from '@/workflow/utils/getManualTrigg
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { IconHandMove, isDefined, useIcons } from 'twenty-ui'; import { IconHandMove, isDefined, useIcons } from 'twenty-ui';
type WorkflowEditTriggerManualFormProps = type WorkflowEditTriggerManualFormProps = {
| { trigger: WorkflowManualTrigger;
trigger: WorkflowManualTrigger; triggerOptions:
readonly: true; | {
onTriggerUpdate?: undefined; readonly: true;
} onTriggerUpdate?: undefined;
| { }
trigger: WorkflowManualTrigger; | {
readonly?: false; readonly?: false;
onTriggerUpdate: (trigger: WorkflowManualTrigger) => void; onTriggerUpdate: (trigger: WorkflowManualTrigger) => void;
}; };
};
export const WorkflowEditTriggerManualForm = ({ export const WorkflowEditTriggerManualForm = ({
trigger, trigger,
readonly, triggerOptions,
onTriggerUpdate,
}: WorkflowEditTriggerManualFormProps) => { }: WorkflowEditTriggerManualFormProps) => {
const theme = useTheme(); const theme = useTheme();
const { getIcon } = useIcons(); const { getIcon } = useIcons();
@ -54,15 +54,15 @@ export const WorkflowEditTriggerManualForm = ({
dropdownId="workflow-edit-manual-trigger-availability" dropdownId="workflow-edit-manual-trigger-availability"
label="Available" label="Available"
fullWidth fullWidth
disabled={readonly} disabled={triggerOptions.readonly}
value={manualTriggerAvailability} value={manualTriggerAvailability}
options={MANUAL_TRIGGER_AVAILABILITY_OPTIONS} options={MANUAL_TRIGGER_AVAILABILITY_OPTIONS}
onChange={(updatedTriggerType) => { onChange={(updatedTriggerType) => {
if (readonly === true) { if (triggerOptions.readonly === true) {
return; return;
} }
onTriggerUpdate({ triggerOptions.onTriggerUpdate({
...trigger, ...trigger,
settings: getManualTriggerDefaultSettings({ settings: getManualTriggerDefaultSettings({
availability: updatedTriggerType, availability: updatedTriggerType,
@ -79,13 +79,13 @@ export const WorkflowEditTriggerManualForm = ({
fullWidth fullWidth
value={trigger.settings.objectType} value={trigger.settings.objectType}
options={availableMetadata} options={availableMetadata}
disabled={readonly} disabled={triggerOptions.readonly}
onChange={(updatedObject) => { onChange={(updatedObject) => {
if (readonly === true) { if (triggerOptions.readonly === true) {
return; return;
} }
onTriggerUpdate({ triggerOptions.onTriggerUpdate({
...trigger, ...trigger,
settings: { settings: {
objectType: updatedObject, objectType: updatedObject,

View File

@ -1,3 +1,4 @@
import { WorkflowEditActionFormRecordCreate } from '@/workflow/components/WorkflowEditActionFormRecordCreate';
import { WorkflowEditActionFormSendEmail } from '@/workflow/components/WorkflowEditActionFormSendEmail'; import { WorkflowEditActionFormSendEmail } from '@/workflow/components/WorkflowEditActionFormSendEmail';
import { WorkflowEditActionFormServerlessFunction } from '@/workflow/components/WorkflowEditActionFormServerlessFunction'; import { WorkflowEditActionFormServerlessFunction } from '@/workflow/components/WorkflowEditActionFormServerlessFunction';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/components/WorkflowEditTriggerDatabaseEventForm'; import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/components/WorkflowEditTriggerDatabaseEventForm';
@ -9,6 +10,7 @@ import {
} from '@/workflow/types/Workflow'; } from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow'; import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { isWorkflowRecordCreateAction } from '@/workflow/utils/isWorkflowRecordCreateAction';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
type WorkflowStepDetailProps = type WorkflowStepDetailProps =
@ -53,8 +55,7 @@ export const WorkflowStepDetail = ({
return ( return (
<WorkflowEditTriggerDatabaseEventForm <WorkflowEditTriggerDatabaseEventForm
trigger={stepDefinition.definition} trigger={stepDefinition.definition}
// eslint-disable-next-line react/jsx-props-no-spreading triggerOptions={props}
{...props}
/> />
); );
} }
@ -62,8 +63,7 @@ export const WorkflowStepDetail = ({
return ( return (
<WorkflowEditTriggerManualForm <WorkflowEditTriggerManualForm
trigger={stepDefinition.definition} trigger={stepDefinition.definition}
// eslint-disable-next-line react/jsx-props-no-spreading triggerOptions={props}
{...props}
/> />
); );
} }
@ -80,8 +80,7 @@ export const WorkflowStepDetail = ({
return ( return (
<WorkflowEditActionFormServerlessFunction <WorkflowEditActionFormServerlessFunction
action={stepDefinition.definition} action={stepDefinition.definition}
// eslint-disable-next-line react/jsx-props-no-spreading actionOptions={props}
{...props}
/> />
); );
} }
@ -89,11 +88,22 @@ export const WorkflowStepDetail = ({
return ( return (
<WorkflowEditActionFormSendEmail <WorkflowEditActionFormSendEmail
action={stepDefinition.definition} action={stepDefinition.definition}
// eslint-disable-next-line react/jsx-props-no-spreading actionOptions={props}
{...props}
/> />
); );
} }
case 'RECORD_CRUD': {
if (isWorkflowRecordCreateAction(stepDefinition.definition)) {
return (
<WorkflowEditActionFormRecordCreate
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
return null;
}
} }
return assertUnreachable( return assertUnreachable(

View File

@ -1,5 +1,9 @@
import { WorkflowStepType } from '@/workflow/types/Workflow'; import { WorkflowStepType } from '@/workflow/types/Workflow';
import { IconComponent, IconSettingsAutomation } from 'twenty-ui'; import {
IconAddressBook,
IconComponent,
IconSettingsAutomation,
} from 'twenty-ui';
export const ACTIONS: Array<{ export const ACTIONS: Array<{
label: string; label: string;
@ -16,4 +20,9 @@ export const ACTIONS: Array<{
type: 'SEND_EMAIL', type: 'SEND_EMAIL',
icon: IconSettingsAutomation, icon: IconSettingsAutomation,
}, },
{
label: 'Create Record',
type: 'RECORD_CRUD.CREATE',
icon: IconAddressBook,
},
]; ];

View File

@ -1,7 +1,9 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useComputeStepOutputSchema } from '@/workflow/hooks/useComputeStepOutputSchema';
import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion'; import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion';
import { workflowCreateStepFromParentStepIdState } from '@/workflow/states/workflowCreateStepFromParentStepIdState'; import { workflowCreateStepFromParentStepIdState } from '@/workflow/states/workflowCreateStepFromParentStepIdState';
import { workflowDiagramTriggerNodeSelectionState } from '@/workflow/states/workflowDiagramTriggerNodeSelectionState'; import { workflowDiagramTriggerNodeSelectionState } from '@/workflow/states/workflowDiagramTriggerNodeSelectionState';
@ -16,7 +18,6 @@ import { getStepDefaultDefinition } from '@/workflow/utils/getStepDefaultDefinit
import { insertStep } from '@/workflow/utils/insertStep'; import { insertStep } from '@/workflow/utils/insertStep';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
import { useComputeStepOutputSchema } from '@/workflow/hooks/useComputeStepOutputSchema';
export const useCreateStep = ({ export const useCreateStep = ({
workflow, workflow,
@ -43,6 +44,8 @@ export const useCreateStep = ({
const { computeStepOutputSchema } = useComputeStepOutputSchema(); const { computeStepOutputSchema } = useComputeStepOutputSchema();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const insertNodeAndSave = async ({ const insertNodeAndSave = async ({
parentNodeId, parentNodeId,
nodeToAdd, nodeToAdd,
@ -86,7 +89,10 @@ export const useCreateStep = ({
throw new Error('Select a step to create a new step from first.'); throw new Error('Select a step to create a new step from first.');
} }
const newStep = getStepDefaultDefinition(newStepType); const newStep = getStepDefaultDefinition({
type: newStepType,
activeObjectMetadataItems,
});
const outputSchema = ( const outputSchema = (
await computeStepOutputSchema({ await computeStepOutputSchema({

View File

@ -1,4 +1,4 @@
type BaseWorkflowStepSettings = { type BaseWorkflowActionSettings = {
input: object; input: object;
outputSchema: object; outputSchema: object;
errorHandlingOptions: { errorHandlingOptions: {
@ -11,7 +11,7 @@ type BaseWorkflowStepSettings = {
}; };
}; };
export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & { export type WorkflowCodeActionSettings = BaseWorkflowActionSettings & {
input: { input: {
serverlessFunctionId: string; serverlessFunctionId: string;
serverlessFunctionVersion: string; serverlessFunctionVersion: string;
@ -21,7 +21,7 @@ export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & {
}; };
}; };
export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & { export type WorkflowSendEmailActionSettings = BaseWorkflowActionSettings & {
input: { input: {
connectedAccountId: string; connectedAccountId: string;
email: string; email: string;
@ -30,29 +30,83 @@ export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & {
}; };
}; };
type BaseWorkflowStep = { type ObjectRecord = Record<string, any>;
export type WorkflowCreateRecordActionInput = {
type: 'CREATE';
objectName: string;
objectRecord: ObjectRecord;
};
export type WorkflowUpdateRecordActionInput = {
type: 'UPDATE';
objectName: string;
objectRecord: ObjectRecord;
objectRecordId: string;
};
export type WorkflowDeleteRecordActionInput = {
type: 'DELETE';
objectName: string;
objectRecordId: string;
};
export type WorkflowRecordCRUDActionInput =
| WorkflowCreateRecordActionInput
| WorkflowUpdateRecordActionInput
| WorkflowDeleteRecordActionInput;
export type WorkflowRecordCRUDType = WorkflowRecordCRUDActionInput['type'];
export type WorkflowRecordCRUDActionSettings = BaseWorkflowActionSettings & {
input: WorkflowRecordCRUDActionInput;
};
type BaseWorkflowAction = {
id: string; id: string;
name: string; name: string;
valid: boolean; valid: boolean;
}; };
export type WorkflowCodeStep = BaseWorkflowStep & { export type WorkflowCodeAction = BaseWorkflowAction & {
type: 'CODE'; type: 'CODE';
settings: WorkflowCodeStepSettings; settings: WorkflowCodeActionSettings;
}; };
export type WorkflowSendEmailStep = BaseWorkflowStep & { export type WorkflowSendEmailAction = BaseWorkflowAction & {
type: 'SEND_EMAIL'; type: 'SEND_EMAIL';
settings: WorkflowSendEmailStepSettings; settings: WorkflowSendEmailActionSettings;
}; };
export type WorkflowAction = WorkflowCodeStep | WorkflowSendEmailStep; export type WorkflowRecordCRUDAction = BaseWorkflowAction & {
type: 'RECORD_CRUD';
settings: WorkflowRecordCRUDActionSettings;
};
export type WorkflowRecordCreateAction = WorkflowRecordCRUDAction & {
settings: { input: { type: 'CREATE' } };
};
export type WorkflowRecordUpdateAction = WorkflowRecordCRUDAction & {
settings: { input: { type: 'UPDATE' } };
};
export type WorkflowRecordDeleteAction = WorkflowRecordCRUDAction & {
settings: { input: { type: 'DELETE' } };
};
export type WorkflowAction =
| WorkflowCodeAction
| WorkflowSendEmailAction
| WorkflowRecordCRUDAction;
export type WorkflowStep = WorkflowAction; export type WorkflowStep = WorkflowAction;
export type WorkflowActionType = WorkflowAction['type']; export type WorkflowActionType =
| Exclude<WorkflowAction['type'], WorkflowRecordCRUDAction['type']>
| `${WorkflowRecordCRUDAction['type']}.${WorkflowRecordCRUDType}`;
export type WorkflowStepType = WorkflowStep['type']; export type WorkflowStepType = WorkflowActionType;
type BaseTrigger = { type BaseTrigger = {
type: string; type: string;

View File

@ -1,5 +1,9 @@
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId'; import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow'; import {
WorkflowActionType,
WorkflowStep,
WorkflowTrigger,
} from '@/workflow/types/Workflow';
import { import {
WorkflowDiagram, WorkflowDiagram,
WorkflowDiagramEdge, WorkflowDiagramEdge,
@ -30,12 +34,26 @@ export const generateWorkflowDiagram = ({
yPos: number, yPos: number,
) => { ) => {
const nodeId = step.id; const nodeId = step.id;
let nodeActionType: WorkflowActionType;
if (step.type === 'RECORD_CRUD') {
nodeActionType = `RECORD_CRUD.${step.settings.input.type}`;
} else {
nodeActionType = step.type;
}
let nodeLabel = step.name;
if (step.type === 'RECORD_CRUD') {
// FIXME: use activeObjectMetadataItems to get labelSingular
nodeLabel = `${capitalize(step.settings.input.type.toLowerCase())} ${capitalize(step.settings.input.objectName)}`;
}
nodes.push({ nodes.push({
id: nodeId, id: nodeId,
data: { data: {
nodeType: 'action', nodeType: 'action',
actionType: step.type, actionType: nodeActionType,
label: step.name, label: nodeLabel,
}, },
position: { position: {
x: xPos, x: xPos,

View File

@ -1,10 +1,15 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { WorkflowStep, WorkflowStepType } from '@/workflow/types/Workflow'; import { WorkflowStep, WorkflowStepType } from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
export const getStepDefaultDefinition = ( export const getStepDefaultDefinition = ({
type: WorkflowStepType, type,
): WorkflowStep => { activeObjectMetadataItems,
}: {
type: WorkflowStepType;
activeObjectMetadataItems: ObjectMetadataItem[];
}): WorkflowStep => {
const newStepId = v4(); const newStepId = v4();
switch (type) { switch (type) {
@ -57,6 +62,34 @@ export const getStepDefaultDefinition = (
}, },
}; };
} }
case 'RECORD_CRUD.CREATE': {
return {
id: newStepId,
name: 'Record Create',
type: 'RECORD_CRUD',
valid: false,
settings: {
input: {
type: 'CREATE',
objectName: activeObjectMetadataItems[0].nameSingular,
objectRecord: {},
},
outputSchema: {},
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
};
}
case 'RECORD_CRUD.DELETE':
case 'RECORD_CRUD.UPDATE': {
throw new Error('Not implemented yet');
}
default: { default: {
return assertUnreachable(type, `Unknown type: ${type}`); return assertUnreachable(type, `Unknown type: ${type}`);
} }

View File

@ -0,0 +1,12 @@
import {
WorkflowAction,
WorkflowRecordCreateAction,
} from '@/workflow/types/Workflow';
export const isWorkflowRecordCreateAction = (
action: WorkflowAction,
): action is WorkflowRecordCreateAction => {
return (
action.type === 'RECORD_CRUD' && action.settings.input.type === 'CREATE'
);
};

View File

@ -3,12 +3,22 @@ import { useTheme } from '@emotion/react';
import IconAddressBookRaw from '@ui/display/icon/assets/address-book.svg?react'; import IconAddressBookRaw from '@ui/display/icon/assets/address-book.svg?react';
import { IconComponentProps } from '@ui/display/icon/types/IconComponent'; import { IconComponentProps } from '@ui/display/icon/types/IconComponent';
type IconAddressBookProps = Pick<IconComponentProps, 'size' | 'stroke'>; type IconAddressBookProps = Pick<
IconComponentProps,
'size' | 'stroke' | 'color'
>;
export const IconAddressBook = (props: IconAddressBookProps) => { export const IconAddressBook = (props: IconAddressBookProps) => {
const theme = useTheme(); const theme = useTheme();
const size = props.size ?? 24; const size = props.size ?? 24;
const stroke = props.stroke ?? theme.icon.stroke.md; const stroke = props.stroke ?? theme.icon.stroke.md;
return <IconAddressBookRaw height={size} width={size} strokeWidth={stroke} />; return (
<IconAddressBookRaw
height={size}
width={size}
stroke={props.color ?? 'currentColor'}
strokeWidth={stroke}
/>
);
}; };