Activity injection into Apollo cache (#3665)

- Created addRecordInCache to inject a record in Apollo cache and inject single read query on this record
- Created createOneRecordInCache and createManyRecordsInCache that uses this addRecordInCache
- Created useOpenCreateActivityDrawerV2 hook to create an activity in cache and inject it into all other relevant requests in the app before opening activity drawer
- Refactored DEFAULT_SEARCH_REQUEST_LIMIT constant and hardcoded arbitrary request limits
- Added Apollo dev logs to see errors in the console when manipulating cache
This commit is contained in:
Lucas Bordeau
2024-01-29 16:12:52 +01:00
committed by GitHub
parent 64d0e15ada
commit 3b458d5207
57 changed files with 1160 additions and 190 deletions

View File

@ -0,0 +1,72 @@
import { useApolloClient } from '@apollo/client';
import gql from 'graphql-tag';
import { useRecoilCallback } from 'recoil';
import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery';
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 mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery();
const apolloClient = useApolloClient();
const generateFindOneRecordQuery = useGenerateFindOneRecordQuery();
const findOneRecordQuery = generateFindOneRecordQuery({
objectMetadataItem,
});
return useRecoilCallback(
({ set }) =>
(record: ObjectRecord) => {
apolloClient.writeFragment({
id: `${capitalize(objectMetadataItem.nameSingular)}:${record.id}`,
fragment: gql`
fragment Create${capitalize(
objectMetadataItem.nameSingular,
)}InCache on ${capitalize(objectMetadataItem.nameSingular)} {
__typename
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field))
.join('\n')}
}
`,
data: {
__typename: `${capitalize(objectMetadataItem.nameSingular)}`,
...record,
},
});
// TODO: Turn into injectIntoFindOneRecordQueryCache
apolloClient.writeQuery({
query: findOneRecordQuery,
variables: {
objectRecordId: record.id,
},
data: {
[objectMetadataItem.nameSingular]: {
__typename: `${capitalize(objectMetadataItem.nameSingular)}`,
...record,
},
},
});
// TODO: remove this once we get rid of entityFieldsFamilyState
set(recordStoreFamilyState(record.id), record);
},
[
objectMetadataItem,
apolloClient,
mapFieldMetadataToGraphQLQuery,
findOneRecordQuery,
],
);
};

View File

@ -0,0 +1,31 @@
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

@ -0,0 +1,43 @@
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

@ -0,0 +1,16 @@
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

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

View File

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

View File

@ -0,0 +1,8 @@
export const getEmptyPageInfo = () => {
return {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
};
};

View File

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

View File

@ -0,0 +1,19 @@
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

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

View File

@ -0,0 +1,21 @@
import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename';
import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge';
export const getRecordEdgeFromRecord = <T extends ObjectRecord>({
objectNameSingular,
record,
}: {
objectNameSingular: string;
record: T;
}) => {
return {
__typename: getEdgeTypename({ objectNameSingular }),
node: {
__typename: getNodeTypename({ objectNameSingular }),
...record,
},
cursor: '',
} as ObjectRecordEdge<T>;
};

View File

@ -0,0 +1,10 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
export const getRecordsFromRecordConnection = <T extends ObjectRecord>({
recordConnection,
}: {
recordConnection: ObjectRecordConnection<T>;
}): T[] => {
return recordConnection.edges.map((edge) => edge.node);
};

View File

@ -0,0 +1 @@
export const DEFAULT_SEARCH_REQUEST_LIMIT = 60;

View File

@ -5,7 +5,7 @@ export const query = gql`
$filter: PersonFilterInput
$orderBy: PersonOrderByInput
$lastCursor: String
$limit: Float = 30
$limit: Float
) {
people(
filter: $filter

View File

@ -5,7 +5,7 @@ import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { useModifyRecordFromCache } from '@/object-record/hooks/useModifyRecordFromCache';
import { useModifyRecordFromCache } from '@/object-record/cache/hooks/useModifyRecordFromCache';
const Wrapper = ({ children }: { children: ReactNode }) => (
<MockedProvider addTypename={false}>

View File

@ -3,7 +3,7 @@ import { useApolloClient } from '@apollo/client';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useGenerateCachedObjectRecord } from '@/object-record/hooks/useGenerateCachedObjectRecord';
import { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord';
import { getCreateManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateCreateManyRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';

View File

@ -0,0 +1,48 @@
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 { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const useCreateManyRecordsInCache = <T extends ObjectRecord>({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({
objectMetadataItem,
});
const addRecordInCache = useAddRecordInCache({
objectMetadataItem,
});
const createManyRecordsInCache = async (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 = generateCachedObjectRecord<T>({
...record,
});
if (generatedCachedObjectRecord) {
addRecordInCache(generatedCachedObjectRecord);
createdRecordsInCache.push(generatedCachedObjectRecord);
}
}
return createdRecordsInCache;
};
return { createManyRecordsInCache };
};

View File

@ -2,7 +2,7 @@ import { useApolloClient } from '@apollo/client';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useGenerateCachedObjectRecord } from '@/object-record/hooks/useGenerateCachedObjectRecord';
import { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord';
import { getCreateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';

View File

@ -0,0 +1,39 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useAddRecordInCache } from '@/object-record/cache/hooks/useAddRecordInCache';
import { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
type useCreateOneRecordInCacheProps = {
objectNameSingular: string;
};
export const useCreateOneRecordInCache = <T>({
objectNameSingular,
}: useCreateOneRecordInCacheProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({
objectMetadataItem,
});
const addRecordInCache = useAddRecordInCache({
objectMetadataItem,
});
const createOneRecordInCache = async (input: ObjectRecord) => {
const generatedCachedObjectRecord = generateCachedObjectRecord({
createdAt: new Date().toISOString(),
...input,
});
addRecordInCache(generatedCachedObjectRecord);
return generatedCachedObjectRecord as T;
};
return {
createOneRecordInCache,
};
};

View File

@ -13,7 +13,6 @@ import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnec
import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
import { filterUniqueRecordEdgesByCursor } from '@/object-record/utils/filterUniqueRecordEdgesByCursor';
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/search/hooks/useFilteredSearchEntityQuery';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { logError } from '~/utils/logError';
import { capitalize } from '~/utils/string/capitalize';
@ -28,7 +27,7 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
filter,
orderBy,
limit = DEFAULT_SEARCH_REQUEST_LIMIT,
limit,
onCompleted,
skip,
useRecordsWithoutConnection = false,

View File

@ -20,7 +20,7 @@ export const useGenerateFindManyRecordsQuery = () => {
objectMetadataItem.nameSingular,
)}FilterInput, $orderBy: ${capitalize(
objectMetadataItem.nameSingular,
)}OrderByInput, $lastCursor: String, $limit: Float = 60) {
)}OrderByInput, $lastCursor: String, $limit: Float) {
${
objectMetadataItem.namePlural
}(filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor){

View File

@ -10,7 +10,7 @@ import {
URLFilter,
UUIDFilter,
} from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { andFilterVariables } from '@/object-record/utils/andFilterVariables';
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { Field } from '~/generated/graphql';
@ -260,5 +260,5 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
}
}
return andFilterVariables(objectRecordFilters);
return makeAndFilterVariables(objectRecordFilters);
};

View File

@ -238,16 +238,21 @@ export const RecordShowContainer = ({
</>
)}
</ShowPageLeftContainer>
<ShowPageRightContainer
targetableObject={{
id: record?.id ?? '',
targetObjectNameSingular: objectMetadataItem?.nameSingular,
}}
timeline
tasks
notes
emails
/>
{record ? (
<ShowPageRightContainer
targetableObject={{
id: record.id,
targetObjectNameSingular: objectMetadataItem?.nameSingular,
targetObjectRecord: record,
}}
timeline
tasks
notes
emails
/>
) : (
<></>
)}
</ShowPageContainer>
</RecoilScope>
);

View File

@ -1,5 +1,5 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/search/hooks/useFilteredSearchEntityQuery';
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';

View File

@ -5,7 +5,7 @@ import { useMultiObjectSearchSelectedItemsQuery } from '@/object-record/relation
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordIdentifier } from '@/object-record/types/ObjectRecordIdentifier';
export const DEFAULT_SEARCH_REQUEST_LIMIT = 5;
export const MULTI_OBJECT_SEARCH_REQUEST_LIMIT = 5;
export type ObjectRecordForSelect = {
objectMetadataItem: ObjectMetadataItem;

View File

@ -13,7 +13,7 @@ import {
import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch';
import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem';
import { useSearchFilterPerMetadataItem } from '@/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem';
import { andFilterVariables } from '@/object-record/utils/andFilterVariables';
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
@ -69,7 +69,7 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({
return [
`filter${capitalize(nameSingular)}`,
andFilterVariables(searchFilters),
makeAndFilterVariables(searchFilters),
];
})
.filter(isDefined),

View File

@ -2,13 +2,12 @@ import { isNonEmptyString } from '@sniptt/guards';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { OrderBy } from '@/object-metadata/types/OrderBy';
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SelectableRecord } from '@/object-record/select/types/SelectableRecord';
import { getObjectFilterFields } from '@/object-record/select/utils/getObjectFilterFields';
import { andFilterVariables } from '@/object-record/utils/andFilterVariables';
import { orFilterVariables } from '@/object-record/utils/orFilterVariables';
export const DEFAULT_SEARCH_REQUEST_LIMIT = 60;
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables';
export const useRecordsForSelect = ({
searchFilterText,
@ -53,7 +52,7 @@ export const useRecordsForSelect = ({
return undefined;
}
return orFilterVariables(
return makeOrFilterVariables(
fieldNames.map((fieldName) => {
const [parentFieldName, subFieldName] = fieldName.split('.');
@ -81,7 +80,7 @@ export const useRecordsForSelect = ({
loading: filteredSelectedRecordsLoading,
records: filteredSelectedRecordsData,
} = useFindManyRecords({
filter: andFilterVariables([...searchFilters, selectedIdsFilter]),
filter: makeAndFilterVariables([...searchFilters, selectedIdsFilter]),
orderBy: orderByField,
objectNameSingular,
skip: !selectedIds.length,
@ -93,7 +92,7 @@ export const useRecordsForSelect = ({
: undefined;
const { loading: recordsToSelectLoading, records: recordsToSelectData } =
useFindManyRecords({
filter: andFilterVariables([...searchFilters, notFilter]),
filter: makeAndFilterVariables([...searchFilters, notFilter]),
limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT,
orderBy: orderByField,
objectNameSingular,

View File

@ -1,5 +1,8 @@
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,
@ -39,7 +42,21 @@ export const generateEmptyFieldValue = (
return true;
}
case FieldMetadataType.Relation: {
return null;
if (
!isNonEmptyString(
fieldMetadataItem.fromRelationMetadata?.toObjectMetadata
?.nameSingular,
)
) {
return null;
}
return {
__typename: `${capitalize(
fieldMetadataItem.fromRelationMetadata.toObjectMetadata.nameSingular,
)}Connection`,
edges: [],
};
}
case FieldMetadataType.Currency: {
return {

View File

@ -1,7 +1,7 @@
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { isDefined } from '~/utils/isDefined';
export const andFilterVariables = (
export const makeAndFilterVariables = (
filters: (ObjectRecordQueryFilter | undefined)[],
): ObjectRecordQueryFilter | undefined => {
const definedFilters = filters.filter(isDefined);

View File

@ -1,7 +1,7 @@
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { isDefined } from '~/utils/isDefined';
export const orFilterVariables = (
export const makeOrFilterVariables = (
filters: (ObjectRecordQueryFilter | undefined)[],
): ObjectRecordQueryFilter | undefined => {
const definedFilters = filters.filter(isDefined);

View File

@ -0,0 +1,5 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const mapToRecordId = (objectRecord: ObjectRecord) => {
return objectRecord.id;
};