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:
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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) });
|
||||
});
|
||||
};
|
||||
|
||||
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user