From b2684ce107e416625d82c33a4e805c0f365b8d4b Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Thu, 6 Aug 2026 15:36:26 +0200 Subject: [PATCH] Clear to-one relation key instead of storing {id: null} in record workflow steps (#23869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deselecting a to-one relation in a Create Record / Upsert Record step stored `{ "id": null }` instead of removing the field from the step input. The step form then renders the field as empty, so nothing signals that a value is still there, and the run fails later with: ``` Relation "idOpportunity" requires connect or disconnect operation ``` ### Why The relation picker fires `onChange(null)` when the current selection is dropped (`FormSingleRecordPicker`, "No record" entry). `handleFieldChange` wraps every to-one relation value as `{ id: value }`, so `null` became `{ id: null }` and got persisted in `objectRecord`. At runtime that shape is not a legacy `{ id: "" }`, so it is left untouched by `formatWorkflowRecordRelationFields` and reaches the common API data arg processor, which rejects any relation value that is not a `connect`/`disconnect` operation. The field also reads as empty afterwards (`formData[field]?.id` is `null`), so the poisoned state is indistinguishable from a clean one in the UI, and the ✕ that would have cleared it properly is not rendered. ### Fix Treat a cleared to-one relation as a field removal in both record forms, matching what the chip's ✕ (`handleFieldClear`) already does. The logic lives in `buildUpdatedRecordActionFormData`, shared by both components along with the `RecordActionFormData` / `RelationManyToOneField` types they each declared separately. Update Record is unaffected: it stores relations under the join column (`pointOfContactId`) with a raw value, where `null` is a valid disconnect. ### Test Manually, on a Companies Create Record and Create or Update Record step, using the `Account Owner` relation: pick a record, then pick "No record" in the same dropdown, and read the persisted step from `workflowVersion.steps`. | | `objectRecord` after "No record" | |---|---| | before | `{"accountOwner": {"id": null}}` | | after | `{}` | Selecting a record still stores `{"accountOwner": {"id": ""}}`. --- .../WorkflowEditActionCreateRecord.tsx | 43 +++----- .../WorkflowEditActionUpsertRecord.tsx | 43 +++----- .../buildUpdatedRecordActionFormData.test.ts | 104 ++++++++++++++++++ .../utils/buildUpdatedRecordActionFormData.ts | 42 +++++++ 4 files changed, 174 insertions(+), 58 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/__tests__/buildUpdatedRecordActionFormData.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/buildUpdatedRecordActionFormData.ts diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx index 63f9e1dbc1..817b64aa1c 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionCreateRecord.tsx @@ -3,13 +3,18 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat import { useObjectMetadataSelectHelpers } from '@/object-metadata/hooks/useObjectMetadataSelectHelpers'; 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 { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne'; import { Select } from '@/ui/input/components/Select'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { useViewOrDefaultView } from '@/views/hooks/useViewOrDefaultView'; import { type WorkflowCreateRecordAction } from '@/workflow/types/Workflow'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter'; +import { + buildUpdatedRecordActionFormData, + type RecordActionFormData, + type RelationManyToOneField, +} from '@/workflow/workflow-steps/workflow-actions/utils/buildUpdatedRecordActionFormData'; import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField'; import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { t } from '@lingui/core/macro'; @@ -20,16 +25,8 @@ import { HorizontalSeparator } from 'twenty-ui/layout'; 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 CreateRecordFormData = RecordActionFormData; type WorkflowEditActionCreateRecordProps = { action: WorkflowCreateRecordAction; @@ -146,20 +143,12 @@ export const WorkflowEditActionCreateRecord = ({ return; } - const isFieldRelationManyToOne = - isFieldRelation(fieldDefinition) && - fieldDefinition.metadata.relationType === RelationType.MANY_TO_ONE; - - const fieldValue = isFieldRelationManyToOne - ? { - id: updatedValue, - } - : updatedValue; - - const newFormData: CreateRecordFormData = { - ...formData, - [fieldName]: fieldValue, - }; + const newFormData = buildUpdatedRecordActionFormData({ + formData, + fieldName, + fieldDefinition, + updatedValue, + }); setFormData(newFormData); @@ -231,11 +220,7 @@ export const WorkflowEditActionCreateRecord = ({ {inlineFieldDefinitions?.map((fieldDefinition) => { - const isFieldRelationManyToOne = - isFieldRelation(fieldDefinition) && - fieldDefinition.metadata.relationType === RelationType.MANY_TO_ONE; - - const currentValue = isFieldRelationManyToOne + const currentValue = isFieldRelationManyToOne(fieldDefinition) ? ( formData[ fieldDefinition.metadata.fieldName diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord.tsx index 56289706ab..edc9c685df 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpsertRecord.tsx @@ -4,7 +4,7 @@ import { useObjectMetadataSelectHelpers } from '@/object-metadata/hooks/useObjec 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 { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne'; import { Select } from '@/ui/input/components/Select'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { useViewOrDefaultView } from '@/views/hooks/useViewOrDefaultView'; @@ -12,6 +12,11 @@ import { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpd import { type WorkflowUpsertRecordAction } from '@/workflow/types/Workflow'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter'; +import { + buildUpdatedRecordActionFormData, + type RecordActionFormData, + type RelationManyToOneField, +} from '@/workflow/workflow-steps/workflow-actions/utils/buildUpdatedRecordActionFormData'; import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField'; import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { t } from '@lingui/core/macro'; @@ -22,16 +27,8 @@ import { HorizontalSeparator } from 'twenty-ui/layout'; 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 UpsertRecordFormData = RecordActionFormData; type WorkflowEditActionUpsertRecordProps = { action: WorkflowUpsertRecordAction; @@ -163,20 +160,12 @@ export const WorkflowEditActionUpsertRecord = ({ return; } - const isFieldRelationManyToOne = - isFieldRelation(fieldDefinition) && - fieldDefinition.metadata.relationType === RelationType.MANY_TO_ONE; - - const fieldValue = isFieldRelationManyToOne - ? { - id: updatedValue, - } - : updatedValue; - - const newFormData: UpsertRecordFormData = { - ...formData, - [fieldName]: fieldValue, - }; + const newFormData = buildUpdatedRecordActionFormData({ + formData, + fieldName, + fieldDefinition, + updatedValue, + }); setFormData(newFormData); @@ -291,11 +280,7 @@ export const WorkflowEditActionUpsertRecord = ({ ); } - const isFieldRelationManyToOne = - isFieldRelation(fieldDefinition) && - fieldDefinition.metadata.relationType === RelationType.MANY_TO_ONE; - - const currentValue = isFieldRelationManyToOne + const currentValue = isFieldRelationManyToOne(fieldDefinition) ? ( formData[ fieldDefinition.metadata.fieldName diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/__tests__/buildUpdatedRecordActionFormData.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/__tests__/buildUpdatedRecordActionFormData.test.ts new file mode 100644 index 0000000000..ff6434183a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/__tests__/buildUpdatedRecordActionFormData.test.ts @@ -0,0 +1,104 @@ +import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; +import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { + buildUpdatedRecordActionFormData, + type RecordActionFormData, +} from '@/workflow/workflow-steps/workflow-actions/utils/buildUpdatedRecordActionFormData'; +import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql'; + +const relationToOneFieldDefinition = { + fieldMetadataId: 'field-metadata-id', + label: 'Company', + type: FieldMetadataType.RELATION, + iconName: 'IconBuildingSkyscraper', + metadata: { + fieldName: 'company', + relationType: RelationType.MANY_TO_ONE, + objectMetadataNameSingular: 'person', + relationObjectMetadataNameSingular: 'company', + relationObjectMetadataNamePlural: 'companies', + }, +} as unknown as FieldDefinition; + +const textFieldDefinition = { + fieldMetadataId: 'field-metadata-id', + label: 'City', + type: FieldMetadataType.TEXT, + iconName: 'IconMap', + metadata: { + fieldName: 'city', + objectMetadataNameSingular: 'person', + }, +} as unknown as FieldDefinition; + +const formData: RecordActionFormData = { + objectName: 'person', + city: 'Paris', +}; + +describe('buildUpdatedRecordActionFormData', () => { + it('should wrap a selected record id in an object when the field is a to-one relation', () => { + const result = buildUpdatedRecordActionFormData({ + formData, + fieldName: 'company', + fieldDefinition: relationToOneFieldDefinition, + updatedValue: '20202020-3ec3-4fe3-8997-b76aa0bfa408', + }); + + expect(result).toEqual({ + objectName: 'person', + city: 'Paris', + company: { id: '20202020-3ec3-4fe3-8997-b76aa0bfa408' }, + }); + }); + + it('should wrap a variable in an object when the field is a to-one relation', () => { + const result = buildUpdatedRecordActionFormData({ + formData, + fieldName: 'company', + fieldDefinition: relationToOneFieldDefinition, + updatedValue: '{{trigger.object.id}}', + }); + + expect(result.company).toEqual({ id: '{{trigger.object.id}}' }); + }); + + it('should remove the field when a to-one relation is cleared', () => { + const result = buildUpdatedRecordActionFormData({ + formData: { ...formData, company: { id: 'previous-record-id' } }, + fieldName: 'company', + fieldDefinition: relationToOneFieldDefinition, + updatedValue: null, + }); + + expect(result).not.toHaveProperty('company'); + expect(result).toEqual({ objectName: 'person', city: 'Paris' }); + }); + + it('should keep null values as-is when the field is not a relation', () => { + const result = buildUpdatedRecordActionFormData({ + formData, + fieldName: 'city', + fieldDefinition: textFieldDefinition, + updatedValue: null, + }); + + expect(result.city).toBeNull(); + }); + + it('should not mutate the form data it receives', () => { + const initialFormData: RecordActionFormData = { + objectName: 'person', + company: { id: 'previous-record-id' }, + }; + + buildUpdatedRecordActionFormData({ + formData: initialFormData, + fieldName: 'company', + fieldDefinition: relationToOneFieldDefinition, + updatedValue: null, + }); + + expect(initialFormData.company).toEqual({ id: 'previous-record-id' }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/buildUpdatedRecordActionFormData.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/buildUpdatedRecordActionFormData.ts new file mode 100644 index 0000000000..648ebf80b3 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/buildUpdatedRecordActionFormData.ts @@ -0,0 +1,42 @@ +import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; +import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne'; +import { isDefined } from 'twenty-shared/utils'; +import { type JsonValue } from 'type-fest'; + +export type RelationManyToOneField = { + id: string; +}; + +export type RecordActionFormData = { + objectName: string; + [field: string]: RelationManyToOneField | JsonValue; +}; + +export const buildUpdatedRecordActionFormData = ({ + formData, + fieldName, + fieldDefinition, + updatedValue, +}: { + formData: RecordActionFormData; + fieldName: keyof RecordActionFormData; + fieldDefinition: FieldDefinition; + updatedValue: JsonValue; +}): RecordActionFormData => { + const isRelationManyToOne = isFieldRelationManyToOne(fieldDefinition); + + const updatedFormData: RecordActionFormData = { ...formData }; + + if (isRelationManyToOne && !isDefined(updatedValue)) { + delete updatedFormData[fieldName]; + + return updatedFormData; + } + + updatedFormData[fieldName] = isRelationManyToOne + ? { id: updatedValue } + : updatedValue; + + return updatedFormData; +};