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:
+14
-1
@@ -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;
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
-266
@@ -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>
|
||||
);
|
||||
};
|
||||
+224
-25
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
|
||||
+1
@@ -198,6 +198,7 @@ export const WorkflowEditActionUpdateRecord = ({
|
||||
}
|
||||
readonly={isFormDisabled ?? false}
|
||||
defaultFields={formData.fieldsToUpdate}
|
||||
actionType="UPDATE_RECORD"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+281
-24
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
|
||||
+8
-1
@@ -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 &&
|
||||
|
||||
+1
@@ -257,6 +257,7 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
|
||||
handleFieldsChange={handleFieldsChange}
|
||||
readonly={triggerOptions.readonly ?? false}
|
||||
defaultFields={trigger.settings.fields}
|
||||
actionType="UPDATE_RECORD"
|
||||
/>
|
||||
)}
|
||||
</WorkflowStepBody>
|
||||
|
||||
Reference in New Issue
Block a user