Clear to-one relation key instead of storing {id: null} in record workflow steps (#23869)

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: "<uuid>" }`, 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": "<uuid>"}}`.
This commit is contained in:
Thomas Trompette
2026-08-06 15:36:26 +02:00
committed by GitHub
parent 8699766303
commit b2684ce107
4 changed files with 174 additions and 58 deletions
@@ -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 = ({
<HorizontalSeparator noMargin />
{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
@@ -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
@@ -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<FieldMetadata>;
const textFieldDefinition = {
fieldMetadataId: 'field-metadata-id',
label: 'City',
type: FieldMetadataType.TEXT,
iconName: 'IconMap',
metadata: {
fieldName: 'city',
objectMetadataNameSingular: 'person',
},
} as unknown as FieldDefinition<FieldMetadata>;
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' });
});
});
@@ -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<FieldMetadata>;
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;
};