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:
+14
-29
@@ -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
|
||||
|
||||
+14
-29
@@ -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
|
||||
|
||||
+104
@@ -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' });
|
||||
});
|
||||
});
|
||||
+42
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user