Upsert based on ID field (#16275)

<img width="225" height="187" alt="Capture d’écran 2025-12-03 à 11 49
39"
src="https://github.com/user-attachments/assets/042daa56-71d1-4f6c-811b-c626adde4ceb"
/>


- Split CreateRecordBody in two, there are too many differences with
CreateRecord
- Add ID field to the upsert multiselect
- Provide the Record id field in upsert action so the user can edit

Fixes https://github.com/twentyhq/twenty/issues/15929
This commit is contained in:
Thomas Trompette
2025-12-03 13:54:37 +01:00
committed by GitHub
parent 9b0e8d3232
commit 21a433dd93
8 changed files with 532 additions and 317 deletions
@@ -16,6 +16,7 @@ export const WorkflowFieldsMultiSelect = ({
defaultFields,
placeholder,
hint,
actionType,
}: {
label: string;
placeholder: string;
@@ -23,6 +24,7 @@ export const WorkflowFieldsMultiSelect = ({
handleFieldsChange: (field: FieldMultiSelectValue | string) => void;
readonly: boolean;
defaultFields: string[] | undefined | null;
actionType: 'UPDATE_RECORD' | 'UPSERT_RECORD';
hint?: string;
}) => {
const { getIcon } = useIcons();
@@ -31,7 +33,7 @@ export const WorkflowFieldsMultiSelect = ({
.filter((fieldMetadataItem) =>
shouldDisplayFormField({
fieldMetadataItem,
actionType: 'UPDATE_RECORD',
actionType,
}),
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
@@ -55,6 +57,17 @@ export const WorkflowFieldsMultiSelect = ({
label={label}
defaultValue={defaultFields}
options={inlineFieldDefinitions.map((field) => {
const isIdField = field.metadata.fieldName === 'id';
if (isIdField && actionType === 'UPSERT_RECORD') {
return {
label: 'ID',
value: field.metadata.fieldName,
Icon: getIcon('IconId'),
color: 'gray',
};
}
const isFieldRelationManyToOne =
isFieldRelation(field) &&
field.metadata.relationType === RelationType.MANY_TO_ONE;
@@ -92,6 +92,7 @@ export const Default: Story = {
handleFieldsChange: () => {},
readonly: false,
defaultFields: [],
actionType: 'UPDATE_RECORD',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -105,6 +106,7 @@ export const WithDefaultValues: Story = {
args: {
...Default.args,
defaultFields: ['name', 'domainName'],
actionType: 'UPDATE_RECORD',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -118,6 +120,7 @@ export const ReadOnly: Story = {
...Default.args,
readonly: true,
defaultFields: ['name', 'domainName'],
actionType: 'UPDATE_RECORD',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@@ -1,266 +0,0 @@
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 { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpdateEventFieldsMultiSelect';
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 { t } from '@lingui/core/macro';
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 objectLabelSingular = objectMetadataItem?.labelSingular;
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 uniqueFieldMetadataItems = inlineFieldMetadataItems?.filter(
(fieldMetadataItem) => fieldMetadataItem.isUnique,
);
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}
/>
{actionType === 'UPSERT_RECORD' &&
isDefined(objectMetadataItem) &&
isDefined(uniqueFieldMetadataItems) &&
uniqueFieldMetadataItems.length > 0 && (
<WorkflowFieldsMultiSelect
label={t`Unique fields`}
objectMetadataItem={objectMetadataItem}
handleFieldsChange={() => {}}
defaultFields={
uniqueFieldMetadataItems?.map(
(fieldMetadataItem) => fieldMetadataItem.name,
) ?? []
}
placeholder={t`Object unique fields`}
readonly
hint={t`We match on these fields. If a ${objectLabelSingular} already exists, we update it. Otherwise, we create a new one.`}
/>
)}
<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,9 +1,34 @@
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 { 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;
};
type CreateRecordFormData = {
objectName: string;
[field: string]: RelationManyToOneField | JsonValue;
};
type WorkflowEditActionCreateRecordProps = {
action: WorkflowCreateRecordAction;
@@ -17,40 +42,214 @@ type WorkflowEditActionCreateRecordProps = {
};
};
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 isFormDisabled = actionOptions.readonly;
const theme = useTheme();
const handleUpdate = (formData: CreateRecordFormData) => {
if (actionOptions.readonly === true) {
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 === true;
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: 'CREATE_RECORD',
}),
)
.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 { objectName: updatedObjectName, ...updatedOtherFields } = formData;
const isFieldRelationManyToOne =
isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationType === RelationType.MANY_TO_ONE;
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
objectName: updatedObjectName,
objectRecord: updatedOtherFields,
},
},
});
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]);
return (
<>
<WorkflowCreateRecordBody
defaultObjectName={action.settings.input.objectName}
defaultObjectRecord={action.settings.input.objectRecord}
readonly={isFormDisabled ?? false}
actionType="CREATE_RECORD"
onUpdate={handleUpdate}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-create-record-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>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
</>
);
@@ -198,6 +198,7 @@ export const WorkflowEditActionUpdateRecord = ({
}
readonly={isFormDisabled ?? false}
defaultFields={formData.fieldsToUpdate}
actionType="UPDATE_RECORD"
/>
)}
@@ -1,9 +1,37 @@
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 { 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 { useViewOrDefaultViewFromPrefetchedViews } from '@/views/hooks/useViewOrDefaultViewFromPrefetchedViews';
import { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpdateEventFieldsMultiSelect';
import { type WorkflowUpsertRecordAction } 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 { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
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;
};
type UpsertRecordFormData = {
objectName: string;
[field: string]: RelationManyToOneField | JsonValue;
};
type WorkflowEditActionUpsertRecordProps = {
action: WorkflowUpsertRecordAction;
@@ -17,40 +45,269 @@ type WorkflowEditActionUpsertRecordProps = {
};
};
const sortFieldsWithIdFirst = (
a: { name: string; viewFieldPosition?: number },
b: { name: string; viewFieldPosition?: number },
) => {
if (a.name === 'id') {
return -1;
}
if (b.name === 'id') {
return 1;
}
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 WorkflowEditActionUpsertRecord = ({
action,
actionOptions,
}: WorkflowEditActionUpsertRecordProps) => {
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<UpsertRecordFormData>({
objectName: action.settings.input.objectName,
...action.settings.input.objectRecord,
});
const isFormDisabled = actionOptions.readonly === true;
const handleUpdate = (formData: CreateRecordFormData) => {
if (actionOptions.readonly === true) {
const objectNameSingular = formData.objectName;
const { objectMetadataItems } = useObjectMetadataItems();
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === objectNameSingular,
);
const objectLabelSingular = objectMetadataItem?.labelSingular;
const { view: indexView } = useViewOrDefaultViewFromPrefetchedViews({
objectMetadataItemId: objectMetadataItem?.id ?? '',
});
const viewFields = indexView?.viewFields ?? [];
const inlineFieldMetadataItems = objectMetadataItem?.fields
.filter((fieldMetadataItem) =>
shouldDisplayFormField({
fieldMetadataItem,
actionType: 'UPSERT_RECORD',
}),
)
.map((fieldMetadataItem) => {
const viewField = viewFields.find(
(viewField) => viewField.fieldMetadataId === fieldMetadataItem.id,
);
return {
...fieldMetadataItem,
viewFieldPosition: viewField?.position,
};
})
.sort(sortFieldsWithIdFirst);
const uniqueFieldMetadataItems = inlineFieldMetadataItems?.filter(
(fieldMetadataItem) =>
fieldMetadataItem.isUnique || fieldMetadataItem.name === 'id',
);
const inlineFieldDefinitions = isDefined(objectMetadataItem)
? inlineFieldMetadataItems?.map((fieldMetadataItem) =>
formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
)
: [];
const handleFieldChange = (
fieldName: keyof UpsertRecordFormData,
updatedValue: JsonValue,
) => {
const fieldDefinition = inlineFieldDefinitions?.find(
(definition) => definition.metadata.fieldName === fieldName,
);
if (!isDefined(fieldDefinition)) {
return;
}
const { objectName: updatedObjectName, ...updatedOtherFields } = formData;
const isFieldRelationManyToOne =
isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationType === RelationType.MANY_TO_ONE;
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
objectName: updatedObjectName,
objectRecord: updatedOtherFields,
},
},
});
const fieldValue = isFieldRelationManyToOne
? {
id: updatedValue,
}
: updatedValue;
const newFormData: UpsertRecordFormData = {
...formData,
[fieldName]: fieldValue,
};
setFormData(newFormData);
saveAction(newFormData);
};
const saveAction = useDebouncedCallback(
async (formData: UpsertRecordFormData) => {
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]);
return (
<>
<WorkflowCreateRecordBody
defaultObjectName={action.settings.input.objectName}
defaultObjectRecord={action.settings.input.objectRecord}
readonly={isFormDisabled}
actionType="UPSERT_RECORD"
onUpdate={handleUpdate}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-upsert-record-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
const newFormData: UpsertRecordFormData = {
objectName: updatedObjectName,
};
setFormData(newFormData);
saveAction(newFormData);
}}
withSearchInput
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
{isDefined(objectMetadataItem) &&
isDefined(uniqueFieldMetadataItems) &&
uniqueFieldMetadataItems.length > 0 && (
<WorkflowFieldsMultiSelect
label={t`Unique fields`}
objectMetadataItem={objectMetadataItem}
handleFieldsChange={() => {}}
defaultFields={
uniqueFieldMetadataItems?.map(
(fieldMetadataItem) => fieldMetadataItem.name,
) ?? []
}
placeholder={t`Object unique fields`}
readonly
hint={t`We match on these fields. If a ${objectLabelSingular} already exists, we update it. Otherwise, we create a new one.`}
actionType="UPSERT_RECORD"
/>
)}
<HorizontalSeparator noMargin />
{inlineFieldDefinitions?.map((fieldDefinition) => {
const isIdField = fieldDefinition.metadata.fieldName === 'id';
if (isIdField) {
return (
<FormSingleRecordPicker
key="id"
testId="workflow-upsert-record-id"
label={t`Record (ID)`}
onChange={(recordId) => {
handleFieldChange('id', recordId);
}}
objectNameSingulars={
isDefined(objectNameSingular) ? [objectNameSingular] : []
}
defaultValue={(formData.id as string) ?? ''}
disabled={isFormDisabled}
VariablePicker={WorkflowVariablePicker}
/>
);
}
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>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
</>
);
@@ -43,13 +43,20 @@ export const shouldDisplayFormField = ({
switch (actionType) {
case 'CREATE_RECORD':
case 'UPDATE_RECORD':
case 'UPSERT_RECORD':
return (
!isNotSupportedRelation &&
!fieldMetadataItem.isUIReadOnly &&
!fieldMetadataItem.isSystem &&
fieldMetadataItem.isActive
);
case 'UPSERT_RECORD':
return (
(!isNotSupportedRelation &&
!fieldMetadataItem.isUIReadOnly &&
!fieldMetadataItem.isSystem &&
fieldMetadataItem.isActive) ||
isIdField
);
case 'FIND_RECORDS':
return (
!isNotSupportedRelation &&
@@ -257,6 +257,7 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
handleFieldsChange={handleFieldsChange}
readonly={triggerOptions.readonly ?? false}
defaultFields={trigger.settings.fields}
actionType="UPDATE_RECORD"
/>
)}
</WorkflowStepBody>