Fixed Apollo cache bug (#16523)

Fixes https://github.com/twentyhq/twenty/issues/16520

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2025-12-12 17:45:50 +01:00
committed by GitHub
parent f0af947331
commit ee47a773bd
22 changed files with 826 additions and 104 deletions
@@ -2,13 +2,17 @@ import { type ApolloCache, type StoreObject } from '@apollo/client';
import { triggerUpdateGroupByQueriesOptimisticEffect } from '@/apollo/optimistic-effect/group-by/utils/triggerUpdateGroupByQueriesOptimisticEffect';
import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect';
import { type CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection';
import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs';
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
export const triggerDestroyRecordsOptimisticEffect = ({
cache,
@@ -32,16 +36,50 @@ export const triggerDestroyRecordsOptimisticEffect = ({
fields: {
[objectMetadataItem.namePlural]: (
rootQueryCachedResponse,
{ readField },
{ readField, storeFieldName },
) => {
const rootQueryCachedResponseIsNotACachedObjectRecordConnection =
const { fieldVariables: rootQueryVariables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
);
if (
!isObjectRecordConnection(
objectMetadataItem.nameSingular,
rootQueryCachedResponse,
)
) {
return rootQueryCachedResponse;
}
const totalCount = readField<number | undefined>(
'totalCount',
rootQueryCachedResponse,
);
const recordsMatchingRootQueryFilter = recordsToDestroy.filter(
(record) =>
isRecordMatchingFilter({
record,
filter: rootQueryVariables?.filter ?? {},
objectMetadataItem,
}),
);
const newTotalCount = isDefined(totalCount)
? Math.max(totalCount - recordsMatchingRootQueryFilter.length, 0)
: undefined;
if (
!isObjectRecordConnectionWithRefs(
objectMetadataItem.nameSingular,
rootQueryCachedResponse,
);
if (rootQueryCachedResponseIsNotACachedObjectRecordConnection) {
return rootQueryCachedResponse;
)
) {
return {
...rootQueryCachedResponse,
totalCount: newTotalCount,
};
}
const rootQueryCachedObjectRecordConnection = rootQueryCachedResponse;
@@ -52,11 +90,6 @@ export const triggerDestroyRecordsOptimisticEffect = ({
rootQueryCachedObjectRecordConnection,
);
const totalCount = readField<number | undefined>(
'totalCount',
rootQueryCachedObjectRecordConnection,
);
const nextCachedEdges =
cachedEdges?.filter((cachedEdge) => {
const nodeId = readField<string>('id', cachedEdge.node);
@@ -65,14 +98,15 @@ export const triggerDestroyRecordsOptimisticEffect = ({
}) || [];
if (nextCachedEdges.length === cachedEdges?.length)
return rootQueryCachedObjectRecordConnection;
return {
...rootQueryCachedObjectRecordConnection,
totalCount: newTotalCount,
};
return {
...rootQueryCachedObjectRecordConnection,
edges: nextCachedEdges,
totalCount: isDefined(totalCount)
? totalCount - recordIdsToDestroy.length
: undefined,
totalCount: newTotalCount,
};
},
},
@@ -82,6 +82,26 @@ export const triggerUpdateRecordOptimisticEffect = ({
objectMetadataItem,
});
const currentRecordIndexInRootQueryEdges = isRecordMatchingFilter({
record: currentRecord,
filter: rootQueryFilter ?? {},
objectMetadataItem,
});
const totalCount = readField<number | undefined>(
'totalCount',
rootQueryConnection,
);
const newTotalCount = isDefined(totalCount)
? Math.max(
totalCount +
(updatedRecordMatchesThisRootQueryFilter ? 1 : 0) +
(currentRecordIndexInRootQueryEdges ? -1 : 0),
0,
)
: undefined;
const updatedRecordIndexInRootQueryEdges =
rootQueryCurrentEdges.findIndex(
(cachedEdge) =>
@@ -131,6 +151,7 @@ export const triggerUpdateRecordOptimisticEffect = ({
return {
...rootQueryConnection,
edges: rootQueryNextEdges,
totalCount: newTotalCount,
};
},
},
@@ -7,15 +7,18 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById';
import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type ApolloCache } from '@apollo/client';
import { isArray } from '@sniptt/guards';
import { FieldMetadataType, type ObjectPermissions } from 'twenty-shared/types';
import {
FieldMetadataType,
RelationType,
type ObjectPermissions,
} from 'twenty-shared/types';
import {
computeMorphRelationFieldName,
CustomError,
@@ -147,12 +150,12 @@ const triggerUpdateRelationOptimisticEffect = ({
}
const currentFieldValueOnSourceRecord:
| RecordGqlConnection
| RecordGqlConnectionEdgesRequired
| RecordGqlNode
| null = currentSourceRecord?.[fieldMetadataItemOnSourceRecord.name];
const updatedFieldValueOnSourceRecord:
| RecordGqlConnection
| RecordGqlConnectionEdgesRequired
| RecordGqlNode
| null = updatedSourceRecord?.[fieldMetadataItemOnSourceRecord.name];
@@ -179,6 +182,7 @@ const triggerUpdateRelationOptimisticEffect = ({
CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH.includes(
targetObjectMetadata.nameSingular as CoreObjectNameSingular,
);
const gqlFieldNameOnTargetRecord =
targetFieldMetadataFullObject.type === FieldMetadataType.RELATION
? targetFieldMetadataFullObject.name
@@ -189,7 +193,10 @@ const triggerUpdateRelationOptimisticEffect = ({
sourceObjectMetadataItem.nameSingular,
targetObjectMetadataNamePlural: sourceObjectMetadataItem.namePlural,
});
if (shouldCascadeDeleteTargetRecords) {
if (
shouldCascadeDeleteTargetRecords &&
targetRecordsToDetachFrom.length > 0
) {
triggerDestroyRecordsOptimisticEffect({
cache,
objectMetadataItem: fullTargetObjectMetadataItem,
@@ -198,7 +205,10 @@ const triggerUpdateRelationOptimisticEffect = ({
upsertRecordsInStore,
objectPermissionsByObjectMetadataId,
});
} else if (isDefined(currentSourceRecord)) {
} else if (
isDefined(currentSourceRecord) &&
targetRecordsToDetachFrom.length > 0
) {
targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => {
triggerDetachRelationOptimisticEffect({
cache,
@@ -306,12 +316,12 @@ const triggerUpdateMorphRelationOptimisticEffect = ({
}
const currentFieldValueOnSourceRecord:
| RecordGqlConnection
| RecordGqlConnectionEdgesRequired
| RecordGqlNode
| null = currentSourceRecord?.[gqlFieldMorphRelation];
const updatedFieldValueOnSourceRecord:
| RecordGqlConnection
| RecordGqlConnectionEdgesRequired
| RecordGqlNode
| null = updatedSourceRecord?.[gqlFieldMorphRelation];
@@ -338,7 +348,10 @@ const triggerUpdateMorphRelationOptimisticEffect = ({
CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH.includes(
targetObjectMetadata.nameSingular as CoreObjectNameSingular,
);
if (shouldCascadeDeleteTargetRecords) {
if (
shouldCascadeDeleteTargetRecords &&
targetRecordsToDetachFrom.length > 0
) {
triggerDestroyRecordsOptimisticEffect({
cache,
objectMetadataItem: fullTargetObjectMetadataItem,
@@ -347,7 +360,10 @@ const triggerUpdateMorphRelationOptimisticEffect = ({
objectPermissionsByObjectMetadataId,
upsertRecordsInStore,
});
} else if (isDefined(currentSourceRecord)) {
} else if (
isDefined(currentSourceRecord) &&
targetRecordsToDetachFrom.length > 0
) {
targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => {
triggerDetachRelationOptimisticEffect({
cache,
@@ -387,7 +403,7 @@ const triggerUpdateMorphRelationOptimisticEffect = ({
};
const extractTargetRecordsFromRelation = (
value: RecordGqlConnection | RecordGqlNode | null,
value: RecordGqlConnectionEdgesRequired | RecordGqlNode | null,
relation: FieldMetadataItemRelation,
): RecordGqlNode[] => {
// TODO investigate on the root cause of array injection here, should never occurs
@@ -399,9 +415,9 @@ const extractTargetRecordsFromRelation = (
if (!isDefined(relation)) {
throw new Error('Relation found is undefined');
}
if (isObjectRecordConnection(relation, value)) {
return value.edges.map(({ node }) => node);
if (relation.type === RelationType.ONE_TO_MANY) {
return value.edges.map(({ node }: { node: RecordGqlNode }) => node);
}
return [value];
return [value as RecordGqlNode];
};
@@ -1,6 +1,9 @@
import { type RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
export type RecordGqlRefConnection = Omit<RecordGqlConnection, 'edges'> & {
export type RecordGqlRefConnection = Omit<
RecordGqlConnectionEdgesRequired,
'edges'
> & {
edges: RecordGqlRefEdge[];
};
@@ -0,0 +1,107 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getRecordFromRecordNode should convert a simple record node 1`] = `
{
"__typename": "Person",
"email": "john@example.com",
"id": "123",
"name": "John Doe",
}
`;
exports[`getRecordFromRecordNode should handle array values 1`] = `
{
"__typename": "Person",
"id": "123",
"scores": [
100,
200,
300,
],
"tags": [
"developer",
"designer",
],
}
`;
exports[`getRecordFromRecordNode should handle connection fields with edges 1`] = `
{
"__typename": "Company",
"id": "123",
"name": "Acme Inc",
"people": [
{
"__typename": "Person",
"id": "456",
"name": "John Doe",
},
{
"__typename": "Person",
"id": "789",
"name": "Jane Smith",
},
],
}
`;
exports[`getRecordFromRecordNode should handle deeply nested structures 1`] = `
{
"__typename": "Workspace",
"id": "123",
"settings": {
"__typename": "WorkspaceSettings",
"display": {
"__typename": "DisplaySettings",
"layout": "compact",
"theme": "dark",
},
},
}
`;
exports[`getRecordFromRecordNode should handle mixed nested objects and connections 1`] = `
{
"__typename": "Company",
"address": {
"__typename": "Address",
"city": "New York",
"country": "USA",
},
"employees": [
{
"__typename": "Person",
"id": "456",
"name": {
"__typename": "FullName",
"firstName": "John",
"lastName": "Doe",
},
},
],
"id": "123",
"name": "Acme Inc",
}
`;
exports[`getRecordFromRecordNode should handle nested object fields 1`] = `
{
"__typename": "Person",
"id": "123",
"name": {
"__typename": "FullName",
"firstName": "John",
"lastName": "Doe",
},
}
`;
exports[`getRecordFromRecordNode should handle null and undefined values 1`] = `
{
"__typename": "Person",
"email": null,
"id": "123",
"name": "John Doe",
"phone": undefined,
}
`;
@@ -0,0 +1,140 @@
import { getRecordFromRecordNode } from '../getRecordFromRecordNode';
describe('getRecordFromRecordNode', () => {
it('should convert a simple record node', () => {
const recordNode = {
id: '123',
__typename: 'Person',
name: 'John Doe',
email: 'john@example.com',
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
it('should handle nested object fields', () => {
const recordNode = {
id: '123',
__typename: 'Person',
name: {
__typename: 'FullName',
firstName: 'John',
lastName: 'Doe',
},
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
it('should handle connection fields with edges', () => {
const recordNode = {
id: '123',
__typename: 'Company',
name: 'Acme Inc',
people: {
edges: [
{
node: {
id: '456',
__typename: 'Person',
name: 'John Doe',
},
},
{
node: {
id: '789',
__typename: 'Person',
name: 'Jane Smith',
},
},
],
},
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
it('should handle null and undefined values', () => {
const recordNode = {
id: '123',
__typename: 'Person',
name: 'John Doe',
email: null,
phone: undefined,
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
it('should handle array values', () => {
const recordNode = {
id: '123',
__typename: 'Person',
tags: ['developer', 'designer'],
scores: [100, 200, 300],
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
it('should handle deeply nested structures', () => {
const recordNode = {
id: '123',
__typename: 'Workspace',
settings: {
__typename: 'WorkspaceSettings',
display: {
__typename: 'DisplaySettings',
theme: 'dark',
layout: 'compact',
},
},
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
it('should handle mixed nested objects and connections', () => {
const recordNode = {
id: '123',
__typename: 'Company',
name: 'Acme Inc',
address: {
__typename: 'Address',
city: 'New York',
country: 'USA',
},
employees: {
edges: [
{
node: {
id: '456',
__typename: 'Person',
name: {
__typename: 'FullName',
firstName: 'John',
lastName: 'Doe',
},
},
},
],
},
};
const result = getRecordFromRecordNode({ recordNode });
expect(result).toMatchSnapshot();
});
});
@@ -1,36 +1,132 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection';
import { RelationType } from '~/generated-metadata/graphql';
import { isObjectRecordConnection } from '../isObjectRecordConnection';
describe('isObjectRecordConnection', () => {
const relationDefinitionMap: { [K in RelationType]: boolean } = {
[RelationType.ONE_TO_MANY]: true,
[RelationType.MANY_TO_ONE]: false,
};
it.each(Object.entries(relationDefinitionMap))(
'.$relation',
(relation, expected) => {
const emptyRecord = {};
const result = isObjectRecordConnection(
it('should return true for valid connection with edges', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
type: relation,
} as NonNullable<FieldMetadataItem['relation']>,
emptyRecord,
);
expect(result).toEqual(expected);
},
);
it('should throw on unknown relation direction', () => {
const emptyRecord = {};
expect(() =>
isObjectRecordConnection(
__typename: 'PersonEdge',
node: {
id: '123',
},
},
{
direction: 'UNKNOWN_TYPE',
} as any,
emptyRecord,
),
).toThrowError();
__typename: 'PersonEdge',
node: {
id: '456',
},
},
],
};
const result = isObjectRecordConnection('person', storeValue);
expect(result).toBe(true);
});
it('should return true for valid connection with empty edges array', () => {
const storeValue = {
__typename: 'CompanyConnection',
edges: [],
};
const result = isObjectRecordConnection('company', storeValue);
expect(result).toBe(true);
});
it('should return true for valid connection without edges (optional)', () => {
const storeValue = {
__typename: 'PersonConnection',
};
const result = isObjectRecordConnection('person', storeValue);
expect(result).toBe(true);
});
it('should return false for incorrect __typename', () => {
const storeValue = {
__typename: 'WrongConnection',
edges: [],
};
const result = isObjectRecordConnection('person', storeValue);
expect(result).toBe(false);
});
it('should return false for incorrect edge __typename', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
__typename: 'WrongEdge',
node: {
id: '123',
},
},
],
};
const result = isObjectRecordConnection('person', storeValue);
expect(result).toBe(false);
});
it('should return true regardless of node content', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
__typename: 'PersonEdge',
node: {
id: '123',
name: 'John Doe',
},
},
],
};
const result = isObjectRecordConnection('person', storeValue);
expect(result).toBe(true);
});
it('should return false for null value', () => {
const result = isObjectRecordConnection('person', null);
expect(result).toBe(false);
});
it('should return false for undefined value', () => {
const result = isObjectRecordConnection('person', undefined);
expect(result).toBe(false);
});
it('should return false for primitive value', () => {
const result = isObjectRecordConnection('person', 'not an object');
expect(result).toBe(false);
});
it('should handle camelCase object names', () => {
const storeValue = {
__typename: 'CalendarEventConnection',
edges: [
{
__typename: 'CalendarEventEdge',
node: {
id: '123',
},
},
],
};
const result = isObjectRecordConnection('calendarEvent', storeValue);
expect(result).toBe(true);
});
});
@@ -0,0 +1,152 @@
import { isObjectRecordConnectionWithRefs } from '../isObjectRecordConnectionWithRefs';
describe('isObjectRecordConnectionWithRefs', () => {
it('should return true for valid connection with edges', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
__typename: 'PersonEdge',
node: {
__ref: 'Person:123',
},
},
{
__typename: 'PersonEdge',
node: {
__ref: 'Person:456',
},
},
],
};
const result = isObjectRecordConnectionWithRefs('person', storeValue);
expect(result).toBe(true);
});
it('should return true for valid connection with empty edges array', () => {
const storeValue = {
__typename: 'CompanyConnection',
edges: [],
};
const result = isObjectRecordConnectionWithRefs('company', storeValue);
expect(result).toBe(true);
});
it('should return false for connection without edges (required)', () => {
const storeValue = {
__typename: 'PersonConnection',
};
const result = isObjectRecordConnectionWithRefs('person', storeValue);
expect(result).toBe(false);
});
it('should return false for incorrect __typename', () => {
const storeValue = {
__typename: 'WrongConnection',
edges: [],
};
const result = isObjectRecordConnectionWithRefs('person', storeValue);
expect(result).toBe(false);
});
it('should return false for incorrect edge __typename', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
__typename: 'WrongEdge',
node: {
__ref: 'Person:123',
},
},
],
};
const result = isObjectRecordConnectionWithRefs('person', storeValue);
expect(result).toBe(false);
});
it('should return false for incorrect __ref prefix', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
__typename: 'PersonEdge',
node: {
__ref: 'Company:123',
},
},
],
};
const result = isObjectRecordConnectionWithRefs('person', storeValue);
expect(result).toBe(false);
});
it('should return false for null value', () => {
const result = isObjectRecordConnectionWithRefs('person', null);
expect(result).toBe(false);
});
it('should return false for undefined value', () => {
const result = isObjectRecordConnectionWithRefs('person', undefined);
expect(result).toBe(false);
});
it('should return false for primitive value', () => {
const result = isObjectRecordConnectionWithRefs('person', 'not an object');
expect(result).toBe(false);
});
it('should handle camelCase object names', () => {
const storeValue = {
__typename: 'CalendarEventConnection',
edges: [
{
__typename: 'CalendarEventEdge',
node: {
__ref: 'CalendarEvent:123',
},
},
],
};
const result = isObjectRecordConnectionWithRefs(
'calendarEvent',
storeValue,
);
expect(result).toBe(true);
});
it('should return false when node is missing __ref', () => {
const storeValue = {
__typename: 'PersonConnection',
edges: [
{
__typename: 'PersonEdge',
node: {
id: '123',
},
},
],
};
const result = isObjectRecordConnectionWithRefs('person', storeValue);
expect(result).toBe(false);
});
});
@@ -2,7 +2,7 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI
import { getConnectionTypename } from '@/object-record/cache/utils/getConnectionTypename';
import { getEmptyPageInfo } from '@/object-record/cache/utils/getEmptyPageInfo';
import { getRecordEdgeFromRecord } from '@/object-record/cache/utils/getRecordEdgeFromRecord';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
@@ -40,5 +40,5 @@ export const getRecordConnectionFromRecords = <T extends ObjectRecord>({
}),
...(withPageInfo && { pageInfo: getEmptyPageInfo() }),
...(withPageInfo && { totalCount: records.length }),
} as RecordGqlConnection;
} as RecordGqlConnectionEdgesRequired;
};
@@ -1,11 +1,11 @@
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
export const getRecordsFromRecordConnection = <T extends ObjectRecord>({
recordConnection,
}: {
recordConnection: RecordGqlConnection;
recordConnection: RecordGqlConnectionEdgesRequired;
}): T[] => {
return recordConnection?.edges?.map((edge) =>
getRecordFromRecordNode<T>({ recordNode: edge.node }),
@@ -1,20 +1,30 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type StoreValue } from '@apollo/client';
import { z } from 'zod';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { assertUnreachable } from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';
import { capitalize } from 'twenty-shared/utils';
export const isObjectRecordConnection = (
relation: NonNullable<FieldMetadataItem['relation']>,
value: unknown,
): value is RecordGqlConnection => {
switch (relation.type) {
case RelationType.ONE_TO_MANY: {
return true;
}
case RelationType.MANY_TO_ONE:
return false;
default: {
return assertUnreachable(relation.type);
}
}
objectNameSingular: string,
storeValue: StoreValue,
): storeValue is RecordGqlConnection => {
const objectConnectionTypeName = `${capitalize(
objectNameSingular,
)}Connection`;
const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`;
const cachedObjectConnectionSchema = z.object({
__typename: z.literal(objectConnectionTypeName),
edges: z
.array(
z.object({
__typename: z.literal(objectEdgeTypeName),
node: z.object(),
}),
)
.optional(),
});
const cachedConnectionValidation =
cachedObjectConnectionSchema.safeParse(storeValue);
return cachedConnectionValidation.success;
};
@@ -3,8 +3,8 @@ import { type Nullable } from 'twenty-ui/utilities';
export type RecordGqlConnection = {
__typename?: string;
edges: RecordGqlEdge[];
pageInfo: {
edges?: RecordGqlEdge[];
pageInfo?: {
__typename?: Nullable<string>;
hasNextPage?: Nullable<boolean>;
hasPreviousPage?: Nullable<boolean>;
@@ -0,0 +1,7 @@
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
export type RecordGqlConnectionEdgesRequired = Omit<
RecordGqlConnection,
'edges'
> &
Required<Pick<RecordGqlConnection, 'edges'>>;
@@ -1,5 +1,5 @@
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
export type RecordGqlOperationFindDuplicatesResult = {
[objectNamePlural: string]: RecordGqlConnection[];
[objectNamePlural: string]: RecordGqlConnectionEdgesRequired[];
};
@@ -1,5 +1,5 @@
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
export type RecordGqlOperationFindManyResult = {
[objectNamePlural: string]: RecordGqlConnection;
[objectNamePlural: string]: RecordGqlConnectionEdgesRequired;
};
@@ -1,5 +1,5 @@
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
export type RecordGqlOperationSearchResult = {
[objectNamePlural: string]: RecordGqlConnection;
[objectNamePlural: string]: RecordGqlConnectionEdgesRequired;
};
@@ -1,4 +1,4 @@
import { RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
import { gql } from '@apollo/client';
import { peopleQueryResult } from '~/testing/mock-data/people';
@@ -36,7 +36,7 @@ export const query = gql`
export const mockPageSize = 2;
export const peopleMockWithIdsOnly: RecordGqlConnection = {
export const peopleMockWithIdsOnly: RecordGqlConnectionEdgesRequired = {
...peopleQueryResult.people,
edges: peopleQueryResult.people.edges.map((edge) => ({
...edge,
@@ -72,7 +72,7 @@ export const variablesThirdRequest = {
};
const paginateRequestResponse = (
response: RecordGqlConnection,
response: RecordGqlConnectionEdgesRequired,
start: number,
end: number,
hasNextPage: boolean,
@@ -86,7 +86,7 @@ const paginateRequestResponse = (
startCursor: response.edges[start].cursor,
endCursor: response.edges[end].cursor,
hasNextPage,
} satisfies RecordGqlConnection['pageInfo'],
} satisfies RecordGqlConnectionEdgesRequired['pageInfo'],
totalCount,
};
};
@@ -5,7 +5,7 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
import { type RecordGqlOperationFindDuplicatesResult } from '@/object-record/graphql/types/RecordGqlOperationFindDuplicatesResults';
import { useFindDuplicateRecordsQuery } from '@/object-record/hooks/useFindDuplicatesRecordsQuery';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
@@ -20,7 +20,7 @@ export const useFindDuplicateRecords = <T extends ObjectRecord = ObjectRecord>({
skip,
}: ObjectMetadataItemIdentifier & {
objectRecordIds: string[] | undefined;
onCompleted?: (data: RecordGqlConnection[]) => void;
onCompleted?: (data: RecordGqlConnectionEdgesRequired[]) => void;
skip?: boolean;
}) => {
const findDuplicateQueryStateIdentifier = objectNameSingular;
@@ -69,7 +69,7 @@ export const useFindDuplicateRecords = <T extends ObjectRecord = ObjectRecord>({
const results = useMemo(
() =>
objectResults?.map((result: RecordGqlConnection) => {
objectResults?.map((result: RecordGqlConnectionEdgesRequired) => {
return result
? (getRecordsFromRecordConnection({
recordConnection: result,
@@ -1,5 +1,5 @@
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
export type CombinedFindManyRecordsQueryResult = {
[namePlural: string]: RecordGqlConnection;
[namePlural: string]: RecordGqlConnectionEdgesRequired;
};
@@ -0,0 +1,136 @@
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { type SearchRecord } from '~/generated-metadata/graphql';
import { sortMorphItems } from '../sortMorphItems';
const createMorphItem = (
recordId: string,
isSelected: boolean,
): RecordPickerPickableMorphItem => ({
recordId,
objectMetadataId: 'object-1',
isSelected,
isMatchingSearchFilter: true,
});
const createSearchRecord = (recordId: string): SearchRecord => ({
recordId,
label: `Record ${recordId}`,
objectNameSingular: 'person',
tsRank: 0,
tsRankCD: 0,
});
describe('sortMorphItems', () => {
it('should sort selected items before non-selected items', () => {
const morphItems: RecordPickerPickableMorphItem[] = [
createMorphItem('1', false),
createMorphItem('2', true),
createMorphItem('3', false),
];
const searchRecords: SearchRecord[] = [
createSearchRecord('1'),
createSearchRecord('2'),
createSearchRecord('3'),
];
const result = sortMorphItems(morphItems, searchRecords);
expect(result[0].recordId).toBe('2');
expect(result[0].isSelected).toBe(true);
expect(result[1].isSelected).toBe(false);
expect(result[2].isSelected).toBe(false);
});
it('should sort by search record order within non-selected items', () => {
const morphItems: RecordPickerPickableMorphItem[] = [
createMorphItem('3', false),
createMorphItem('1', false),
createMorphItem('2', false),
];
const searchRecords: SearchRecord[] = [
createSearchRecord('1'),
createSearchRecord('2'),
createSearchRecord('3'),
];
const result = sortMorphItems(morphItems, searchRecords);
expect(result.map((item) => item.recordId)).toEqual(['1', '2', '3']);
});
it('should sort by search record order within selected items', () => {
const morphItems: RecordPickerPickableMorphItem[] = [
createMorphItem('3', true),
createMorphItem('1', true),
createMorphItem('2', true),
];
const searchRecords: SearchRecord[] = [
createSearchRecord('1'),
createSearchRecord('2'),
createSearchRecord('3'),
];
const result = sortMorphItems(morphItems, searchRecords);
expect(result.map((item) => item.recordId)).toEqual(['1', '2', '3']);
});
it('should handle mixed selected and non-selected items with correct ordering', () => {
const morphItems: RecordPickerPickableMorphItem[] = [
createMorphItem('4', false),
createMorphItem('2', true),
createMorphItem('1', false),
createMorphItem('3', true),
];
const searchRecords: SearchRecord[] = [
createSearchRecord('1'),
createSearchRecord('2'),
createSearchRecord('3'),
createSearchRecord('4'),
];
const result = sortMorphItems(morphItems, searchRecords);
expect(result.map((item) => item.recordId)).toEqual(['2', '3', '1', '4']);
expect(result[0].isSelected).toBe(true);
expect(result[1].isSelected).toBe(true);
expect(result[2].isSelected).toBe(false);
expect(result[3].isSelected).toBe(false);
});
it('should handle empty morphItems array', () => {
const morphItems: RecordPickerPickableMorphItem[] = [];
const searchRecords: SearchRecord[] = [createSearchRecord('1')];
const result = sortMorphItems(morphItems, searchRecords);
expect(result).toEqual([]);
});
it('should handle empty searchRecords array', () => {
const morphItems: RecordPickerPickableMorphItem[] = [
createMorphItem('1', false),
createMorphItem('2', true),
];
const searchRecords: SearchRecord[] = [];
const result = sortMorphItems(morphItems, searchRecords);
expect(result[0].recordId).toBe('2');
expect(result[0].isSelected).toBe(true);
});
it('should place items not present in searchRecords before indexed items', () => {
const morphItems: RecordPickerPickableMorphItem[] = [
createMorphItem('unknown', false),
createMorphItem('1', false),
];
const searchRecords: SearchRecord[] = [createSearchRecord('1')];
const result = sortMorphItems(morphItems, searchRecords);
// Items not in searchRecords get rank -1, so they come before items with rank >= 0
expect(result[0].recordId).toBe('unknown');
expect(result[1].recordId).toBe('1');
});
});
@@ -1,5 +1,5 @@
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
import { type RecordGqlEdge } from '@/object-record/graphql/types/RecordGqlEdge';
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -1377,7 +1377,7 @@ const baseMockToRecordConnection = {
) as any,
pageInfo: {},
__typename: 'CompanyConnection',
} as RecordGqlConnection;
} as RecordGqlConnectionEdgesRequired;
// eslint-disable-next-line @nx/workspace-max-consts-per-file
export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = getRecordsFromRecordConnection(
@@ -1,5 +1,5 @@
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { type RecordGqlConnection } from '@/object-record/graphql/types/RecordGqlConnection';
import { type RecordGqlConnectionEdgesRequired } from '@/object-record/graphql/types/RecordGqlConnectionEdgesRequired';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { type FieldMetadataType } from 'twenty-shared/types';
@@ -1746,7 +1746,7 @@ export const peopleQueryResult = {
},
],
},
} satisfies { people: RecordGqlConnection };
} satisfies { people: RecordGqlConnectionEdgesRequired };
export const allMockPersonRecords = getRecordsFromRecordConnection({
recordConnection: peopleQueryResult.people,