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
@@ -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);
@@ -45,7 +45,7 @@ export const useUploadAttachmentFile = () => {
fullPath: attachmentUrl,
type: getFileType(file.name),
[targetableObjectFieldIdName]: targetableObject.id,
};
} as Partial<Attachment>;
await createOneAttachment(attachmentToCreate);
};
@@ -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({});
});
});
});
@@ -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,
};
};
@@ -1,8 +0,0 @@
import { atom } from 'recoil';
import { OptimisticEffect } from '../types/internal/OptimisticEffect';
export const optimisticEffectState = atom<Record<string, OptimisticEffect>>({
key: 'optimisticEffectState',
default: {},
});
@@ -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;
};
@@ -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;
@@ -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;
};
@@ -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;
};
@@ -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;
};
@@ -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);
});
};
@@ -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 };
},
},
});
};
@@ -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) }));
};
@@ -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 };
},
},
});
};
@@ -0,0 +1,3 @@
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export type CachedObjectRecord = ObjectRecord & { __typename: string };
@@ -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[];
};
@@ -0,0 +1,7 @@
import { Reference } from '@apollo/client';
import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge';
export type CachedObjectRecordEdge = Omit<ObjectRecordEdge, 'node'> & {
node: Reference;
};
@@ -0,0 +1,6 @@
import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables';
export type CachedObjectRecordQueryVariables = Omit<
ObjectRecordQueryVariables,
'limit'
> & { first?: ObjectRecordQueryVariables['limit'] };
@@ -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();
});
});
@@ -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,
]);
};
@@ -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,
});
@@ -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 };
@@ -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 {
@@ -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 };
@@ -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,
],
);
@@ -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);
@@ -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,
};
};
@@ -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))
@@ -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))
@@ -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
}
}
@@ -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,
};
};
@@ -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))
@@ -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,
});
@@ -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,
});
@@ -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));
@@ -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 {
@@ -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();
@@ -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);
};
@@ -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;
@@ -139,7 +139,6 @@ export const RecordRelationFieldCardSection = () => {
],
orderByField: 'createdAt',
selectedIds: relationRecordIds,
excludeEntityIds: relationRecordIds,
objectNameSingular: relationObjectMetadataNameSingular,
});
@@ -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' },
},
},
@@ -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),
@@ -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),
@@ -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,
@@ -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;
@@ -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 };
};
@@ -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
}
}
@@ -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 };
};
@@ -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' };
@@ -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],
@@ -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 },
});
@@ -5,4 +5,5 @@ export type ApiKey = {
deletedAt: string | null;
name: string;
expiresAt: string;
revokedAt: string | null;
};
@@ -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);
@@ -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 }],
};
},
@@ -80,7 +80,7 @@ export const SettingsDevelopersApiKeyDetail = () => {
) => {
const newApiKey = await createOneApiKey?.({
name: name,
expiresAt: newExpiresAt,
expiresAt: newExpiresAt ?? '',
});
if (!newApiKey) {
@@ -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 };
+21
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);