[FIX] Optimistically compute position only for objectMetadataItem that has the field (#10510)

# Introduction
In this PR https://github.com/twentyhq/twenty/pull/10493 introduced a
regression on optimistic cache for record creation, by expecting a
`position` `fieldMetadataItem` on every `ObjectMetadataItem` which is a
wrong assertion
Some `Tasks` and `ApiKeys` do not have one

## Fix
Dynamically compute optimistic record input position depending on
current `ObjectMetadataItem`

## Refactor
Refactored a failing test following [jest
each](https://jestjs.io/docs/api#describeeachtablename-fn-timeout)
pattern to avoid error prone duplicated env tests. Created a "standard'
and applied it to an other test also using `it.each`.
This commit is contained in:
Paul Rastoin
2025-02-26 17:04:52 +01:00
committed by GitHub
parent e6355c7c49
commit ea0d3c605f
11 changed files with 215 additions and 120 deletions

View File

@ -1,7 +1,7 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldMetadataType } from 'twenty-shared'; import { FieldMetadataType } from 'twenty-shared';
export const checkObjectMetadataItemHasFieldCreatedBy = ( export const hasObjectMetadataItemFieldCreatedBy = (
objectMetadataItem: ObjectMetadataItem, objectMetadataItem: ObjectMetadataItem,
) => ) =>
objectMetadataItem.fields.some( objectMetadataItem.fields.some(

View File

@ -0,0 +1,10 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldMetadataType } from 'twenty-shared';
export const hasObjectMetadataItemPositionField = (
objectMetadataItem: ObjectMetadataItem,
) =>
!objectMetadataItem.isRemote &&
objectMetadataItem.fields.some(
(field) => field.type === FieldMetadataType.POSITION,
);

View File

@ -1,4 +0,0 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const hasPositionField = (objectMetadataItem: ObjectMetadataItem) =>
!objectMetadataItem.isRemote;

View File

@ -6,7 +6,7 @@ import { triggerDestroyRecordsOptimisticEffect } from '@/apollo/optimistic-effec
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { checkObjectMetadataItemHasFieldCreatedBy } from '@/object-metadata/utils/checkObjectMetadataItemHasFieldCreatedBy'; import { hasObjectMetadataItemFieldCreatedBy } from '@/object-metadata/utils/hasObjectMetadataItemFieldCreatedBy';
import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache'; import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache'; import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
@ -49,7 +49,7 @@ export const useCreateManyRecords = <
}); });
const objectMetadataHasCreatedByField = const objectMetadataHasCreatedByField =
checkObjectMetadataItemHasFieldCreatedBy(objectMetadataItem); hasObjectMetadataItemFieldCreatedBy(objectMetadataItem);
const computedRecordGqlFields = const computedRecordGqlFields =
recordGqlFields ?? generateDepthOneRecordGqlFields({ objectMetadataItem }); recordGqlFields ?? generateDepthOneRecordGqlFields({ objectMetadataItem });

View File

@ -7,7 +7,6 @@ import { triggerDestroyRecordsOptimisticEffect } from '@/apollo/optimistic-effec
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { checkObjectMetadataItemHasFieldCreatedBy } from '@/object-metadata/utils/checkObjectMetadataItemHasFieldCreatedBy';
import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache'; import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache'; import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
@ -16,8 +15,8 @@ import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation'; import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { FieldActorForInputValue } from '@/object-record/record-field/types/FieldMetadata';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticCreateRecordBaseRecordInput } from '@/object-record/utils/computeOptimisticCreateRecordBaseRecordInput';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput'; import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField'; import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
@ -46,9 +45,6 @@ export const useCreateOneRecord = <
objectNameSingular, objectNameSingular,
}); });
const objectMetadataHasCreatedByField =
checkObjectMetadataItemHasFieldCreatedBy(objectMetadataItem);
const computedRecordGqlFields = const computedRecordGqlFields =
recordGqlFields ?? generateDepthOneRecordGqlFields({ objectMetadataItem }); recordGqlFields ?? generateDepthOneRecordGqlFields({ objectMetadataItem });
@ -84,25 +80,14 @@ export const useCreateOneRecord = <
id: idForCreation, id: idForCreation,
}; };
const baseOptimisticRecordInputCreatedBy:
| { createdBy: FieldActorForInputValue }
| undefined = objectMetadataHasCreatedByField
? {
createdBy: {
source: 'MANUAL',
context: {},
},
}
: undefined;
const optimisticRecordInput = computeOptimisticRecordFromInput({ const optimisticRecordInput = computeOptimisticRecordFromInput({
cache: apolloClient.cache, cache: apolloClient.cache,
currentWorkspaceMember: currentWorkspaceMember, currentWorkspaceMember: currentWorkspaceMember,
objectMetadataItem, objectMetadataItem,
objectMetadataItems, objectMetadataItems,
recordInput: { recordInput: {
...baseOptimisticRecordInputCreatedBy, ...computeOptimisticCreateRecordBaseRecordInput(objectMetadataItem),
...recordInput, ...recordInput,
position: Number.NEGATIVE_INFINITY,
id: idForCreation, id: idForCreation,
}, },
}); });

View File

@ -1,11 +1,23 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { RecordSort } from '@/object-record/record-sort/types/RecordSort'; import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { EachTestingContext } from '~/types/EachTestingContext';
const objectMetadataItem: ObjectMetadataItem = { const objectMetadataItemWithPositionField: ObjectMetadataItem = {
id: 'object1', id: 'object1',
fields: [], fields: [
{
name: 'name',
updatedAt: '2021-01-01',
createdAt: '2021-01-01',
id: '20202020-18b3-4099-86e3-c46b2d5d42f2',
type: FieldMetadataType.POSITION,
label: 'label',
},
],
indexMetadatas: [], indexMetadatas: [],
createdAt: '2021-01-01', createdAt: '2021-01-01',
updatedAt: '2021-01-01', updatedAt: '2021-01-01',
@ -22,34 +34,61 @@ const objectMetadataItem: ObjectMetadataItem = {
isLabelSyncedWithName: true, isLabelSyncedWithName: true,
}; };
describe('turnSortsIntoOrderBy', () => { type PartialFieldMetadaItemWithRequiredId = Pick<FieldMetadataItem, 'id'> &
it('should sort by recordPosition if no sorts', () => { Partial<Omit<FieldMetadataItem, 'id'>>;
const fields = [{ id: 'field1', name: 'createdAt' }] as FieldMetadataItem[]; const getMockFieldMetadataItem = (
expect(turnSortsIntoOrderBy({ ...objectMetadataItem, fields }, [])).toEqual( overrides: PartialFieldMetadaItemWithRequiredId,
[ ): FieldMetadataItem => ({
name: 'name',
updatedAt: '2021-01-01',
createdAt: '2021-01-01',
type: FieldMetadataType.TEXT,
label: 'label',
...overrides,
});
type TurnSortsIntoOrderTestContext = EachTestingContext<{
fields: PartialFieldMetadaItemWithRequiredId[];
expected: RecordGqlOperationOrderBy;
sort: RecordSort[];
objectMetadataItemOverrides?: Partial<Omit<ObjectMetadataItem, 'fields'>>;
}>;
const turnSortsIntoOrderByTestUseCases: TurnSortsIntoOrderTestContext[] = [
{
title: 'It should sort by recordPosition if no sorts',
context: {
fields: [{ id: 'field1', name: 'field1' }],
sort: [],
expected: [
{ {
position: 'AscNullsFirst', position: 'AscNullsFirst',
}, },
], ],
); },
}); },
{
it('should create OrderByField with single sort', () => { title: 'It should create OrderByField with single sort',
const sorts: RecordSort[] = [ context: {
fields: [{ id: 'field1', name: 'field1' }],
sort: [
{ {
id: 'id', id: 'id',
fieldMetadataId: 'field1', fieldMetadataId: 'field1',
direction: 'asc', direction: 'asc',
}, },
]; ],
const fields = [{ id: 'field1', name: 'field1' }] as FieldMetadataItem[]; expected: [{ field1: 'AscNullsFirst' }, { position: 'AscNullsFirst' }],
expect( },
turnSortsIntoOrderBy({ ...objectMetadataItem, fields }, sorts), },
).toEqual([{ field1: 'AscNullsFirst' }, { position: 'AscNullsFirst' }]); {
}); title: 'It should create OrderByField with multiple sorts',
context: {
it('should create OrderByField with multiple sorts', () => { fields: [
const sorts: RecordSort[] = [ { id: 'field1', name: 'field1' },
{ id: 'field2', name: 'field2' },
],
sort: [
{ {
id: 'id', id: 'id',
fieldMetadataId: 'field1', fieldMetadataId: 'field1',
@ -60,43 +99,61 @@ describe('turnSortsIntoOrderBy', () => {
fieldMetadataId: 'field2', fieldMetadataId: 'field2',
direction: 'desc', direction: 'desc',
}, },
]; ],
const fields = [ expected: [
{ id: 'field1', name: 'field1' },
{ id: 'field2', name: 'field2' },
] as FieldMetadataItem[];
expect(
turnSortsIntoOrderBy({ ...objectMetadataItem, fields }, sorts),
).toEqual([
{ field1: 'AscNullsFirst' }, { field1: 'AscNullsFirst' },
{ field2: 'DescNullsLast' }, { field2: 'DescNullsLast' },
{ position: 'AscNullsFirst' }, { position: 'AscNullsFirst' },
]); ],
}); },
},
it('should ignore if field not found', () => { {
const sorts: RecordSort[] = [ title: 'It should ignore if field not found',
context: {
fields: [],
sort: [
{ {
id: 'id', id: 'id',
fieldMetadataId: 'invalidField', fieldMetadataId: 'invalidField',
direction: 'asc', direction: 'asc',
}, },
]; ],
expect(turnSortsIntoOrderBy(objectMetadataItem, sorts)).toEqual([ expected: [{ position: 'AscNullsFirst' }],
{ position: 'AscNullsFirst' }, },
]); },
}); {
title: 'It should not return position for remotes',
it('should not return position for remotes', () => { context: {
const sorts: RecordSort[] = [ fields: [],
sort: [
{ {
id: 'id', id: 'id',
fieldMetadataId: 'invalidField', fieldMetadataId: 'invalidField',
direction: 'asc', direction: 'asc',
}, },
],
expected: [],
objectMetadataItemOverrides: {
isRemote: true,
},
},
},
]; ];
describe('turnSortsIntoOrderBy', () => {
it.each<TurnSortsIntoOrderTestContext>(turnSortsIntoOrderByTestUseCases)(
'.$title',
({ context: { fields, sort, expected, objectMetadataItemOverrides } }) => {
const newFields = fields.map(getMockFieldMetadataItem);
const objectMetadataItemWithNewFields = {
...objectMetadataItemWithPositionField,
...objectMetadataItemOverrides,
fields: [...objectMetadataItemWithPositionField.fields, ...newFields],
};
expect( expect(
turnSortsIntoOrderBy({ ...objectMetadataItem, isRemote: true }, sorts), turnSortsIntoOrderBy(objectMetadataItemWithNewFields, sort),
).toEqual([]); ).toEqual(expected);
}); },
);
}); });

View File

@ -1,6 +1,5 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { hasPositionField } from '@/object-metadata/utils/hasPositionField';
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy'; import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; import { mapArrayToObject } from '~/utils/array/mapArrayToObject';
@ -8,6 +7,7 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getOrderByForFieldMetadataType } from '@/object-metadata/utils/getOrderByForFieldMetadataType'; import { getOrderByForFieldMetadataType } from '@/object-metadata/utils/getOrderByForFieldMetadataType';
import { hasObjectMetadataItemPositionField } from '@/object-metadata/utils/hasObjectMetadataItemPositionField';
import { RecordSort } from '@/object-record/record-sort/types/RecordSort'; import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { OrderBy } from '@/types/OrderBy'; import { OrderBy } from '@/types/OrderBy';
@ -35,7 +35,7 @@ export const turnSortsIntoOrderBy = (
}) })
.filter(isDefined); .filter(isDefined);
if (hasPositionField(objectMetadataItem)) { if (hasObjectMetadataItemPositionField(objectMetadataItem)) {
const positionOrderBy = [ const positionOrderBy = [
{ {
position: 'AscNullsFirst', position: 'AscNullsFirst',

View File

@ -2,7 +2,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectMetadataIdentifierFields } from '@/object-metadata/utils/getObjectMetadataIdentifierFields'; import { getObjectMetadataIdentifierFields } from '@/object-metadata/utils/getObjectMetadataIdentifierFields';
import { hasPositionField } from '@/object-metadata/utils/hasPositionField'; import { hasObjectMetadataItemPositionField } from '@/object-metadata/utils/hasObjectMetadataItemPositionField';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { recordBoardVisibleFieldDefinitionsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsComponentSelector'; import { recordBoardVisibleFieldDefinitionsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsComponentSelector';
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';
@ -58,7 +58,9 @@ export const useRecordBoardRecordGqlFields = ({
true, true,
]), ]),
), ),
...(hasPositionField(objectMetadataItem) ? { position: true } : undefined), ...(hasObjectMetadataItemPositionField(objectMetadataItem)
? { position: true }
: undefined),
...identifierQueryFields, ...identifierQueryFields,
noteTargets: generateDepthOneRecordGqlFields({ noteTargets: generateDepthOneRecordGqlFields({
objectMetadataItem: noteTargetObjectMetadataItem, objectMetadataItem: noteTargetObjectMetadataItem,

View File

@ -0,0 +1,24 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { hasObjectMetadataItemFieldCreatedBy } from '@/object-metadata/utils/hasObjectMetadataItemFieldCreatedBy';
import { hasObjectMetadataItemPositionField } from '@/object-metadata/utils/hasObjectMetadataItemPositionField';
import { FieldActorForInputValue } from '@/object-record/record-field/types/FieldMetadata';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const computeOptimisticCreateRecordBaseRecordInput = (
objectMetadataItem: ObjectMetadataItem,
) => {
const baseRecordInput: Partial<ObjectRecord> = {};
if (hasObjectMetadataItemFieldCreatedBy(objectMetadataItem)) {
baseRecordInput.createdBy = {
source: 'MANUAL',
context: {},
} satisfies FieldActorForInputValue;
}
if (hasObjectMetadataItemPositionField(objectMetadataItem)) {
baseRecordInput.position = Number.NEGATIVE_INFINITY;
}
return baseRecordInput;
};

View File

@ -0,0 +1,4 @@
export type EachTestingContext<T> = {
title: string;
context: T;
};

View File

@ -1,23 +1,40 @@
import { EachTestingContext } from '~/types/EachTestingContext';
import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue'; import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue';
describe('buildRecordFromKeysWithSameValue', () => { type BuildRecordFromKeysWithSameValueTestContext = EachTestingContext<{
test.each([ array: string[];
{ array: [], expected: {}, arg: undefined }, expected: Record<string, boolean | string>;
{ arg?: boolean | string;
array: ['foo', 'bar'], }>;
expected: { foo: 'oui', bar: 'oui' },
arg: 'oui', const buildRecordFromKeysWithSameValueTestUseCases: BuildRecordFromKeysWithSameValueTestContext[] =
}, [
{ {
title: 'It should create record from array and fill with boolean',
context: {
array: ['foo', 'bar'] as const, array: ['foo', 'bar'] as const,
expected: { foo: true, bar: true }, expected: { foo: true, bar: true },
arg: true, arg: true,
}, },
])( },
'.buildRecordFromKeysWithSameValue($array, $arg)', {
({ array, arg, expected }) => { title: 'It should create record from array and fill with string',
context: {
array: ['foo', 'bar'],
expected: { foo: 'oui', bar: 'oui' },
arg: 'oui',
},
},
{
title: 'It should create empty record from empty array',
context: { array: [], expected: {}, arg: undefined },
},
];
describe('buildRecordFromKeysWithSameValue', () => {
test.each<BuildRecordFromKeysWithSameValueTestContext>(
buildRecordFromKeysWithSameValueTestUseCases,
)('.$title', ({ context: { array, arg, expected } }) => {
const result = buildRecordFromKeysWithSameValue(array, arg); const result = buildRecordFromKeysWithSameValue(array, arg);
expect(result).toEqual(expected); expect(result).toEqual(expected);
}, });
);
}); });