feat: delete favorite in cache on related record deletion (#3751)

* feat: delete favorite in cache on related record deletion

* fix: fix useCreateOneRecord tests

* fix: fix usePipelineSteps tests

* fix: fix useCreateManyRecords tests

* fix: add null relation field values in useGenerateObjectRecordOptimisticResponse
This commit is contained in:
Thaïs 2024-02-01 12:09:32 -03:00 committed by GitHub
parent 142affbeea
commit 7adb5cc00d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 376 additions and 275 deletions

View File

@ -1,8 +1,10 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection';
import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
@ -15,15 +17,27 @@ export const triggerCreateRecordsOptimisticEffect = ({
cache,
objectMetadataItem,
records,
getRelationMetadata,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
records: CachedObjectRecord[];
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
}) => {
const objectEdgeTypeName = `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`;
records.forEach((record) =>
triggerUpdateRelationsOptimisticEffect({
cache,
objectMetadataItem,
previousRecord: null,
nextRecord: record,
getRelationMetadata,
}),
);
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (

View File

@ -1,9 +1,11 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection';
import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
@ -12,10 +14,12 @@ export const triggerDeleteRecordsOptimisticEffect = ({
cache,
objectMetadataItem,
records,
getRelationMetadata,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
records: Pick<CachedObjectRecord, 'id' | '__typename'>[];
records: CachedObjectRecord[];
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
}) => {
cache.modify<StoreObject>({
fields: {
@ -64,5 +68,15 @@ export const triggerDeleteRecordsOptimisticEffect = ({
},
});
cache.gc();
records.forEach((record) => {
triggerUpdateRelationsOptimisticEffect({
cache,
objectMetadataItem,
previousRecord: record,
nextRecord: null,
getRelationMetadata,
});
cache.evict({ id: cache.identify(record) });
});
};

View File

@ -2,9 +2,11 @@ import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection';
import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges';
import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { isDefined } from '~/utils/isDefined';
@ -14,16 +16,29 @@ import { capitalize } from '~/utils/string/capitalize';
export const triggerUpdateRecordOptimisticEffect = ({
cache,
objectMetadataItem,
record,
previousRecord,
nextRecord,
getRelationMetadata,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
record: CachedObjectRecord;
previousRecord: CachedObjectRecord;
nextRecord: CachedObjectRecord;
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
}) => {
const objectEdgeTypeName = `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`;
triggerUpdateRelationsOptimisticEffect({
cache,
objectMetadataItem,
previousRecord,
nextRecord,
getRelationMetadata,
});
// Optimistically update record lists
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
@ -49,18 +64,20 @@ export const triggerUpdateRecordOptimisticEffect = ({
);
let nextCachedEdges = cachedEdges ? [...cachedEdges] : [];
// Test if the record matches this list's filters
if (variables?.filter) {
const matchesFilter = isRecordMatchingFilter({
record,
record: nextRecord,
filter: variables.filter,
objectMetadataItem,
});
const recordIndex = nextCachedEdges.findIndex(
(cachedEdge) => readField('id', cachedEdge.node) === record.id,
(cachedEdge) => readField('id', cachedEdge.node) === nextRecord.id,
);
// If after update, the record matches this list's filters, then add it to the list
if (matchesFilter && recordIndex === -1) {
const nodeReference = toReference(record);
const nodeReference = toReference(nextRecord);
nodeReference &&
nextCachedEdges.push({
__typename: objectEdgeTypeName,
@ -69,11 +86,13 @@ export const triggerUpdateRecordOptimisticEffect = ({
});
}
// If after update, the record does not match this list's filters anymore, then remove it from the list
if (!matchesFilter && recordIndex > -1) {
nextCachedEdges.splice(recordIndex, 1);
}
}
// Sort updated list
if (variables?.orderBy) {
nextCachedEdges = sortCachedObjectEdges({
edges: nextCachedEdges,
@ -82,6 +101,7 @@ export const triggerUpdateRecordOptimisticEffect = ({
});
}
// Limit the updated list to the required size
if (isDefined(variables?.first)) {
// If previous edges length was exactly at the required limit,
// but after update next edges length is under the limit,

View File

@ -0,0 +1,112 @@
import { ApolloCache } from '@apollo/client';
import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection';
import { triggerAttachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { coreObjectNamesToDeleteOnRelationDetach } from '@/apollo/types/coreObjectNamesToDeleteOnRelationDetach';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isDefined } from '~/utils/isDefined';
export const triggerUpdateRelationsOptimisticEffect = ({
cache,
objectMetadataItem,
previousRecord,
nextRecord,
getRelationMetadata,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
previousRecord: CachedObjectRecord | null;
nextRecord: CachedObjectRecord | null;
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
}) =>
// Optimistically update relation records
objectMetadataItem.fields.forEach((fieldMetadataItem) => {
if (nextRecord && !(fieldMetadataItem.name in nextRecord)) return;
const relationMetadata = getRelationMetadata({
fieldMetadataItem,
});
if (!relationMetadata) return;
const {
// Object metadata for the related record
relationObjectMetadataItem,
// Field on the related record
relationFieldMetadataItem,
} = relationMetadata;
const previousFieldValue:
| ObjectRecordConnection
| CachedObjectRecord
| null = previousRecord?.[fieldMetadataItem.name];
const nextFieldValue: ObjectRecordConnection | CachedObjectRecord | null =
nextRecord?.[fieldMetadataItem.name];
if (isDeeplyEqual(previousFieldValue, nextFieldValue)) return;
const isPreviousFieldValueRecordConnection = isObjectRecordConnection(
relationObjectMetadataItem.nameSingular,
previousFieldValue,
);
const relationRecordsToDetach = isPreviousFieldValueRecordConnection
? previousFieldValue.edges.map(({ node }) => node as CachedObjectRecord)
: [previousFieldValue].filter(isDefined);
const isNextFieldValueRecordConnection = isObjectRecordConnection(
relationObjectMetadataItem.nameSingular,
nextFieldValue,
);
const relationRecordsToAttach = isNextFieldValueRecordConnection
? nextFieldValue.edges.map(({ node }) => node as CachedObjectRecord)
: [nextFieldValue].filter(isDefined);
if (previousRecord && relationRecordsToDetach.length) {
const shouldDeleteRelationRecord =
coreObjectNamesToDeleteOnRelationDetach.includes(
relationObjectMetadataItem.nameSingular as CoreObjectNameSingular,
);
if (shouldDeleteRelationRecord) {
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem: relationObjectMetadataItem,
records: relationRecordsToDetach,
getRelationMetadata,
});
} else {
relationRecordsToDetach.forEach((relationRecordToDetach) => {
triggerDetachRelationOptimisticEffect({
cache,
objectNameSingular: objectMetadataItem.nameSingular,
recordId: previousRecord.id,
relationFieldName: relationFieldMetadataItem.name,
relationObjectMetadataNameSingular:
relationObjectMetadataItem.nameSingular,
relationRecordId: relationRecordToDetach.id,
});
});
}
}
if (nextRecord && relationRecordsToAttach.length) {
relationRecordsToAttach.forEach((relationRecordToAttach) =>
triggerAttachRelationOptimisticEffect({
cache,
objectNameSingular: objectMetadataItem.nameSingular,
recordId: nextRecord.id,
relationFieldName: relationFieldMetadataItem.name,
relationObjectMetadataNameSingular:
relationObjectMetadataItem.nameSingular,
relationRecordId: relationRecordToAttach.id,
}),
);
}
});

View File

@ -0,0 +1,5 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
export const coreObjectNamesToDeleteOnRelationDetach = [
CoreObjectNameSingular.Favorite,
];

View File

@ -1,41 +0,0 @@
import { v4 } from 'uuid';
import { z } from 'zod';
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 useGenerateCachedObjectRecord = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const generateCachedObjectRecord = <
GeneratedObjectRecord extends ObjectRecord,
>(
input: Record<string, unknown>,
) => {
const recordSchema = z.object(
Object.fromEntries(
objectMetadataItem.fields.map((fieldMetadataItem) => [
fieldMetadataItem.name,
z.unknown().default(generateEmptyFieldValue(fieldMetadataItem)),
]),
),
);
return {
__typename: capitalize(objectMetadataItem.nameSingular),
...recordSchema.parse({
id: v4(),
createdAt: new Date().toISOString(),
...input,
}),
} as GeneratedObjectRecord & { __typename: string };
};
return {
generateCachedObjectRecord,
};
};

View File

@ -0,0 +1,72 @@
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;
return {
...result,
[fieldMetadataItem.name]: relationRecordId
? {
__typename: relationRecordTypeName,
id: relationRecordId,
}
: 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,5 +1,7 @@
import { gql } from '@apollo/client';
import { Person } from '@/people/types/Person';
export const query = gql`
mutation CreatePeople($data: [PersonCreateInput!]!) {
createPeople(data: $data) {
@ -67,12 +69,15 @@ export const query = gql`
}
`;
export const variables = {
data: [
{ id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9' },
{ id: '37faabcd-cb39-4a0a-8618-7e3fda9afca0' },
],
};
const data = [
{
id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9',
name: { firstName: 'John', lastName: 'Doe' },
},
{ id: '37faabcd-cb39-4a0a-8618-7e3fda9afca0', jobTitle: 'manager' },
] satisfies Partial<Person>[];
export const variables = { data };
export const responseData = {
opportunities: {
@ -114,3 +119,8 @@ export const responseData = {
avatarUrl: '',
companyId: '',
};
export const response = data.map((personData) => ({
...responseData,
...personData,
}));

View File

@ -67,10 +67,6 @@ export const query = gql`
}
`;
export const variables = {
input: { id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9' },
};
export const responseData = {
opportunities: {
edges: [],

View File

@ -1,19 +1,27 @@
import { ReactNode } from 'react';
import { MockedProvider } from '@apollo/client/testing';
import { mocked } from '@storybook/test';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { v4 } from 'uuid';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import {
query,
responseData,
response,
variables,
} from '@/object-record/hooks/__mocks__/useCreateManyRecords';
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords';
const people = [
{ id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9' },
{ id: '37faabcd-cb39-4a0a-8618-7e3fda9afca0' },
];
jest.mock('uuid', () => ({
v4: jest.fn(),
}));
mocked(v4)
.mockReturnValueOnce(variables.data[0].id)
.mockReturnValueOnce(variables.data[1].id);
const input = variables.data.map(({ id: _id, ...personInput }) => personInput);
const mocks = [
{
@ -23,10 +31,7 @@ const mocks = [
},
result: jest.fn(() => ({
data: {
createPeople: people.map((person) => ({
id: person.id,
...responseData,
})),
createPeople: response,
},
})),
},
@ -43,19 +48,18 @@ const Wrapper = ({ children }: { children: ReactNode }) => (
describe('useCreateManyRecords', () => {
it('works as expected', async () => {
const { result } = renderHook(
() => useCreateManyRecords({ objectNameSingular: 'person' }),
() =>
useCreateManyRecords({
objectNameSingular: CoreObjectNameSingular.Person,
}),
{
wrapper: Wrapper,
},
);
await act(async () => {
const res = await result.current.createManyRecords(people);
expect(res).toBeDefined();
expect(Array.isArray(res)).toBe(true);
expect(res?.length).toBe(2);
expect(res?.[0].id).toBe(people[0].id);
expect(res?.[1].id).toBe(people[1].id);
const res = await result.current.createManyRecords(input);
expect(res).toEqual(response);
});
expect(mocks[0].result).toHaveBeenCalled();

View File

@ -3,24 +3,29 @@ import { MockedProvider } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import {
query,
responseData,
variables,
} from '@/object-record/hooks/__mocks__/useCreateOneRecord';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
const person = { id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9' };
const personId = 'a7286b9a-c039-4a89-9567-2dfa7953cda9';
const input = { name: { firstName: 'John', lastName: 'Doe' } };
jest.mock('uuid', () => ({
v4: jest.fn(() => personId),
}));
const mocks = [
{
request: {
query,
variables,
variables: { input: { ...input, id: personId } },
},
result: jest.fn(() => ({
data: {
createPerson: { ...person, ...responseData },
createPerson: { ...responseData, ...input, id: personId },
},
})),
},
@ -37,16 +42,20 @@ const Wrapper = ({ children }: { children: ReactNode }) => (
describe('useCreateOneRecord', () => {
it('works as expected', async () => {
const { result } = renderHook(
() => useCreateOneRecord({ objectNameSingular: 'person' }),
() =>
useCreateOneRecord({
objectNameSingular: CoreObjectNameSingular.Person,
}),
{
wrapper: Wrapper,
},
);
await act(async () => {
const res = await result.current.createOneRecord(person);
const res = await result.current.createOneRecord(input);
console.log('res', res);
expect(res).toBeDefined();
expect(res).toHaveProperty('id', person.id);
expect(res).toHaveProperty('id', personId);
});
expect(mocks[0].result).toHaveBeenCalled();

View File

@ -1,9 +1,11 @@
import { useApolloClient } from '@apollo/client';
import { v4 } from 'uuid';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { getCreateManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateCreateManyRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
@ -13,29 +15,33 @@ export const useCreateManyRecords = <
>({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const apolloClient = useApolloClient();
const { objectMetadataItem, createManyRecordsMutation } =
useObjectMetadataItem({
objectNameSingular,
});
const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({
objectMetadataItem,
});
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
const apolloClient = useApolloClient();
const getRelationMetadata = useGetRelationMetadata();
const createManyRecords = async (data: Partial<CreatedObjectRecord>[]) => {
const optimisticallyCreatedRecords = data.map((record) =>
generateCachedObjectRecord<CreatedObjectRecord>(record),
);
const sanitizedCreateManyRecordsInput = data.map((input, index) =>
const sanitizedCreateManyRecordsInput = data.map((input) =>
sanitizeRecordInput({
objectMetadataItem,
recordInput: { ...input, id: optimisticallyCreatedRecords[index].id },
recordInput: { ...input, id: v4() },
}),
);
const optimisticallyCreatedRecords = sanitizedCreateManyRecordsInput.map(
(record) =>
generateObjectRecordOptimisticResponse<CreatedObjectRecord>(record),
);
const mutationResponseField = getCreateManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
@ -57,6 +63,7 @@ export const useCreateManyRecords = <
cache,
objectMetadataItem,
records,
getRelationMetadata,
});
},
});

View File

@ -3,7 +3,7 @@ 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 { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const useCreateManyRecordsInCache = <T extends ObjectRecord>({
@ -13,9 +13,10 @@ export const useCreateManyRecordsInCache = <T extends ObjectRecord>({
objectNameSingular,
});
const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({
objectMetadataItem,
});
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
const addRecordInCache = useAddRecordInCache({
objectMetadataItem,
@ -30,9 +31,8 @@ export const useCreateManyRecordsInCache = <T extends ObjectRecord>({
const createdRecordsInCache = [] as T[];
for (const record of recordsWithId) {
const generatedCachedObjectRecord = generateCachedObjectRecord<T>({
...record,
});
const generatedCachedObjectRecord =
generateObjectRecordOptimisticResponse<T>(record);
if (generatedCachedObjectRecord) {
addRecordInCache(generatedCachedObjectRecord);

View File

@ -1,8 +1,10 @@
import { useApolloClient } from '@apollo/client';
import { v4 } from 'uuid';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { getCreateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
@ -16,25 +18,27 @@ export const useCreateOneRecord = <
>({
objectNameSingular,
}: useCreateOneRecordProps) => {
const apolloClient = useApolloClient();
const { objectMetadataItem, createOneRecordMutation } = useObjectMetadataItem(
{ objectNameSingular },
);
// TODO: type this with a minimal type at least with Record<string, any>
const apolloClient = useApolloClient();
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({
objectMetadataItem,
});
const getRelationMetadata = useGetRelationMetadata();
const createOneRecord = async (input: Partial<CreatedObjectRecord>) => {
const sanitizedCreateOneRecordInput = sanitizeRecordInput({
objectMetadataItem,
recordInput: input,
recordInput: { ...input, id: v4() },
});
const optimisticallyCreatedRecord =
generateCachedObjectRecord<CreatedObjectRecord>({
generateObjectRecordOptimisticResponse<CreatedObjectRecord>({
...input,
...sanitizedCreateOneRecordInput,
});
@ -45,10 +49,7 @@ export const useCreateOneRecord = <
const createdObject = await apolloClient.mutate({
mutation: createOneRecordMutation,
variables: {
input: {
...sanitizedCreateOneRecordInput,
id: optimisticallyCreatedRecord.id,
},
input: sanitizedCreateOneRecordInput,
},
optimisticResponse: {
[mutationResponseField]: optimisticallyCreatedRecord,
@ -62,6 +63,7 @@ export const useCreateOneRecord = <
cache,
objectMetadataItem,
records: [record],
getRelationMetadata,
});
},
});

View File

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

View File

@ -1,13 +1,9 @@
import { useApolloClient } from '@apollo/client';
import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
@ -19,13 +15,13 @@ type useDeleteOneRecordProps = {
export const useDeleteManyRecords = ({
objectNameSingular,
}: useDeleteOneRecordProps) => {
const apolloClient = useApolloClient();
const { objectMetadataItem, deleteManyRecordsMutation, getRecordFromCache } =
useObjectMetadataItem({ objectNameSingular });
const getRelationMetadata = useGetRelationMetadata();
const apolloClient = useApolloClient();
const mutationResponseField = getDeleteManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
@ -47,49 +43,15 @@ export const useDeleteManyRecords = ({
if (!records?.length) return;
objectMetadataItem.fields.forEach((fieldMetadataItem) => {
const relationMetadata = getRelationMetadata({ fieldMetadataItem });
if (!relationMetadata) return;
const { relationObjectMetadataItem, relationFieldMetadataItem } =
relationMetadata;
records.forEach((record) => {
const cachedRecord = getRecordFromCache(record.id, cache);
if (!cachedRecord) return;
const previousFieldValue:
| ObjectRecordConnection
| ObjectRecord
| null = cachedRecord[fieldMetadataItem.name];
const relationRecordIds = isObjectRecordConnection(
relationObjectMetadataItem.nameSingular,
previousFieldValue,
)
? previousFieldValue.edges.map(({ node }) => node.id)
: [previousFieldValue?.id].filter(isDefined);
relationRecordIds.forEach((relationRecordId) =>
triggerDetachRelationOptimisticEffect({
cache,
objectNameSingular,
recordId: record.id,
relationObjectMetadataNameSingular:
relationObjectMetadataItem.nameSingular,
relationFieldName: relationFieldMetadataItem.name,
relationRecordId,
}),
);
});
});
const cachedRecords = records
.map((record) => getRecordFromCache(record.id, cache))
.filter(isDefined);
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
records: records,
records: cachedRecords,
getRelationMetadata,
});
},
});

View File

@ -1,15 +1,10 @@
import { useCallback } from 'react';
import { useApolloClient } from '@apollo/client';
import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/generateDeleteOneRecordMutation';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
type useDeleteOneRecordProps = {
@ -20,13 +15,13 @@ type useDeleteOneRecordProps = {
export const useDeleteOneRecord = ({
objectNameSingular,
}: useDeleteOneRecordProps) => {
const apolloClient = useApolloClient();
const { objectMetadataItem, deleteOneRecordMutation, getRecordFromCache } =
useObjectMetadataItem({ objectNameSingular });
const getRelationMetadata = useGetRelationMetadata();
const apolloClient = useApolloClient();
const mutationResponseField =
getDeleteOneRecordMutationResponseField(objectNameSingular);
@ -46,47 +41,15 @@ export const useDeleteOneRecord = ({
if (!record) return;
objectMetadataItem.fields.forEach((fieldMetadataItem) => {
const relationMetadata = getRelationMetadata({ fieldMetadataItem });
const cachedRecord = getRecordFromCache(record.id, cache);
if (!relationMetadata) return;
const { relationObjectMetadataItem, relationFieldMetadataItem } =
relationMetadata;
const cachedRecord = getRecordFromCache(record.id, cache);
if (!cachedRecord) return;
const previousFieldValue:
| ObjectRecordConnection
| ObjectRecord
| null = cachedRecord[fieldMetadataItem.name];
const relationRecordIds = isObjectRecordConnection(
relationObjectMetadataItem.nameSingular,
previousFieldValue,
)
? previousFieldValue.edges.map(({ node }) => node.id)
: [previousFieldValue?.id].filter(isDefined);
relationRecordIds.forEach((relationRecordId) =>
triggerDetachRelationOptimisticEffect({
cache,
objectNameSingular,
recordId: record.id,
relationObjectMetadataNameSingular:
relationObjectMetadataItem.nameSingular,
relationFieldName: relationFieldMetadataItem.name,
relationRecordId,
}),
);
});
if (!cachedRecord) return;
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
records: [record],
records: [cachedRecord],
getRelationMetadata,
});
},
});

View File

@ -1,16 +1,12 @@
import { useApolloClient } from '@apollo/client';
import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection';
import { triggerAttachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect';
import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect';
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { capitalize } from '~/utils/string/capitalize';
type useUpdateOneRecordProps = {
objectNameSingular: string;
@ -21,12 +17,17 @@ export const useUpdateOneRecord = <
>({
objectNameSingular,
}: useUpdateOneRecordProps) => {
const apolloClient = useApolloClient();
const { objectMetadataItem, updateOneRecordMutation, getRecordFromCache } =
useObjectMetadataItem({ objectNameSingular });
const getRelationMetadata = useGetRelationMetadata();
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
const apolloClient = useApolloClient();
const getRelationMetadata = useGetRelationMetadata();
const updateOneRecord = async ({
idToUpdate,
@ -42,13 +43,11 @@ export const useUpdateOneRecord = <
recordInput: updateOneRecordInput,
});
const optimisticallyUpdatedRecord = {
const optimisticallyUpdatedRecord = generateObjectRecordOptimisticResponse({
...(cachedRecord ?? {}),
...updateOneRecordInput,
...sanitizedUpdateOneRecordInput,
__typename: capitalize(objectNameSingular),
id: idToUpdate,
};
});
const mutationResponseField =
getUpdateOneRecordMutationResponseField(objectNameSingular);
@ -65,60 +64,14 @@ export const useUpdateOneRecord = <
update: (cache, { data }) => {
const record = data?.[mutationResponseField];
if (!record) return;
objectMetadataItem.fields.forEach((fieldMetadataItem) => {
const relationMetadata = getRelationMetadata({ fieldMetadataItem });
if (!relationMetadata) return;
const { relationObjectMetadataItem, relationFieldMetadataItem } =
relationMetadata;
const previousFieldValue = cachedRecord?.[fieldMetadataItem.name];
const nextFieldValue =
updateOneRecordInput[fieldMetadataItem.name] ?? null;
if (
!(fieldMetadataItem.name in updateOneRecordInput) ||
isObjectRecordConnection(
relationObjectMetadataItem.nameSingular,
previousFieldValue,
) ||
isDeeplyEqual(previousFieldValue, nextFieldValue)
) {
return;
}
if (previousFieldValue) {
triggerDetachRelationOptimisticEffect({
cache,
objectNameSingular,
recordId: record.id,
relationObjectMetadataNameSingular:
relationObjectMetadataItem.nameSingular,
relationFieldName: relationFieldMetadataItem.name,
relationRecordId: previousFieldValue.id,
});
}
if (nextFieldValue) {
triggerAttachRelationOptimisticEffect({
cache,
objectNameSingular,
recordId: record.id,
relationObjectMetadataNameSingular:
relationObjectMetadataItem.nameSingular,
relationFieldName: relationFieldMetadataItem.name,
relationRecordId: nextFieldValue.id,
});
}
});
if (!record || !cachedRecord) return;
triggerUpdateRecordOptimisticEffect({
cache,
objectMetadataItem,
record,
previousRecord: cachedRecord,
nextRecord: record,
getRelationMetadata,
});
},
});

View File

@ -13,22 +13,22 @@ export const sanitizeRecordInput = ({
return Object.fromEntries(
Object.entries(recordInput)
.map<[string, unknown] | undefined>(([fieldName, fieldValue]) => {
const fieldDefinition = objectMetadataItem.fields.find(
const fieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.name === fieldName,
);
if (!fieldDefinition) return undefined;
if (!fieldMetadataItem) return undefined;
if (
fieldDefinition.type === FieldMetadataType.Relation &&
fieldMetadataItem.type === FieldMetadataType.Relation &&
isFieldRelationValue(fieldValue)
) {
const relationIdFieldName = `${fieldDefinition.name}Id`;
const relationIdFieldDefinition = objectMetadataItem.fields.find(
const relationIdFieldName = `${fieldMetadataItem.name}Id`;
const relationIdFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.name === relationIdFieldName,
);
return relationIdFieldDefinition
return relationIdFieldMetadataItem
? [relationIdFieldName, fieldValue?.id ?? null]
: undefined;
}

View File

@ -34,7 +34,7 @@ export const currentPipelineId = 'f088c8c9-05d2-4276-b065-b863cc7d0b33';
const data = {
color: 'yellow',
id: 'columnId',
id: mockId,
position: 1,
name: 'Column Title',
};