Make upsert action body common with create record instead of update record (#15442)

We do not want the fields to update multiselect for upsert record
action. We want all available fields displayed by default.
This makes upsert record action closer to create record than update
record.

This PR: 
- deletes WorkflowUpdateRecordBody that was common between update and
upsert and put back content into update
- creates WorkflowCreateRecordBody that is now common between create and
upsert
- simplifies shouldDisplayFormField

Before - using fields to update as update record action
<img width="546" height="823" alt="Capture d’écran 2025-10-30 à 10 00
24"
src="https://github.com/user-attachments/assets/9206bc8b-75c2-40fa-a8de-e708b6b2cd05"
/>

After - displaying all fields as create record action
<img width="546" height="823" alt="Capture d’écran 2025-10-30 à 10 00
04"
src="https://github.com/user-attachments/assets/87141a47-946f-4604-be55-f4c21ff4a3d8"
/>
This commit is contained in:
Thomas Trompette
2025-10-30 13:36:16 +01:00
committed by GitHub
parent 8edb2c5520
commit a82b74c1ff
12 changed files with 526 additions and 545 deletions
@@ -0,0 +1,239 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useViewOrDefaultViewFromPrefetchedViews } from '@/views/hooks/useViewOrDefaultViewFromPrefetchedViews';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { type JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { RelationType } from '~/generated-metadata/graphql';
type RelationManyToOneField = {
id: string;
};
export type CreateRecordFormData = {
objectName: string;
[field: string]: RelationManyToOneField | JsonValue;
};
type WorkflowCreateRecordBodyProps = {
defaultObjectName: string;
defaultObjectRecord: Record<string, unknown>;
readonly: boolean;
actionType: 'CREATE_RECORD' | 'UPSERT_RECORD';
onUpdate: (formData: CreateRecordFormData) => void;
};
const sortByViewFieldPosition = (
a: { viewFieldPosition?: number },
b: { viewFieldPosition?: number },
) => {
if (isDefined(a.viewFieldPosition) && isDefined(b.viewFieldPosition)) {
return a.viewFieldPosition - b.viewFieldPosition;
}
if (isDefined(a.viewFieldPosition)) {
return -1;
}
if (isDefined(b.viewFieldPosition)) {
return 1;
}
return 0;
};
export const WorkflowCreateRecordBody = ({
defaultObjectName,
defaultObjectRecord,
readonly,
actionType,
onUpdate,
}: WorkflowCreateRecordBodyProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { activeNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const availableMetadata: Array<SelectOption<string>> =
activeNonSystemObjectMetadataItems
.filter((objectMetadataItem) =>
canObjectBeManagedByWorkflow({
nameSingular: objectMetadataItem.nameSingular,
isSystem: objectMetadataItem.isSystem,
}),
)
.map((item) => ({
Icon: getIcon(item.icon),
label: item.labelPlural,
value: item.nameSingular,
}));
const [formData, setFormData] = useState<CreateRecordFormData>({
objectName: defaultObjectName,
...defaultObjectRecord,
});
const objectNameSingular = formData.objectName;
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === objectNameSingular,
);
const { view: indexView } = useViewOrDefaultViewFromPrefetchedViews({
objectMetadataItemId: objectMetadataItem?.id ?? '',
});
const viewFields = indexView?.viewFields ?? [];
const inlineFieldMetadataItems = objectMetadataItem?.fields
.filter((fieldMetadataItem) =>
shouldDisplayFormField({ fieldMetadataItem, actionType }),
)
.map((fieldMetadataItem) => {
const viewField = viewFields.find(
(viewField) => viewField.fieldMetadataId === fieldMetadataItem.id,
);
return {
...fieldMetadataItem,
viewFieldPosition: viewField?.position,
};
})
.sort(sortByViewFieldPosition);
const inlineFieldDefinitions = isDefined(objectMetadataItem)
? inlineFieldMetadataItems?.map((fieldMetadataItem) =>
formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
)
: [];
const handleFieldChange = (
fieldName: keyof CreateRecordFormData,
updatedValue: JsonValue,
) => {
const fieldDefinition = inlineFieldDefinitions?.find(
(definition) => definition.metadata.fieldName === fieldName,
);
if (!isDefined(fieldDefinition)) {
return;
}
const isFieldRelationManyToOne =
isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationType === RelationType.MANY_TO_ONE;
const fieldValue = isFieldRelationManyToOne
? {
id: updatedValue,
}
: updatedValue;
const newFormData: CreateRecordFormData = {
...formData,
[fieldName]: fieldValue,
};
setFormData(newFormData);
saveAction(newFormData);
};
const saveAction = useDebouncedCallback(
async (formData: CreateRecordFormData) => {
if (readonly === true) {
return;
}
const { objectName: updatedObjectName, ...updatedOtherFields } = formData;
onUpdate({
objectName: updatedObjectName,
...updatedOtherFields,
});
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
return (
<WorkflowStepBody>
<Select
dropdownId="workflow-create-record-object-name"
label="Object"
fullWidth
disabled={readonly}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: CreateRecordFormData = {
objectName: updatedObjectName,
};
setFormData(newFormData);
saveAction(newFormData);
}}
withSearchInput
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
<HorizontalSeparator noMargin />
{inlineFieldDefinitions?.map((fieldDefinition) => {
const isFieldRelationManyToOne =
isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationType === RelationType.MANY_TO_ONE;
const currentValue = isFieldRelationManyToOne
? (
formData[
fieldDefinition.metadata.fieldName
] as RelationManyToOneField
)?.id
: (formData[fieldDefinition.metadata.fieldName] as JsonValue);
return (
<FormFieldInput
key={fieldDefinition.metadata.fieldName}
defaultValue={currentValue}
field={fieldDefinition}
onChange={(value) => {
handleFieldChange(fieldDefinition.metadata.fieldName, value);
}}
VariablePicker={WorkflowVariablePicker}
readonly={readonly}
/>
);
})}
</WorkflowStepBody>
);
};
@@ -1,28 +1,13 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useViewOrDefaultViewFromPrefetchedViews } from '@/views/hooks/useViewOrDefaultViewFromPrefetchedViews';
import { type WorkflowCreateRecordAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import {
WorkflowCreateRecordBody,
type CreateRecordFormData,
} from '@/workflow/workflow-steps/workflow-actions/components/WorkflowCreateRecordBody';
import { CREATE_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/CreateRecordAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { type JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { RelationType } from '~/generated-metadata/graphql';
import { useIcons } from 'twenty-ui/display';
type WorkflowEditActionCreateRecordProps = {
action: WorkflowCreateRecordAction;
@@ -36,172 +21,39 @@ type WorkflowEditActionCreateRecordProps = {
};
};
type RelationManyToOneField = {
id: string;
};
type CreateRecordFormData = {
objectName: string;
[field: string]: RelationManyToOneField | JsonValue;
};
const sortByViewFieldPosition = (
a: { viewFieldPosition?: number },
b: { viewFieldPosition?: number },
) => {
if (isDefined(a.viewFieldPosition) && isDefined(b.viewFieldPosition)) {
return a.viewFieldPosition - b.viewFieldPosition;
}
if (isDefined(a.viewFieldPosition)) {
return -1;
}
if (isDefined(b.viewFieldPosition)) {
return 1;
}
return 0;
};
export const WorkflowEditActionCreateRecord = ({
action,
actionOptions,
}: WorkflowEditActionCreateRecordProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { activeNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const availableMetadata: Array<SelectOption<string>> =
activeNonSystemObjectMetadataItems
.filter((objectMetadataItem) =>
canObjectBeManagedByWorkflow({
nameSingular: objectMetadataItem.nameSingular,
isSystem: objectMetadataItem.isSystem,
}),
)
.map((item) => ({
Icon: getIcon(item.icon),
label: item.labelPlural,
value: item.nameSingular,
}));
const [formData, setFormData] = useState<CreateRecordFormData>({
objectName: action.settings.input.objectName,
...action.settings.input.objectRecord,
});
const isFormDisabled = actionOptions.readonly;
const objectNameSingular = formData.objectName;
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === objectNameSingular,
);
const { view: indexView } = useViewOrDefaultViewFromPrefetchedViews({
objectMetadataItemId: objectMetadataItem?.id ?? '',
});
const viewFields = indexView?.viewFields ?? [];
const inlineFieldMetadataItems = objectMetadataItem?.fields
.filter((fieldMetadataItem) =>
shouldDisplayFormField({ fieldMetadataItem, actionType: action.type }),
)
.map((fieldMetadataItem) => {
const viewField = viewFields.find(
(viewField) => viewField.fieldMetadataId === fieldMetadataItem.id,
);
return {
...fieldMetadataItem,
viewFieldPosition: viewField?.position,
};
})
.sort(sortByViewFieldPosition);
const inlineFieldDefinitions = isDefined(objectMetadataItem)
? inlineFieldMetadataItems?.map((fieldMetadataItem) =>
formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
)
: [];
const handleFieldChange = (
fieldName: keyof CreateRecordFormData,
updatedValue: JsonValue,
) => {
const fieldDefinition = inlineFieldDefinitions?.find(
(definition) => definition.metadata.fieldName === fieldName,
);
if (!isDefined(fieldDefinition)) {
return;
}
const isFieldRelationManyToOne =
isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationType === RelationType.MANY_TO_ONE;
const fieldValue = isFieldRelationManyToOne
? {
id: updatedValue,
}
: updatedValue;
const newFormData: CreateRecordFormData = {
...formData,
[fieldName]: fieldValue,
};
setFormData(newFormData);
saveAction(newFormData);
};
const saveAction = useDebouncedCallback(
async (formData: CreateRecordFormData) => {
if (actionOptions.readonly === true) {
return;
}
const { objectName: updatedObjectName, ...updatedOtherFields } = formData;
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
objectName: updatedObjectName,
objectRecord: updatedOtherFields,
},
},
});
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: CREATE_RECORD_ACTION.defaultLabel,
});
const handleUpdate = (formData: CreateRecordFormData) => {
if (actionOptions.readonly === true) {
return;
}
const { objectName: updatedObjectName, ...updatedOtherFields } = formData;
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
objectName: updatedObjectName,
objectRecord: updatedOtherFields,
},
},
});
};
return (
<>
<SidePanelHeader
@@ -222,58 +74,13 @@ export const WorkflowEditActionCreateRecord = ({
disabled={isFormDisabled}
iconTooltip={CREATE_RECORD_ACTION.defaultLabel}
/>
<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,
};
setFormData(newFormData);
saveAction(newFormData);
}}
withSearchInput
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
<HorizontalSeparator noMargin />
{inlineFieldDefinitions?.map((fieldDefinition) => {
const isFieldRelationManyToOne =
isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationType === RelationType.MANY_TO_ONE;
const currentValue = isFieldRelationManyToOne
? (
formData[
fieldDefinition.metadata.fieldName
] as RelationManyToOneField
)?.id
: (formData[fieldDefinition.metadata.fieldName] as JsonValue);
return (
<FormFieldInput
key={fieldDefinition.metadata.fieldName}
defaultValue={currentValue}
field={fieldDefinition}
onChange={(value) => {
handleFieldChange(fieldDefinition.metadata.fieldName, value);
}}
VariablePicker={WorkflowVariablePicker}
readonly={isFormDisabled}
/>
);
})}
</WorkflowStepBody>
<WorkflowCreateRecordBody
defaultObjectName={action.settings.input.objectName}
defaultObjectRecord={action.settings.input.objectRecord}
readonly={isFormDisabled ?? false}
actionType="CREATE_RECORD"
onUpdate={handleUpdate}
/>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
</>
);
@@ -2,11 +2,13 @@ import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope';
import { MICROSOFT_SEND_SCOPE } from '@/accounts/constants/MicrosoftSendScope';
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { WorkflowSendEmailAttachments } from '@/advanced-text-editor/components/WorkflowSendEmailAttachments';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -16,6 +18,7 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { SEND_EMAIL_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/SendEmailAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
@@ -31,9 +34,6 @@ import { type SelectOption } from 'twenty-ui/input';
import { type JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { WorkflowSendEmailAttachments } from '@/advanced-text-editor/components/WorkflowSendEmailAttachments';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
const EMAIL_EDITOR_MIN_HEIGHT = 340;
@@ -331,13 +331,6 @@ export const WorkflowEditActionSendEmail = ({
}}
VariablePicker={WorkflowVariablePicker}
/>
<WorkflowSendEmailAttachments
label="Attachments"
files={formData.files}
onChange={(files) => {
handleFieldChange('files', files);
}}
/>
<FormAdvancedTextFieldInput
label="Body"
placeholder="Enter email body"
@@ -366,6 +359,13 @@ export const WorkflowEditActionSendEmail = ({
minHeight={EMAIL_EDITOR_MIN_HEIGHT}
maxWidth={EMAIL_EDITOR_MAX_WIDTH}
/>
<WorkflowSendEmailAttachments
label="Attachments"
files={formData.files}
onChange={(files) => {
handleFieldChange('files', files);
}}
/>
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
</>
@@ -1,12 +1,29 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput';
import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpdateEventFieldsMultiSelect';
import { type WorkflowUpdateRecordAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { WorkflowUpdateRecordBody } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowUpdateRecordBody';
import { UPDATE_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/UpdateRecordAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { type UpdateRecordFormData } from '@/workflow/workflow-steps/workflow-actions/types/update-record-form-data.type';
import { useIcons } from 'twenty-ui/display';
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { type JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { RelationType } from '~/generated-metadata/graphql';
type WorkflowEditActionUpdateRecordProps = {
action: WorkflowUpdateRecordAction;
@@ -24,35 +41,119 @@ export const WorkflowEditActionUpdateRecord = ({
action,
actionOptions,
}: WorkflowEditActionUpdateRecordProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { activeNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const availableMetadata: Array<SelectOption<string>> =
activeNonSystemObjectMetadataItems
.filter((objectMetadataItem) =>
canObjectBeManagedByWorkflow({
nameSingular: objectMetadataItem.nameSingular,
isSystem: objectMetadataItem.isSystem,
}),
)
.map((item) => ({
Icon: getIcon(item.icon),
label: item.labelPlural,
value: item.nameSingular,
}));
const [formData, setFormData] = useState<UpdateRecordFormData>({
objectNameSingular: action.settings.input.objectName,
objectRecordId: action.settings.input.objectRecordId,
fieldsToUpdate: action.settings.input.fieldsToUpdate ?? [],
...action.settings.input.objectRecord,
});
const isFormDisabled = actionOptions.readonly === true;
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: UPDATE_RECORD_ACTION.defaultLabel,
});
const { getIcon } = useIcons();
const handleFieldChange = (
fieldName: keyof UpdateRecordFormData,
updatedValue: JsonValue,
) => {
const newFormData: UpdateRecordFormData = {
...formData,
[fieldName]: updatedValue,
};
const isFormDisabled = actionOptions.readonly === true;
setFormData(newFormData);
const handleUpdate = (formData: UpdateRecordFormData) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
objectName: formData.objectNameSingular,
objectRecord: formData,
objectRecordId: formData.objectRecordId ?? '',
fieldsToUpdate: formData.fieldsToUpdate,
},
},
});
saveAction(newFormData);
};
const selectedObjectMetadataItem = activeNonSystemObjectMetadataItems.find(
(item) => item.nameSingular === formData.objectNameSingular,
);
const objectNameSingular = selectedObjectMetadataItem?.nameSingular;
const inlineFieldMetadataItems = selectedObjectMetadataItem?.fields
.filter((fieldMetadataItem) =>
shouldDisplayFormField({
fieldMetadataItem,
actionType: 'UPDATE_RECORD',
}),
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
);
const inlineFieldDefinitions = isDefined(selectedObjectMetadataItem)
? inlineFieldMetadataItems?.map((fieldMetadataItem) =>
formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem: selectedObjectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
)
: [];
const saveAction = useDebouncedCallback(
async (formData: UpdateRecordFormData) => {
if (actionOptions.readonly === true) {
return;
}
const {
objectNameSingular: updatedObjectName,
objectRecordId: updatedObjectRecordId,
fieldsToUpdate: updatedFieldsToUpdate,
...updatedOtherFields
} = formData;
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
objectName: updatedObjectName,
objectRecordId: updatedObjectRecordId ?? '',
fieldsToUpdate: updatedFieldsToUpdate ?? [],
objectRecord: updatedOtherFields,
},
},
});
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
return (
<>
<SidePanelHeader
@@ -73,16 +174,95 @@ export const WorkflowEditActionUpdateRecord = ({
disabled={isFormDisabled}
iconTooltip={UPDATE_RECORD_ACTION.defaultLabel}
/>
<WorkflowUpdateRecordBody
defaultObjectNameSingular={action.settings.input.objectName}
defaultObjectRecordId={action.settings.input.objectRecordId}
defaultFieldsToUpdate={action.settings.input.fieldsToUpdate}
defaultObjectRecord={action.settings.input.objectRecord}
readonly={isFormDisabled}
actionType="UPDATE_RECORD"
onUpdate={handleUpdate}
shouldPickRecord={true}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-update-record-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectNameSingular}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: UpdateRecordFormData = {
objectNameSingular: updatedObjectName,
objectRecordId: '',
fieldsToUpdate: [],
};
setFormData(newFormData);
saveAction(newFormData);
}}
withSearchInput
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
<HorizontalSeparator noMargin />
{isDefined(objectNameSingular) && (
<FormSingleRecordPicker
testId="workflow-update-record-object-record-id"
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
}
objectNameSingulars={[objectNameSingular]}
defaultValue={formData.objectRecordId}
disabled={isFormDisabled}
VariablePicker={WorkflowVariablePicker}
/>
)}
{isDefined(selectedObjectMetadataItem) && (
<WorkflowFieldsMultiSelect
label="Fields to update"
placeholder="Select fields to update"
objectMetadataItem={selectedObjectMetadataItem}
handleFieldsChange={(fieldsToUpdate) =>
handleFieldChange('fieldsToUpdate', fieldsToUpdate)
}
readonly={isFormDisabled ?? false}
defaultFields={formData.fieldsToUpdate}
/>
)}
<HorizontalSeparator noMargin />
{formData.fieldsToUpdate.map((fieldName) => {
const fieldDefinition = inlineFieldDefinitions?.find((definition) => {
const isFieldRelationManyToOne =
isFieldRelation(definition) &&
definition.metadata.relationType === RelationType.MANY_TO_ONE;
const value = isFieldRelationManyToOne
? `${definition.metadata.fieldName}Id`
: definition.metadata.fieldName;
return value === fieldName;
});
if (!isDefined(fieldDefinition)) {
return null;
}
const currentValue = formData[fieldName] as JsonValue;
return (
<FormFieldInput
key={fieldName}
defaultValue={currentValue}
field={fieldDefinition}
onChange={(value) => {
handleFieldChange(fieldName, value);
}}
VariablePicker={WorkflowVariablePicker}
readonly={isFormDisabled}
/>
);
})}
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
</>
);
@@ -1,11 +1,13 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { type WorkflowUpsertRecordAction } from '@/workflow/types/Workflow';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { WorkflowUpdateRecordBody } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowUpdateRecordBody';
import {
WorkflowCreateRecordBody,
type CreateRecordFormData,
} from '@/workflow/workflow-steps/workflow-actions/components/WorkflowCreateRecordBody';
import { UPSERT_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/UpsertRecordAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { type UpdateRecordFormData } from '@/workflow/workflow-steps/workflow-actions/types/update-record-form-data.type';
import { useIcons } from 'twenty-ui/display';
type WorkflowEditActionUpsertRecordProps = {
@@ -34,19 +36,20 @@ export const WorkflowEditActionUpsertRecord = ({
const isFormDisabled = actionOptions.readonly === true;
const handleUpdate = (formData: UpdateRecordFormData) => {
const handleUpdate = (formData: CreateRecordFormData) => {
if (actionOptions.readonly === true) {
return;
}
const { objectName: updatedObjectName, ...updatedOtherFields } = formData;
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
objectName: formData.objectNameSingular,
objectRecord: formData,
fieldsToUpdate: formData.fieldsToUpdate,
objectName: updatedObjectName,
objectRecord: updatedOtherFields,
},
},
});
@@ -72,14 +75,12 @@ export const WorkflowEditActionUpsertRecord = ({
disabled={isFormDisabled}
iconTooltip={UPSERT_RECORD_ACTION.defaultLabel}
/>
<WorkflowUpdateRecordBody
defaultObjectNameSingular={action.settings.input.objectName}
defaultFieldsToUpdate={action.settings.input.fieldsToUpdate}
<WorkflowCreateRecordBody
defaultObjectName={action.settings.input.objectName}
defaultObjectRecord={action.settings.input.objectRecord}
readonly={isFormDisabled}
actionType="UPSERT_RECORD"
onUpdate={handleUpdate}
shouldPickRecord={false}
/>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
</>
@@ -1,232 +0,0 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select } from '@/ui/input/components/Select';
import { useEffect, useState } from 'react';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput';
import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpdateEventFieldsMultiSelect';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { type UpdateRecordFormData } from '@/workflow/workflow-steps/workflow-actions/types/update-record-form-data.type';
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import { isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { type JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { RelationType } from '~/generated-metadata/graphql';
type WorkflowUpdateRecordBodyProps = {
defaultObjectNameSingular: string;
defaultFieldsToUpdate: string[];
defaultObjectRecord: Record<string, unknown>;
defaultObjectRecordId?: string;
actionType: 'UPDATE_RECORD' | 'UPSERT_RECORD';
readonly: boolean;
onUpdate: (formData: UpdateRecordFormData) => void;
shouldPickRecord: boolean;
};
export const WorkflowUpdateRecordBody = ({
defaultObjectNameSingular,
defaultFieldsToUpdate,
defaultObjectRecord,
defaultObjectRecordId,
readonly,
actionType,
onUpdate,
shouldPickRecord,
}: WorkflowUpdateRecordBodyProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { activeNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const availableMetadata: Array<SelectOption<string>> =
activeNonSystemObjectMetadataItems
.filter((objectMetadataItem) =>
canObjectBeManagedByWorkflow({
nameSingular: objectMetadataItem.nameSingular,
isSystem: objectMetadataItem.isSystem,
}),
)
.map((item) => ({
Icon: getIcon(item.icon),
label: item.labelPlural,
value: item.nameSingular,
}));
const [formData, setFormData] = useState<UpdateRecordFormData>({
objectNameSingular: defaultObjectNameSingular,
objectRecordId: defaultObjectRecordId,
fieldsToUpdate: defaultFieldsToUpdate ?? [],
...defaultObjectRecord,
});
const handleFieldChange = (
fieldName: keyof UpdateRecordFormData,
updatedValue: JsonValue,
) => {
const newFormData: UpdateRecordFormData = {
...formData,
[fieldName]: updatedValue,
};
setFormData(newFormData);
saveAction(newFormData);
};
const selectedObjectMetadataItem = activeNonSystemObjectMetadataItems.find(
(item) => item.nameSingular === formData.objectNameSingular,
);
const objectNameSingular = selectedObjectMetadataItem?.nameSingular;
const inlineFieldMetadataItems = selectedObjectMetadataItem?.fields
.filter((fieldMetadataItem) =>
shouldDisplayFormField({ fieldMetadataItem, actionType }),
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
);
const inlineFieldDefinitions = isDefined(selectedObjectMetadataItem)
? inlineFieldMetadataItems?.map((fieldMetadataItem) =>
formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem: selectedObjectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
)
: [];
const saveAction = useDebouncedCallback(
async (formData: UpdateRecordFormData) => {
if (readonly === true) {
return;
}
const {
objectNameSingular: updatedObjectName,
objectRecordId: updatedObjectRecordId,
fieldsToUpdate: updatedFieldsToUpdate,
...updatedOtherFields
} = formData;
onUpdate({
objectNameSingular: updatedObjectName,
objectRecordId: updatedObjectRecordId ?? '',
fieldsToUpdate: updatedFieldsToUpdate ?? [],
...updatedOtherFields,
});
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
return (
<WorkflowStepBody>
<Select
dropdownId="workflow-update-record-object-name"
label="Object"
fullWidth
disabled={readonly}
value={formData.objectNameSingular}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: UpdateRecordFormData = {
objectNameSingular: updatedObjectName,
objectRecordId: '',
fieldsToUpdate: [],
};
setFormData(newFormData);
saveAction(newFormData);
}}
withSearchInput
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
<HorizontalSeparator noMargin />
{isDefined(objectNameSingular) && shouldPickRecord && (
<FormSingleRecordPicker
testId="workflow-update-record-object-record-id"
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
}
objectNameSingulars={[objectNameSingular]}
defaultValue={formData.objectRecordId}
disabled={readonly}
VariablePicker={WorkflowVariablePicker}
/>
)}
{isDefined(selectedObjectMetadataItem) && (
<WorkflowFieldsMultiSelect
label="Fields to update"
placeholder="Select fields to update"
objectMetadataItem={selectedObjectMetadataItem}
handleFieldsChange={(fieldsToUpdate) =>
handleFieldChange('fieldsToUpdate', fieldsToUpdate)
}
readonly={readonly ?? false}
defaultFields={formData.fieldsToUpdate}
/>
)}
<HorizontalSeparator noMargin />
{formData.fieldsToUpdate.map((fieldName) => {
const fieldDefinition = inlineFieldDefinitions?.find((definition) => {
const isFieldRelationManyToOne =
isFieldRelation(definition) &&
definition.metadata.relationType === RelationType.MANY_TO_ONE;
const value = isFieldRelationManyToOne
? `${definition.metadata.fieldName}Id`
: definition.metadata.fieldName;
return value === fieldName;
});
if (!isDefined(fieldDefinition)) {
return null;
}
const currentValue = formData[fieldName] as JsonValue;
return (
<FormFieldInput
key={fieldName}
defaultValue={currentValue}
field={fieldDefinition}
onChange={(value) => {
handleFieldChange(fieldName, value);
}}
VariablePicker={WorkflowVariablePicker}
readonly={readonly}
/>
);
})}
</WorkflowStepBody>
);
};
@@ -3,7 +3,7 @@ import { type WorkflowActionType } from '@/workflow/types/Workflow';
import { CustomError } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const COMMON_DISPLAYABLE_FIELD_TYPES = [
const SUPPORTED_FORM_FIELD_TYPES = [
FieldMetadataType.TEXT,
FieldMetadataType.NUMBER,
FieldMetadataType.DATE,
@@ -19,10 +19,6 @@ const COMMON_DISPLAYABLE_FIELD_TYPES = [
FieldMetadataType.DATE_TIME,
FieldMetadataType.RAW_JSON,
FieldMetadataType.UUID,
];
const FIND_RECORDS_DISPLAYABLE_FIELD_TYPES = [
...COMMON_DISPLAYABLE_FIELD_TYPES,
FieldMetadataType.ARRAY,
FieldMetadataType.RELATION,
];
@@ -34,35 +30,28 @@ export const shouldDisplayFormField = ({
fieldMetadataItem: FieldMetadataItem;
actionType: WorkflowActionType;
}) => {
let isTypeAllowedForAction = false;
if (!SUPPORTED_FORM_FIELD_TYPES.includes(fieldMetadataItem.type)) {
return false;
}
const isIdField = fieldMetadataItem.name === 'id';
const isNotSupportedRelation =
fieldMetadataItem.type === FieldMetadataType.RELATION &&
fieldMetadataItem.settings?.['relationType'] !== 'MANY_TO_ONE';
switch (actionType) {
case 'CREATE_RECORD':
isTypeAllowedForAction =
fieldMetadataItem.type !== FieldMetadataType.RELATION ||
fieldMetadataItem.settings?.['relationType'] === 'MANY_TO_ONE';
return (
isTypeAllowedForAction &&
!fieldMetadataItem.isSystem &&
fieldMetadataItem.isActive
);
case 'UPDATE_RECORD':
case 'UPSERT_RECORD':
isTypeAllowedForAction =
COMMON_DISPLAYABLE_FIELD_TYPES.includes(fieldMetadataItem.type) ||
fieldMetadataItem.settings?.['relationType'] === 'MANY_TO_ONE';
return (
isTypeAllowedForAction &&
!isNotSupportedRelation &&
!fieldMetadataItem.isUIReadOnly &&
!fieldMetadataItem.isSystem &&
fieldMetadataItem.isActive
);
case 'FIND_RECORDS':
isTypeAllowedForAction = FIND_RECORDS_DISPLAYABLE_FIELD_TYPES.includes(
fieldMetadataItem.type,
);
return (
isTypeAllowedForAction &&
!isNotSupportedRelation &&
(!fieldMetadataItem.isSystem || isIdField) &&
fieldMetadataItem.isActive
);
@@ -95,10 +95,13 @@ export const WorkflowEditTriggerWebhookForm = ({
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
triggerOptions.onTriggerUpdate(
{
...trigger,
name: newName,
},
{ computeOutputSchema: false },
);
}}
Icon={getIcon(headerIcon)}
iconColor={getTriggerIconColor({ theme, triggerType: trigger.type })}
@@ -30,13 +30,8 @@ export class UpsertRecordService {
) {}
async execute(params: UpsertRecordParams): Promise<ToolOutput> {
const {
objectName,
objectRecord,
fieldsToUpdate,
workspaceId,
rolePermissionConfig,
} = params;
const { objectName, objectRecord, workspaceId, rolePermissionConfig } =
params;
if (!workspaceId) {
return {
@@ -54,7 +49,9 @@ export class UpsertRecordService {
rolePermissionConfig,
);
const fieldsToUpdateArray = fieldsToUpdate || Object.keys(objectRecord);
const fieldsToUpdateArray = Object.keys(objectRecord).filter((field) =>
isDefined(objectRecord[field]),
);
const { objectMetadataItemWithFieldsMaps } =
await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps(
@@ -43,5 +43,4 @@ export type FindRecordsInput = {
export type UpsertRecordInput = {
objectName: string;
objectRecord: ObjectRecordProperties;
fieldsToUpdate?: string[];
};
@@ -74,7 +74,6 @@ export class UpsertRecordWorkflowAction implements WorkflowAction {
const toolOutput = await this.upsertRecordService.execute({
objectName: workflowActionInput.objectName,
objectRecord: workflowActionInput.objectRecord,
fieldsToUpdate: workflowActionInput.fieldsToUpdate,
workspaceId,
rolePermissionConfig: executionContext.rolePermissionConfig,
});
@@ -7,6 +7,5 @@ export const workflowUpsertRecordActionSettingsSchema =
input: z.object({
objectName: z.string(),
objectRecord: objectRecordSchema,
fieldsToUpdate: z.array(z.string()),
}),
});