Activity cache injection (#3791)

* WIP

* Minor fixes

* Added TODO

* Fix post merge

* Fix

* Fixed warnings

* Fixed comments

* Fixed comments

* Fixed naming

* Removed comment

* WIP

* WIP 2

* Finished working version

* Fixes

* Fixed typing

* Fixes

* Fixes

* Fixes

* Naming fixes

* WIP

* Fix import

* WIP

* Working version on title

* Fixed create record id overwrite

* Removed unecessary callback

* Masterpiece

* Fixed delete on click outside drawer or delete

* Cleaned

* Cleaned

* Cleaned

* Minor fixes

* Fixes

* Fixed naming

* WIP

* Fix

* Fixed create from target inline cell

* Removed console.log

* Fixed delete activity optimistic effect

* Fixed no title

* Fixed debounce and title body creation

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2024-02-09 14:51:30 +01:00
committed by GitHub
parent 9ceff84bbf
commit cca72da708
87 changed files with 2195 additions and 1058 deletions

View File

@ -0,0 +1,62 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RelationType } from '@/settings/data-model/types/RelationType';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
export const getRelationDefinition = ({
objectMetadataItems,
fieldMetadataItemOnSourceRecord,
}: {
objectMetadataItems: ObjectMetadataItem[];
fieldMetadataItemOnSourceRecord: FieldMetadataItem;
}) => {
if (fieldMetadataItemOnSourceRecord.type !== FieldMetadataType.Relation) {
return null;
}
const relationMetadataItem =
fieldMetadataItemOnSourceRecord.fromRelationMetadata ||
fieldMetadataItemOnSourceRecord.toRelationMetadata;
if (!relationMetadataItem) return null;
const relationSourceFieldMetadataItemId =
'toFieldMetadataId' in relationMetadataItem
? relationMetadataItem.toFieldMetadataId
: relationMetadataItem.fromFieldMetadataId;
if (!relationSourceFieldMetadataItemId) return null;
// TODO: precise naming, is it relationTypeFromTargetPointOfView or relationTypeFromSourcePointOfView ?
const relationType =
relationMetadataItem.relationType === RelationMetadataType.OneToMany &&
fieldMetadataItemOnSourceRecord.toRelationMetadata
? ('MANY_TO_ONE' satisfies RelationType)
: (relationMetadataItem.relationType as RelationType);
const targetObjectMetadataNameSingular =
'toObjectMetadata' in relationMetadataItem
? relationMetadataItem.toObjectMetadata.nameSingular
: relationMetadataItem.fromObjectMetadata.nameSingular;
const targetObjectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === targetObjectMetadataNameSingular,
);
if (!targetObjectMetadataItem) return null;
const fieldMetadataItemOnTargetRecord = targetObjectMetadataItem.fields.find(
(field) => field.id === relationSourceFieldMetadataItemId,
);
if (!fieldMetadataItemOnTargetRecord) return null;
return {
fieldMetadataItemOnTargetRecord,
targetObjectMetadataItem,
relationType,
};
};

View File

@ -2,57 +2,69 @@ import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const triggerAttachRelationOptimisticEffect = ({
cache,
objectNameSingular,
recordId,
relationObjectMetadataNameSingular,
relationFieldName,
relationRecordId,
sourceObjectNameSingular,
sourceRecordId,
targetObjectNameSingular,
fieldNameOnTargetRecord,
targetRecordId,
}: {
cache: ApolloCache<unknown>;
objectNameSingular: string;
recordId: string;
relationObjectMetadataNameSingular: string;
relationFieldName: string;
relationRecordId: string;
sourceObjectNameSingular: string;
sourceRecordId: string;
targetObjectNameSingular: string;
fieldNameOnTargetRecord: string;
targetRecordId: string;
}) => {
const recordTypeName = capitalize(objectNameSingular);
const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular);
const sourceRecordTypeName = capitalize(sourceObjectNameSingular);
const targetRecordTypeName = capitalize(targetObjectNameSingular);
const targetRecordCacheId = cache.identify({
id: targetRecordId,
__typename: targetRecordTypeName,
});
cache.modify<StoreObject>({
id: cache.identify({
id: relationRecordId,
__typename: relationRecordTypeName,
}),
id: targetRecordCacheId,
fields: {
[relationFieldName]: (cachedFieldValue, { toReference }) => {
const nodeReference = toReference({
id: recordId,
__typename: recordTypeName,
[fieldNameOnTargetRecord]: (targetRecordFieldValue, { toReference }) => {
const fieldValueIsCachedObjectRecordConnection =
isCachedObjectRecordConnection(
sourceObjectNameSingular,
targetRecordFieldValue,
);
const sourceRecordReference = toReference({
id: sourceRecordId,
__typename: sourceRecordTypeName,
});
if (!nodeReference) return cachedFieldValue;
if (!isDefined(sourceRecordReference)) {
return targetRecordFieldValue;
}
if (
isCachedObjectRecordConnection(objectNameSingular, cachedFieldValue)
) {
// To many objects => add record to next relation field list
if (fieldValueIsCachedObjectRecordConnection) {
const nextEdges: CachedObjectRecordEdge[] = [
...cachedFieldValue.edges,
...targetRecordFieldValue.edges,
{
__typename: `${recordTypeName}Edge`,
node: nodeReference,
__typename: `${sourceRecordTypeName}Edge`,
node: sourceRecordReference,
cursor: '',
},
];
return { ...cachedFieldValue, edges: nextEdges };
}
// To one object => attach next relation record
return nodeReference;
return {
...targetRecordFieldValue,
edges: nextEdges,
};
} else {
// To one object => attach next relation record
return sourceRecordReference;
}
},
},
});

View File

@ -4,9 +4,8 @@ import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils
import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
/*
TODO: for now new records are added to all cached record lists, no matter what the variables (filters, orderBy, etc.) are.
@ -16,32 +15,32 @@ import { capitalize } from '~/utils/string/capitalize';
export const triggerCreateRecordsOptimisticEffect = ({
cache,
objectMetadataItem,
records,
getRelationMetadata,
recordsToCreate,
objectMetadataItems,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
records: CachedObjectRecord[];
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
recordsToCreate: CachedObjectRecord[];
objectMetadataItems: ObjectMetadataItem[];
}) => {
const objectEdgeTypeName = `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`;
const objectEdgeTypeName = getEdgeTypename({
objectNameSingular: objectMetadataItem.nameSingular,
});
records.forEach((record) =>
recordsToCreate.forEach((record) =>
triggerUpdateRelationsOptimisticEffect({
cache,
objectMetadataItem,
previousRecord: null,
nextRecord: record,
getRelationMetadata,
sourceObjectMetadataItem: objectMetadataItem,
currentSourceRecord: null,
updatedSourceRecord: record,
objectMetadataItems,
}),
);
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
cachedConnection,
rootQueryCachedResponse,
{
DELETE: _DELETE,
readField,
@ -49,42 +48,49 @@ export const triggerCreateRecordsOptimisticEffect = ({
toReference,
},
) => {
if (
!isCachedObjectRecordConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
)
return cachedConnection;
/* const { variables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
); */
const cachedEdges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedConnection,
const shouldSkip = !isCachedObjectRecordConnection(
objectMetadataItem.nameSingular,
rootQueryCachedResponse,
);
const nextCachedEdges = cachedEdges ? [...cachedEdges] : [];
const hasAddedRecords = records
.map((record) => {
/* const matchesFilter =
!variables?.filter ||
isRecordMatchingFilter({
record,
filter: variables.filter,
objectMetadataItem,
}); */
if (shouldSkip) {
return rootQueryCachedResponse;
}
if (/* matchesFilter && */ record.id) {
const nodeReference = toReference(record);
const rootQueryCachedObjectRecordConnection = rootQueryCachedResponse;
if (nodeReference) {
nextCachedEdges.unshift({
const rootQueryCachedRecordEdges = readField<CachedObjectRecordEdge[]>(
'edges',
rootQueryCachedObjectRecordConnection,
);
const nextRootQueryCachedRecordEdges = rootQueryCachedRecordEdges
? [...rootQueryCachedRecordEdges]
: [];
const hasAddedRecords = recordsToCreate
.map((recordToCreate) => {
if (recordToCreate.id) {
const recordToCreateReference = toReference(recordToCreate);
if (!recordToCreateReference) {
throw new Error(
`Failed to create reference for record with id: ${recordToCreate.id}`,
);
}
const recordAlreadyInCache = rootQueryCachedRecordEdges?.some(
(cachedEdge) => {
return (
cache.identify(recordToCreateReference) ===
cache.identify(cachedEdge.node)
);
},
);
if (recordToCreateReference && !recordAlreadyInCache) {
nextRootQueryCachedRecordEdges.unshift({
__typename: objectEdgeTypeName,
node: nodeReference,
node: recordToCreateReference,
cursor: '',
});
@ -96,30 +102,14 @@ export const triggerCreateRecordsOptimisticEffect = ({
})
.some((hasAddedRecord) => hasAddedRecord);
if (!hasAddedRecords) return cachedConnection;
/* if (variables?.orderBy) {
nextCachedEdges = sortCachedObjectEdges({
edges: nextCachedEdges,
orderBy: variables.orderBy,
readCacheField: readField,
});
if (!hasAddedRecords) {
return rootQueryCachedObjectRecordConnection;
}
if (isDefined(variables?.first)) {
if (
cachedEdges?.length === variables.first &&
nextCachedEdges.length < variables.first
) {
return DELETE;
}
if (nextCachedEdges.length > variables.first) {
nextCachedEdges.splice(variables.first);
}
} */
return { ...cachedConnection, edges: nextCachedEdges };
return {
...rootQueryCachedObjectRecordConnection,
edges: nextRootQueryCachedRecordEdges,
};
},
},
});

View File

@ -5,7 +5,6 @@ import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effe
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
@ -13,70 +12,79 @@ import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
export const triggerDeleteRecordsOptimisticEffect = ({
cache,
objectMetadataItem,
records,
getRelationMetadata,
recordsToDelete,
objectMetadataItems,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
records: CachedObjectRecord[];
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
recordsToDelete: CachedObjectRecord[];
objectMetadataItems: ObjectMetadataItem[];
}) => {
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
cachedConnection,
rootQueryCachedResponse,
{ DELETE, readField, storeFieldName },
) => {
if (
const rootQueryCachedResponseIsNotACachedObjectRecordConnection =
!isCachedObjectRecordConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
) {
return cachedConnection;
rootQueryCachedResponse,
);
if (rootQueryCachedResponseIsNotACachedObjectRecordConnection) {
return rootQueryCachedResponse;
}
const { variables } =
const rootQueryCachedObjectRecordConnection = rootQueryCachedResponse;
const { fieldArguments: rootQueryVariables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
);
const recordIds = records.map(({ id }) => id);
const recordIdsToDelete = recordsToDelete.map(({ id }) => id);
const cachedEdges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedConnection,
rootQueryCachedObjectRecordConnection,
);
const nextCachedEdges =
cachedEdges?.filter((cachedEdge) => {
const nodeId = readField<string>('id', cachedEdge.node);
return nodeId && !recordIds.includes(nodeId);
return nodeId && !recordIdsToDelete.includes(nodeId);
}) || [];
if (nextCachedEdges.length === cachedEdges?.length)
return cachedConnection;
return rootQueryCachedObjectRecordConnection;
// TODO: same as in update, should we trigger DELETE ?
if (
isDefined(variables?.first) &&
cachedEdges?.length === variables.first
isDefined(rootQueryVariables?.first) &&
cachedEdges?.length === rootQueryVariables.first
) {
return DELETE;
}
return { ...cachedConnection, edges: nextCachedEdges };
return {
...rootQueryCachedObjectRecordConnection,
edges: nextCachedEdges,
};
},
},
});
records.forEach((record) => {
recordsToDelete.forEach((recordToDelete) => {
triggerUpdateRelationsOptimisticEffect({
cache,
objectMetadataItem,
previousRecord: record,
nextRecord: null,
getRelationMetadata,
sourceObjectMetadataItem: objectMetadataItem,
currentSourceRecord: recordToDelete,
updatedSourceRecord: null,
objectMetadataItems,
});
cache.evict({ id: cache.identify(record) });
cache.evict({ id: cache.identify(recordToDelete) });
});
};

View File

@ -5,44 +5,60 @@ import { capitalize } from '~/utils/string/capitalize';
export const triggerDetachRelationOptimisticEffect = ({
cache,
objectNameSingular,
recordId,
relationObjectMetadataNameSingular,
relationFieldName,
relationRecordId,
sourceObjectNameSingular,
sourceRecordId,
targetObjectNameSingular,
fieldNameOnTargetRecord,
targetRecordId,
}: {
cache: ApolloCache<unknown>;
objectNameSingular: string;
recordId: string;
relationObjectMetadataNameSingular: string;
relationFieldName: string;
relationRecordId: string;
sourceObjectNameSingular: string;
sourceRecordId: string;
targetObjectNameSingular: string;
fieldNameOnTargetRecord: string;
targetRecordId: string;
}) => {
const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular);
const targetRecordTypeName = capitalize(targetObjectNameSingular);
const targetRecordCacheId = cache.identify({
id: targetRecordId,
__typename: targetRecordTypeName,
});
cache.modify<StoreObject>({
id: cache.identify({
id: relationRecordId,
__typename: relationRecordTypeName,
}),
id: targetRecordCacheId,
fields: {
[relationFieldName]: (cachedFieldValue, { isReference, readField }) => {
// To many objects => remove record from previous relation field list
if (
isCachedObjectRecordConnection(objectNameSingular, cachedFieldValue)
) {
const nextEdges = cachedFieldValue.edges.filter(
({ node }) => readField('id', node) !== recordId,
[fieldNameOnTargetRecord]: (
targetRecordFieldValue,
{ isReference, readField },
) => {
const isRelationTargetFieldAnObjectRecordConnection =
isCachedObjectRecordConnection(
sourceObjectNameSingular,
targetRecordFieldValue,
);
return { ...cachedFieldValue, edges: nextEdges };
if (isRelationTargetFieldAnObjectRecordConnection) {
const relationTargetFieldEdgesWithoutRelationSourceRecordToDetach =
targetRecordFieldValue.edges.filter(
({ node }) => readField('id', node) !== sourceRecordId,
);
return {
...targetRecordFieldValue,
edges: relationTargetFieldEdgesWithoutRelationSourceRecordToDetach,
};
}
// To one object => detach previous relation record
if (isReference(cachedFieldValue)) {
const isRelationTargetFieldASingleObjectRecord = isReference(
targetRecordFieldValue,
);
if (isRelationTargetFieldASingleObjectRecord) {
return null;
}
return cachedFieldValue;
return targetRecordFieldValue;
},
},
});

View File

@ -6,124 +6,185 @@ import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effe
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
import { capitalize } from '~/utils/string/capitalize';
// TODO: add extensive unit tests for this function
// That will also serve as documentation
export const triggerUpdateRecordOptimisticEffect = ({
cache,
objectMetadataItem,
previousRecord,
nextRecord,
getRelationMetadata,
currentRecord,
updatedRecord,
objectMetadataItems,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
previousRecord: CachedObjectRecord;
nextRecord: CachedObjectRecord;
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
currentRecord: CachedObjectRecord;
updatedRecord: CachedObjectRecord;
objectMetadataItems: ObjectMetadataItem[];
}) => {
const objectEdgeTypeName = `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`;
const objectEdgeTypeName = getEdgeTypename({
objectNameSingular: objectMetadataItem.nameSingular,
});
triggerUpdateRelationsOptimisticEffect({
cache,
objectMetadataItem,
previousRecord,
nextRecord,
getRelationMetadata,
sourceObjectMetadataItem: objectMetadataItem,
currentSourceRecord: currentRecord,
updatedSourceRecord: updatedRecord,
objectMetadataItems,
});
// Optimistically update record lists
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
cachedConnection,
rootQueryCachedResponse,
{ DELETE, readField, storeFieldName, toReference },
) => {
if (
const rootQueryCachedResponseIsNotACachedObjectRecordConnection =
!isCachedObjectRecordConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
)
return cachedConnection;
rootQueryCachedResponse,
);
const { variables } =
if (rootQueryCachedResponseIsNotACachedObjectRecordConnection) {
return rootQueryCachedResponse;
}
const rootQueryCachedObjectRecordConnection = rootQueryCachedResponse;
const { fieldArguments: rootQueryVariables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
);
const cachedEdges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedConnection,
);
let nextCachedEdges = cachedEdges ? [...cachedEdges] : [];
const rootQueryCurrentCachedRecordEdges =
readField<CachedObjectRecordEdge[]>(
'edges',
rootQueryCachedObjectRecordConnection,
) ?? [];
// Test if the record matches this list's filters
if (variables?.filter) {
const matchesFilter = isRecordMatchingFilter({
record: nextRecord,
filter: variables.filter,
objectMetadataItem,
});
const recordIndex = nextCachedEdges.findIndex(
(cachedEdge) => readField('id', cachedEdge.node) === nextRecord.id,
);
let rootQueryNextCachedRecordEdges = [
...rootQueryCurrentCachedRecordEdges,
];
// If after update, the record matches this list's filters, then add it to the list
if (matchesFilter && recordIndex === -1) {
const nodeReference = toReference(nextRecord);
nodeReference &&
nextCachedEdges.push({
const rootQueryFilter = rootQueryVariables?.filter;
const rootQueryOrderBy = rootQueryVariables?.orderBy;
const rootQueryLimit = rootQueryVariables?.first;
const shouldTestThatUpdatedRecordMatchesThisRootQueryFilter =
isDefined(rootQueryFilter);
if (shouldTestThatUpdatedRecordMatchesThisRootQueryFilter) {
const updatedRecordMatchesThisRootQueryFilter =
isRecordMatchingFilter({
record: updatedRecord,
filter: rootQueryFilter,
objectMetadataItem,
});
const updatedRecordIndexInRootQueryEdges =
rootQueryCurrentCachedRecordEdges.findIndex(
(cachedEdge) =>
readField('id', cachedEdge.node) === updatedRecord.id,
);
const updatedRecordShouldBeAddedToRootQueryEdges =
updatedRecordMatchesThisRootQueryFilter &&
updatedRecordIndexInRootQueryEdges === -1;
const updatedRecordShouldBeRemovedFromRootQueryEdges =
updatedRecordMatchesThisRootQueryFilter &&
updatedRecordIndexInRootQueryEdges === -1;
if (updatedRecordShouldBeAddedToRootQueryEdges) {
const updatedRecordNodeReference = toReference(updatedRecord);
if (isDefined(updatedRecordNodeReference)) {
rootQueryNextCachedRecordEdges.push({
__typename: objectEdgeTypeName,
node: nodeReference,
node: updatedRecordNodeReference,
cursor: '',
});
}
}
// If after update, the record does not match this list's filters anymore, then remove it from the list
if (!matchesFilter && recordIndex > -1) {
nextCachedEdges.splice(recordIndex, 1);
if (updatedRecordShouldBeRemovedFromRootQueryEdges) {
rootQueryNextCachedRecordEdges.splice(
updatedRecordIndexInRootQueryEdges,
1,
);
}
}
// Sort updated list
if (variables?.orderBy) {
nextCachedEdges = sortCachedObjectEdges({
edges: nextCachedEdges,
orderBy: variables.orderBy,
const nextRootQueryEdgesShouldBeSorted = isDefined(rootQueryOrderBy);
if (nextRootQueryEdgesShouldBeSorted) {
rootQueryNextCachedRecordEdges = sortCachedObjectEdges({
edges: rootQueryNextCachedRecordEdges,
orderBy: rootQueryOrderBy,
readCacheField: readField,
});
}
// Limit the updated list to the required size
if (isDefined(variables?.first)) {
const shouldLimitNextRootQueryEdges = isDefined(rootQueryLimit);
// TODO: not sure that we should trigger a DELETE here, as it will trigger a network request
// Is it the responsibility of this optimistic effect function to delete a root query that will trigger a network request ?
// Shouldn't we let the response from the network overwrite the cache and keep this util purely about cache updates ?
//
// Shoud we even apply the limit at all since with pagination we cannot really do optimistic rendering and should
// wait for the network response to update the cache
//
// Maybe we could apply a merging function instead and exclude limit from the caching field arguments ?
// Also we have a problem that is not yet present with this but may arise if we start
// to use limit arguments, as for now we rely on the hard coded limit of 60 in pg_graphql.
// This is as if we had a { limit: 60 } argument in every query but we don't.
// so Apollo correctly merges the return of fetchMore for now, because of this,
// but wouldn't do it well like Thomas had the problem with mail threads
// because he applied a limit of 2 and Apollo created one root query in the cache for each.
// In Thomas' case we should implement this because he use a hack to overwrite the first request with the return of the other.
// See: https://www.apollographql.com/docs/react/pagination/cursor-based/#relay-style-cursor-pagination
// See: https://www.apollographql.com/docs/react/pagination/core-api/#merging-queries
if (shouldLimitNextRootQueryEdges) {
// If previous edges length was exactly at the required limit,
// but after update next edges length is under the limit,
// we cannot for sure know if re-fetching the query
// would return more edges, so we cannot optimistically deduce
// the query's result.
// In this case, invalidate the cache entry so it can be re-fetched.
if (
cachedEdges?.length === variables.first &&
nextCachedEdges.length < variables.first
) {
const rootQueryCurrentCachedRecordEdgesLengthIsAtLimit =
rootQueryCurrentCachedRecordEdges.length === rootQueryLimit;
// If next edges length is under limit, then we can wait for the network response and merge the result
// then in the merge function we could implement this mechanism to limit the number of edges in the cache
const rootQueryNextCachedRecordEdgesLengthIsUnderLimit =
rootQueryNextCachedRecordEdges.length < rootQueryLimit;
const shouldDeleteRootQuerySoItCanBeRefetched =
rootQueryCurrentCachedRecordEdgesLengthIsAtLimit &&
rootQueryNextCachedRecordEdgesLengthIsUnderLimit;
if (shouldDeleteRootQuerySoItCanBeRefetched) {
return DELETE;
}
// If next edges length exceeds the required limit,
// trim the next edges array to the correct length.
if (nextCachedEdges.length > variables.first) {
nextCachedEdges.splice(variables.first);
const rootQueryNextCachedRecordEdgesLengthIsAboveRootQueryLimit =
rootQueryNextCachedRecordEdges.length > rootQueryLimit;
if (rootQueryNextCachedRecordEdgesLengthIsAboveRootQueryLimit) {
rootQueryNextCachedRecordEdges.splice(rootQueryLimit);
}
}
return { ...cachedConnection, edges: nextCachedEdges };
return {
...rootQueryCachedObjectRecordConnection,
edges: rootQueryNextCachedRecordEdges,
};
},
},
});

View File

@ -1,111 +1,147 @@
import { ApolloCache } from '@apollo/client';
import { getRelationDefinition } from '@/apollo/optimistic-effect/utils/getRelationDefinition';
import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection';
import { triggerAttachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { coreObjectNamesToDeleteOnRelationDetach } from '@/apollo/types/coreObjectNamesToDeleteOnRelationDetach';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH as CORE_OBJECT_NAMES_TO_DELETE_ON_OPTIMISTIC_RELATION_DETACH } from '@/apollo/types/coreObjectNamesToDeleteOnRelationDetach';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isDefined } from '~/utils/isDefined';
export const triggerUpdateRelationsOptimisticEffect = ({
cache,
objectMetadataItem,
previousRecord,
nextRecord,
getRelationMetadata,
sourceObjectMetadataItem,
currentSourceRecord,
updatedSourceRecord,
objectMetadataItems,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
previousRecord: CachedObjectRecord | null;
nextRecord: CachedObjectRecord | null;
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
sourceObjectMetadataItem: ObjectMetadataItem;
currentSourceRecord: CachedObjectRecord | null;
updatedSourceRecord: CachedObjectRecord | null;
objectMetadataItems: ObjectMetadataItem[];
}) =>
// Optimistically update relation records
objectMetadataItem.fields.forEach((fieldMetadataItem) => {
if (nextRecord && !(fieldMetadataItem.name in nextRecord)) return;
sourceObjectMetadataItem.fields.forEach((fieldMetadataItemOnSourceRecord) => {
const notARelationField =
fieldMetadataItemOnSourceRecord.type !== FieldMetadataType.Relation;
const relationMetadata = getRelationMetadata({
fieldMetadataItem,
if (notARelationField) {
return;
}
const fieldDoesNotExist =
isDefined(updatedSourceRecord) &&
!(fieldMetadataItemOnSourceRecord.name in updatedSourceRecord);
if (fieldDoesNotExist) {
return;
}
const relationDefinition = getRelationDefinition({
fieldMetadataItemOnSourceRecord,
objectMetadataItems,
});
if (!relationMetadata) return;
if (!relationDefinition) {
return;
}
const {
// Object metadata for the related record
relationObjectMetadataItem,
// Field on the related record
relationFieldMetadataItem,
} = relationMetadata;
const { targetObjectMetadataItem, fieldMetadataItemOnTargetRecord } =
relationDefinition;
const previousFieldValue:
const currentFieldValueOnSourceRecord:
| ObjectRecordConnection
| CachedObjectRecord
| null = previousRecord?.[fieldMetadataItem.name];
const nextFieldValue: ObjectRecordConnection | CachedObjectRecord | null =
nextRecord?.[fieldMetadataItem.name];
| null = currentSourceRecord?.[fieldMetadataItemOnSourceRecord.name];
if (isDeeplyEqual(previousFieldValue, nextFieldValue)) return;
const updatedFieldValueOnSourceRecord:
| ObjectRecordConnection
| CachedObjectRecord
| null = updatedSourceRecord?.[fieldMetadataItemOnSourceRecord.name];
const isPreviousFieldValueRecordConnection = isObjectRecordConnection(
relationObjectMetadataItem.nameSingular,
previousFieldValue,
);
const relationRecordsToDetach = isPreviousFieldValueRecordConnection
? previousFieldValue.edges.map(({ node }) => node as CachedObjectRecord)
: [previousFieldValue].filter(isDefined);
if (
isDeeplyEqual(
currentFieldValueOnSourceRecord,
updatedFieldValueOnSourceRecord,
)
) {
return;
}
const isNextFieldValueRecordConnection = isObjectRecordConnection(
relationObjectMetadataItem.nameSingular,
nextFieldValue,
);
const relationRecordsToAttach = isNextFieldValueRecordConnection
? nextFieldValue.edges.map(({ node }) => node as CachedObjectRecord)
: [nextFieldValue].filter(isDefined);
const currentFieldValueOnSourceRecordIsARecordConnection =
isObjectRecordConnection(
targetObjectMetadataItem.nameSingular,
currentFieldValueOnSourceRecord,
);
if (previousRecord && relationRecordsToDetach.length) {
const shouldDeleteRelationRecord =
coreObjectNamesToDeleteOnRelationDetach.includes(
relationObjectMetadataItem.nameSingular as CoreObjectNameSingular,
const targetRecordsToDetachFrom =
currentFieldValueOnSourceRecordIsARecordConnection
? currentFieldValueOnSourceRecord.edges.map(
({ node }) => node as CachedObjectRecord,
)
: [currentFieldValueOnSourceRecord].filter(isDefined);
const updatedFieldValueOnSourceRecordIsARecordConnection =
isObjectRecordConnection(
targetObjectMetadataItem.nameSingular,
updatedFieldValueOnSourceRecord,
);
const targetRecordsToAttachTo =
updatedFieldValueOnSourceRecordIsARecordConnection
? updatedFieldValueOnSourceRecord.edges.map(
({ node }) => node as CachedObjectRecord,
)
: [updatedFieldValueOnSourceRecord].filter(isDefined);
const shouldDetachSourceFromAllTargets =
isDefined(currentSourceRecord) && targetRecordsToDetachFrom.length > 0;
if (shouldDetachSourceFromAllTargets) {
const shouldStartByDeletingRelationTargetRecordsFromCache =
CORE_OBJECT_NAMES_TO_DELETE_ON_OPTIMISTIC_RELATION_DETACH.includes(
targetObjectMetadataItem.nameSingular as CoreObjectNameSingular,
);
if (shouldDeleteRelationRecord) {
if (shouldStartByDeletingRelationTargetRecordsFromCache) {
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem: relationObjectMetadataItem,
records: relationRecordsToDetach,
getRelationMetadata,
objectMetadataItem: targetObjectMetadataItem,
recordsToDelete: targetRecordsToDetachFrom,
objectMetadataItems,
});
} else {
relationRecordsToDetach.forEach((relationRecordToDetach) => {
targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => {
triggerDetachRelationOptimisticEffect({
cache,
objectNameSingular: objectMetadataItem.nameSingular,
recordId: previousRecord.id,
relationFieldName: relationFieldMetadataItem.name,
relationObjectMetadataNameSingular:
relationObjectMetadataItem.nameSingular,
relationRecordId: relationRecordToDetach.id,
sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular,
sourceRecordId: currentSourceRecord.id,
fieldNameOnTargetRecord: fieldMetadataItemOnTargetRecord.name,
targetObjectNameSingular: targetObjectMetadataItem.nameSingular,
targetRecordId: targetRecordToDetachFrom.id,
});
});
}
}
if (nextRecord && relationRecordsToAttach.length) {
relationRecordsToAttach.forEach((relationRecordToAttach) =>
const shouldAttachSourceToAllTargets =
updatedSourceRecord && targetRecordsToAttachTo.length;
if (shouldAttachSourceToAllTargets) {
targetRecordsToAttachTo.forEach((targetRecordToAttachTo) =>
triggerAttachRelationOptimisticEffect({
cache,
objectNameSingular: objectMetadataItem.nameSingular,
recordId: nextRecord.id,
relationFieldName: relationFieldMetadataItem.name,
relationObjectMetadataNameSingular:
relationObjectMetadataItem.nameSingular,
relationRecordId: relationRecordToAttach.id,
sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular,
sourceRecordId: updatedSourceRecord.id,
fieldNameOnTargetRecord: fieldMetadataItemOnTargetRecord.name,
targetObjectNameSingular: targetObjectMetadataItem.nameSingular,
targetRecordId: targetRecordToAttachTo.id,
}),
);
}