perf: apply record optimistic effects with cache.modify on mutation (#3540)

* perf: apply record optimistic effects with cache.modify on mutation

Closes #3509

* refactor: return early when created records do not match filter

* fix: fix id generation on record creation

* fix: comment filtering behavior on record creation

* Fixed typing error

* refactor: review - use ??

* refactor: review - add variables in readFieldValueToSort

* docs: review - add comments for variables.first in triggerUpdateRecordOptimisticEffect

* refactor: review - add intermediary variable for 'not' filter in useMultiObjectSearchMatchesSearchFilterAndToSelectQuery

* refactor: review - add filter utils

* fix: fix tests

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Thaïs
2024-01-23 14:13:00 -03:00
committed by GitHub
parent 9ebc0deaaf
commit 014f11fb6f
57 changed files with 852 additions and 1118 deletions

View File

@ -62,10 +62,9 @@ export const AttachmentRow = ({ attachment }: { attachment: Attachment }) => {
[attachment?.id],
);
const { deleteOneRecord: deleteOneAttachment } =
useDeleteOneRecord<Attachment>({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const { deleteOneRecord: deleteOneAttachment } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const handleDelete = () => {
deleteOneAttachment(attachment.id);

View File

@ -45,7 +45,7 @@ export const useUploadAttachmentFile = () => {
fullPath: attachmentUrl,
type: getFileType(file.name),
[targetableObjectFieldIdName]: targetableObject.id,
};
} as Partial<Attachment>;
await createOneAttachment(attachmentToCreate);
};

View File

@ -1,100 +0,0 @@
import { useApolloClient } from '@apollo/client';
import { MockedProvider } from '@apollo/client/testing';
import { act, renderHook, waitFor } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { optimisticEffectState } from '@/apollo/optimistic-effect/states/optimisticEffectState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<MockedProvider addTypename={false}>
<RecoilRoot>{children}</RecoilRoot>
</MockedProvider>
);
describe('useOptimisticEffect', () => {
it('should work as expected', async () => {
const { result } = renderHook(
() => {
const optimisticEffect = useRecoilValue(optimisticEffectState);
const client = useApolloClient();
const { findManyRecordsQuery } = useObjectMetadataItem({
objectNameSingular: 'person',
});
return {
...useOptimisticEffect({ objectNameSingular: 'person' }),
optimisticEffect,
cache: client.cache,
findManyRecordsQuery,
};
},
{
wrapper: Wrapper,
},
);
const {
registerOptimisticEffect,
unregisterOptimisticEffect,
triggerOptimisticEffects,
optimisticEffect,
findManyRecordsQuery,
} = result.current;
expect(registerOptimisticEffect).toBeDefined();
expect(typeof registerOptimisticEffect).toBe('function');
expect(optimisticEffect).toEqual({});
const optimisticEffectDefinition = {
variables: {},
definition: {
typename: 'Person',
resolver: () => ({
people: [],
pageInfo: {
endCursor: '',
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
},
edges: [],
}),
},
};
act(() => {
registerOptimisticEffect(optimisticEffectDefinition);
});
await waitFor(() => {
expect(result.current.optimisticEffect).toHaveProperty('Person-{}');
});
expect(
result.current.cache.readQuery({ query: findManyRecordsQuery }),
).toBeNull();
act(() => {
triggerOptimisticEffects({
typename: 'Person',
createdRecords: [{ id: 'id-0' }],
});
});
await waitFor(() => {
expect(
result.current.cache.readQuery({ query: findManyRecordsQuery }),
).toHaveProperty('people');
});
act(() => {
unregisterOptimisticEffect(optimisticEffectDefinition);
});
await waitFor(() => {
expect(result.current.optimisticEffect).not.toHaveProperty('Person-{}');
expect(result.current.optimisticEffect).toEqual({});
});
});
});

View File

@ -1,198 +0,0 @@
import { useApolloClient } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
import { computeOptimisticEffectKey } from '@/apollo/optimistic-effect/utils/computeOptimisticEffectKey';
import {
EMPTY_QUERY,
useObjectMetadataItem,
} from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
import { optimisticEffectState } from '../states/optimisticEffectState';
import {
OptimisticEffect,
OptimisticEffectWriter,
} from '../types/internal/OptimisticEffect';
import { OptimisticEffectDefinition } from '../types/OptimisticEffectDefinition';
export const useOptimisticEffect = ({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const apolloClient = useApolloClient();
const { findManyRecordsQuery, objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const unregisterOptimisticEffect = useRecoilCallback(
({ snapshot, set }) =>
({
variables,
definition,
}: {
variables: ObjectRecordQueryVariables;
definition: OptimisticEffectDefinition;
}) => {
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
.getValue();
const computedKey = computeOptimisticEffectKey({
variables,
definition,
});
const { [computedKey]: _, ...rest } = optimisticEffects;
set(optimisticEffectState, rest);
},
);
const registerOptimisticEffect = useRecoilCallback(
({ snapshot, set }) =>
({
variables,
definition,
}: {
variables: ObjectRecordQueryVariables;
definition: OptimisticEffectDefinition;
}) => {
if (findManyRecordsQuery === EMPTY_QUERY) {
throw new Error(
`Trying to register an optimistic effect for unknown object ${objectNameSingular}`,
);
}
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
.getValue();
const optimisticEffectWriter: OptimisticEffectWriter = ({
cache,
createdRecords,
updatedRecords,
deletedRecordIds,
query,
variables,
objectMetadataItem,
}) => {
if (objectMetadataItem) {
const existingData = cache.readQuery({
query: findManyRecordsQuery,
variables,
});
if (
!existingData &&
(isNonEmptyArray(updatedRecords) ||
isNonEmptyArray(deletedRecordIds))
) {
return;
}
cache.writeQuery({
query: findManyRecordsQuery,
variables,
data: {
[objectMetadataItem.namePlural]: definition.resolver({
currentCacheData: (existingData as any)?.[
objectMetadataItem.namePlural
],
updatedRecords,
createdRecords,
deletedRecordIds,
variables,
}),
},
});
return;
}
const existingData = cache.readQuery({
query: query ?? findManyRecordsQuery,
variables,
});
if (!existingData) {
return;
}
};
const computedKey = computeOptimisticEffectKey({
variables,
definition,
});
const optimisticEffect = {
variables,
typename: definition.typename,
query: definition.query,
writer: optimisticEffectWriter,
objectMetadataItem,
} satisfies OptimisticEffect;
set(optimisticEffectState, {
...optimisticEffects,
[computedKey]: optimisticEffect,
});
},
[findManyRecordsQuery, objectNameSingular, objectMetadataItem],
);
const triggerOptimisticEffects = useRecoilCallback(
({ snapshot }) =>
({
typename,
createdRecords = [],
updatedRecords = [],
deletedRecordIds,
}: {
typename: string;
createdRecords?: Record<string, unknown>[];
updatedRecords?: Record<string, unknown>[];
deletedRecordIds?: string[];
}) => {
const optimisticEffects = snapshot
.getLoadable(optimisticEffectState)
.getValue();
for (const optimisticEffect of Object.values(optimisticEffects)) {
// We need to update the typename when createObject type differs from listObject types
// It is the case for apiKey, where the creation route returns an ApiKeyToken type
const formattedCreatedRecords = createdRecords.map((createdRecord) =>
typename.endsWith('Edge')
? createdRecord
: { ...createdRecord, __typename: typename },
);
const formattedUpdatedRecords = updatedRecords.map((updatedRecord) =>
typename.endsWith('Edge')
? updatedRecord
: { ...updatedRecord, __typename: typename },
);
if (optimisticEffect.typename === typename) {
optimisticEffect.writer({
cache: apolloClient.cache,
query: optimisticEffect.query,
createdRecords: formattedCreatedRecords,
updatedRecords: formattedUpdatedRecords,
deletedRecordIds,
variables: optimisticEffect.variables,
objectMetadataItem: optimisticEffect.objectMetadataItem,
});
}
}
},
[apolloClient.cache],
);
return {
registerOptimisticEffect,
triggerOptimisticEffects,
unregisterOptimisticEffect,
};
};

View File

@ -1,8 +0,0 @@
import { atom } from 'recoil';
import { OptimisticEffect } from '../types/internal/OptimisticEffect';
export const optimisticEffectState = atom<Record<string, OptimisticEffect>>({
key: 'optimisticEffectState',
default: {},
});

View File

@ -1,12 +0,0 @@
import { DocumentNode } from 'graphql';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { OptimisticEffectResolver } from './OptimisticEffectResolver';
export type OptimisticEffectDefinition = {
query?: DocumentNode;
typename: string;
resolver: OptimisticEffectResolver;
objectMetadataItem?: ObjectMetadataItem;
};

View File

@ -1,15 +0,0 @@
import { OperationVariables } from '@apollo/client';
export type OptimisticEffectResolver = ({
currentCacheData,
createdRecords,
updatedRecords,
deletedRecordIds,
variables,
}: {
currentCacheData: any; //TODO: Change when decommissioning v1
createdRecords?: Record<string, unknown>[];
updatedRecords?: Record<string, unknown>[];
deletedRecordIds?: string[];
variables: OperationVariables;
}) => void;

View File

@ -1,30 +0,0 @@
import { ApolloCache, DocumentNode } from '@apollo/client';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
export type OptimisticEffectWriter = ({
cache,
query,
createdRecords,
updatedRecords,
deletedRecordIds,
variables,
objectMetadataItem,
}: {
cache: ApolloCache<any>;
query?: DocumentNode;
createdRecords?: Record<string, unknown>[];
updatedRecords?: Record<string, unknown>[];
deletedRecordIds?: string[];
variables: ObjectRecordQueryVariables;
objectMetadataItem: ObjectMetadataItem;
}) => void;
export type OptimisticEffect = {
query?: DocumentNode;
typename: string;
variables: ObjectRecordQueryVariables;
writer: OptimisticEffectWriter;
objectMetadataItem: ObjectMetadataItem;
};

View File

@ -1,17 +0,0 @@
import { OptimisticEffectDefinition } from '@/apollo/optimistic-effect/types/OptimisticEffectDefinition';
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
export const computeOptimisticEffectKey = ({
variables,
definition,
}: {
variables: ObjectRecordQueryVariables;
definition: OptimisticEffectDefinition;
}) => {
const computedKey =
(definition.objectMetadataItem?.namePlural ?? definition.typename) +
'-' +
JSON.stringify(variables);
return computedKey;
};

View File

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

View File

@ -0,0 +1,66 @@
import { Reference, StoreObject } from '@apollo/client';
import { ReadFieldFunction } from '@apollo/client/cache/core/types/common';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { OrderBy } from '@/object-metadata/types/OrderBy';
import { OrderByField } from '@/object-metadata/types/OrderByField';
import { isDefined } from '~/utils/isDefined';
import { sortAsc, sortDesc, sortNullsFirst, sortNullsLast } from '~/utils/sort';
export const sortCachedObjectEdges = ({
edges,
orderBy,
readCacheField,
}: {
edges: CachedObjectRecordEdge[];
orderBy: OrderByField;
readCacheField: ReadFieldFunction;
}) => {
const [orderByFieldName, orderByFieldValue] = Object.entries(orderBy)[0];
const [orderBySubFieldName, orderBySubFieldValue] =
typeof orderByFieldValue === 'string'
? []
: Object.entries(orderByFieldValue)[0];
const readFieldValueToSort = (
edge: CachedObjectRecordEdge,
): string | number | null => {
const recordFromCache = edge.node;
const fieldValue =
readCacheField<Reference | StoreObject | string | number | null>(
orderByFieldName,
recordFromCache,
) ?? null;
const isSubFieldFilter = isDefined(fieldValue) && !!orderBySubFieldName;
if (!isSubFieldFilter) return fieldValue as string | number | null;
const subFieldValue =
readCacheField<string | number | null>(
orderBySubFieldName,
fieldValue as Reference | StoreObject,
) ?? null;
return subFieldValue;
};
const orderByValue = orderBySubFieldValue || (orderByFieldValue as OrderBy);
const isAsc = orderByValue.startsWith('Asc');
const isNullsFirst = orderByValue.endsWith('NullsFirst');
return [...edges].sort((edgeA, edgeB) => {
const fieldValueA = readFieldValueToSort(edgeA);
const fieldValueB = readFieldValueToSort(edgeB);
if (fieldValueA === null || fieldValueB === null) {
return isNullsFirst
? sortNullsFirst(fieldValueA, fieldValueB)
: sortNullsLast(fieldValueA, fieldValueB);
}
return isAsc
? sortAsc(fieldValueA, fieldValueB)
: sortDesc(fieldValueA, fieldValueB);
});
};

View File

@ -0,0 +1,112 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
/*
TODO: for now new records are added to all cached record lists, no matter what the variables (filters, orderBy, etc.) are.
We need to refactor how the record creation works in the RecordTable so the created record row is temporarily displayed with a local state,
then we'll be able to uncomment the code below so the cached lists are updated coherently with the variables.
*/
export const triggerCreateRecordsOptimisticEffect = ({
cache,
objectMetadataItem,
records,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
records: CachedObjectRecord[];
}) => {
const objectEdgeTypeName = `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`;
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
cachedConnection,
{
INVALIDATE: _INVALIDATE,
readField,
storeFieldName: _storeFieldName,
toReference,
},
) => {
if (
!isCachedObjectConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
)
return cachedConnection;
/* const { variables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
); */
const cachedEdges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedConnection,
);
const nextCachedEdges = cachedEdges ? [...cachedEdges] : [];
const hasAddedRecords = records
.map((record) => {
/* const matchesFilter =
!variables?.filter ||
isRecordMatchingFilter({
record,
filter: variables.filter,
objectMetadataItem,
}); */
if (/* matchesFilter && */ record.id) {
const nodeReference = toReference(record);
if (nodeReference) {
nextCachedEdges.unshift({
__typename: objectEdgeTypeName,
node: nodeReference,
cursor: '',
});
return true;
}
}
return false;
})
.some((hasAddedRecord) => hasAddedRecord);
if (!hasAddedRecords) return cachedConnection;
/* if (variables?.orderBy) {
nextCachedEdges = sortCachedObjectEdges({
edges: nextCachedEdges,
orderBy: variables.orderBy,
readCacheField: readField,
});
}
if (isDefined(variables?.first)) {
if (
cachedEdges?.length === variables.first &&
nextCachedEdges.length < variables.first
) {
return INVALIDATE;
}
if (nextCachedEdges.length > variables.first) {
nextCachedEdges.splice(variables.first);
}
} */
return { ...cachedConnection, edges: nextCachedEdges };
},
},
});
};

View File

@ -0,0 +1,65 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
export const triggerDeleteRecordsOptimisticEffect = ({
cache,
objectMetadataItem,
records,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
records: Pick<CachedObjectRecord, 'id' | '__typename'>[];
}) => {
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
cachedConnection,
{ INVALIDATE, readField, storeFieldName },
) => {
if (
!isCachedObjectConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
)
return cachedConnection;
const { variables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
);
const recordIds = records.map(({ id }) => id);
const cachedEdges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedConnection,
);
const nextCachedEdges =
cachedEdges?.filter((cachedEdge) => {
const nodeId = readField<string>('id', cachedEdge.node);
return nodeId && !recordIds.includes(nodeId);
}) || [];
if (
isDefined(variables?.first) &&
cachedEdges?.length === variables.first &&
nextCachedEdges.length < variables.first
) {
return INVALIDATE;
}
return { ...cachedConnection, edges: nextCachedEdges };
},
},
});
records.forEach((record) => cache.evict({ id: cache.identify(record) }));
};

View File

@ -0,0 +1,110 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection';
import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
import { capitalize } from '~/utils/string/capitalize';
export const triggerUpdateRecordOptimisticEffect = ({
cache,
objectMetadataItem,
record,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
record: CachedObjectRecord;
}) => {
const objectEdgeTypeName = `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`;
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
cachedConnection,
{ INVALIDATE, readField, storeFieldName, toReference },
) => {
if (
!isCachedObjectConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
)
return cachedConnection;
const { variables } =
parseApolloStoreFieldName<CachedObjectRecordQueryVariables>(
storeFieldName,
);
const cachedEdges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedConnection,
);
let nextCachedEdges = cachedEdges ? [...cachedEdges] : [];
if (variables?.filter) {
const matchesFilter = isRecordMatchingFilter({
record,
filter: variables.filter,
objectMetadataItem,
});
const recordIndex = nextCachedEdges.findIndex(
(cachedEdge) => readField('id', cachedEdge.node) === record.id,
);
if (matchesFilter && recordIndex === -1) {
const nodeReference = toReference(record);
nodeReference &&
nextCachedEdges.push({
__typename: objectEdgeTypeName,
node: nodeReference,
cursor: '',
});
}
if (!matchesFilter && recordIndex > -1) {
nextCachedEdges.splice(recordIndex, 1);
}
}
if (variables?.orderBy) {
nextCachedEdges = sortCachedObjectEdges({
edges: nextCachedEdges,
orderBy: variables.orderBy,
readCacheField: readField,
});
}
if (isDefined(variables?.first)) {
// If previous edges length was exactly at the required limit,
// but after update next edges length is under the limit,
// we cannot for sure know if re-fetching the query
// would return more edges, so we cannot optimistically deduce
// the query's result.
// In this case, invalidate the cache entry so it can be re-fetched.
if (
cachedEdges?.length === variables.first &&
nextCachedEdges.length < variables.first
) {
return INVALIDATE;
}
// If next edges length exceeds the required limit,
// trim the next edges array to the correct length.
if (nextCachedEdges.length > variables.first) {
nextCachedEdges.splice(variables.first);
}
}
return { ...cachedConnection, edges: nextCachedEdges };
},
},
});
};

View File

@ -0,0 +1,3 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export type CachedObjectRecord = ObjectRecord & { __typename: string };

View File

@ -0,0 +1,9 @@
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
export type CachedObjectRecordConnection = Omit<
ObjectRecordConnection,
'edges'
> & {
edges: CachedObjectRecordEdge[];
};

View File

@ -0,0 +1,7 @@
import { Reference } from '@apollo/client';
import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge';
export type CachedObjectRecordEdge = Omit<ObjectRecordEdge, 'node'> & {
node: Reference;
};

View File

@ -0,0 +1,6 @@
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
export type CachedObjectRecordQueryVariables = Omit<
ObjectRecordQueryVariables,
'limit'
> & { first?: ObjectRecordQueryVariables['limit'] };

View File

@ -1,38 +0,0 @@
import { ReactNode } from 'react';
import { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { useRecordOptimisticEffect } from '@/object-metadata/hooks/useRecordOptimisticEffect';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
const mockRegisterOptimisticEffect = jest.fn();
jest.mock('@/apollo/optimistic-effect/hooks/useOptimisticEffect', () => ({
useOptimisticEffect: jest.fn(() => ({
registerOptimisticEffect: mockRegisterOptimisticEffect,
unregisterOptimisticEffect: jest.fn(),
})),
}));
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider addTypename={false}>{children}</MockedProvider>
</RecoilRoot>
);
const mockObjectMetadataItems = getObjectMetadataItemsMock();
describe('useRecordOptimisticEffect', () => {
it('should work as expected', async () => {
const objectMetadataItem = mockObjectMetadataItems.find(
(item) => item.namePlural === 'people',
)!;
renderHook(() => useRecordOptimisticEffect({ objectMetadataItem }), {
wrapper: Wrapper,
});
expect(mockRegisterOptimisticEffect).toHaveBeenCalled();
});
});

View File

@ -1,57 +0,0 @@
import { useEffect } from 'react';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { OrderByField } from '@/object-metadata/types/OrderByField';
import { getRecordOptimisticEffectDefinition } from '@/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition';
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
export const useRecordOptimisticEffect = ({
objectMetadataItem,
filter,
orderBy,
limit,
}: {
objectMetadataItem: ObjectMetadataItem;
filter?: ObjectRecordQueryFilter;
orderBy?: OrderByField;
limit?: number;
}) => {
const { registerOptimisticEffect, unregisterOptimisticEffect } =
useOptimisticEffect({
objectNameSingular: objectMetadataItem.nameSingular,
});
useEffect(() => {
registerOptimisticEffect({
definition: getRecordOptimisticEffectDefinition({
objectMetadataItem,
}),
variables: {
filter,
orderBy,
limit,
},
});
return () => {
unregisterOptimisticEffect({
definition: getRecordOptimisticEffectDefinition({
objectMetadataItem,
}),
variables: {
filter,
orderBy,
limit,
},
});
};
}, [
registerOptimisticEffect,
filter,
orderBy,
limit,
objectMetadataItem,
unregisterOptimisticEffect,
]);
};

View File

@ -1,117 +0,0 @@
import { isNonEmptyArray } from '@sniptt/guards';
import { produce } from 'immer';
import { OptimisticEffectDefinition } from '@/apollo/optimistic-effect/types/OptimisticEffectDefinition';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
export const getRecordOptimisticEffectDefinition = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}): OptimisticEffectDefinition => ({
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
resolver: ({
currentCacheData: currentData,
createdRecords,
updatedRecords,
deletedRecordIds,
variables,
}) => {
const newRecordPaginatedCacheField = produce<ObjectRecordConnection<any>>(
currentData as ObjectRecordConnection<any>,
(draft) => {
const existingDataIsEmpty = !draft || !draft.edges || !draft.edges[0];
if (isNonEmptyArray(createdRecords)) {
if (existingDataIsEmpty) {
return {
__typename: `${capitalize(
objectMetadataItem.nameSingular,
)}Connection`,
edges: createdRecords.map((createdRecord) => ({
__typename: `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`,
node: createdRecord,
cursor: '',
})),
pageInfo: {
endCursor: '',
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
},
};
} else {
for (const createdRecord of createdRecords) {
const existingRecord = draft.edges.find(
(edge) => edge.node.id === createdRecord.id,
);
if (existingRecord) {
existingRecord.node = createdRecord;
continue;
}
draft.edges.unshift({
node: createdRecord,
cursor: '',
__typename: `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`,
});
}
}
}
if (isNonEmptyArray(deletedRecordIds)) {
draft.edges = draft.edges.filter(
(edge) => !deletedRecordIds.includes(edge.node.id),
);
}
if (isNonEmptyArray(updatedRecords)) {
for (const updatedRecord of updatedRecords) {
const updatedRecordIsOutOfQueryFilter =
isDefined(variables.filter) &&
!isRecordMatchingFilter({
record: updatedRecord,
filter: variables.filter,
objectMetadataItem,
});
if (updatedRecordIsOutOfQueryFilter) {
draft.edges = draft.edges.filter(
(edge) => edge.node.id !== updatedRecord.id,
);
} else {
const foundUpdatedRecordInCacheQuery = draft.edges.find(
(edge) => edge.node.id === updatedRecord.id,
);
if (foundUpdatedRecordInCacheQuery) {
foundUpdatedRecordInCacheQuery.node = updatedRecord;
} else {
// TODO: add order by
draft.edges.push({
node: updatedRecord,
cursor: '',
__typename: `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`,
});
}
}
}
}
},
);
return newRecordPaginatedCacheField;
},
objectMetadataItem,
});

View File

@ -1,77 +1,67 @@
import { useApolloClient } from '@apollo/client';
import { v4 } from 'uuid';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useGenerateEmptyRecord } from '@/object-record/hooks/useGenerateEmptyRecord';
import { useGenerateCachedObjectRecord } from '@/object-record/hooks/useGenerateCachedObjectRecord';
import { getCreateManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateCreateManyRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from '~/utils/string/capitalize';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
export const useCreateManyRecords = <T extends ObjectRecord>({
export const useCreateManyRecords = <
CreatedObjectRecord extends ObjectRecord = ObjectRecord,
>({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const { triggerOptimisticEffects } = useOptimisticEffect({
objectNameSingular,
});
const { objectMetadataItem, createManyRecordsMutation } =
useObjectMetadataItem({
objectNameSingular,
});
const { generateEmptyRecord } = useGenerateEmptyRecord({
const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({
objectMetadataItem,
});
const apolloClient = useApolloClient();
const createManyRecords = async (data: Partial<T>[]) => {
const withIds = data.map((record) => ({
...record,
id: (record.id as string) ?? v4(),
}));
const createManyRecords = async (data: Partial<CreatedObjectRecord>[]) => {
const optimisticallyCreatedRecords = data.map((record) =>
generateCachedObjectRecord<CreatedObjectRecord>(record),
);
withIds.forEach((record) => {
const emptyRecord: T | undefined = generateEmptyRecord({
id: record.id,
} as T);
const sanitizedCreateManyRecordsInput = data.map((input, index) =>
sanitizeRecordInput({
objectMetadataItem,
recordInput: { ...input, id: optimisticallyCreatedRecords[index].id },
}),
);
if (emptyRecord) {
triggerOptimisticEffects({
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
createdRecords: [emptyRecord],
});
}
});
const mutationResponseField = getCreateManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const createdObjects = await apolloClient.mutate({
mutation: createManyRecordsMutation,
variables: {
data: withIds,
data: sanitizedCreateManyRecordsInput,
},
optimisticResponse: {
[`create${capitalize(objectMetadataItem.namePlural)}`]: withIds.map(
(record) => generateEmptyRecord({ id: record.id }),
),
[mutationResponseField]: optimisticallyCreatedRecords,
},
update: (cache, { data }) => {
const records = data?.[mutationResponseField];
if (!records?.length) return;
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
records,
});
},
});
if (!createdObjects.data) {
return null;
}
const createdRecords =
createdObjects.data[
`create${capitalize(objectMetadataItem.namePlural)}`
] ?? [];
triggerOptimisticEffects({
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
createdRecords,
});
return createdRecords as T[];
return createdObjects.data?.[mutationResponseField] ?? [];
};
return { createManyRecords };

View File

@ -1,73 +1,66 @@
import { useApolloClient } from '@apollo/client';
import { v4 } from 'uuid';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useGenerateEmptyRecord } from '@/object-record/hooks/useGenerateEmptyRecord';
import { useGenerateCachedObjectRecord } from '@/object-record/hooks/useGenerateCachedObjectRecord';
import { getCreateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { capitalize } from '~/utils/string/capitalize';
type useCreateOneRecordProps = {
objectNameSingular: string;
};
export const useCreateOneRecord = <T>({
export const useCreateOneRecord = <
CreatedObjectRecord extends ObjectRecord = ObjectRecord,
>({
objectNameSingular,
}: useCreateOneRecordProps) => {
const { triggerOptimisticEffects } = useOptimisticEffect({
objectNameSingular,
});
const { objectMetadataItem, createOneRecordMutation } = useObjectMetadataItem(
{
objectNameSingular,
},
{ objectNameSingular },
);
// TODO: type this with a minimal type at least with Record<string, any>
const apolloClient = useApolloClient();
const { generateEmptyRecord } = useGenerateEmptyRecord({
const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({
objectMetadataItem,
});
const createOneRecord = async (input: Record<string, any>) => {
const recordId = v4();
const createOneRecord = async (input: Partial<CreatedObjectRecord>) => {
const optimisticallyCreatedRecord =
generateCachedObjectRecord<CreatedObjectRecord>(input);
const generatedEmptyRecord = generateEmptyRecord({
id: recordId,
createdAt: new Date().toISOString(),
...input,
});
const sanitizedUpdateOneRecordInput = sanitizeRecordInput({
const sanitizedCreateOneRecordInput = sanitizeRecordInput({
objectMetadataItem,
recordInput: input,
recordInput: { ...input, id: optimisticallyCreatedRecord.id },
});
triggerOptimisticEffects({
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
createdRecords: [generatedEmptyRecord],
});
const mutationResponseField =
getCreateOneRecordMutationResponseField(objectNameSingular);
const createdObject = await apolloClient.mutate({
mutation: createOneRecordMutation,
variables: {
input: { id: recordId, ...sanitizedUpdateOneRecordInput },
input: sanitizedCreateOneRecordInput,
},
optimisticResponse: {
[`create${capitalize(objectMetadataItem.nameSingular)}`]:
generatedEmptyRecord,
[mutationResponseField]: optimisticallyCreatedRecord,
},
update: (cache, { data }) => {
const record = data?.[mutationResponseField];
if (!record) return;
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
records: [record],
});
},
});
if (!createdObject.data) {
return null;
}
return createdObject.data[
`create${capitalize(objectMetadataItem.nameSingular)}`
] as T;
return createdObject.data?.[mutationResponseField] ?? null;
};
return {

View File

@ -1,9 +1,8 @@
import { useApolloClient } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { useOptimisticEvict } from '@/apollo/optimistic-effect/hooks/useOptimisticEvict';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation';
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { capitalize } from '~/utils/string/capitalize';
@ -12,39 +11,19 @@ type useDeleteOneRecordProps = {
refetchFindManyQuery?: boolean;
};
export const useDeleteManyRecords = <T>({
export const useDeleteManyRecords = ({
objectNameSingular,
refetchFindManyQuery = false,
}: useDeleteOneRecordProps) => {
const { performOptimisticEvict } = useOptimisticEvict();
const { triggerOptimisticEffects } = useOptimisticEffect({
objectNameSingular,
});
const {
objectMetadataItem,
deleteManyRecordsMutation,
findManyRecordsQuery,
} = useObjectMetadataItem({
objectNameSingular,
});
const { objectMetadataItem, deleteManyRecordsMutation } =
useObjectMetadataItem({ objectNameSingular });
const apolloClient = useApolloClient();
const mutationResponseField = getDeleteManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const deleteManyRecords = async (idsToDelete: string[]) => {
triggerOptimisticEffects({
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
deletedRecordIds: idsToDelete,
});
idsToDelete.forEach((idToDelete) => {
performOptimisticEvict(
capitalize(objectMetadataItem.nameSingular),
'id',
idToDelete,
);
});
const deleteRecordFilter: ObjectRecordQueryFilter = {
id: {
in: idsToDelete,
@ -56,14 +35,26 @@ export const useDeleteManyRecords = <T>({
filter: deleteRecordFilter,
// atMost: idsToDelete.length,
},
refetchQueries: refetchFindManyQuery
? [getOperationName(findManyRecordsQuery) ?? '']
: [],
optimisticResponse: {
[mutationResponseField]: idsToDelete.map((idToDelete) => ({
__typename: capitalize(objectNameSingular),
id: idToDelete,
})),
},
update: (cache, { data }) => {
const records = data?.[mutationResponseField];
if (!records?.length) return;
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
records,
});
},
});
return deletedRecords.data[
`delete${capitalize(objectMetadataItem.namePlural)}`
] as T;
return deletedRecords.data?.[mutationResponseField] ?? null;
};
return { deleteManyRecords };

View File

@ -1,10 +1,9 @@
import { useCallback } from 'react';
import { useApolloClient } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { useOptimisticEvict } from '@/apollo/optimistic-effect/hooks/useOptimisticEvict';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/generateDeleteOneRecordMutation';
import { capitalize } from '~/utils/string/capitalize';
type useDeleteOneRecordProps = {
@ -12,57 +11,50 @@ type useDeleteOneRecordProps = {
refetchFindManyQuery?: boolean;
};
export const useDeleteOneRecord = <T>({
export const useDeleteOneRecord = ({
objectNameSingular,
refetchFindManyQuery = false,
}: useDeleteOneRecordProps) => {
const { performOptimisticEvict } = useOptimisticEvict();
const { triggerOptimisticEffects } = useOptimisticEffect({
objectNameSingular,
});
const { objectMetadataItem, deleteOneRecordMutation, findManyRecordsQuery } =
useObjectMetadataItem({
objectNameSingular,
});
const { objectMetadataItem, deleteOneRecordMutation } = useObjectMetadataItem(
{ objectNameSingular },
);
const apolloClient = useApolloClient();
const mutationResponseField =
getDeleteOneRecordMutationResponseField(objectNameSingular);
const deleteOneRecord = useCallback(
async (idToDelete: string) => {
triggerOptimisticEffects({
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
deletedRecordIds: [idToDelete],
});
performOptimisticEvict(
capitalize(objectMetadataItem.nameSingular),
'id',
idToDelete,
);
const deletedRecord = await apolloClient.mutate({
mutation: deleteOneRecordMutation,
variables: {
idToDelete,
variables: { idToDelete },
optimisticResponse: {
[mutationResponseField]: {
__typename: capitalize(objectNameSingular),
id: idToDelete,
},
},
update: (cache, { data }) => {
const record = data?.[mutationResponseField];
if (!record) return;
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
records: [record],
});
},
refetchQueries: refetchFindManyQuery
? [getOperationName(findManyRecordsQuery) ?? '']
: [],
});
return deletedRecord.data[
`delete${capitalize(objectMetadataItem.nameSingular)}`
] as T;
return deletedRecord.data?.[mutationResponseField] ?? null;
},
[
triggerOptimisticEffects,
objectMetadataItem.nameSingular,
performOptimisticEvict,
apolloClient,
deleteOneRecordMutation,
refetchFindManyQuery,
findManyRecordsQuery,
mutationResponseField,
objectMetadataItem,
objectNameSingular,
],
);

View File

@ -6,14 +6,12 @@ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useRecordOptimisticEffect } from '@/object-metadata/hooks/useRecordOptimisticEffect';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { OrderByField } from '@/object-metadata/types/OrderByField';
import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords';
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
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';
@ -34,14 +32,12 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
onCompleted,
skip,
useRecordsWithoutConnection = false,
}: ObjectMetadataItemIdentifier & {
filter?: ObjectRecordQueryFilter;
orderBy?: OrderByField;
limit?: number;
onCompleted?: (data: ObjectRecordConnection<T>) => void;
skip?: boolean;
useRecordsWithoutConnection?: boolean;
}) => {
}: ObjectMetadataItemIdentifier &
ObjectRecordQueryVariables & {
onCompleted?: (data: ObjectRecordConnection<T>) => void;
skip?: boolean;
useRecordsWithoutConnection?: boolean;
}) => {
const findManyQueryStateIdentifier =
objectNameSingular +
JSON.stringify(filter) +
@ -64,13 +60,6 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
});
useRecordOptimisticEffect({
objectMetadataItem,
filter,
orderBy,
limit,
});
const { enqueueSnackBar } = useSnackBar();
const currentWorkspace = useRecoilValue(currentWorkspaceState);

View File

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

@ -5,6 +5,10 @@ import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
export const getCreateManyRecordsMutationResponseField = (
objectNamePlural: string,
) => `create${capitalize(objectNamePlural)}`;
export const useGenerateCreateManyRecordMutation = ({
objectMetadataItem,
}: {
@ -16,11 +20,15 @@ export const useGenerateCreateManyRecordMutation = ({
return EMPTY_MUTATION;
}
const mutationResponseField = getCreateManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
return gql`
mutation Create${capitalize(
objectMetadataItem.namePlural,
)}($data: [${capitalize(objectMetadataItem.nameSingular)}CreateInput!]!) {
create${capitalize(objectMetadataItem.namePlural)}(data: $data) {
${mutationResponseField}(data: $data) {
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field))

View File

@ -5,6 +5,10 @@ import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
export const getCreateOneRecordMutationResponseField = (
objectNameSingular: string,
) => `create${capitalize(objectNameSingular)}`;
export const useGenerateCreateOneRecordMutation = ({
objectMetadataItem,
}: {
@ -18,9 +22,13 @@ export const useGenerateCreateOneRecordMutation = ({
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
const mutationResponseField = getCreateOneRecordMutationResponseField(
objectMetadataItem.nameSingular,
);
return gql`
mutation CreateOne${capitalizedObjectName}($input: ${capitalizedObjectName}CreateInput!) {
create${capitalizedObjectName}(data: $input) {
${mutationResponseField}(data: $input) {
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field))

View File

@ -4,6 +4,10 @@ import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
export const getDeleteManyRecordsMutationResponseField = (
objectNamePlural: string,
) => `delete${capitalize(objectNamePlural)}`;
export const useGenerateDeleteManyRecordMutation = ({
objectMetadataItem,
}: {
@ -15,11 +19,15 @@ export const useGenerateDeleteManyRecordMutation = ({
const capitalizedObjectName = capitalize(objectMetadataItem.namePlural);
const mutationResponseField = getDeleteManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
return gql`
mutation DeleteMany${capitalizedObjectName}($filter: ${capitalize(
objectMetadataItem.nameSingular,
)}FilterInput!) {
delete${capitalizedObjectName}(filter: $filter) {
${mutationResponseField}(filter: $filter) {
id
}
}

View File

@ -1,29 +0,0 @@
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
export const useGenerateEmptyRecord = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
// Todo fix typing once we generate the return base on Metadata
const generateEmptyRecord = <T extends ObjectRecord>(input: T) => {
// Todo replace this by runtime typing
const validatedInput = input as T;
const emptyRecord = {} as any;
for (const fieldMetadataItem of objectMetadataItem.fields) {
emptyRecord[fieldMetadataItem.name] =
validatedInput[fieldMetadataItem.name] ??
generateEmptyFieldValue(fieldMetadataItem);
}
return emptyRecord as T;
};
return {
generateEmptyRecord,
};
};

View File

@ -5,13 +5,9 @@ import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
export const getUpdateOneRecordMutationGraphQLField = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
return `update${capitalize(objectNameSingular)}`;
};
export const getUpdateOneRecordMutationResponseField = (
objectNameSingular: string,
) => `update${capitalize(objectNameSingular)}`;
export const useGenerateUpdateOneRecordMutation = ({
objectMetadataItem,
@ -26,14 +22,13 @@ export const useGenerateUpdateOneRecordMutation = ({
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
const graphQLFieldForUpdateOneRecordMutation =
getUpdateOneRecordMutationGraphQLField({
objectNameSingular: objectMetadataItem.nameSingular,
});
const mutationResponseField = getUpdateOneRecordMutationResponseField(
objectMetadataItem.nameSingular,
);
return gql`
mutation UpdateOne${capitalizedObjectName}($idToUpdate: ID!, $input: ${capitalizedObjectName}UpdateInput!) {
${graphQLFieldForUpdateOneRecordMutation}(id: $idToUpdate, data: $input) {
${mutationResponseField}(id: $idToUpdate, data: $input) {
id
${objectMetadataItem.fields
.map((field) => mapFieldMetadataToGraphQLQuery(field))

View File

@ -1,8 +1,8 @@
import { gql, useApolloClient } from '@apollo/client';
import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery';
import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from '~/utils/string/capitalize';
export const useGetRecordFromCache = ({
@ -13,9 +13,11 @@ export const useGetRecordFromCache = ({
const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery();
const apolloClient = useApolloClient();
return (recordId: string) => {
return <CachedObjectRecord extends ObjectRecord = ObjectRecord>(
recordId: string,
) => {
if (!objectMetadataItem) {
return EMPTY_MUTATION;
return null;
}
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
@ -35,7 +37,7 @@ export const useGetRecordFromCache = ({
id: recordId,
});
return cache.readFragment({
return cache.readFragment<CachedObjectRecord>({
id: cachedRecordId,
fragment: cacheReadFragment,
});

View File

@ -1,8 +1,8 @@
import { useApolloClient } from '@apollo/client';
import { Modifier, Reference } from '@apollo/client/cache';
import { Modifiers } from '@apollo/client/cache';
import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from '~/utils/string/capitalize';
export const useModifyRecordFromCache = ({
@ -10,23 +10,20 @@ export const useModifyRecordFromCache = ({
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const apolloClient = useApolloClient();
const { cache } = useApolloClient();
return (
return <CachedObjectRecord extends ObjectRecord>(
recordId: string,
fieldModifiers: Record<string, Modifier<Reference>>,
fieldModifiers: Modifiers<CachedObjectRecord>,
) => {
if (!objectMetadataItem) {
return EMPTY_MUTATION;
}
if (!objectMetadataItem) return;
const cache = apolloClient.cache;
const cachedRecordId = cache.identify({
__typename: capitalize(objectMetadataItem.nameSingular),
id: recordId,
});
cache.modify<Record<string, Reference>>({
cache.modify<CachedObjectRecord>({
id: cachedRecordId,
fields: fieldModifiers,
});

View File

@ -55,7 +55,7 @@ export const useObjectRecordBoard = () => {
useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.PipelineStep,
filter: {},
filter,
onCompleted: useCallback(
(data: ObjectRecordConnection<PipelineStep>) => {
setSavedPipelineSteps(data.edges.map((edge) => edge.node));

View File

@ -1,27 +1,23 @@
import { Reference, useApolloClient } from '@apollo/client';
import { useApolloClient } from '@apollo/client';
import { useOptimisticEffect } from '@/apollo/optimistic-effect/hooks/useOptimisticEffect';
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
import { capitalize } from '~/utils/string/capitalize';
type useUpdateOneRecordProps = {
objectNameSingular: string;
};
export const useUpdateOneRecord = <T>({
export const useUpdateOneRecord = <
UpdatedObjectRecord extends ObjectRecord = ObjectRecord,
>({
objectNameSingular,
}: useUpdateOneRecordProps) => {
const { objectMetadataItem, updateOneRecordMutation, getRecordFromCache } =
useObjectMetadataItem({
objectNameSingular,
});
const { triggerOptimisticEffects } = useOptimisticEffect({
objectNameSingular,
});
useObjectMetadataItem({ objectNameSingular });
const apolloClient = useApolloClient();
@ -30,13 +26,15 @@ export const useUpdateOneRecord = <T>({
updateOneRecordInput,
}: {
idToUpdate: string;
updateOneRecordInput: Record<string, unknown>;
updateOneRecordInput: Partial<Omit<UpdatedObjectRecord, 'id'>>;
}) => {
const cachedRecord = getRecordFromCache(idToUpdate);
const cachedRecord = getRecordFromCache<UpdatedObjectRecord>(idToUpdate);
const optimisticallyUpdatedRecord: Record<string, any> = {
const optimisticallyUpdatedRecord = {
...(cachedRecord ?? {}),
...updateOneRecordInput,
__typename: capitalize(objectNameSingular),
id: idToUpdate,
};
const sanitizedUpdateOneRecordInput = sanitizeRecordInput({
@ -44,82 +42,32 @@ export const useUpdateOneRecord = <T>({
recordInput: updateOneRecordInput,
});
triggerOptimisticEffects({
typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`,
updatedRecords: [optimisticallyUpdatedRecord],
});
const mutationResponseField =
getUpdateOneRecordMutationResponseField(objectNameSingular);
const updatedRecord = await apolloClient.mutate({
mutation: updateOneRecordMutation,
variables: {
idToUpdate,
input: {
...sanitizedUpdateOneRecordInput,
},
input: sanitizedUpdateOneRecordInput,
},
optimisticResponse: {
[`update${capitalize(objectMetadataItem.nameSingular)}`]:
optimisticallyUpdatedRecord,
[mutationResponseField]: optimisticallyUpdatedRecord,
},
update: (cache, { data }) => {
const response =
data?.[`update${capitalize(objectMetadataItem.nameSingular)}`];
const record = data?.[mutationResponseField];
if (!response) return;
if (!record) return;
cache.modify<Record<string, Reference>>({
fields: {
[objectMetadataItem.namePlural]: (
existingConnectionRef,
{ readField, storeFieldName },
) => {
if (
readField('__typename', existingConnectionRef) !==
`${capitalize(objectMetadataItem.nameSingular)}Connection`
)
return existingConnectionRef;
const { variables } = parseApolloStoreFieldName(storeFieldName);
const edges = readField<{ node: Reference }[]>(
'edges',
existingConnectionRef,
);
if (
variables?.filter &&
!isRecordMatchingFilter({
record: response,
filter: variables.filter,
objectMetadataItem,
}) &&
edges?.length
) {
return {
...existingConnectionRef,
edges: edges.filter(
(edge) =>
readField('id', readField('node', edge)) !== response.id,
),
};
}
return existingConnectionRef;
},
},
triggerUpdateRecordOptimisticEffect({
cache,
objectMetadataItem,
record,
});
},
});
if (!updatedRecord?.data) {
return null;
}
const updatedData = updatedRecord.data[
`update${capitalize(objectMetadataItem.nameSingular)}`
] as T;
return updatedData;
return updatedRecord?.data?.[mutationResponseField] ?? null;
};
return {

View File

@ -4,7 +4,6 @@ import { useRecoilCallback } from 'recoil';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useRecordBoardScopedStates } from '@/object-record/record-board/hooks/internal/useRecordBoardScopedStates';
import { Opportunity } from '@/pipeline/types/Opportunity';
import { useRemoveRecordBoardCardIdsInternal } from './useRemoveRecordBoardCardIdsInternal';
@ -12,10 +11,9 @@ export const useDeleteSelectedRecordBoardCardsInternal = () => {
const removeCardIds = useRemoveRecordBoardCardIdsInternal();
const apolloClient = useApolloClient();
const { deleteManyRecords: deleteManyOpportunities } =
useDeleteManyRecords<Opportunity>({
objectNameSingular: CoreObjectNameSingular.Opportunity,
});
const { deleteManyRecords: deleteManyOpportunities } = useDeleteManyRecords({
objectNameSingular: CoreObjectNameSingular.Opportunity,
});
const { selectedCardIdsSelector } = useRecordBoardScopedStates();

View File

@ -10,6 +10,7 @@ import {
URLFilter,
UUIDFilter,
} from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { andFilterVariables } from '@/object-record/utils/andFilterVariables';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { Field } from '~/generated/graphql';
@ -24,7 +25,7 @@ export type ObjectDropdownFilter = Omit<Filter, 'definition'> & {
export const turnObjectDropdownFilterIntoQueryFilter = (
rawUIFilters: ObjectDropdownFilter[],
fields: Pick<Field, 'id' | 'name'>[],
): ObjectRecordQueryFilter => {
): ObjectRecordQueryFilter | undefined => {
const objectRecordFilters: ObjectRecordQueryFilter[] = [];
for (const rawUIFilter of rawUIFilters) {
@ -134,13 +135,15 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
});
break;
case ViewFilterOperand.IsNot:
objectRecordFilters.push({
not: {
[correspondingField.name + 'Id']: {
in: parsedRecordIds,
} as UUIDFilter,
},
});
if (parsedRecordIds.length) {
objectRecordFilters.push({
not: {
[correspondingField.name + 'Id']: {
in: parsedRecordIds,
} as UUIDFilter,
},
});
}
break;
default:
throw new Error(
@ -257,5 +260,5 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
}
}
return { and: objectRecordFilters };
return andFilterVariables(objectRecordFilters);
};

View File

@ -1,10 +1,10 @@
import { useContext, useEffect } from 'react';
import { Reference } from '@apollo/client';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { LightIconButton, MenuItem } from 'tsup.ui.index';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { FieldDisplay } from '@/object-record/field/components/FieldDisplay';
@ -13,7 +13,6 @@ import { usePersistField } from '@/object-record/field/hooks/usePersistField';
import { entityFieldsFamilyState } from '@/object-record/field/states/entityFieldsFamilyState';
import { FieldRelationMetadata } from '@/object-record/field/types/FieldMetadata';
import { useFieldContext } from '@/object-record/hooks/useFieldContext';
import { useModifyRecordFromCache } from '@/object-record/hooks/useModifyRecordFromCache';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { IconDotsVertical, IconUnlink } from '@/ui/display/icon';
@ -71,14 +70,10 @@ export const RecordRelationFieldCardContent = ({
objectMetadataNameSingular,
} = fieldDefinition.metadata as FieldRelationMetadata;
const { objectMetadataItem } = useObjectMetadataItem({
const { modifyRecordFromCache } = useObjectMetadataItem({
objectNameSingular: objectMetadataNameSingular ?? '',
});
const modifyRecordFromCache = useModifyRecordFromCache({
objectMetadataItem,
});
const isToOneObject = relationType === 'TO_ONE_OBJECT';
const {
labelIdentifierFieldMetadata: relationLabelIdentifierFieldMetadata,
@ -104,13 +99,13 @@ export const RecordRelationFieldCardContent = ({
const { closeDropdown, isDropdownOpen } = useDropdown(dropdownScopeId);
// TODO: temporary as ChipDisplay expect to find the entity in the entityFieldsFamilyState
const setEntityFields = useSetRecoilState(
const setRelationEntityFields = useSetRecoilState(
entityFieldsFamilyState(relationRecord.id),
);
useEffect(() => {
setEntityFields(relationRecord);
}, [relationRecord, setEntityFields]);
setRelationEntityFields(relationRecord);
}, [relationRecord, setRelationEntityFields]);
if (!FieldContextProvider) return null;
@ -137,15 +132,18 @@ export const RecordRelationFieldCardContent = ({
});
modifyRecordFromCache(entityId, {
[fieldName]: (relationRef, { readField }) => {
const edges = readField<{ node: Reference }[]>('edges', relationRef);
[fieldName]: (cachedRelationConnection, { readField }) => {
const edges = readField<CachedObjectRecordEdge[]>(
'edges',
cachedRelationConnection,
);
if (!edges) {
return relationRef;
return cachedRelationConnection;
}
return {
...relationRef,
...cachedRelationConnection,
edges: edges.filter(({ node }) => {
const id = readField('id', node);
return id !== relationRecord.id;

View File

@ -139,7 +139,6 @@ export const RecordRelationFieldCardSection = () => {
],
orderByField: 'createdAt',
selectedIds: relationRecordIds,
excludeEntityIds: relationRecordIds,
objectNameSingular: relationObjectMetadataNameSingular,
});

View File

@ -42,19 +42,6 @@ const response = {
};
const mocks = [
{
request: {
query,
variables: {
filterNameSingular: { and: [{}, { id: { in: ['1'] } }] },
orderByNameSingular: { createdAt: 'DescNullsLast' },
limitNameSingular: 60,
},
},
result: jest.fn(() => ({
data: response,
})),
},
{
request: {
query,
@ -72,8 +59,20 @@ const mocks = [
request: {
query,
variables: {
orderByNameSingular: { createdAt: 'DescNullsLast' },
limitNameSingular: 60,
filterNameSingular: { and: [{}, { not: { id: { in: ['1'] } } }] },
},
},
result: jest.fn(() => ({
data: response,
})),
},
{
request: {
query,
variables: {
limitNameSingular: 60,
filterNameSingular: { not: { id: { in: ['1'] } } },
orderByNameSingular: { createdAt: 'DescNullsLast' },
},
},

View File

@ -53,23 +53,9 @@ export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({
if (!isNonEmptyArray(selectedIds)) return null;
const searchFilter =
searchFilterPerMetadataItemNameSingular[nameSingular] ?? {};
return [
`filter${capitalize(nameSingular)}`,
{
and: [
{
...searchFilter,
},
{
id: {
in: selectedIds,
},
},
],
},
searchFilterPerMetadataItemNameSingular[nameSingular],
];
})
.filter(isDefined),

View File

@ -1,5 +1,4 @@
import { useQuery } from '@apollo/client';
import { isNonEmptyArray } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { EMPTY_QUERY } from '@/object-metadata/hooks/useObjectMetadataItem';
@ -14,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 { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { andFilterVariables } from '@/object-record/utils/andFilterVariables';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
@ -58,35 +57,19 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({
)
.map(({ id }) => id);
const searchFilter =
searchFilterPerMetadataItemNameSingular[nameSingular] ?? {};
const excludedIdsUnion = [...selectedIds, ...excludedIds];
const excludedIdsFilter = excludedIdsUnion.length
? { not: { id: { in: excludedIdsUnion } } }
: undefined;
const noFilter =
!isNonEmptyArray(excludedIdsUnion) &&
isDeeplyEqual(searchFilter, {});
const searchFilters = [
searchFilterPerMetadataItemNameSingular[nameSingular],
excludedIdsFilter,
];
return [
`filter${capitalize(nameSingular)}`,
!noFilter
? {
and: [
{
...searchFilter,
},
isNonEmptyArray(excludedIdsUnion)
? {
not: {
id: {
in: [...selectedIds, ...excludedIds],
},
},
}
: {},
],
}
: {},
andFilterVariables(searchFilters),
];
})
.filter(isDefined),

View File

@ -5,7 +5,8 @@ import { OrderBy } from '@/object-metadata/types/OrderBy';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SelectableRecord } from '@/object-record/select/types/SelectableRecord';
import { getObjectFilterFields } from '@/object-record/select/utils/getObjectFilterFields';
import { isDefined } from '~/utils/isDefined';
import { andFilterVariables } from '@/object-record/utils/andFilterVariables';
import { orFilterVariables } from '@/object-record/utils/orFilterVariables';
export const DEFAULT_SEARCH_REQUEST_LIMIT = 60;
@ -37,85 +38,62 @@ export const useRecordsForSelect = ({
];
const orderByField = getObjectOrderByField(sortOrder);
const selectedIdsFilter = { id: { in: selectedIds } };
const { loading: selectedRecordsLoading, records: selectedRecordsData } =
useFindManyRecords({
filter: {
id: {
in: selectedIds,
},
},
filter: selectedIdsFilter,
orderBy: orderByField,
objectNameSingular,
skip: !selectedIds.length,
});
const searchFilter = filters
.map(({ fieldNames, filter }) => {
if (!isNonEmptyString(filter)) {
return undefined;
}
const searchFilters = filters.map(({ fieldNames, filter }) => {
if (!isNonEmptyString(filter)) {
return undefined;
}
return {
or: fieldNames.map((fieldName) => {
const fieldNameParts = fieldName.split('.');
return orFilterVariables(
fieldNames.map((fieldName) => {
const [parentFieldName, subFieldName] = fieldName.split('.');
if (fieldNameParts.length > 1) {
// Composite field
return {
[fieldNameParts[0]]: {
[fieldNameParts[1]]: {
ilike: `%${filter}%`,
},
},
};
}
if (subFieldName) {
// Composite field
return {
[fieldName]: {
ilike: `%${filter}%`,
[parentFieldName]: {
[subFieldName]: {
ilike: `%${filter}%`,
},
},
};
}),
};
})
.filter(isDefined);
}
return {
[fieldName]: {
ilike: `%${filter}%`,
},
};
}),
);
});
const {
loading: filteredSelectedRecordsLoading,
records: filteredSelectedRecordsData,
} = useFindManyRecords({
filter: {
and: [
{
and: searchFilter,
},
{
id: {
in: selectedIds,
},
},
],
},
filter: andFilterVariables([...searchFilters, selectedIdsFilter]),
orderBy: orderByField,
objectNameSingular,
skip: !selectedIds.length,
});
const notFilterIds = [...selectedIds, ...excludeEntityIds];
const notFilter = notFilterIds.length
? { not: { id: { in: notFilterIds } } }
: undefined;
const { loading: recordsToSelectLoading, records: recordsToSelectData } =
useFindManyRecords({
filter: {
and: [
{
and: searchFilter,
},
{
not: {
id: {
in: [...selectedIds, ...excludeEntityIds],
},
},
},
],
},
filter: andFilterVariables([...searchFilters, notFilter]),
limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT,
orderBy: orderByField,
objectNameSingular,

View File

@ -1,6 +1,6 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export type ObjectRecordEdge<T extends ObjectRecord> = {
export type ObjectRecordEdge<T extends ObjectRecord = ObjectRecord> = {
__typename?: string;
node: T;
cursor: string;

View File

@ -0,0 +1,14 @@
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { isDefined } from '~/utils/isDefined';
export const andFilterVariables = (
filters: (ObjectRecordQueryFilter | undefined)[],
): ObjectRecordQueryFilter | undefined => {
const definedFilters = filters.filter(isDefined);
if (!definedFilters.length) return undefined;
return definedFilters.length === 1
? definedFilters[0]
: { and: definedFilters };
};

View File

@ -4,6 +4,10 @@ import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
export const getDeleteOneRecordMutationResponseField = (
objectNameSingular: string,
) => `delete${capitalize(objectNameSingular)}`;
export const generateDeleteOneRecordMutation = ({
objectMetadataItem,
}: {
@ -15,9 +19,13 @@ export const generateDeleteOneRecordMutation = ({
const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
const mutationResponseField = getDeleteOneRecordMutationResponseField(
objectMetadataItem.nameSingular,
);
return gql`
mutation DeleteOne${capitalizedObjectName}($idToDelete: ID!) {
delete${capitalizedObjectName}(id: $idToDelete) {
${mutationResponseField}(id: $idToDelete) {
id
}
}

View File

@ -0,0 +1,14 @@
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { isDefined } from '~/utils/isDefined';
export const orFilterVariables = (
filters: (ObjectRecordQueryFilter | undefined)[],
): ObjectRecordQueryFilter | undefined => {
const definedFilters = filters.filter(isDefined);
if (!definedFilters.length) return undefined;
return definedFilters.length === 1
? definedFilters[0]
: { or: definedFilters };
};

View File

@ -37,17 +37,10 @@ const data = {
id: 'columnId',
position: 1,
name: 'Column Title',
pipeline: { connect: { id: currentPipelineId } },
type: 'ongoing',
};
export const variables = {
input: {
id: mockId,
variables: {
data,
},
},
input: data,
};
export const deleteVariables = { idToDelete: 'columnId' };

View File

@ -13,10 +13,9 @@ export const usePipelineSteps = () => {
objectNameSingular: CoreObjectNameSingular.PipelineStep,
});
const { deleteOneRecord: deleteOnePipelineStep } =
useDeleteOneRecord<PipelineStep>({
objectNameSingular: CoreObjectNameSingular.PipelineStep,
});
const { deleteOneRecord: deleteOnePipelineStep } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.PipelineStep,
});
const handlePipelineStepAdd = useRecoilCallback(
({ snapshot }) =>
@ -25,16 +24,10 @@ export const usePipelineSteps = () => {
if (!currentPipeline?.id) return;
return createOnePipelineStep?.({
variables: {
data: {
color: boardColumn.colorCode ?? 'gray',
id: boardColumn.id,
position: boardColumn.position,
name: boardColumn.title,
pipeline: { connect: { id: currentPipeline.id } },
type: 'ongoing',
},
},
color: boardColumn.colorCode ?? 'gray',
id: boardColumn.id,
position: boardColumn.position,
name: boardColumn.title,
});
},
[createOnePipelineStep],

View File

@ -6,8 +6,9 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { EntitiesForMultipleEntitySelect } from '@/object-record/relation-picker/types/EntitiesForMultipleEntitySelect';
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { andFilterVariables } from '@/object-record/utils/andFilterVariables';
import { orFilterVariables } from '@/object-record/utils/orFilterVariables';
import { assertNotNull } from '~/utils/assert';
import { isDefined } from '~/utils/isDefined';
type SearchFilter = { fieldNames: string[]; filter: string | number };
@ -40,63 +41,63 @@ export const useFilteredSearchEntityQuery = ({
...mapToObjectRecordIdentifier(record),
record,
});
const selectedIdsFilter = { id: { in: selectedIds } };
const { loading: selectedRecordsLoading, records: selectedRecords } =
useFindManyRecords({
objectNameSingular,
filter: { id: { in: selectedIds } },
filter: selectedIdsFilter,
orderBy: { [orderByField]: sortOrder },
skip: !selectedIds.length,
});
const searchFilter = filters
.map(({ fieldNames, filter }) => {
if (!isNonEmptyString(filter)) {
return undefined;
}
const searchFilters = filters.map(({ fieldNames, filter }) => {
if (!isNonEmptyString(filter)) {
return undefined;
}
return {
or: fieldNames.map((fieldName) => {
const fieldNameParts = fieldName.split('.');
return orFilterVariables(
fieldNames.map((fieldName) => {
const [parentFieldName, subFieldName] = fieldName.split('.');
if (fieldNameParts.length > 1) {
// Composite field
return {
[fieldNameParts[0]]: {
[fieldNameParts[1]]: {
ilike: `%${filter}%`,
},
},
};
}
if (subFieldName) {
// Composite field
return {
[fieldName]: {
ilike: `%${filter}%`,
[parentFieldName]: {
[subFieldName]: {
ilike: `%${filter}%`,
},
},
};
}),
};
})
.filter(isDefined);
}
return {
[fieldName]: {
ilike: `%${filter}%`,
},
};
}),
);
});
const {
loading: filteredSelectedRecordsLoading,
records: filteredSelectedRecords,
} = useFindManyRecords({
objectNameSingular,
filter: { and: [{ and: searchFilter }, { id: { in: selectedIds } }] },
filter: andFilterVariables([...searchFilters, selectedIdsFilter]),
orderBy: { [orderByField]: sortOrder },
skip: !selectedIds.length,
});
const notFilterIds = [...selectedIds, ...excludeEntityIds];
const notFilter = notFilterIds.length
? { not: { id: { in: notFilterIds } } }
: undefined;
const { loading: recordsToSelectLoading, records: recordsToSelect } =
useFindManyRecords({
objectNameSingular,
filter: {
and: [
{ and: searchFilter },
{ not: { id: { in: [...selectedIds, ...excludeEntityIds] } } },
],
},
filter: andFilterVariables([...searchFilters, notFilter]),
limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT,
orderBy: { [orderByField]: sortOrder },
});

View File

@ -5,4 +5,5 @@ export type ApiKey = {
deletedAt: string | null;
name: string;
expiresAt: string;
revokedAt: string | null;
};

View File

@ -39,10 +39,9 @@ export const SettingsWorkspaceMembers = () => {
const { records: workspaceMembers } = useFindManyRecords<WorkspaceMember>({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
});
const { deleteOneRecord: deleteOneWorkspaceMember } =
useDeleteOneRecord<WorkspaceMember>({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
});
const { deleteOneRecord: deleteOneWorkspaceMember } = useDeleteOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
});
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Reference } from '@apollo/client';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { useCreateOneRelationMetadataItem } from '@/object-metadata/hooks/useCreateOneRelationMetadataItem';
import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
@ -227,16 +228,16 @@ export const SettingsObjectNewFieldStep2 = () => {
};
modifyViewFromCache(view.id, {
viewFields: (viewFieldsRef, { readField }) => {
const edges = readField<{ node: Reference }[]>(
viewFields: (cachedViewFieldsConnection, { readField }) => {
const edges = readField<CachedObjectRecordEdge[]>(
'edges',
viewFieldsRef,
cachedViewFieldsConnection,
);
if (!edges) return viewFieldsRef;
if (!edges) return cachedViewFieldsConnection;
return {
...viewFieldsRef,
...cachedViewFieldsConnection,
edges: [...edges, { node: viewFieldToCreate }],
};
},

View File

@ -80,7 +80,7 @@ export const SettingsDevelopersApiKeyDetail = () => {
) => {
const newApiKey = await createOneApiKey?.({
name: name,
expiresAt: newExpiresAt,
expiresAt: newExpiresAt ?? '',
});
if (!newApiKey) {

View File

@ -1,17 +1,23 @@
// There is a feature request for receiving variables in `cache.modify`:
// @see https://github.com/apollographql/apollo-feature-requests/issues/259
// @see https://github.com/apollographql/apollo-client/issues/7129
// For now we need to parse `storeFieldName` to retrieve the variables.
export const parseApolloStoreFieldName = (storeFieldName: string) => {
export const parseApolloStoreFieldName = <
Variables extends Record<string, unknown>,
>(
storeFieldName: string,
) => {
const matches = storeFieldName.match(/([a-zA-Z][a-zA-Z0-9 ]*)\((.*)\)/);
if (!matches?.[1]) return {};
const [, fieldName, stringifiedVariables] = matches;
const [, , stringifiedVariables] = matches;
const fieldName = matches[1] as string;
try {
const variables = stringifiedVariables
? (JSON.parse(stringifiedVariables) as Record<string, unknown>)
? (JSON.parse(stringifiedVariables) as Variables)
: undefined;
return { fieldName, variables };

View File

@ -0,0 +1,21 @@
import { Maybe } from '~/generated/graphql';
export const sortNullsFirst = (
fieldValueA: Maybe<unknown>,
fieldValueB: Maybe<unknown>,
) => (fieldValueA === null ? -1 : fieldValueB === null ? 1 : 0);
export const sortNullsLast = (
fieldValueA: Maybe<unknown>,
fieldValueB: Maybe<unknown>,
) => sortNullsFirst(fieldValueB, fieldValueA);
export const sortAsc = (
fieldValueA: string | number,
fieldValueB: string | number,
) => (fieldValueA < fieldValueB ? -1 : 1);
export const sortDesc = (
fieldValueA: string | number,
fieldValueB: string | number,
) => sortAsc(fieldValueB, fieldValueA);