fix: cannot create record from table view — empty morph to-many relation returns null (#21846)
## Problem
Creating a record from the table view (reproduced on **People**) crashes
the client even though the `createOne…` mutation succeeds server-side,
so the record never appears:
```
Cannot read properties of null (reading 'map')
getRecordConnectionFromRecords → getRecordNodeFromRecord → optimistic cache effect → createOneRecord
```
## Root cause
An empty **morph** to-many relation comes back as `null`, while every
other to-many relation comes back as `{ edges: [] }`. The frontend then
runs `null.map` while building the optimistic cache node; the error
escapes the mutation `update`, the rollback evicts the record, and it
never lands in the table.
## Fix
**Server** — plain to-many relations are hydrated to `[]` and formatted
to `{ edges: [] }` by `ObjectRecordsToGraphqlConnectionHelper`; an empty
morph to-many was left undefined and the field was skipped (→ `null`).
Default an unset to-many value to `[]` so it goes through the **same
connection path as plain to-many relations**.
**Frontend** — defensive guard in `getRecordNodeFromRecord`: a to-many
relation whose value isn't an array is skipped instead of crashing,
mirroring the existing guard in `extractTargetRecordsFromRelation`.
Needed regardless, since cached data / SSE / older servers still send
`null`.
## Tests
- Unit: `getRecordNodeFromRecord` skips a null to-many (reproduces the
exact crash without the guard).
- Integration: an empty morph `ONE_TO_MANY` read returns `{ edges: []
}`, not null.
This commit is contained in:
+52
@@ -3,6 +3,7 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/Enriche
|
||||
import { mockedPersonRecords } from '~/testing/mock-data/generated/data/people/mock-people-data';
|
||||
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
|
||||
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
|
||||
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
|
||||
|
||||
const peopleMock = [...mockedPersonRecords];
|
||||
|
||||
@@ -96,4 +97,55 @@ describe('getRecordNodeFromRecord', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('skips a to-many relation whose value is null instead of crashing', () => {
|
||||
// Given
|
||||
const objectMetadataItems: EnrichedObjectMetadataItem[] =
|
||||
getTestEnrichedObjectMetadataItemsMock();
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(item) => item.nameSingular === 'person',
|
||||
);
|
||||
|
||||
if (!objectMetadataItem) {
|
||||
throw new Error('Object metadata item not found');
|
||||
}
|
||||
|
||||
const oneToManyRelationField = objectMetadataItem.fields.find(
|
||||
(field) =>
|
||||
field.type === FieldMetadataType.RELATION &&
|
||||
field.relation?.type === RelationType.ONE_TO_MANY,
|
||||
);
|
||||
|
||||
if (!oneToManyRelationField) {
|
||||
throw new Error('No to-many relation field found on person');
|
||||
}
|
||||
|
||||
const record = {
|
||||
...peopleMock[0],
|
||||
[oneToManyRelationField.name]: null,
|
||||
};
|
||||
const recordGqlFields = {
|
||||
name: true,
|
||||
[oneToManyRelationField.name]: true,
|
||||
};
|
||||
|
||||
// When / Then
|
||||
expect(() =>
|
||||
getRecordNodeFromRecord({
|
||||
objectMetadataItems,
|
||||
objectMetadataItem,
|
||||
recordGqlFields,
|
||||
record,
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
const result = getRecordNodeFromRecord({
|
||||
objectMetadataItems,
|
||||
objectMetadataItem,
|
||||
recordGqlFields,
|
||||
record,
|
||||
});
|
||||
|
||||
expect(result).not.toHaveProperty(oneToManyRelationField.name);
|
||||
});
|
||||
});
|
||||
|
||||
+8
@@ -67,6 +67,10 @@ export const getRecordNodeFromRecord = <T extends ObjectRecord>({
|
||||
field.type === FieldMetadataType.RELATION &&
|
||||
field.relation?.type === RelationType.ONE_TO_MANY
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const oneToManyObjectMetadataItem = objectMetadataItems.find(
|
||||
(item) =>
|
||||
item.namePlural ===
|
||||
@@ -99,6 +103,10 @@ export const getRecordNodeFromRecord = <T extends ObjectRecord>({
|
||||
field.type === FieldMetadataType.MORPH_RELATION &&
|
||||
field.settings?.relationType === RelationType.ONE_TO_MANY
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (field.morphRelations?.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+8
-1
@@ -17,6 +17,7 @@ import {
|
||||
import { encodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
|
||||
import { getTargetObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-target-object-metadata.util';
|
||||
import { type AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type';
|
||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
@@ -221,7 +222,13 @@ export class ObjectRecordsToGraphqlConnectionHelper {
|
||||
objectRecord[fieldMetadataNameWithId];
|
||||
}
|
||||
|
||||
const objectValue = objectRecord[fieldMetadata.name];
|
||||
const isToManyRelation =
|
||||
fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY;
|
||||
|
||||
const objectValue =
|
||||
!isDefined(objectRecord[fieldMetadata.name]) && isToManyRelation
|
||||
? []
|
||||
: objectRecord[fieldMetadata.name];
|
||||
|
||||
if (!isDefined(objectValue)) {
|
||||
continue;
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
|
||||
import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util';
|
||||
import { makeGraphqlAPIRequestWithApiKey } from 'test/integration/graphql/utils/make-graphql-api-request-with-api-key.util';
|
||||
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
|
||||
import { createMorphRelationBetweenObjects } from 'test/integration/metadata/suites/object-metadata/utils/create-morph-relation-between-objects.util';
|
||||
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
|
||||
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
|
||||
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||
|
||||
describe('empty morph to-many relation read', () => {
|
||||
let opportunityObjectId = '';
|
||||
let personObjectId = '';
|
||||
let companyObjectId = '';
|
||||
let morphFieldId = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
({
|
||||
data: {
|
||||
createOneObject: { id: opportunityObjectId },
|
||||
},
|
||||
} = await createOneObjectMetadata({
|
||||
input: {
|
||||
nameSingular: 'oppForEmptyMorph',
|
||||
namePlural: 'oppsForEmptyMorph',
|
||||
labelSingular: 'Opp For Empty Morph',
|
||||
labelPlural: 'Opps For Empty Morph',
|
||||
icon: 'IconOpportunity',
|
||||
},
|
||||
}));
|
||||
|
||||
({
|
||||
data: {
|
||||
createOneObject: { id: personObjectId },
|
||||
},
|
||||
} = await createOneObjectMetadata({
|
||||
input: {
|
||||
nameSingular: 'personForEmptyMorph',
|
||||
namePlural: 'peopleForEmptyMorph',
|
||||
labelSingular: 'Person For Empty Morph',
|
||||
labelPlural: 'People For Empty Morph',
|
||||
icon: 'IconUser',
|
||||
},
|
||||
}));
|
||||
|
||||
({
|
||||
data: {
|
||||
createOneObject: { id: companyObjectId },
|
||||
},
|
||||
} = await createOneObjectMetadata({
|
||||
input: {
|
||||
nameSingular: 'companyForEmptyMorph',
|
||||
namePlural: 'companiesForEmptyMorph',
|
||||
labelSingular: 'Company For Empty Morph',
|
||||
labelPlural: 'Companies For Empty Morph',
|
||||
icon: 'IconBuildingSkyscraper',
|
||||
},
|
||||
}));
|
||||
|
||||
const createdField = await createMorphRelationBetweenObjects({
|
||||
objectMetadataId: opportunityObjectId,
|
||||
firstTargetObjectMetadataId: personObjectId,
|
||||
secondTargetObjectMetadataId: companyObjectId,
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
name: 'owner',
|
||||
});
|
||||
|
||||
morphFieldId = createdField.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteOneFieldMetadata({ input: { idToDelete: morphFieldId } }).catch(
|
||||
() => {},
|
||||
);
|
||||
|
||||
for (const idToDelete of [
|
||||
opportunityObjectId,
|
||||
personObjectId,
|
||||
companyObjectId,
|
||||
]) {
|
||||
await updateOneObjectMetadata({
|
||||
expectToFail: false,
|
||||
input: { idToUpdate: idToDelete, updatePayload: { isActive: false } },
|
||||
});
|
||||
await deleteOneObjectMetadata({ input: { idToDelete } });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns an empty connection (not null) for each morph to-many target', async () => {
|
||||
await makeGraphqlAPIRequestWithApiKey(
|
||||
createOneOperationFactory({
|
||||
objectMetadataSingularName: 'oppForEmptyMorph',
|
||||
gqlFields: 'id',
|
||||
data: { name: 'No owners' },
|
||||
}),
|
||||
);
|
||||
|
||||
const { body: schemaBody } = await makeGraphqlAPIRequestWithApiKey({
|
||||
query: gql`
|
||||
query {
|
||||
__type(name: "${capitalize('oppForEmptyMorph')}") {
|
||||
fields {
|
||||
name
|
||||
type {
|
||||
name
|
||||
ofType {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
const morphConnectionFieldNames: string[] = schemaBody.data.__type.fields
|
||||
.filter(
|
||||
(field: {
|
||||
name: string;
|
||||
type: { name?: string; ofType?: { name?: string } };
|
||||
}) => {
|
||||
const typeName = field.type?.name ?? field.type?.ofType?.name;
|
||||
|
||||
return (
|
||||
field.name.startsWith('owner') && typeName?.endsWith('Connection')
|
||||
);
|
||||
},
|
||||
)
|
||||
.map((field: { name: string }) => field.name);
|
||||
|
||||
expect(morphConnectionFieldNames.length).toBeGreaterThan(0);
|
||||
|
||||
const gqlFields = [
|
||||
'id',
|
||||
...morphConnectionFieldNames.map(
|
||||
(fieldName) => `${fieldName} { edges { node { id } } }`,
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
const { body } = await makeGraphqlAPIRequestWithApiKey(
|
||||
findManyOperationFactory({
|
||||
objectMetadataSingularName: 'oppForEmptyMorph',
|
||||
objectMetadataPluralName: 'oppsForEmptyMorph',
|
||||
gqlFields,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(body.errors).toBeUndefined();
|
||||
|
||||
const node = body.data.oppsForEmptyMorph.edges[0].node;
|
||||
|
||||
for (const fieldName of morphConnectionFieldNames) {
|
||||
expect(node[fieldName]).toEqual({ edges: [] });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user