Feat/put target object identifier on use activities (#4682)

When writing to the normalized cache (record), it's crucial to use _refs
for relationships to avoid many problems. Essentially, we only deal with
level 0 and generate all fields to be comfortable with their defaults.

When writing in queries (which should be very rare, the only cases are
prefetch and the case of activities due to the nested query; I've
reduced this to a single file for activities
usePrepareFindManyActivitiesQuery 🙂), it's important to use queryFields
to avoid bugs. I've implemented them on the side of query generation and
record generation.

When doing an updateOne / createOne, etc., it's necessary to distinguish
between optimistic writing (which we actually want to do with _refs) and
the server response without refs. This allows for a clean write in the
optimistic cache without worrying about nesting (as the first point).

To simplify the whole activities part, write to the normalized cache
first. Then, base queries on it in an idempotent manner. This way,
there's no need to worry about the current page or action. The
normalized cache is up-to-date, so I update the queries. Same idea as
for optimisticEffects, actually.

Finally, I've triggered optimisticEffects rather than the manual update
of many queries.

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Charles Bochet
2024-04-01 13:12:37 +02:00
committed by GitHub
parent 4e109c9a38
commit 02673a82af
172 changed files with 2182 additions and 4915 deletions

View File

@ -1,64 +0,0 @@
import { useApolloClient } from '@apollo/client';
import gql from 'graphql-tag';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { useInjectIntoFindOneRecordQueryCache } from '@/object-record/cache/hooks/useInjectIntoFindOneRecordQueryCache';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from '~/utils/string/capitalize';
export const useAddRecordInCache = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const apolloClient = useApolloClient();
const { injectIntoFindOneRecordQueryCache } =
useInjectIntoFindOneRecordQueryCache({
objectMetadataItem,
});
return useRecoilCallback(
({ set }) =>
(record: ObjectRecord) => {
const fragment = gql`
fragment Create${capitalize(
objectMetadataItem.nameSingular,
)}InCache on ${capitalize(
objectMetadataItem.nameSingular,
)} ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,
objectMetadataItem,
})}
`;
const cachedObjectRecord = {
__typename: `${capitalize(objectMetadataItem.nameSingular)}`,
...record,
};
apolloClient.writeFragment({
id: `${capitalize(objectMetadataItem.nameSingular)}:${record.id}`,
fragment,
data: cachedObjectRecord,
});
// TODO: should we keep this here ? Or should the caller of createOneRecordInCache/createManyRecordsInCache be responsible for this ?
injectIntoFindOneRecordQueryCache(cachedObjectRecord);
// TODO: remove this once we get rid of entityFieldsFamilyState
set(recordStoreFamilyState(record.id), record);
},
[
objectMetadataItem,
objectMetadataItems,
apolloClient,
injectIntoFindOneRecordQueryCache,
],
);
};

View File

@ -1,50 +0,0 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useReadFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useReadFindManyRecordsQueryInCache';
import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
export const useAppendToFindManyRecordsQueryInCache = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const { readFindManyRecordsQueryInCache } =
useReadFindManyRecordsQueryInCache({
objectMetadataItem,
});
const {
upsertFindManyRecordsQueryInCache: overwriteFindManyRecordsQueryInCache,
} = useUpsertFindManyRecordsQueryInCache({
objectMetadataItem,
});
const appendToFindManyRecordsQueryInCache = <
T extends ObjectRecord = ObjectRecord,
>({
queryVariables,
objectRecordsToAppend,
}: {
queryVariables: ObjectRecordQueryVariables;
objectRecordsToAppend: T[];
}) => {
const existingObjectRecords = readFindManyRecordsQueryInCache({
queryVariables,
});
const newObjectRecordList = [
...existingObjectRecords,
...objectRecordsToAppend,
];
overwriteFindManyRecordsQueryInCache({
objectRecordsToOverwrite: newObjectRecordList,
queryVariables,
});
};
return {
appendToFindManyRecordsQueryInCache,
};
};

View File

@ -0,0 +1,42 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { prefillRecord } from '@/object-record/utils/prefillRecord';
import { isDefined } from '~/utils/isDefined';
export const useCreateManyRecordsInCache = <T extends ObjectRecord>({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const createOneRecordInCache = useCreateOneRecordInCache({
objectMetadataItem,
});
const createManyRecordsInCache = (recordsToCreate: Partial<T>[]) => {
const recordsWithId = recordsToCreate
.map((record) => {
return prefillRecord<T>({
input: record,
objectMetadataItem,
});
})
.filter(isDefined);
const createdRecordsInCache = [] as T[];
for (const record of recordsWithId) {
if (isDefined(record)) {
createOneRecordInCache(record);
createdRecordsInCache.push(record);
}
}
return createdRecordsInCache;
};
return { createManyRecordsInCache };
};

View File

@ -0,0 +1,62 @@
import { useApolloClient } from '@apollo/client';
import gql from 'graphql-tag';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { prefillRecord } from '@/object-record/utils/prefillRecord';
import { capitalize } from '~/utils/string/capitalize';
export const useCreateOneRecordInCache = <T extends ObjectRecord>({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const getRecordFromCache = useGetRecordFromCache({
objectMetadataItem,
});
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const apolloClient = useApolloClient();
return (record: ObjectRecord) => {
const fragment = gql`
fragment Create${capitalize(
objectMetadataItem.nameSingular,
)}InCache on ${capitalize(
objectMetadataItem.nameSingular,
)} ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,
objectMetadataItem,
computeReferences: true,
})}
`;
const prefilledRecord = prefillRecord({
objectMetadataItem,
input: record,
depth: 1,
});
const recordToCreateWithNestedConnections = getRecordNodeFromRecord({
record: prefilledRecord,
objectMetadataItem,
objectMetadataItems,
});
const cachedObjectRecord = {
__typename: `${capitalize(objectMetadataItem.nameSingular)}`,
...recordToCreateWithNestedConnections,
};
apolloClient.writeFragment({
id: `${capitalize(objectMetadataItem.nameSingular)}:${record.id}`,
fragment,
data: cachedObjectRecord,
});
return getRecordFromCache(record.id) as T;
};
};

View File

@ -0,0 +1,29 @@
import { useApolloClient } from '@apollo/client';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const useDeleteRecordFromCache = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const apolloClient = useApolloClient();
const { objectMetadataItem } = useObjectMetadataItemOnly({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
return (recordToDelete: ObjectRecord) => {
deleteRecordFromCache({
objectMetadataItem,
objectMetadataItems,
recordToDelete,
cache: apolloClient.cache,
});
};
};

View File

@ -1,79 +0,0 @@
import { v4 } from 'uuid';
import { z } from 'zod';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
import { capitalize } from '~/utils/string/capitalize';
export const useGenerateObjectRecordOptimisticResponse = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const getRelationMetadata = useGetRelationMetadata();
const generateObjectRecordOptimisticResponse = <
GeneratedObjectRecord extends ObjectRecord,
>(
input: Record<string, unknown>,
) => {
const recordSchema = z.object(
Object.fromEntries(
objectMetadataItem.fields.map((fieldMetadataItem) => [
fieldMetadataItem.name,
z.unknown().default(generateEmptyFieldValue(fieldMetadataItem)),
]),
),
);
const inputWithRelationFields = objectMetadataItem.fields.reduce(
(result, fieldMetadataItem) => {
const relationIdFieldName = `${fieldMetadataItem.name}Id`;
if (!(relationIdFieldName in input)) return result;
const relationMetadata = getRelationMetadata({ fieldMetadataItem });
if (!relationMetadata) return result;
const relationRecordTypeName = capitalize(
relationMetadata.relationObjectMetadataItem.nameSingular,
);
const relationRecordId = result[relationIdFieldName] as string | null;
const relationRecord = input[fieldMetadataItem.name] as
| ObjectRecord
| undefined;
return {
...result,
[fieldMetadataItem.name]: relationRecordId
? {
__typename: relationRecordTypeName,
id: relationRecordId,
// TODO: there are too many bugs if we don't include the entire relation record
// See if we can find a way to work only with the id and typename
...relationRecord,
}
: null,
};
},
input,
);
return {
__typename: capitalize(objectMetadataItem.nameSingular),
...recordSchema.parse({
id: v4(),
createdAt: new Date().toISOString(),
...inputWithRelationFields,
}),
} as GeneratedObjectRecord & { __typename: string };
};
return {
generateObjectRecordOptimisticResponse,
};
};

View File

@ -1,13 +1,11 @@
import { useCallback } from 'react';
import { gql, useApolloClient } from '@apollo/client';
import { useApolloClient } from '@apollo/client';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const useGetRecordFromCache = ({
objectMetadataItem,
@ -23,29 +21,11 @@ export const useGetRecordFromCache = ({
recordId: string,
cache = apolloClient.cache,
) => {
if (isUndefinedOrNull(objectMetadataItem)) {
return null;
}
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
const cacheReadFragment = gql`
fragment ${capitalizedObjectName}Fragment on ${capitalizedObjectName} ${mapObjectMetadataToGraphQLQuery(
{
objectMetadataItems,
objectMetadataItem,
},
)}
`;
const cachedRecordId = cache.identify({
__typename: capitalize(objectMetadataItem.nameSingular),
id: recordId,
});
return cache.readFragment<CachedObjectRecord & { __typename: string }>({
id: cachedRecordId,
fragment: cacheReadFragment,
return getRecordFromCache<CachedObjectRecord>({
cache,
recordId,
objectMetadataItems,
objectMetadataItem,
});
},
[objectMetadataItem, objectMetadataItems, apolloClient],

View File

@ -1,44 +0,0 @@
import { useApolloClient } from '@apollo/client';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from '~/utils/string/capitalize';
export const useInjectIntoFindOneRecordQueryCache = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const apolloClient = useApolloClient();
const generateFindOneRecordQuery = useGenerateFindOneRecordQuery();
const injectIntoFindOneRecordQueryCache = <
T extends ObjectRecord = ObjectRecord,
>(
record: T,
) => {
const findOneRecordQueryForCacheInjection = generateFindOneRecordQuery({
objectMetadataItem,
depth: 1,
});
apolloClient.writeQuery({
query: findOneRecordQueryForCacheInjection,
variables: {
objectRecordId: record.id,
},
data: {
[objectMetadataItem.nameSingular]: {
__typename: `${capitalize(objectMetadataItem.nameSingular)}`,
...record,
},
},
});
};
return {
injectIntoFindOneRecordQueryCache,
};
};

View File

@ -1,32 +0,0 @@
import { useApolloClient } from '@apollo/client';
import { Modifiers } from '@apollo/client/cache';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const useModifyRecordFromCache = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const { cache } = useApolloClient();
return <CachedObjectRecord extends ObjectRecord = ObjectRecord>(
recordId: string,
fieldModifiers: Modifiers<CachedObjectRecord>,
) => {
if (isUndefinedOrNull(objectMetadataItem)) return;
const cachedRecordId = cache.identify({
__typename: capitalize(objectMetadataItem.nameSingular),
id: recordId,
});
cache.modify<CachedObjectRecord>({
id: cachedRecordId,
fields: fieldModifiers,
});
};
};

View File

@ -21,11 +21,17 @@ export const useReadFindManyRecordsQueryInCache = ({
T extends ObjectRecord = ObjectRecord,
>({
queryVariables,
queryFields,
depth,
}: {
queryVariables: ObjectRecordQueryVariables;
queryFields?: Record<string, any>;
depth?: number;
}) => {
const findManyRecordsQueryForCacheRead = generateFindManyRecordsQuery({
objectMetadataItem,
queryFields,
depth,
});
const existingRecordsQueryResult = apolloClient.readQuery<

View File

@ -1,5 +1,7 @@
import { useApolloClient } from '@apollo/client';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { MAX_QUERY_DEPTH_FOR_CACHE_INJECTION } from '@/object-record/cache/constants/MaxQueryDepthForCacheInjection';
import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords';
@ -18,6 +20,7 @@ export const useUpsertFindManyRecordsQueryInCache = ({
const apolloClient = useApolloClient();
const generateFindManyRecordsQuery = useGenerateFindManyRecordsQuery();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const upsertFindManyRecordsQueryInCache = <
T extends ObjectRecord = ObjectRecord,
@ -25,19 +28,28 @@ export const useUpsertFindManyRecordsQueryInCache = ({
queryVariables,
depth = MAX_QUERY_DEPTH_FOR_CACHE_INJECTION,
objectRecordsToOverwrite,
queryFields,
computeReferences = false,
}: {
queryVariables: ObjectRecordQueryVariables;
depth?: number;
objectRecordsToOverwrite: T[];
queryFields?: Record<string, any>;
computeReferences?: boolean;
}) => {
const findManyRecordsQueryForCacheOverwrite = generateFindManyRecordsQuery({
objectMetadataItem,
depth, // TODO: fix this
depth,
queryFields,
computeReferences,
});
const newObjectRecordConnection = getRecordConnectionFromRecords({
objectNameSingular: objectMetadataItem.nameSingular,
objectMetadataItems: objectMetadataItems,
objectMetadataItem: objectMetadataItem,
records: objectRecordsToOverwrite,
queryFields,
computeReferences,
});
apolloClient.writeQuery({

View File

@ -0,0 +1,30 @@
import { ApolloCache } from '@apollo/client';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const deleteRecordFromCache = ({
objectMetadataItem,
objectMetadataItems,
recordToDelete,
cache,
}: {
objectMetadataItem: ObjectMetadataItem;
objectMetadataItems: ObjectMetadataItem[];
recordToDelete: ObjectRecord;
cache: ApolloCache<object>;
}) => {
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
objectMetadataItems,
recordsToDelete: [
{
...recordToDelete,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
},
],
});
};

View File

@ -1,31 +0,0 @@
import { ApolloClient, makeReference, Reference } from '@apollo/client';
import { getCachedRecordFromRecord } from '@/object-record/cache/utils/getCachedRecordFromRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const getCacheReferenceFromRecord = <T extends ObjectRecord>({
apolloClient,
objectNameSingular,
record,
}: {
apolloClient: ApolloClient<object>;
objectNameSingular: string;
record: T;
}): Reference => {
const cachedRecord = getCachedRecordFromRecord({
objectNameSingular,
record,
});
const id = apolloClient.cache.identify(cachedRecord);
if (!id) {
throw new Error(
`Could not identify record "${objectNameSingular}", id : "${record.id}"`,
);
}
const recordReference = makeReference(id);
return recordReference;
};

View File

@ -1,43 +0,0 @@
import { ApolloClient, makeReference } from '@apollo/client';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { getCachedRecordFromRecord } from '@/object-record/cache/utils/getCachedRecordFromRecord';
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const getCachedRecordEdgesFromRecords = <T extends ObjectRecord>({
apolloClient,
objectNameSingular,
records,
}: {
apolloClient: ApolloClient<object>;
objectNameSingular: string;
records: T[];
}): CachedObjectRecordEdge[] => {
const cachedRecordEdges = records.map((record) => {
const cachedRecord = getCachedRecordFromRecord({
objectNameSingular,
record,
});
const id = apolloClient.cache.identify(cachedRecord);
if (!id) {
throw new Error(
`Could not identify record "${objectNameSingular}", id : "${record.id}"`,
);
}
const reference = makeReference(id);
const cachedObjectRecordEdge: CachedObjectRecordEdge = {
cursor: '',
node: reference,
__typename: getEdgeTypename({ objectNameSingular }),
};
return cachedObjectRecordEdge;
});
return cachedRecordEdges;
};

View File

@ -1,16 +0,0 @@
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const getCachedRecordFromRecord = <T extends ObjectRecord>({
objectNameSingular,
record,
}: {
objectNameSingular: string;
record: T;
}): CachedObjectRecord<T> => {
return {
__typename: getNodeTypename({ objectNameSingular }),
...record,
};
};

View File

@ -1,9 +1,6 @@
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { capitalize } from '~/utils/string/capitalize';
export const getConnectionTypename = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
return `${capitalize(objectNameSingular)}Connection`;
export const getConnectionTypename = (objectNameSingular: string) => {
return `${capitalize(getObjectTypename(objectNameSingular))}Connection`;
};

View File

@ -1,9 +1,6 @@
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { capitalize } from '~/utils/string/capitalize';
export const getEdgeTypename = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
return `${capitalize(objectNameSingular)}Edge`;
export const getEdgeTypename = (objectNameSingular: string) => {
return `${capitalize(getObjectTypename(objectNameSingular))}Edge`;
};

View File

@ -1,9 +1,6 @@
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { capitalize } from '~/utils/string/capitalize';
export const getNodeTypename = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
return capitalize(objectNameSingular);
export const getNodeTypename = (objectNameSingular: string) => {
return capitalize(getObjectTypename(objectNameSingular));
};

View File

@ -0,0 +1,5 @@
import { capitalize } from '~/utils/string/capitalize';
export const getObjectTypename = (objectNameSingular: string) => {
return capitalize(objectNameSingular);
};

View File

@ -1,19 +0,0 @@
import { getConnectionTypename } from '@/object-record/cache/utils/getConnectionTypename';
import { getEmptyPageInfo } from '@/object-record/cache/utils/getEmptyPageInfo';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge';
export const getRecordConnectionFromEdges = <T extends ObjectRecord>({
objectNameSingular,
edges,
}: {
objectNameSingular: string;
edges: ObjectRecordEdge<T>[];
}) => {
return {
__typename: getConnectionTypename({ objectNameSingular }),
edges: edges,
pageInfo: getEmptyPageInfo(),
} as ObjectRecordConnection<T>;
};

View File

@ -1,3 +1,4 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getConnectionTypename } from '@/object-record/cache/utils/getConnectionTypename';
import { getEmptyPageInfo } from '@/object-record/cache/utils/getEmptyPageInfo';
import { getRecordEdgeFromRecord } from '@/object-record/cache/utils/getRecordEdgeFromRecord';
@ -5,21 +6,41 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
export const getRecordConnectionFromRecords = <T extends ObjectRecord>({
objectNameSingular,
objectMetadataItems,
objectMetadataItem,
records,
queryFields,
withPageInfo = true,
computeReferences = false,
isRootLevel = true,
depth = 1,
}: {
objectNameSingular: string;
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: Pick<
ObjectMetadataItem,
'fields' | 'namePlural' | 'nameSingular'
>;
records: T[];
queryFields?: Record<string, any>;
withPageInfo?: boolean;
isRootLevel?: boolean;
computeReferences?: boolean;
depth?: number;
}) => {
return {
__typename: getConnectionTypename({ objectNameSingular }),
__typename: getConnectionTypename(objectMetadataItem.nameSingular),
edges: records.map((record) => {
return getRecordEdgeFromRecord({
objectNameSingular,
objectMetadataItems,
objectMetadataItem,
queryFields,
record,
isRootLevel,
computeReferences,
depth,
});
}),
pageInfo: getEmptyPageInfo(),
totalCount: records.length,
...(withPageInfo && { pageInfo: getEmptyPageInfo() }),
...(withPageInfo && { totalCount: records.length }),
} as ObjectRecordConnection<T>;
};

View File

@ -1,37 +1,40 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename';
import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge';
export const getRecordEdgeFromRecord = <T extends ObjectRecord>({
objectNameSingular,
objectMetadataItems,
objectMetadataItem,
queryFields,
record,
computeReferences = false,
isRootLevel = false,
}: {
objectNameSingular: string;
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: Pick<
ObjectMetadataItem,
'fields' | 'namePlural' | 'nameSingular'
>;
queryFields?: Record<string, any>;
computeReferences?: boolean;
isRootLevel?: boolean;
depth?: number;
record: T;
}) => {
const nestedRecord = Object.fromEntries(
Object.entries(record).map(([key, value]) => {
if (Array.isArray(value)) {
return [
key,
getRecordConnectionFromRecords({
// Todo: this is a ugly and broken hack to get the singular, we need to infer this from metadata
objectNameSingular: key.slice(0, -1),
records: value as ObjectRecord[],
}),
];
}
return [key, value];
}),
) as T; // Todo fix typing once we have investigated apollo edges / nodes removal
return {
__typename: getEdgeTypename({ objectNameSingular }),
__typename: getEdgeTypename(objectMetadataItem.nameSingular),
node: {
__typename: getNodeTypename({ objectNameSingular }),
...nestedRecord,
...getRecordNodeFromRecord({
objectMetadataItems,
objectMetadataItem,
queryFields,
record,
computeReferences,
isRootLevel,
depth: 1,
}),
},
cursor: '',
} as ObjectRecordEdge<T>;

View File

@ -0,0 +1,55 @@
import { ApolloCache, gql } from '@apollo/client';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const getRecordFromCache = <T extends ObjectRecord = ObjectRecord>({
objectMetadataItem,
objectMetadataItems,
cache,
recordId,
}: {
cache: ApolloCache<object>;
recordId: string;
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: ObjectMetadataItem;
}) => {
if (isUndefinedOrNull(objectMetadataItem)) {
return null;
}
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
const cacheReadFragment = gql`
fragment ${capitalizedObjectName}Fragment on ${capitalizedObjectName} ${mapObjectMetadataToGraphQLQuery(
{
objectMetadataItems,
objectMetadataItem,
},
)}
`;
const cachedRecordId = cache.identify({
__typename: capitalize(objectMetadataItem.nameSingular),
id: recordId,
});
const record = cache.readFragment<T & { __typename: string }>({
id: cachedRecordId,
fragment: cacheReadFragment,
returnPartialData: true,
});
if (isUndefinedOrNull(record)) {
return null;
}
return getRecordFromRecordNode({
recordNode: record,
}) as CachedObjectRecord<T>;
};

View File

@ -0,0 +1,34 @@
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from '~/utils/isDefined';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const getRecordFromRecordNode = <T extends ObjectRecord>({
recordNode,
}: {
recordNode: T;
}): T => {
return {
...Object.fromEntries(
Object.entries(recordNode).map(([fieldName, value]) => {
if (isUndefinedOrNull(value)) {
return [fieldName, value];
}
if (typeof value === 'object' && isDefined(value.edges)) {
return [
fieldName,
getRecordsFromRecordConnection({ recordConnection: value }),
];
}
if (typeof value === 'object' && !isDefined(value.edges)) {
return [fieldName, getRecordFromRecordNode<T>({ recordNode: value })];
}
return [fieldName, value];
}),
),
id: recordNode.id,
} as T;
};

View File

@ -0,0 +1,143 @@
import { isNull, isUndefined } from '@sniptt/guards';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
export const getRecordNodeFromRecord = <T extends ObjectRecord>({
objectMetadataItems,
objectMetadataItem,
queryFields,
record,
computeReferences = true,
isRootLevel = true,
depth = 1,
}: {
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: Pick<
ObjectMetadataItem,
'fields' | 'namePlural' | 'nameSingular'
>;
queryFields?: Record<string, any>;
computeReferences?: boolean;
isRootLevel?: boolean;
record: T | null;
depth?: number;
}) => {
if (isNull(record)) {
return null;
}
const nodeTypeName = getNodeTypename(objectMetadataItem.nameSingular);
if (!isRootLevel && computeReferences) {
return {
__ref: `${nodeTypeName}:${record.id}`,
} as unknown as CachedObjectRecord<T>; // Todo Fix typing
}
const nestedRecord = Object.fromEntries(
Object.entries(record)
.map(([fieldName, value]) => {
if (isDefined(queryFields) && !queryFields[fieldName]) {
return undefined;
}
const field = objectMetadataItem.fields.find(
(field) => field.name === fieldName,
);
if (isUndefined(field)) {
return undefined;
}
if (
!isUndefined(depth) &&
depth < 1 &&
field.type === FieldMetadataType.Relation
) {
return undefined;
}
if (Array.isArray(value)) {
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.namePlural === fieldName,
);
if (!objectMetadataItem) {
return undefined;
}
return [
fieldName,
getRecordConnectionFromRecords({
objectMetadataItems,
objectMetadataItem: objectMetadataItem,
records: value as ObjectRecord[],
queryFields:
queryFields?.[fieldName] === true ||
isUndefined(queryFields?.[fieldName])
? undefined
: queryFields?.[fieldName],
withPageInfo: false,
isRootLevel: false,
computeReferences,
depth: depth - 1,
}),
];
}
if (field.type === 'RELATION') {
if (
isUndefined(
field.relationDefinition?.targetObjectMetadata.nameSingular,
)
) {
return undefined;
}
if (isNull(value)) {
return [fieldName, null];
}
if (isUndefined(value?.id)) {
return undefined;
}
const typeName = getObjectTypename(
field.relationDefinition?.targetObjectMetadata.nameSingular,
);
if (computeReferences) {
return [
fieldName,
{
__ref: `${typeName}:${value.id}`,
},
];
}
return [
fieldName,
{
__typename: typeName,
...value,
},
];
}
return [fieldName, value];
})
.filter(isDefined),
) as T; // Todo fix typing once we have investigated apollo edges / nodes removal
return {
__typename: getNodeTypename(objectMetadataItem.nameSingular),
...nestedRecord,
};
};

View File

@ -1,3 +1,4 @@
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
@ -6,5 +7,7 @@ export const getRecordsFromRecordConnection = <T extends ObjectRecord>({
}: {
recordConnection: ObjectRecordConnection<T>;
}): T[] => {
return recordConnection.edges.map((edge) => edge.node);
return recordConnection.edges.map((edge) =>
getRecordFromRecordNode<T>({ recordNode: edge.node }),
);
};

View File

@ -0,0 +1,30 @@
import { z } from 'zod';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { capitalize } from '~/utils/string/capitalize';
export const isObjectRecordConnection = (
objectNameSingular: string,
value: unknown,
): value is ObjectRecordConnection => {
const objectConnectionTypeName = `${capitalize(
objectNameSingular,
)}Connection`;
const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`;
const objectConnectionSchema = z.object({
__typename: z.literal(objectConnectionTypeName).optional(),
edges: z.array(
z.object({
__typename: z.literal(objectEdgeTypeName).optional(),
node: z.object({
id: z.string().uuid(),
}),
}),
),
});
const connectionValidation = objectConnectionSchema.safeParse(value);
return connectionValidation.success;
};

View File

@ -0,0 +1,30 @@
import { StoreValue } from '@apollo/client';
import { z } from 'zod';
import { CachedObjectRecordConnection } from '@/apollo/types/CachedObjectRecordConnection';
import { capitalize } from '~/utils/string/capitalize';
export const isObjectRecordConnectionWithRefs = (
objectNameSingular: string,
storeValue: StoreValue,
): storeValue is CachedObjectRecordConnection => {
const objectConnectionTypeName = `${capitalize(
objectNameSingular,
)}Connection`;
const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`;
const cachedObjectConnectionSchema = z.object({
__typename: z.literal(objectConnectionTypeName),
edges: z.array(
z.object({
__typename: z.literal(objectEdgeTypeName),
node: z.object({
__ref: z.string().startsWith(`${capitalize(objectNameSingular)}:`),
}),
}),
),
});
const cachedConnectionValidation =
cachedObjectConnectionSchema.safeParse(storeValue);
return cachedConnectionValidation.success;
};

View File

@ -0,0 +1,33 @@
import { ApolloCache, Modifiers } from '@apollo/client/cache';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const modifyRecordFromCache = <
CachedObjectRecord extends ObjectRecord = ObjectRecord,
>({
objectMetadataItem,
cache,
fieldModifiers,
recordId,
}: {
objectMetadataItem: ObjectMetadataItem;
cache: ApolloCache<object>;
fieldModifiers: Modifiers<CachedObjectRecord>;
recordId: string;
}) => {
if (isUndefinedOrNull(objectMetadataItem)) return;
const cachedRecordId = cache.identify({
__typename: capitalize(objectMetadataItem.nameSingular),
id: recordId,
});
cache.modify<CachedObjectRecord>({
id: cachedRecordId,
fields: fieldModifiers,
optimistic: true,
});
};

View File

@ -0,0 +1,59 @@
import { ApolloCache } from '@apollo/client/cache';
import gql from 'graphql-tag';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { capitalize } from '~/utils/string/capitalize';
export const updateRecordFromCache = <T extends ObjectRecord>({
objectMetadataItems,
objectMetadataItem,
cache,
record,
}: {
objectMetadataItems: ObjectMetadataItem[];
objectMetadataItem: ObjectMetadataItem;
cache: ApolloCache<object>;
record: T;
}) => {
if (isUndefinedOrNull(objectMetadataItem)) {
return null;
}
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
const cacheWriteFragment = gql`
fragment ${capitalizedObjectName}Fragment on ${capitalizedObjectName} ${mapObjectMetadataToGraphQLQuery(
{
objectMetadataItems,
objectMetadataItem,
computeReferences: true,
},
)}
`;
const cachedRecordId = cache.identify({
__typename: capitalize(objectMetadataItem.nameSingular),
id: record.id,
});
const recordWithConnection = getRecordNodeFromRecord<T>({
objectMetadataItems,
objectMetadataItem,
record,
depth: 1,
});
if (isUndefinedOrNull(recordWithConnection)) {
return;
}
cache.writeFragment<T & { __typename: string }>({
id: cachedRecordId,
fragment: cacheWriteFragment,
data: recordWithConnection,
});
};

View File

@ -1,783 +0,0 @@
import { Company } from '@/companies/types/Company';
import { Favorite } from '@/favorites/types/Favorite';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { Person } from '@/people/types/Person';
export const emptyConnectionMock: ObjectRecordConnection = {
edges: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
endCursor: '',
},
totalCount: 0,
__typename: 'ObjectRecordConnection',
};
export const companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock: ObjectRecordConnection<
Partial<Company> &
Pick<Company, 'id'> & {
people: ObjectRecordConnection<
Pick<Person, 'id' | 'name'> & {
favorites: ObjectRecordConnection<
Pick<Favorite, 'id' | 'personId' | 'companyId' | 'position'>
>;
}
>;
}
> = {
pageInfo: {
endCursor: 'WyJmZTI1NmIzOS0zZWMzLTRmZTMtODk5Ny1iNzZhYTBiZmE0MDgiXQ==',
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'WyIwNGIyZTlmNS0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==',
},
edges: [
{
cursor: 'WyIwNGIyZTlmNS0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==',
node: {
id: '04b2e9f5-0713-40a5-8216-82802401d33e',
name: 'Qonto',
people: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor: 'WyIwZDk0MDk5Ny1jMjFlLTRlYzItODczYi1kZTQyNjRkODkwMjUiXQ==',
node: {
id: '0d940997-c21e-4ec2-873b-de4264d89025',
name: 'Google',
people: {
edges: [
{
cursor:
'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZGYiXQ==',
node: {
id: '240da2ec-2d40-4e49-8df4-9c6a049190df',
name: {
firstName: 'Bertrand',
lastName: 'Voulzy',
},
favorites: {
edges: [
{
cursor:
'WyJjODVhODY3Yy01YThmLTQ4NjEtOGVkMi05NmMzOTAyNDg0MjMiXQ==',
node: {
id: 'c85a867c-5a8f-4861-8ed2-96c390248423',
personId: '240da2ec-2d40-4e49-8df4-9c6a049190df',
companyId: null,
position: 2,
},
},
],
pageInfo: {
endCursor:
'WyJjODVhODY3Yy01YThmLTQ4NjEtOGVkMi05NmMzOTAyNDg0MjMiXQ==',
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'WyJjODVhODY3Yy01YThmLTQ4NjEtOGVkMi05NmMzOTAyNDg0MjMiXQ==',
},
totalCount: 1,
},
},
},
{
cursor:
'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZWYiXQ==',
node: {
id: '240da2ec-2d40-4e49-8df4-9c6a049190ef',
name: {
firstName: 'Madison',
lastName: 'Perez',
},
favorites: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor:
'WyI1Njk1NTQyMi01ZDU0LTQxYjctYmEzNi1mMGQyMGUxNDE3YWUiXQ==',
node: {
id: '56955422-5d54-41b7-ba36-f0d20e1417ae',
name: {
firstName: 'Avery',
lastName: 'Carter',
},
favorites: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor:
'WyI3NTUwMzVkYi02MjNkLTQxZmUtOTJlNy1kZDQ1YjdjNTY4ZTEiXQ==',
node: {
id: '755035db-623d-41fe-92e7-dd45b7c568e1',
name: {
firstName: 'Ethan',
lastName: 'Mitchell',
},
favorites: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor:
'WyJhMmU3OGE1Zi0zMzhiLTQ2ZGYtODgxMS1mYTA4YzdkMTlkMzUiXQ==',
node: {
id: 'a2e78a5f-338b-46df-8811-fa08c7d19d35',
name: {
firstName: 'Elizabeth',
lastName: 'Baker',
},
favorites: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor:
'WyJjYTFmNWJmMy02NGFkLTRiMGUtYmJmZC1lOWZkNzk1YjcwMTYiXQ==',
node: {
id: 'ca1f5bf3-64ad-4b0e-bbfd-e9fd795b7016',
name: {
firstName: 'Christopher',
lastName: 'Nelson',
},
favorites: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
],
pageInfo: {
endCursor:
'WyJjYTFmNWJmMy02NGFkLTRiMGUtYmJmZC1lOWZkNzk1YjcwMTYiXQ==',
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZGYiXQ==',
},
totalCount: 6,
},
},
},
{
cursor: 'WyIxMTg5OTVmMy01ZDgxLTQ2ZDYtYmY4My1mN2ZkMzNlYTYxMDIiXQ==',
node: {
id: '118995f3-5d81-46d6-bf83-f7fd33ea6102',
name: 'Facebook',
people: {
edges: [
{
cursor:
'WyI5M2M3MmQyZS1mNTE3LTQyZmQtODBhZS0xNDE3M2IzYjcwYWUiXQ==',
node: {
id: '93c72d2e-f517-42fd-80ae-14173b3b70ae',
name: {
firstName: 'Christopher',
lastName: 'Gonzalez',
},
favorites: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor:
'WyJlZWVhY2FjZi1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==',
node: {
id: 'eeeacacf-eee1-4690-ad2c-8619e5b56a2e',
name: {
firstName: 'Ashley',
lastName: 'Parker',
},
favorites: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
],
pageInfo: {
endCursor:
'WyJlZWVhY2FjZi1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==',
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'WyI5M2M3MmQyZS1mNTE3LTQyZmQtODBhZS0xNDE3M2IzYjcwYWUiXQ==',
},
totalCount: 2,
},
},
},
{
cursor: 'WyIxZDNhMWM2ZS03MDdlLTQ0ZGMtYTFkMi0zMDAzMGJmMWE5NDQiXQ==',
node: {
id: '1d3a1c6e-707e-44dc-a1d2-30030bf1a944',
name: 'Netflix',
people: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor: 'WyI0NjBiNmZiMS1lZDg5LTQxM2EtYjMxYS05NjI5ODZlNjdiYjQiXQ==',
node: {
id: '460b6fb1-ed89-413a-b31a-962986e67bb4',
name: 'Microsoft',
people: {
edges: [
{
cursor:
'WyIxZDE1MTg1Mi00OTBmLTQ0NjYtODM5MS03MzNjZmQ2NmEwYzgiXQ==',
node: {
id: '1d151852-490f-4466-8391-733cfd66a0c8',
name: {
firstName: 'Isabella',
lastName: 'Scott',
},
favorites: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor:
'WyI5ODQwNmUyNi04MGYxLTRkZmYtYjU3MC1hNzQ5NDI1MjhkZTMiXQ==',
node: {
id: '98406e26-80f1-4dff-b570-a74942528de3',
name: {
firstName: 'Matthew',
lastName: 'Green',
},
favorites: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor:
'WyI5YjMyNGE4OC02Nzg0LTQ0NDktYWZkZi1kYzYyY2I4NzAyZjIiXQ==',
node: {
id: '9b324a88-6784-4449-afdf-dc62cb8702f2',
name: {
firstName: 'Nicholas',
lastName: 'Wright',
},
favorites: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
],
pageInfo: {
endCursor:
'WyI5YjMyNGE4OC02Nzg0LTQ0NDktYWZkZi1kYzYyY2I4NzAyZjIiXQ==',
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'WyIxZDE1MTg1Mi00OTBmLTQ0NjYtODM5MS03MzNjZmQ2NmEwYzgiXQ==',
},
totalCount: 3,
},
},
},
{
cursor: 'WyI3YTkzZDFlNS0zZjc0LTQ5MmQtYTEwMS0yYTcwZjUwYTE2NDUiXQ==',
node: {
id: '7a93d1e5-3f74-492d-a101-2a70f50a1645',
name: 'Libeo',
people: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor: 'WyI4OWJiODI1Yy0xNzFlLTRiY2MtOWNmNy00MzQ0OGQ2ZmIyNzgiXQ==',
node: {
id: '89bb825c-171e-4bcc-9cf7-43448d6fb278',
name: 'Airbnb',
people: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor: 'WyI5ZDE2MmRlNi1jZmJmLTQxNTYtYTc5MC1lMzk4NTRkY2Q0ZWIiXQ==',
node: {
id: '9d162de6-cfbf-4156-a790-e39854dcd4eb',
name: 'Claap',
people: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor: 'WyJhNjc0ZmE2Yy0xNDU1LTRjNTctYWZhZi1kZDVkYzA4NjM2MWQiXQ==',
node: {
id: 'a674fa6c-1455-4c57-afaf-dd5dc086361d',
name: 'Algolia',
people: {
edges: [
{
cursor:
'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGYiXQ==',
node: {
id: '240da2ec-2d40-4e49-8df4-9c6a049191df',
name: {
firstName: 'Lorie',
lastName: 'Vladim',
},
favorites: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
],
pageInfo: {
endCursor:
'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGYiXQ==',
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGYiXQ==',
},
totalCount: 1,
},
},
},
{
cursor: 'WyJhN2JjNjhkNS1mNzllLTQwZGQtYmQwNi1jMzZlNmFiYjQ2NzgiXQ==',
node: {
id: 'a7bc68d5-f79e-40dd-bd06-c36e6abb4678',
name: 'Samsung',
people: {
edges: [
{
cursor:
'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGUiXQ==',
node: {
id: '240da2ec-2d40-4e49-8df4-9c6a049191de',
name: {
firstName: 'Louis',
lastName: 'Duss',
},
favorites: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
],
pageInfo: {
endCursor:
'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGUiXQ==',
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGUiXQ==',
},
totalCount: 1,
},
},
},
{
cursor: 'WyJhYWZmY2ZiZC1mODZiLTQxOWYtYjc5NC0wMjMxOWFiZTg2MzciXQ==',
node: {
id: 'aaffcfbd-f86b-419f-b794-02319abe8637',
name: 'Hasura',
people: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor: 'WyJmMzNkYzI0Mi01NTE4LTQ1NTMtOTQzMy00MmQ4ZWI4MjgzNGIiXQ==',
node: {
id: 'f33dc242-5518-4553-9433-42d8eb82834b',
name: 'Wework',
people: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
{
cursor: 'WyJmZTI1NmIzOS0zZWMzLTRmZTMtODk5Ny1iNzZhYTBiZmE0MDgiXQ==',
node: {
id: 'fe256b39-3ec3-4fe3-8997-b76aa0bfa408',
name: 'Linkedin',
people: {
edges: [
{
cursor:
'WyIwYWEwMGJlYi1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==',
node: {
id: '0aa00beb-ac73-4797-824e-87a1f5aea9e0',
name: {
firstName: 'Sylvie',
lastName: 'Palmer',
},
favorites: {
edges: [
{
cursor:
'WyIzN2I5NzE0MC0yNmI5LTQ5OGMtODM3Yi00ZjNkZTQ5OWFkODMiXQ==',
node: {
id: '37b97140-26b9-498c-837b-4f3de499ad83',
personId: '0aa00beb-ac73-4797-824e-87a1f5aea9e0',
companyId: null,
position: 1,
},
},
],
pageInfo: {
endCursor:
'WyIzN2I5NzE0MC0yNmI5LTQ5OGMtODM3Yi00ZjNkZTQ5OWFkODMiXQ==',
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'WyIzN2I5NzE0MC0yNmI5LTQ5OGMtODM3Yi00ZjNkZTQ5OWFkODMiXQ==',
},
totalCount: 1,
},
},
},
{
cursor:
'WyI4NjA4MzE0MS0xYzBlLTQ5NGMtYTFiNi04NWIxYzZmZWZhYTUiXQ==',
node: {
id: '86083141-1c0e-494c-a1b6-85b1c6fefaa5',
name: {
firstName: 'Christoph',
lastName: 'Callisto',
},
favorites: {
edges: [],
pageInfo: {
endCursor: null,
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
},
totalCount: 0,
},
},
},
],
pageInfo: {
endCursor:
'WyI4NjA4MzE0MS0xYzBlLTQ5NGMtYTFiNi04NWIxYzZmZWZhYTUiXQ==',
hasNextPage: false,
hasPreviousPage: false,
startCursor:
'WyIwYWEwMGJlYi1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==',
},
totalCount: 2,
},
},
},
],
totalCount: 13,
};
export const peopleWithTheirUniqueCompanies: ObjectRecordConnection<
Pick<Person, 'id'> & { company: Pick<Company, 'id' | 'name'> }
> = {
pageInfo: {
endCursor: 'WyJlZWVhY2FjZi1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==',
hasNextPage: false,
hasPreviousPage: false,
startCursor: 'WyIwYWEwMGJlYi1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==',
},
totalCount: 15,
edges: [
{
cursor: 'WyIwYWEwMGJlYi1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==',
node: {
id: '0aa00beb-ac73-4797-824e-87a1f5aea9e0',
company: {
id: 'fe256b39-3ec3-4fe3-8997-b76aa0bfa408',
name: 'Linkedin',
},
},
},
{
cursor: 'WyIxZDE1MTg1Mi00OTBmLTQ0NjYtODM5MS03MzNjZmQ2NmEwYzgiXQ==',
node: {
id: '1d151852-490f-4466-8391-733cfd66a0c8',
company: {
id: '460b6fb1-ed89-413a-b31a-962986e67bb4',
name: 'Microsoft',
},
},
},
{
cursor: 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZGYiXQ==',
node: {
id: '240da2ec-2d40-4e49-8df4-9c6a049190df',
company: {
id: '0d940997-c21e-4ec2-873b-de4264d89025',
name: 'Google',
},
},
},
{
cursor: 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZWYiXQ==',
node: {
id: '240da2ec-2d40-4e49-8df4-9c6a049190ef',
company: {
id: '0d940997-c21e-4ec2-873b-de4264d89025',
name: 'Google',
},
},
},
{
cursor: 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGUiXQ==',
node: {
id: '240da2ec-2d40-4e49-8df4-9c6a049191de',
company: {
id: 'a7bc68d5-f79e-40dd-bd06-c36e6abb4678',
name: 'Samsung',
},
},
},
{
cursor: 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGYiXQ==',
node: {
id: '240da2ec-2d40-4e49-8df4-9c6a049191df',
company: {
id: 'a674fa6c-1455-4c57-afaf-dd5dc086361d',
name: 'Algolia',
},
},
},
{
cursor: 'WyI1Njk1NTQyMi01ZDU0LTQxYjctYmEzNi1mMGQyMGUxNDE3YWUiXQ==',
node: {
id: '56955422-5d54-41b7-ba36-f0d20e1417ae',
company: {
id: '0d940997-c21e-4ec2-873b-de4264d89025',
name: 'Google',
},
},
},
{
cursor: 'WyI3NTUwMzVkYi02MjNkLTQxZmUtOTJlNy1kZDQ1YjdjNTY4ZTEiXQ==',
node: {
id: '755035db-623d-41fe-92e7-dd45b7c568e1',
company: {
id: '0d940997-c21e-4ec2-873b-de4264d89025',
name: 'Google',
},
},
},
{
cursor: 'WyI4NjA4MzE0MS0xYzBlLTQ5NGMtYTFiNi04NWIxYzZmZWZhYTUiXQ==',
node: {
id: '86083141-1c0e-494c-a1b6-85b1c6fefaa5',
company: {
id: 'fe256b39-3ec3-4fe3-8997-b76aa0bfa408',
name: 'Linkedin',
},
},
},
{
cursor: 'WyI5M2M3MmQyZS1mNTE3LTQyZmQtODBhZS0xNDE3M2IzYjcwYWUiXQ==',
node: {
id: '93c72d2e-f517-42fd-80ae-14173b3b70ae',
company: {
id: '118995f3-5d81-46d6-bf83-f7fd33ea6102',
name: 'Facebook',
},
},
},
{
cursor: 'WyI5ODQwNmUyNi04MGYxLTRkZmYtYjU3MC1hNzQ5NDI1MjhkZTMiXQ==',
node: {
id: '98406e26-80f1-4dff-b570-a74942528de3',
company: {
id: '460b6fb1-ed89-413a-b31a-962986e67bb4',
name: 'Microsoft',
},
},
},
{
cursor: 'WyI5YjMyNGE4OC02Nzg0LTQ0NDktYWZkZi1kYzYyY2I4NzAyZjIiXQ==',
node: {
id: '9b324a88-6784-4449-afdf-dc62cb8702f2',
company: {
id: '460b6fb1-ed89-413a-b31a-962986e67bb4',
name: 'Microsoft',
},
},
},
{
cursor: 'WyJhMmU3OGE1Zi0zMzhiLTQ2ZGYtODgxMS1mYTA4YzdkMTlkMzUiXQ==',
node: {
id: 'a2e78a5f-338b-46df-8811-fa08c7d19d35',
company: {
id: '0d940997-c21e-4ec2-873b-de4264d89025',
name: 'Google',
},
},
},
{
cursor: 'WyJjYTFmNWJmMy02NGFkLTRiMGUtYmJmZC1lOWZkNzk1YjcwMTYiXQ==',
node: {
id: 'ca1f5bf3-64ad-4b0e-bbfd-e9fd795b7016',
company: {
id: '0d940997-c21e-4ec2-873b-de4264d89025',
name: 'Google',
},
},
},
{
cursor: 'WyJlZWVhY2FjZi1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==',
node: {
id: 'eeeacacf-eee1-4690-ad2c-8619e5b56a2e',
company: {
id: '118995f3-5d81-46d6-bf83-f7fd33ea6102',
name: 'Facebook',
},
},
},
],
};

View File

@ -53,7 +53,6 @@ describe('useCreateOneRecord', () => {
await act(async () => {
const res = await result.current.createOneRecord(input);
console.log('res', res);
expect(res).toBeDefined();
expect(res).toHaveProperty('id', personId);
});

View File

@ -84,14 +84,5 @@ describe('useFindManyRecords', () => {
expect(result.current.loading).toBe(true);
expect(result.current.error).toBeUndefined();
expect(result.current.records.length).toBe(0);
// FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
// await waitFor(() => {
// expect(result.current.loading).toBe(false);
// expect(result.current.records).toBeDefined();
// console.log({ res: result.current.records });
// expect(result.current.records.length > 0).toBe(true);
// });
});
});

View File

@ -3,7 +3,7 @@ import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/multiple-objects/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>{children}</RecoilRoot>

View File

@ -1,190 +0,0 @@
import { isNonEmptyArray } from '@sniptt/guards';
import { renderHook } from '@testing-library/react';
import { Company } from '@/companies/types/Company';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import {
companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock,
emptyConnectionMock,
peopleWithTheirUniqueCompanies,
} from '@/object-record/hooks/__mocks__/useMapConnectionToRecords';
import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords';
import { Person } from '@/people/types/Person';
import { getJestHookWrapper } from '~/testing/jest/getJestHookWrapper';
import { isDefined } from '~/utils/isDefined';
const Wrapper = getJestHookWrapper({
apolloMocks: [],
onInitializeRecoilSnapshot: (snapshot) => {
snapshot.set(objectMetadataItemsState, getObjectMetadataItemsMock());
},
});
describe('useMapConnectionToRecords', () => {
it('Empty edges - should return an empty array if no edge', async () => {
const { result } = renderHook(
() => {
const mapConnectionToRecords = useMapConnectionToRecords();
const records = mapConnectionToRecords({
objectNameSingular: CoreObjectNameSingular.Company,
objectRecordConnection: emptyConnectionMock,
depth: 5,
});
return records;
},
{
wrapper: Wrapper,
},
);
expect(Array.isArray(result.current)).toBe(true);
});
it('No relation fields - should return an array of company records', async () => {
const { result } = renderHook(
() => {
const mapConnectionToRecords = useMapConnectionToRecords();
const records = mapConnectionToRecords({
objectNameSingular: CoreObjectNameSingular.Company,
objectRecordConnection:
companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock,
depth: 5,
});
return records;
},
{
wrapper: Wrapper,
},
);
expect(Array.isArray(result.current)).toBe(true);
});
it('n+1 relation fields - should return an array of company records with their people records', async () => {
const { result } = renderHook(
() => {
const mapConnectionToRecords = useMapConnectionToRecords();
const records = mapConnectionToRecords({
objectNameSingular: CoreObjectNameSingular.Company,
objectRecordConnection:
companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock,
depth: 5,
});
return records;
},
{
wrapper: Wrapper,
},
);
const secondCompanyMock =
companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock
.edges[1];
const secondCompanyPeopleMock = secondCompanyMock.node.people.edges.map(
(edge) => edge.node,
);
const companiesResult = result.current;
const secondCompanyResult = result.current[1];
const secondCompanyPeopleResult = secondCompanyResult.people;
expect(isNonEmptyArray(companiesResult)).toBe(true);
expect(secondCompanyResult.id).toBe(secondCompanyMock.node.id);
expect(isNonEmptyArray(secondCompanyPeopleResult)).toBe(true);
expect(secondCompanyPeopleResult[0].id).toEqual(
secondCompanyPeopleMock[0].id,
);
});
it('n+2 relation fields - should return an array of company records with their people records with their favorites records', async () => {
const { result } = renderHook(
() => {
const mapConnectionToRecords = useMapConnectionToRecords();
const records = mapConnectionToRecords({
objectNameSingular: CoreObjectNameSingular.Company,
objectRecordConnection:
companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock,
depth: 5,
});
return records;
},
{
wrapper: Wrapper,
},
);
const secondCompanyMock =
companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock
.edges[1];
const secondCompanyPeopleMock = secondCompanyMock.node.people;
const secondCompanyFirstPersonMock = secondCompanyPeopleMock.edges[0].node;
const secondCompanyFirstPersonFavoritesMock =
secondCompanyFirstPersonMock.favorites;
const companiesResult = result.current;
const secondCompanyResult = companiesResult[1];
const secondCompanyPeopleResult = secondCompanyResult.people;
const secondCompanyFirstPersonResult = secondCompanyPeopleResult[0];
const secondCompanyFirstPersonFavoritesResult =
secondCompanyFirstPersonResult.favorites;
expect(isNonEmptyArray(companiesResult)).toBe(true);
expect(secondCompanyResult.id).toBe(secondCompanyMock.node.id);
expect(isNonEmptyArray(secondCompanyPeopleResult)).toBe(true);
expect(secondCompanyFirstPersonResult.id).toEqual(
secondCompanyFirstPersonMock.id,
);
expect(isNonEmptyArray(secondCompanyFirstPersonFavoritesResult)).toBe(true);
expect(secondCompanyFirstPersonFavoritesResult[0].id).toEqual(
secondCompanyFirstPersonFavoritesMock.edges[0].node.id,
);
});
it("n+1 relation field TO_ONE_OBJECT - should return an array of people records with their company, mapConnectionToRecords shouldn't try to parse TO_ONE_OBJECT", async () => {
const { result } = renderHook(
() => {
const mapConnectionToRecords = useMapConnectionToRecords();
const records = mapConnectionToRecords({
objectNameSingular: CoreObjectNameSingular.Person,
objectRecordConnection: peopleWithTheirUniqueCompanies,
depth: 5,
});
return records as (Person & { company: Company })[];
},
{
wrapper: Wrapper,
},
);
const firstPersonMock = peopleWithTheirUniqueCompanies.edges[0].node;
const firstPersonsCompanyMock = firstPersonMock.company;
const peopleResult = result.current;
const firstPersonResult = result.current[0];
const firstPersonsCompanyresult = firstPersonResult.company;
expect(isNonEmptyArray(peopleResult)).toBe(true);
expect(firstPersonResult.id).toBe(firstPersonMock.id);
expect(isDefined(firstPersonsCompanyresult)).toBe(true);
expect(firstPersonsCompanyresult.id).toEqual(firstPersonsCompanyMock.id);
});
});

View File

@ -1,52 +0,0 @@
import { ReactNode } from 'react';
import { useApolloClient } from '@apollo/client';
import { MockedProvider } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { useModifyRecordFromCache } from '@/object-record/cache/hooks/useModifyRecordFromCache';
const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider addTypename={false}>
<RecoilRoot>{children}</RecoilRoot>
</MockedProvider>
);
const recordId = '91408718-a29f-4678-b573-c791e8664c2a';
describe('useModifyRecordFromCache', () => {
it('should work as expected', async () => {
const { result } = renderHook(
() => {
const apolloClient = useApolloClient();
const mockObjectMetadataItems = getObjectMetadataItemsMock();
const personMetadataItem = mockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
)!;
return {
modifyRecordFromCache: useModifyRecordFromCache({
objectMetadataItem: personMetadataItem,
}),
cache: apolloClient.cache,
};
},
{
wrapper: Wrapper,
},
);
const spy = jest.spyOn(result.current.cache, 'modify');
act(() => {
result.current.modifyRecordFromCache(recordId, {});
});
expect(spy).toHaveBeenCalledWith({
id: `Person:${recordId}`,
fields: {},
});
});
});

View File

@ -2,57 +2,87 @@ import { useApolloClient } from '@apollo/client';
import { v4 } from 'uuid';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { getCreateManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateCreateManyRecordMutation';
import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import {
getCreateManyRecordsMutationResponseField,
useGenerateCreateManyRecordMutation,
} from '@/object-record/hooks/useGenerateCreateManyRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isDefined } from '~/utils/isDefined';
type CreateManyRecordsOptions = {
skipOptimisticEffect?: boolean;
type useCreateManyRecordsProps = {
objectNameSingular: string;
queryFields?: Record<string, any>;
depth?: number;
skipPostOptmisticEffect?: boolean;
};
export const useCreateManyRecords = <
CreatedObjectRecord extends ObjectRecord = ObjectRecord,
>({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
queryFields,
depth = 1,
skipPostOptmisticEffect = false,
}: useCreateManyRecordsProps) => {
const apolloClient = useApolloClient();
const { objectMetadataItem, createManyRecordsMutation } =
useObjectMetadataItem({
objectNameSingular,
});
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
const createManyRecordsMutation = useGenerateCreateManyRecordMutation({
objectMetadataItem,
queryFields,
depth,
});
const createOneRecordInCache = useCreateOneRecordInCache<CachedObjectRecord>({
objectMetadataItem,
});
const { objectMetadataItems } = useObjectMetadataItems();
const createManyRecords = async (
data: Partial<CreatedObjectRecord>[],
options?: CreateManyRecordsOptions,
recordsToCreate: Partial<CreatedObjectRecord>[],
) => {
const sanitizedCreateManyRecordsInput = data.map((input) => {
const idForCreation = input.id ?? v4();
const sanitizedCreateManyRecordsInput = recordsToCreate.map(
(recordToCreate) => {
const idForCreation = recordToCreate?.id ?? v4();
const sanitizedRecordInput = sanitizeRecordInput({
objectMetadataItem,
recordInput: { ...input, id: idForCreation },
});
return sanitizedRecordInput;
});
const optimisticallyCreatedRecords = sanitizedCreateManyRecordsInput.map(
(record) =>
generateObjectRecordOptimisticResponse<CreatedObjectRecord>(record),
return {
...sanitizeRecordInput({
objectMetadataItem,
recordInput: recordToCreate,
}),
id: idForCreation,
};
},
);
const recordsCreatedInCache = [];
for (const recordToCreate of sanitizedCreateManyRecordsInput) {
const recordCreatedInCache = createOneRecordInCache(recordToCreate);
if (isDefined(recordCreatedInCache)) {
recordsCreatedInCache.push(recordCreatedInCache);
}
}
if (recordsCreatedInCache.length > 0) {
triggerCreateRecordsOptimisticEffect({
cache: apolloClient.cache,
objectMetadataItem,
recordsToCreate: recordsCreatedInCache,
objectMetadataItems,
});
}
const mutationResponseField = getCreateManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
@ -62,25 +92,18 @@ export const useCreateManyRecords = <
variables: {
data: sanitizedCreateManyRecordsInput,
},
optimisticResponse: options?.skipOptimisticEffect
? undefined
: {
[mutationResponseField]: optimisticallyCreatedRecords,
},
update: options?.skipOptimisticEffect
? undefined
: (cache, { data }) => {
const records = data?.[mutationResponseField];
update: (cache, { data }) => {
const records = data?.[mutationResponseField];
if (!records?.length) return;
if (!records?.length || skipPostOptmisticEffect) return;
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: records,
objectMetadataItems,
});
},
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: records,
objectMetadataItems,
});
},
});
return createdObjects.data?.[mutationResponseField] ?? [];

View File

@ -1,49 +0,0 @@
import { v4 } from 'uuid';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useAddRecordInCache } from '@/object-record/cache/hooks/useAddRecordInCache';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from '~/utils/isDefined';
export const useCreateManyRecordsInCache = <T extends ObjectRecord>({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
const addRecordInCache = useAddRecordInCache({
objectMetadataItem,
});
const createManyRecordsInCache = (data: Partial<T>[]) => {
const recordsWithId = data.map((record) => ({
...record,
id: (record.id as string) ?? v4(),
}));
const createdRecordsInCache = [] as T[];
for (const record of recordsWithId) {
const generatedCachedObjectRecord =
generateObjectRecordOptimisticResponse<T>(record);
if (isDefined(generatedCachedObjectRecord)) {
addRecordInCache(generatedCachedObjectRecord);
createdRecordsInCache.push(generatedCachedObjectRecord);
}
}
return createdRecordsInCache;
};
return { createManyRecordsInCache };
};

View File

@ -2,55 +2,73 @@ import { useApolloClient } from '@apollo/client';
import { v4 } from 'uuid';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { getCreateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import {
getCreateOneRecordMutationResponseField,
useGenerateCreateOneRecordMutation,
} from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isDefined } from '~/utils/isDefined';
type useCreateOneRecordProps = {
objectNameSingular: string;
};
type CreateOneRecordOptions = {
skipOptimisticEffect?: boolean;
queryFields?: Record<string, any>;
depth?: number;
skipPostOptmisticEffect?: boolean;
};
export const useCreateOneRecord = <
CreatedObjectRecord extends ObjectRecord = ObjectRecord,
>({
objectNameSingular,
queryFields,
depth = 1,
skipPostOptmisticEffect = false,
}: useCreateOneRecordProps) => {
const apolloClient = useApolloClient();
const { objectMetadataItem, createOneRecordMutation } = useObjectMetadataItem(
{ objectNameSingular },
);
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
const createOneRecordMutation = useGenerateCreateOneRecordMutation({
objectMetadataItem,
queryFields,
depth,
});
const createOneRecordInCache = useCreateOneRecordInCache<CachedObjectRecord>({
objectMetadataItem,
});
const { objectMetadataItems } = useObjectMetadataItems();
const createOneRecord = async (
input: Partial<CreatedObjectRecord>,
options?: CreateOneRecordOptions,
) => {
const createOneRecord = async (input: Partial<CreatedObjectRecord>) => {
const idForCreation = input.id ?? v4();
const sanitizedCreateOneRecordInput = sanitizeRecordInput({
objectMetadataItem,
recordInput: { ...input, id: idForCreation },
const sanitizedInput = {
...sanitizeRecordInput({
objectMetadataItem,
recordInput: input,
}),
id: idForCreation,
};
const recordCreatedInCache = createOneRecordInCache({
...input,
id: idForCreation,
});
const optimisticallyCreatedRecord =
generateObjectRecordOptimisticResponse<CreatedObjectRecord>({
...input,
...sanitizedCreateOneRecordInput,
if (isDefined(recordCreatedInCache)) {
triggerCreateRecordsOptimisticEffect({
cache: apolloClient.cache,
objectMetadataItem,
recordsToCreate: [recordCreatedInCache],
objectMetadataItems,
});
}
const mutationResponseField =
getCreateOneRecordMutationResponseField(objectNameSingular);
@ -58,27 +76,20 @@ export const useCreateOneRecord = <
const createdObject = await apolloClient.mutate({
mutation: createOneRecordMutation,
variables: {
input: sanitizedCreateOneRecordInput,
input: sanitizedInput,
},
optimisticResponse: options?.skipOptimisticEffect
? undefined
: {
[mutationResponseField]: optimisticallyCreatedRecord,
},
update: options?.skipOptimisticEffect
? undefined
: (cache, { data }) => {
const record = data?.[mutationResponseField];
update: (cache, { data }) => {
const record = data?.[mutationResponseField];
if (!record) return;
if (!record || skipPostOptmisticEffect) return;
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: [record],
objectMetadataItems,
});
},
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: [record],
objectMetadataItems,
});
},
});
return createdObject.data?.[mutationResponseField] ?? null;

View File

@ -1,38 +0,0 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useAddRecordInCache } from '@/object-record/cache/hooks/useAddRecordInCache';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
type useCreateOneRecordInCacheProps = {
objectNameSingular: string;
};
export const useCreateOneRecordInCache = <T>({
objectNameSingular,
}: useCreateOneRecordInCacheProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
const addRecordInCache = useAddRecordInCache({
objectMetadataItem,
});
const createOneRecordInCache = (input: ObjectRecord) => {
const generatedCachedObjectRecord =
generateObjectRecordOptimisticResponse(input);
addRecordInCache(generatedCachedObjectRecord);
return generatedCachedObjectRecord as T;
};
return {
createOneRecordInCache,
};
};

View File

@ -3,8 +3,8 @@ import { useQuery } from '@apollo/client';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { getFindDuplicateRecordsQueryResponseField } from '@/object-record/hooks/useGenerateFindDuplicateRecordsQuery';
import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@ -60,16 +60,14 @@ export const useFindDuplicateRecords = <T extends ObjectRecord = ObjectRecord>({
const objectRecordConnection = data?.[queryResponseField];
const mapConnectionToRecords = useMapConnectionToRecords();
const records = useMemo(
() =>
mapConnectionToRecords({
objectRecordConnection,
objectNameSingular,
depth: 5,
}) as T[],
[mapConnectionToRecords, objectRecordConnection, objectNameSingular],
objectRecordConnection
? (getRecordsFromRecordConnection({
recordConnection: objectRecordConnection,
}) as T[])
: [],
[objectRecordConnection],
);
return {

View File

@ -7,7 +7,7 @@ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords';
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge';
@ -22,7 +22,6 @@ import { cursorFamilyState } from '../states/cursorFamilyState';
import { hasNextPageFamilyState } from '../states/hasNextPageFamilyState';
import { isFetchingMoreRecordsFamilyState } from '../states/isFetchingMoreRecordsFamilyState';
import { ObjectRecordQueryResult } from '../types/ObjectRecordQueryResult';
import { mapPaginatedRecordsToRecords } from '../utils/mapPaginatedRecordsToRecords';
export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
@ -31,17 +30,20 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
limit,
onCompleted,
skip,
useRecordsWithoutConnection = false,
depth,
depth = 1,
queryFields,
}: ObjectMetadataItemIdentifier &
ObjectRecordQueryVariables & {
onCompleted?: (
data: ObjectRecordConnection<T>,
pageInfo: ObjectRecordConnection<T>['pageInfo'],
records: T[],
options?: {
pageInfo?: ObjectRecordConnection['pageInfo'];
totalCount?: number;
},
) => void;
skip?: boolean;
useRecordsWithoutConnection?: boolean;
depth?: number;
queryFields?: Record<string, any>;
}) => {
const findManyQueryStateIdentifier =
objectNameSingular +
@ -66,6 +68,7 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
},
depth,
queryFields,
);
const { enqueueSnackBar } = useSnackBar();
@ -81,9 +84,20 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
orderBy,
},
onCompleted: (data) => {
if (!isDefined(data)) {
onCompleted?.([]);
}
const pageInfo = data?.[objectMetadataItem.namePlural]?.pageInfo;
onCompleted?.(data[objectMetadataItem.namePlural], pageInfo);
const records = getRecordsFromRecordConnection({
recordConnection: data?.[objectMetadataItem.namePlural],
}) as T[];
onCompleted?.(records, {
pageInfo,
totalCount: data?.[objectMetadataItem.namePlural]?.totalCount,
});
if (isDefined(data?.[objectMetadataItem.namePlural])) {
setLastCursor(pageInfo.endCursor ?? '');
@ -132,24 +146,24 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
const pageInfo =
fetchMoreResult?.[objectMetadataItem.namePlural]?.pageInfo;
if (isDefined(data?.[objectMetadataItem.namePlural])) {
setLastCursor(pageInfo.endCursor ?? '');
setHasNextPage(pageInfo.hasNextPage ?? false);
}
onCompleted?.(
{
__typename: `${capitalize(
objectMetadataItem.nameSingular,
)}Connection`,
const records = getRecordsFromRecordConnection({
recordConnection: {
edges: newEdges,
pageInfo:
fetchMoreResult?.[objectMetadataItem.namePlural].pageInfo,
totalCount:
fetchMoreResult?.[objectMetadataItem.namePlural].totalCount,
pageInfo,
},
}) as T[];
onCompleted?.(records, {
pageInfo,
);
totalCount:
fetchMoreResult?.[objectMetadataItem.namePlural]?.totalCount,
});
return Object.assign({}, prev, {
[objectMetadataItem.namePlural]: {
@ -196,40 +210,23 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
enqueueSnackBar,
]);
// TODO: remove this and use only mapConnectionToRecords when we've finished the refactor
const totalCount = data?.[objectMetadataItem.namePlural].totalCount ?? 0;
const records = useMemo(
() =>
mapPaginatedRecordsToRecords({
pagedRecords: data,
objectNamePlural: objectMetadataItem.namePlural,
}) as T[],
[data, objectMetadataItem],
);
data?.[objectMetadataItem.namePlural]
? getRecordsFromRecordConnection({
recordConnection: data?.[objectMetadataItem.namePlural],
})
: ([] as T[]),
const mapConnectionToRecords = useMapConnectionToRecords();
const recordsWithoutConnection = useMemo(
() =>
useRecordsWithoutConnection
? (mapConnectionToRecords({
objectRecordConnection: data?.[objectMetadataItem.namePlural],
objectNameSingular,
depth: 5,
}) as T[])
: [],
[
data,
objectNameSingular,
objectMetadataItem.namePlural,
mapConnectionToRecords,
useRecordsWithoutConnection,
],
[data, objectMetadataItem.namePlural],
);
return {
objectMetadataItem,
records: useRecordsWithoutConnection ? recordsWithoutConnection : records,
totalCount: data?.[objectMetadataItem.namePlural].totalCount || 0,
records,
totalCount,
loading,
error,
fetchMoreRecords,

View File

@ -1,8 +1,11 @@
import { useMemo } from 'react';
import { useQuery } from '@apollo/client';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from '~/utils/isDefined';
// TODO: fix connection in relation => automatically change to an array
export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
@ -28,11 +31,29 @@ export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
>(findOneRecordQuery, {
skip: !objectMetadataItem || !objectRecordId || skip,
variables: { objectRecordId },
onCompleted: (data) => onCompleted?.(data[objectNameSingular]),
onCompleted: (data) => {
const recordWithoutConnection = getRecordFromRecordNode({
recordNode: { ...data[objectNameSingular] },
});
if (isDefined(recordWithoutConnection)) {
onCompleted?.(recordWithoutConnection);
}
},
});
const recordWithoutConnection = useMemo(
() =>
data?.[objectNameSingular]
? getRecordFromRecordNode({
recordNode: data?.[objectNameSingular],
})
: undefined,
[data, objectNameSingular],
);
return {
record: data?.[objectNameSingular] || undefined,
record: recordWithoutConnection,
loading,
error,
};

View File

@ -14,8 +14,12 @@ export const getCreateManyRecordsMutationResponseField = (
export const useGenerateCreateManyRecordMutation = ({
objectMetadataItem,
queryFields,
depth = 1,
}: {
objectMetadataItem: ObjectMetadataItem;
queryFields?: Record<string, any>;
depth?: number;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
@ -34,6 +38,8 @@ export const useGenerateCreateManyRecordMutation = ({
${mutationResponseField}(data: $data) ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,
objectMetadataItem,
queryFields,
depth,
})}
}`;
};

View File

@ -14,8 +14,12 @@ export const getCreateOneRecordMutationResponseField = (
export const useGenerateCreateOneRecordMutation = ({
objectMetadataItem,
queryFields,
depth = 1,
}: {
objectMetadataItem: ObjectMetadataItem;
queryFields?: Record<string, any>;
depth?: number;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
@ -34,6 +38,8 @@ export const useGenerateCreateOneRecordMutation = ({
${mutationResponseField}(data: $input) ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems,
objectMetadataItem,
queryFields,
depth,
})}
}
`;

View File

@ -12,14 +12,16 @@ export const useGenerateFindManyRecordsQuery = () => {
return ({
objectMetadataItem,
depth,
eagerLoadedRelations,
queryFields,
computeReferences = false,
}: {
objectMetadataItem: Pick<
ObjectMetadataItem,
'fields' | 'nameSingular' | 'namePlural'
>;
depth?: number;
eagerLoadedRelations?: Record<string, any>;
queryFields?: Record<string, any>;
computeReferences?: boolean;
}) => gql`
query FindMany${capitalize(
objectMetadataItem.namePlural,
@ -36,7 +38,8 @@ export const useGenerateFindManyRecordsQuery = () => {
objectMetadataItems,
objectMetadataItem,
depth,
eagerLoadedRelations,
queryFields,
computeReferences,
})}
cursor
}

View File

@ -14,8 +14,12 @@ export const getUpdateOneRecordMutationResponseField = (
export const useGenerateUpdateOneRecordMutation = ({
objectMetadataItem,
depth = 1,
computeReferences = false,
}: {
objectMetadataItem: ObjectMetadataItem;
depth?: number;
computeReferences?: boolean;
}) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
@ -35,6 +39,8 @@ export const useGenerateUpdateOneRecordMutation = ({
{
objectMetadataItems,
objectMetadataItem,
depth,
computeReferences,
},
)}
}

View File

@ -1,113 +0,0 @@
import { useCallback } from 'react';
import { isNonEmptyArray } from '@sniptt/guards';
import { produce } from 'immer';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelationType';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
export const useMapConnectionToRecords = () => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const mapConnectionToRecords = useCallback(
<T extends ObjectRecord>({
objectRecordConnection,
objectNameSingular,
objectNamePlural,
depth,
}: {
objectRecordConnection: ObjectRecordConnection<T> | undefined | null;
objectNameSingular?: string;
objectNamePlural?: string;
depth: number;
}): ObjectRecord[] => {
if (
!isDefined(objectRecordConnection) ||
!isNonEmptyArray(objectMetadataItems)
) {
return [];
}
const currentLevelObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectNameSingular ||
objectMetadataItem.namePlural === objectNamePlural,
);
if (!currentLevelObjectMetadataItem) {
throw new Error(
`Could not find object metadata item for object name singular "${objectNameSingular}" in mapConnectionToRecords`,
);
}
const relationFields = currentLevelObjectMetadataItem.fields.filter(
(field) => field.type === FieldMetadataType.Relation,
);
const objectRecords = [
...(objectRecordConnection.edges?.map((edge) => edge.node) ?? []),
];
return produce(objectRecords, (objectRecordsDraft) => {
for (const objectRecordDraft of objectRecordsDraft) {
for (const relationField of relationFields) {
const relationType = parseFieldRelationType(relationField);
if (
relationType === 'TO_ONE_OBJECT' ||
relationType === 'FROM_ONE_OBJECT'
) {
continue;
}
const relatedObjectMetadataSingularName =
relationField.toRelationMetadata?.fromObjectMetadata
.nameSingular ??
relationField.fromRelationMetadata?.toObjectMetadata
.nameSingular ??
null;
const relationFieldMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular ===
relatedObjectMetadataSingularName,
);
if (
!relationFieldMetadataItem ||
!isDefined(relatedObjectMetadataSingularName)
) {
throw new Error(
`Could not find relation object metadata item for object name plural ${relationField.name} in mapConnectionToRecords`,
);
}
const relationConnection = objectRecordDraft?.[
relationField.name
] as ObjectRecordConnection | undefined | null;
if (!isDefined(relationConnection)) {
continue;
}
const relationConnectionMappedToRecords = mapConnectionToRecords({
objectRecordConnection: relationConnection,
objectNameSingular: relatedObjectMetadataSingularName,
depth: depth - 1,
});
(objectRecordDraft as any)[relationField.name] =
relationConnectionMappedToRecords;
}
}
}) as ObjectRecord[];
},
[objectMetadataItems],
);
return mapConnectionToRecords;
};

View File

@ -3,29 +3,29 @@ import { useApolloClient } from '@apollo/client';
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
type useUpdateOneRecordProps = {
objectNameSingular: string;
queryFields?: Record<string, any>;
depth?: number;
};
export const useUpdateOneRecord = <
UpdatedObjectRecord extends ObjectRecord = ObjectRecord,
>({
objectNameSingular,
queryFields,
depth = 1,
}: useUpdateOneRecordProps) => {
const apolloClient = useApolloClient();
const { objectMetadataItem, updateOneRecordMutation, getRecordFromCache } =
useObjectMetadataItem({ objectNameSingular }, 1);
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
useObjectMetadataItem({ objectNameSingular }, depth, queryFields);
const { objectMetadataItems } = useObjectMetadataItems();
@ -36,17 +36,57 @@ export const useUpdateOneRecord = <
idToUpdate: string;
updateOneRecordInput: Partial<Omit<UpdatedObjectRecord, 'id'>>;
}) => {
const sanitizedInput = {
...sanitizeRecordInput({
objectMetadataItem,
recordInput: updateOneRecordInput,
}),
};
const cachedRecord = getRecordFromCache<UpdatedObjectRecord>(idToUpdate);
const sanitizedUpdateOneRecordInput = sanitizeRecordInput({
const cachedRecordWithConnection = getRecordNodeFromRecord<ObjectRecord>({
record: cachedRecord,
objectMetadataItem,
recordInput: updateOneRecordInput,
objectMetadataItems,
depth,
queryFields,
computeReferences: true,
});
const optimisticallyUpdatedRecord = generateObjectRecordOptimisticResponse({
...(cachedRecord ?? {}),
...sanitizedUpdateOneRecordInput,
id: idToUpdate,
const optimisticRecord = {
...cachedRecord,
...sanitizedInput,
...{ id: idToUpdate },
};
const optimisticRecordWithConnection =
getRecordNodeFromRecord<ObjectRecord>({
record: optimisticRecord,
objectMetadataItem,
objectMetadataItems,
depth,
queryFields,
computeReferences: true,
});
if (!optimisticRecordWithConnection || !cachedRecordWithConnection) {
return null;
}
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloClient.cache,
record: optimisticRecord,
});
triggerUpdateRecordOptimisticEffect({
cache: apolloClient.cache,
objectMetadataItem,
currentRecord: cachedRecordWithConnection,
updatedRecord: optimisticRecordWithConnection,
objectMetadataItems,
});
const mutationResponseField =
@ -56,10 +96,7 @@ export const useUpdateOneRecord = <
mutation: updateOneRecordMutation,
variables: {
idToUpdate,
input: sanitizedUpdateOneRecordInput,
},
optimisticResponse: {
[mutationResponseField]: optimisticallyUpdatedRecord,
input: sanitizedInput,
},
update: (cache, { data }) => {
const record = data?.[mutationResponseField];

View File

@ -1,24 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useUpsertRecordFieldFromState = () =>
useRecoilCallback(
({ set }) =>
<T extends { id: string }, F extends keyof T>({
record,
fieldName,
}: {
record: T;
fieldName: F extends string ? F : never;
}) =>
set(
recordStoreFamilySelector({ recordId: record.id, fieldName }),
(previousField) =>
isDeeplyEqual(previousField, record[fieldName])
? previousField
: record[fieldName],
),
[],
);

View File

@ -0,0 +1,44 @@
import { useQuery } from '@apollo/client';
import { EMPTY_QUERY } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/multiple-objects/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { MultiObjectRecordQueryResult } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray';
export const useFindManyRecordsForMultipleMetadataItems = ({
objectMetadataItems,
skip = false,
depth = 2,
}: {
objectMetadataItems: ObjectMetadataItem[];
skip: boolean;
depth?: number;
}) => {
const findManyQuery = useGenerateFindManyRecordsForMultipleMetadataItemsQuery(
{
targetObjectMetadataItems: objectMetadataItems,
depth,
},
);
const { data } = useQuery<MultiObjectRecordQueryResult>(
findManyQuery ?? EMPTY_QUERY,
{
skip,
},
);
const resultWithoutConnection = Object.fromEntries(
Object.entries(data ?? {}).map(([namePlural, objectRecordConnection]) => [
namePlural,
getRecordsFromRecordConnection({
recordConnection: objectRecordConnection,
}),
]),
);
return {
result: resultWithoutConnection,
};
};

View File

@ -3,5 +3,7 @@ import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQu
export type QueryKey = {
objectNameSingular: string;
variables: ObjectRecordQueryVariables;
depth: number;
depth?: number;
fields?: Record<string, any>; // Todo: Fields should be required
fieldsFactory?: (fieldsFactoryParam: any) => Record<string, any>;
};

View File

@ -109,6 +109,19 @@ export const usePersistField = () => {
valueToPersist,
);
if (fieldIsRelation) {
updateRecord?.({
variables: {
where: { id: entityId },
updateOneRecordInput: {
[fieldName]: valueToPersist,
[`${fieldName}Id`]: valueToPersist?.id ?? null,
},
},
});
return;
}
updateRecord?.({
variables: {
where: { id: entityId },

View File

@ -68,7 +68,7 @@ const AddressInputWithContext = ({
onEscape={onEscape}
onClickOutside={onClickOutside}
value={value}
hotkeyScope=""
hotkeyScope="hotkey-scope"
onTab={onTab}
onShiftTab={onShiftTab}
/>
@ -96,7 +96,7 @@ const clearMocksDecorator: Decorator = (Story, context) => {
};
const meta: Meta = {
title: 'UI/Data/Field/Input/AddressInput',
title: 'UI/Data/Field/Input/AddressFieldInput',
component: AddressInputWithContext,
args: {
value: 'text',

View File

@ -8,6 +8,11 @@ export type FieldDefinitionRelationType =
| 'TO_MANY_OBJECTS'
| 'TO_ONE_OBJECT';
export type RelationDirections = {
from: FieldDefinitionRelationType;
to: FieldDefinitionRelationType;
};
export type FieldDefinition<T extends FieldMetadata> = {
fieldMetadataId: string;
label: string;

View File

@ -2,14 +2,13 @@ import { useRecoilValue, useSetRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState.ts';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { SIGN_IN_BACKGROUND_MOCK_COMPANIES } from '@/sign-in-background-mock/constants/SignInBackgroundMockCompanies';
import { useFindManyRecords } from '../../hooks/useFindManyRecords';
export const useFindManyParams = (
objectNameSingular: string,
recordTableId?: string,

View File

@ -141,8 +141,8 @@ export const useExportTableData = ({
...usedFindManyParams,
depth: 0,
limit: pageSize,
onCompleted: (_data, { hasNextPage }) => {
setHasNextPage(hasNextPage ?? false);
onCompleted: (_data, options) => {
setHasNextPage(options?.pageInfo?.hasNextPage ?? false);
},
});

View File

@ -1,7 +1,6 @@
import { useEffect } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { useActivityConnectionUtils } from '@/activities/hooks/useActivityConnectionUtils';
import { Activity } from '@/activities/types/Activity';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { recordLoadingFamilyState } from '@/object-record/record-store/states/recordLoadingFamilyState';
@ -15,7 +14,7 @@ export const RecordShowContainer = ({
objectRecordId: string;
objectNameSingular: string;
}) => {
const { record, loading } = useFindOneRecord({
const { record: activity, loading } = useFindOneRecord<Activity>({
objectRecordId,
objectNameSingular,
depth: 3,
@ -35,14 +34,9 @@ export const RecordShowContainer = ({
}
}, [loading, recordLoading, setRecordLoading]);
const { makeActivityWithoutConnection } = useActivityConnectionUtils();
useEffect(() => {
if (!loading && isDefined(record)) {
const { activity: activityWithoutConnection } =
makeActivityWithoutConnection(record as any);
setRecordStore(activityWithoutConnection as Activity);
if (!loading && isDefined(activity)) {
setRecordStore(activity);
}
}, [loading, record, setRecordStore, makeActivityWithoutConnection]);
}, [loading, setRecordStore, activity]);
};

View File

@ -51,16 +51,17 @@ export const RecordDetailRelationSection = () => {
);
const fieldValue = useRecoilValue<
({ id: string } & Record<string, any>) | null
({ id: string } & Record<string, any>) | ObjectRecord[] | null
>(recordStoreFamilySelector({ recordId: entityId, fieldName }));
// TODO: use new relation type
const isToOneObject = relationType === 'TO_ONE_OBJECT';
const isFromManyObjects = relationType === 'FROM_MANY_OBJECTS';
const relationRecords: ObjectRecord[] =
fieldValue && isToOneObject
? [fieldValue]
: fieldValue?.edges.map(({ node }: { node: ObjectRecord }) => node) ?? [];
? [fieldValue as ObjectRecord]
: (fieldValue as ObjectRecord[]) ?? [];
const relationRecordIds = relationRecords.map(({ id }) => id);
const dropdownId = `record-field-card-relation-picker-${fieldDefinition.label}`;

View File

@ -81,7 +81,7 @@ const StyledTable = styled.table<{
z-index: 6;
}
thead th:nth-child(n + 3) {
thead th:nth-of-type(n + 3) {
top: 0;
z-index: 5;
position: sticky;

View File

@ -4,7 +4,7 @@ import { useRecoilValue } from 'recoil';
import { EMPTY_QUERY } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/multiple-objects/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem';
import {
MultiObjectRecordQueryResult,

View File

@ -4,7 +4,7 @@ import { useRecoilValue } from 'recoil';
import { EMPTY_QUERY } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/multiple-objects/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem';
import {
MultiObjectRecordQueryResult,

View File

@ -4,7 +4,7 @@ import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/multiple-objects/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery';
import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem';
import {
MultiObjectRecordQueryResult,

View File

@ -10,6 +10,7 @@ export type ObjectRecordConnection<T extends ObjectRecord = ObjectRecord> = {
hasPreviousPage?: boolean;
startCursor?: Nullable<string>;
endCursor?: Nullable<string>;
totalCount?: number;
};
totalCount: number;
totalCount?: number;
};

View File

@ -2,7 +2,6 @@ import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldMetadataType } from '~/generated/graphql';
import { capitalize } from '~/utils/string/capitalize';
export const generateEmptyFieldValue = (
fieldMetadataItem: FieldMetadataItem,
@ -53,8 +52,6 @@ export const generateEmptyFieldValue = (
return true;
}
case FieldMetadataType.Relation: {
// TODO: refactor with relationDefiniton once the PR is merged : https://github.com/twentyhq/twenty/pull/4378
// so we can directly check the relation type from this field point of view.
if (
!isNonEmptyString(
fieldMetadataItem.fromRelationMetadata?.toObjectMetadata
@ -64,12 +61,7 @@ export const generateEmptyFieldValue = (
return null;
}
return {
__typename: `${capitalize(
fieldMetadataItem.fromRelationMetadata.toObjectMetadata.nameSingular,
)}Connection`,
edges: [],
};
return [];
}
case FieldMetadataType.Currency: {
return {

View File

@ -1,24 +0,0 @@
export const mapPaginatedRecordsToRecords = <
RecordType extends { id: string } & Record<string, any>,
RecordTypeQuery extends {
[objectNamePlural: string]: {
edges: RecordEdge[];
};
},
RecordEdge extends {
node: RecordType;
},
>({
pagedRecords,
objectNamePlural,
}: {
pagedRecords: RecordTypeQuery | undefined;
objectNamePlural: string;
}) => {
const formattedRecords: RecordType[] =
pagedRecords?.[objectNamePlural]?.edges?.map((recordEdge: RecordEdge) => ({
...recordEdge.node,
})) ?? [];
return formattedRecords;
};

View File

@ -0,0 +1,35 @@
import { isUndefined } from '@sniptt/guards';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
import { isDefined } from '~/utils/isDefined';
export const prefillRecord = <T extends ObjectRecord>({
objectMetadataItem,
input,
depth = 1,
}: {
objectMetadataItem: ObjectMetadataItem;
input: Record<string, unknown>;
depth?: number;
}) => {
return Object.fromEntries(
objectMetadataItem.fields
.filter(
(fieldMetadataItem) =>
depth > 0 || fieldMetadataItem.type !== 'RELATION',
)
.map((fieldMetadataItem) => {
const inputValue = input[fieldMetadataItem.name];
return [
fieldMetadataItem.name,
isUndefined(inputValue)
? generateEmptyFieldValue(fieldMetadataItem)
: inputValue,
];
})
.filter(isDefined),
) as T;
};

View File

@ -3,6 +3,7 @@ import { isString } from '@sniptt/guards';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isFieldRelationValue } from '@/object-record/record-field/types/guards/isFieldRelationValue';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeLink } from '@/object-record/utils/sanitizeLinkRecordInput';
import { FieldMetadataType } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
@ -12,7 +13,7 @@ export const sanitizeRecordInput = ({
recordInput,
}: {
objectMetadataItem: ObjectMetadataItem;
recordInput: Record<string, unknown>;
recordInput: Partial<ObjectRecord>;
}) => {
const filteredResultRecord = Object.fromEntries(
Object.entries(recordInput)
@ -23,6 +24,10 @@ export const sanitizeRecordInput = ({
if (!fieldMetadataItem) return undefined;
if (!fieldMetadataItem.isNullable && fieldValue == null) {
return undefined;
}
if (
fieldMetadataItem.type === FieldMetadataType.Relation &&
isFieldRelationValue(fieldValue)