Upgrade Apollo Client to v4 and refactor error handling (#18584)

## Summary
This PR upgrades Apollo Client from v3.10.0 to v4 and refactors error
handling patterns across the codebase to use a new centralized
`useSnackBarOnQueryError` hook.

## Key Changes

- **Dependency Update**: Upgraded `@apollo/client` from `^3.10.0` to
`^3.11.0` in root package.json
- **New Hook**: Added `useSnackBarOnQueryError` hook for centralized
Apollo query error handling with snack bar notifications
- **Error Handling Refactor**: Updated 100+ files to use the new error
handling pattern:
  - Removed direct `ApolloError` imports where no longer needed
- Replaced manual error handling logic with `useSnackBarOnQueryError`
hook
- Simplified error handling in hooks and components across multiple
modules
- **GraphQL Codegen**: Updated codegen configuration files to work with
Apollo Client v3.11.0
- **Type Definitions**: Added TypeScript declaration file for
`apollo-upload-client` module
- **Test Updates**: Updated test files to reflect new error handling
patterns

## Notable Implementation Details

- The new `useSnackBarOnQueryError` hook provides a consistent way to
handle Apollo query errors with automatic snack bar notifications
- Changes span across multiple feature areas: auth, object records,
settings, workflows, billing, and more
- All changes maintain backward compatibility while improving code
maintainability and reducing duplication
- Jest configuration updated to work with the new Apollo Client version

https://claude.ai/code/session_019WGZ6Rd7sEHuBg9sTrXRqJ

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-03-13 14:59:46 +01:00
committed by GitHub
parent 172bbd01bc
commit b470cb21a1
386 changed files with 3482 additions and 13593 deletions
@@ -6,12 +6,15 @@ import {
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { useAggregateRecordsQuery } from '@/object-record/hooks/useAggregateRecordsQuery';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { useQuery } from '@apollo/client';
import { useQuery } from '@apollo/client/react';
import { renderHook } from '@testing-library/react';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
// Mocks
jest.mock('@apollo/client');
jest.mock('@apollo/client/react', () => ({
...jest.requireActual('@apollo/client/react'),
useQuery: jest.fn(),
}));
jest.mock('@/object-metadata/hooks/useObjectMetadataItem');
jest.mock('@/object-record/hooks/useAggregateRecordsQuery');
@@ -41,7 +44,7 @@ describe('useAggregateRecords', () => {
gqlFieldToFieldMap: mockGqlFieldToFieldMap,
});
(useQuery as jest.Mock).mockReturnValue({
(useQuery as unknown as jest.Mock).mockReturnValue({
data: mockResponse,
loading: false,
error: undefined,
@@ -77,7 +80,7 @@ describe('useAggregateRecords', () => {
});
it('should handle loading state', () => {
(useQuery as jest.Mock).mockReturnValue({
(useQuery as unknown as jest.Mock).mockReturnValue({
data: undefined,
loading: true,
error: undefined,
@@ -102,7 +105,7 @@ describe('useAggregateRecords', () => {
it('should handle error state', () => {
const mockError = new Error('Query failed');
(useQuery as jest.Mock).mockReturnValue({
(useQuery as unknown as jest.Mock).mockReturnValue({
data: undefined,
loading: false,
error: mockError,
@@ -201,9 +201,7 @@ describe('useDeleteManyRecords', () => {
});
fail('Should have thrown an error');
} catch (e) {
expect(e).toMatchInlineSnapshot(
`[ApolloError: Internal server error]`,
);
expect(e).toMatchInlineSnapshot(`[Error: Internal server error]`);
assertCachedRecordsMatch(personRecords);
}
});
@@ -211,9 +211,7 @@ describe('useUpdateManyRecords', () => {
});
fail('Should have thrown an error');
} catch (e) {
expect(e).toMatchInlineSnapshot(
`[ApolloError: Internal server error]`,
);
expect(e).toMatchInlineSnapshot(`[Error: Internal server error]`);
assertCachedRecordsMatch(personRecords);
}
});
@@ -1,4 +1,4 @@
import { useQuery } from '@apollo/client';
import { useQuery } from '@apollo/client/react';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
@@ -1,4 +1,3 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields';
import { type RecordGqlFieldsAggregate } from '@/object-record/graphql/types/RecordGqlFieldsAggregate';
@@ -26,7 +25,6 @@ export const useAggregateRecordsQuery = ({
objectNameSingular,
});
const apolloCoreClient = useApolloCoreClient();
const availableAggregations = useMemo(
() =>
getAvailableAggregationsFromObjectFields(
@@ -35,33 +33,38 @@ export const useAggregateRecordsQuery = ({
[objectMetadataItem.readableFields],
);
const recordGqlFields: RecordGqlFields = {};
const gqlFieldToFieldMap: GqlFieldToFieldMap = {};
const { recordGqlFields, gqlFieldToFieldMap } = useMemo(() => {
const fields: RecordGqlFields = {};
const fieldMap: GqlFieldToFieldMap = {};
Object.entries(recordGqlFieldsAggregate).forEach(
([fieldName, aggregateOperations]) => {
aggregateOperations.forEach((aggregateOperation) => {
const fieldToQuery =
availableAggregations[fieldName]?.[aggregateOperation];
Object.entries(recordGqlFieldsAggregate).forEach(
([fieldName, aggregateOperations]) => {
aggregateOperations.forEach((aggregateOperation) => {
const fieldToQuery =
availableAggregations[fieldName]?.[aggregateOperation];
if (!isDefined(fieldToQuery)) {
return;
}
gqlFieldToFieldMap[fieldToQuery] = [fieldName, aggregateOperation];
if (!isDefined(fieldToQuery)) {
return;
}
fieldMap[fieldToQuery] = [fieldName, aggregateOperation];
recordGqlFields[fieldToQuery] = true;
});
},
{
client: apolloCoreClient,
},
fields[fieldToQuery] = true;
});
},
);
return { recordGqlFields: fields, gqlFieldToFieldMap: fieldMap };
}, [availableAggregations, recordGqlFieldsAggregate]);
const aggregateQuery = useMemo(
() =>
generateAggregateQuery({
objectMetadataItem,
recordGqlFields,
}),
[objectMetadataItem, recordGqlFields],
);
const aggregateQuery = generateAggregateQuery({
objectMetadataItem,
recordGqlFields,
});
return {
aggregateQuery,
gqlFieldToFieldMap,
@@ -9,7 +9,7 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { t } from '@lingui/core/macro';
export const useBatchCreateManyRecords = <
@@ -81,7 +81,10 @@ export const useBatchCreateManyRecords = <
allCreatedRecords.push(...createdRecords);
}
} catch (error) {
if (error instanceof ApolloError && error.message.includes('aborted')) {
if (
CombinedGraphQLErrors.is(error) &&
error.message.includes('aborted')
) {
const formattedCreatedRecordsCount = formatNumber(createdRecordsCount);
enqueueWarningSnackBar({
message: t`Record creation stopped. ${formattedCreatedRecordsCount} records created.`,
@@ -195,7 +195,9 @@ export const useCreateManyRecords = <
},
},
update: (cache, { data }) => {
const records = data?.[mutationResponseField];
const records = (data as Record<string, any>)?.[
mutationResponseField
];
if (
!isDefined(records?.length) ||
@@ -251,7 +253,10 @@ export const useCreateManyRecords = <
operation: { type: 'create-many' },
});
return createdObjects.data?.[mutationResponseField] ?? [];
return (
(createdObjects.data as Record<string, any>)?.[mutationResponseField] ??
[]
);
};
return { createManyRecords };
@@ -1,4 +1,3 @@
import { type ApolloError } from '@apollo/client';
import { useCallback } from 'react';
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
@@ -111,7 +110,9 @@ export const useDeleteOneRecord = ({
idToDelete: idToDelete,
},
update: (cache, { data }) => {
const record = data?.[mutationResponseField];
const record = (data as Record<string, any>)?.[
mutationResponseField
];
if (!isDefined(record) || !shouldHandleOptimisticCache) {
return;
}
@@ -127,7 +128,7 @@ export const useDeleteOneRecord = ({
});
},
})
.catch((error: ApolloError) => {
.catch((error) => {
if (!shouldHandleOptimisticCache) {
throw error;
}
@@ -172,7 +173,10 @@ export const useDeleteOneRecord = ({
},
});
return deletedRecord.data?.[mutationResponseField] ?? null;
return (
(deletedRecord.data as Record<string, any>)?.[mutationResponseField] ??
null
);
},
[
getRecordFromCache,
@@ -58,7 +58,9 @@ export const useDestroyOneRecord = ({
},
},
update: (cache, { data }) => {
const record = data?.[mutationResponseField];
const record = (data as Record<string, any>)?.[
mutationResponseField
];
if (!isDefined(record)) return;
const cachedRecord = getRecordFromCache(record.id, cache);
@@ -95,7 +97,10 @@ export const useDestroyOneRecord = ({
},
});
return deletedRecord.data?.[mutationResponseField] ?? null;
return (
(deletedRecord.data as Record<string, any>)?.[mutationResponseField] ??
null
);
},
[
getRecordFromCache,
@@ -1,12 +1,10 @@
import {
type ApolloError,
type ApolloQueryResult,
type FetchMoreQueryOptions,
type ApolloClient,
type ErrorLike,
type ObservableQuery,
type OperationVariables,
type WatchQueryFetchPolicy,
} from '@apollo/client';
import { type Unmasked } from '@apollo/client/masking';
import { isNonEmptyArray } from '@apollo/client/utilities';
import { isNonEmptyString } from '@sniptt/guards';
import { useCallback, useMemo } from 'react';
@@ -31,7 +29,7 @@ import { hasNextPageFamilyState } from '@/object-record/states/hasNextPageFamily
import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { capitalize, isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { useStore } from 'jotai';
export type UseFindManyRecordsParams<T> = ObjectMetadataItemIdentifier &
@@ -50,21 +48,18 @@ type UseFindManyRecordsStateParams<
'skip' | 'recordGqlFields' | 'fetchPolicy'
> & {
data: RecordGqlOperationFindManyResult | undefined;
error: ApolloError | undefined;
error: ErrorLike | undefined;
fetchMore<
TFetchData = TData,
TFetchVars extends OperationVariables = OperationVariables,
>(
fetchMoreOptions: FetchMoreQueryOptions<TFetchVars, TFetchData> & {
updateQuery?: (
previousQueryResult: TData,
options: {
fetchMoreResult: Unmasked<TFetchData>;
variables: TFetchVars;
},
) => TData;
},
): Promise<ApolloQueryResult<TFetchData>>;
fetchMoreOptions: ObservableQuery.FetchMoreOptions<
TData,
OperationVariables,
TFetchData,
TFetchVars
>,
): Promise<ApolloClient.QueryResult<TFetchData>>;
objectMetadataItem: ObjectMetadataItem;
};
@@ -190,8 +185,8 @@ export const useFetchMoreRecordsWithPagination = <
data: fetchMoreDataResult?.[objectMetadataItem.namePlural],
};
} catch (error) {
handleFindManyRecordsError(error as ApolloError);
return { error: error as ApolloError };
handleFindManyRecordsError(error as ErrorLike);
return { error: error as ErrorLike };
} finally {
setIsFetchingMoreRecords(false);
}
@@ -1,7 +1,8 @@
import { useQuery } from '@apollo/client';
import { useQuery } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import { useMemo } from 'react';
import { useSnackBarOnQueryError } from '@/apollo/hooks/useSnackBarOnQueryError';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
@@ -11,17 +12,13 @@ import { type RecordGqlOperationFindDuplicatesResult } from '@/object-record/gra
import { useFindDuplicateRecordsQuery } from '@/object-record/hooks/useFindDuplicatesRecordsQuery';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getFindDuplicateRecordsQueryResponseField } from '@/object-record/utils/getFindDuplicateRecordsQueryResponseField';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { logError } from '~/utils/logError';
export const useFindDuplicateRecords = <T extends ObjectRecord = ObjectRecord>({
objectRecordIds = [],
objectNameSingular,
onCompleted,
skip,
}: ObjectMetadataItemIdentifier & {
objectRecordIds: string[] | undefined;
onCompleted?: (data: RecordGqlConnectionEdgesRequired[]) => void;
skip?: boolean;
}) => {
const findDuplicateQueryStateIdentifier = objectNameSingular;
@@ -36,8 +33,6 @@ export const useFindDuplicateRecords = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
});
const { enqueueErrorSnackBar } = useSnackBar();
const queryResponseField = getFindDuplicateRecordsQueryResponseField(
objectMetadataItem.nameSingular,
);
@@ -51,21 +46,11 @@ export const useFindDuplicateRecords = <T extends ObjectRecord = ObjectRecord>({
ids: objectRecordIds,
},
client: apolloCoreClient,
onCompleted: (data) => {
onCompleted?.(data[queryResponseField]);
},
onError: (error) => {
logError(
`useFindDuplicateRecords for "${objectMetadataItem.nameSingular}" error : ` +
error,
);
enqueueErrorSnackBar({
apolloError: error,
});
},
},
);
useSnackBarOnQueryError(error);
const objectResults = data?.[queryResponseField];
const results = useMemo(
@@ -1,4 +1,5 @@
import gql from 'graphql-tag';
import { useMemo } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
@@ -22,7 +23,8 @@ export const useFindDuplicateRecordsQuery = ({
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const findDuplicateRecordsQuery = gql`
const findDuplicateRecordsQuery = useMemo(
() => gql`
query FindDuplicate${capitalize(
objectMetadataItem.nameSingular,
)}($ids: [UUID!]!) {
@@ -44,7 +46,13 @@ export const useFindDuplicateRecordsQuery = ({
}
}
}
`;
`,
[
objectMetadataItem,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
],
);
return {
findDuplicateRecordsQuery,
@@ -1,4 +1,6 @@
import { useQuery, type WatchQueryFetchPolicy } from '@apollo/client';
import { type WatchQueryFetchPolicy } from '@apollo/client';
import { useQuery } from '@apollo/client/react';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
@@ -98,11 +100,21 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
limit,
},
fetchPolicy: fetchPolicy,
onCompleted: handleFindManyRecordsCompleted,
onError: handleFindManyRecordsError,
client: apolloCoreClient,
});
useEffect(() => {
if (data) {
handleFindManyRecordsCompleted(data);
}
}, [data, handleFindManyRecordsCompleted]);
useEffect(() => {
if (error) {
handleFindManyRecordsError(error);
}
}, [error, handleFindManyRecordsError]);
const { fetchMoreRecords, records, hasNextPage } =
useFetchMoreRecordsWithPagination<T>({
objectNameSingular,
@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -27,14 +29,25 @@ export const useFindManyRecordsQuery = ({
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const findManyRecordsQuery = generateFindManyRecordsQuery({
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
computeReferences,
cursorDirection,
objectPermissionsByObjectMetadataId,
});
const findManyRecordsQuery = useMemo(
() =>
generateFindManyRecordsQuery({
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
computeReferences,
cursorDirection,
objectPermissionsByObjectMetadataId,
}),
[
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
computeReferences,
cursorDirection,
objectPermissionsByObjectMetadataId,
],
);
return {
findManyRecordsQuery,
@@ -1,4 +1,4 @@
import { useQuery } from '@apollo/client';
import { useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
@@ -17,13 +17,11 @@ export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
objectRecordId = '',
recordGqlFields,
onCompleted,
skip,
withSoftDeleted = false,
}: ObjectMetadataItemIdentifier & {
objectRecordId: string | undefined;
recordGqlFields?: RecordGqlOperationGqlRecordFields;
onCompleted?: (data: T) => void;
skip?: boolean;
withSoftDeleted?: boolean;
}) => {
@@ -63,15 +61,6 @@ export const useFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
!hasReadPermission,
variables: { objectRecordId },
client: apolloCoreClient,
onCompleted: (data) => {
const recordWithoutConnection = getRecordFromRecordNode<T>({
recordNode: { ...data[objectNameSingular] },
});
if (isDefined(recordWithoutConnection)) {
onCompleted?.(recordWithoutConnection);
}
},
});
// TODO: Remove connection from record
@@ -1,4 +1,5 @@
import gql from 'graphql-tag';
import { useMemo } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
@@ -25,7 +26,8 @@ export const useFindOneRecordQuery = ({
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const findOneRecordQuery = gql`
const findOneRecordQuery = useMemo(
() => gql`
query FindOne${capitalize(
objectMetadataItem.nameSingular,
)}($objectRecordId: UUID!) {
@@ -50,7 +52,15 @@ export const useFindOneRecordQuery = ({
objectPermissionsByObjectMetadataId,
})}
},
`;
`,
[
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
withSoftDeleted,
objectPermissionsByObjectMetadataId,
],
);
return {
findOneRecordQuery,
@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
@@ -22,13 +24,23 @@ export const useGroupByRecordsQuery = ({
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const groupByRecordsQuery = generateGroupByRecordsQuery({
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
computeReferences,
objectPermissionsByObjectMetadataId,
});
const groupByRecordsQuery = useMemo(
() =>
generateGroupByRecordsQuery({
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
computeReferences,
objectPermissionsByObjectMetadataId,
}),
[
objectMetadataItem,
objectMetadataItems,
recordGqlFields,
computeReferences,
objectPermissionsByObjectMetadataId,
],
);
return {
groupByRecordsQuery,
@@ -1,4 +1,5 @@
import { type ApolloError } from '@apollo/client';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { type ErrorLike } from '@apollo/client';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -15,15 +16,19 @@ export const useHandleFindManyRecordsError = ({
const { enqueueErrorSnackBar } = useSnackBar();
const handleFindManyRecordsError = useCallback(
(error: ApolloError) => {
(error: ErrorLike) => {
logError(
`useFindManyRecords for "${objectMetadataItem.namePlural}" error : ` +
error,
);
enqueueErrorSnackBar({
apolloError: error,
});
handleError?.(error);
if (CombinedGraphQLErrors.is(error)) {
enqueueErrorSnackBar({
apolloError: error,
});
} else {
enqueueErrorSnackBar({});
}
handleError?.(error as Error);
},
[enqueueErrorSnackBar, handleError, objectMetadataItem.namePlural],
);
@@ -1,12 +1,10 @@
import {
type ApolloError,
type ApolloQueryResult,
type FetchMoreQueryOptions,
type ApolloClient,
type ErrorLike,
type ObservableQuery,
type OperationVariables,
type WatchQueryFetchPolicy,
} from '@apollo/client';
import { type Unmasked } from '@apollo/client/masking';
import { isNonEmptyArray } from '@apollo/client/utilities';
import { isNonEmptyString } from '@sniptt/guards';
import { useCallback } from 'react';
@@ -29,7 +27,7 @@ import {
import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit';
import { cursorFamilyState } from '@/object-record/states/cursorFamilyState';
import { hasNextPageFamilyState } from '@/object-record/states/hasNextPageFamilyState';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { capitalize, isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { useStore } from 'jotai';
export type UseFindManyRecordsParams<T> = ObjectMetadataItemIdentifier &
@@ -48,21 +46,18 @@ type UseFindManyRecordsStateParams<
'skip' | 'recordGqlFields' | 'fetchPolicy' | 'onCompleted'
> & {
data: RecordGqlOperationFindManyResult | undefined;
error: ApolloError | undefined;
error: ErrorLike | undefined;
fetchMore<
TFetchData = TData,
TFetchVars extends OperationVariables = OperationVariables,
>(
fetchMoreOptions: FetchMoreQueryOptions<TFetchVars, TFetchData> & {
updateQuery?: (
previousQueryResult: TData,
options: {
fetchMoreResult: Unmasked<TFetchData>;
variables: TFetchVars;
},
) => TData;
},
): Promise<ApolloQueryResult<TFetchData>>;
fetchMoreOptions: ObservableQuery.FetchMoreOptions<
TData,
OperationVariables,
TFetchData,
TFetchVars
>,
): Promise<ApolloClient.QueryResult<TFetchData>>;
objectMetadataItem: ObjectMetadataItem;
};
@@ -168,7 +163,8 @@ export const useLazyFetchMoreRecordsWithPagination = <
records: getRecordsFromRecordConnection({
recordConnection: {
edges:
fetchMoreDataResult?.[objectMetadataItem.namePlural]?.edges,
fetchMoreDataResult?.[objectMetadataItem.namePlural]?.edges ??
[],
pageInfo:
fetchMoreDataResult?.[objectMetadataItem.namePlural]
?.pageInfo,
@@ -176,8 +172,8 @@ export const useLazyFetchMoreRecordsWithPagination = <
}) as T[],
};
} catch (error) {
handleFindManyRecordsError(error as ApolloError);
return { error: error as ApolloError };
handleFindManyRecordsError(error as ErrorLike);
return { error: error as ErrorLike };
}
}
},
@@ -1,5 +1,5 @@
import { useLazyQuery } from '@apollo/client';
import { useCallback } from 'react';
import { useLazyQuery } from '@apollo/client/react';
import { useCallback, useMemo } from 'react';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
@@ -60,13 +60,17 @@ export const useLazyFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
const hasReadPermission = objectPermissions.canReadObjectRecords;
const defaultVariables = useMemo(
() => ({
filter,
limit,
orderBy,
}),
[filter, limit, orderBy],
);
const [findManyRecords, { data, error, fetchMore }] =
useLazyQuery<RecordGqlOperationFindManyResult>(findManyRecordsQuery, {
variables: {
filter,
limit,
orderBy,
},
fetchPolicy,
client: apolloCoreClient,
});
@@ -77,7 +81,7 @@ export const useLazyFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
orderBy,
limit,
fetchMore,
data,
data: data as RecordGqlOperationFindManyResult | undefined,
error,
objectMetadataItem,
});
@@ -96,7 +100,14 @@ export const useLazyFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
};
}
const result = await findManyRecords();
// In Apollo v4, useLazyQuery's execute aborts in-flight queries when
// the query document changes (e.g. metadata/permissions loading).
// Calling .retain() keeps the query running to completion even if
// the ObservableQuery is updated, preventing AbortError rejections.
const result = await findManyRecords({
variables: defaultVariables,
}).retain();
if (isDefined(result?.error)) {
handleFindManyRecordsError(result.error);
}
@@ -136,6 +147,7 @@ export const useLazyFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
}, [
hasReadPermission,
findManyRecords,
defaultVariables,
objectMetadataItem.namePlural,
queryIdentifier,
handleFindManyRecordsError,
@@ -1,4 +1,3 @@
import { useLazyQuery } from '@apollo/client';
import { useCallback } from 'react';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
@@ -49,17 +48,6 @@ export const useLazyFindManyRecordsWithOffset = ({
const hasReadPermission = objectPermissions.canReadObjectRecords;
const [findManyRecords] = useLazyQuery<RecordGqlOperationFindManyResult>(
findManyRecordsQuery,
{
variables: {
...params,
},
onError: handleFindManyRecordsError,
client: apolloCoreClient,
},
);
const findManyRecordsLazyWithOffset = useCallback(
async (limit: number, offset: number) => {
if (!hasReadPermission) {
@@ -71,12 +59,19 @@ export const useLazyFindManyRecordsWithOffset = ({
};
}
const result = await findManyRecords({
variables: {
limit,
offset,
},
});
const result =
await apolloCoreClient.query<RecordGqlOperationFindManyResult>({
query: findManyRecordsQuery,
variables: {
...params,
limit,
offset,
},
});
if (result?.error) {
handleFindManyRecordsError(result.error);
}
const records = getRecordsFromRecordConnection({
recordConnection: {
@@ -96,7 +91,14 @@ export const useLazyFindManyRecordsWithOffset = ({
error: result?.error,
};
},
[hasReadPermission, findManyRecords, objectMetadataItem.namePlural],
[
hasReadPermission,
apolloCoreClient,
findManyRecordsQuery,
params,
objectMetadataItem.namePlural,
handleFindManyRecordsError,
],
);
return {
@@ -1,4 +1,5 @@
import { useLazyQuery, type WatchQueryFetchPolicy } from '@apollo/client';
import { type WatchQueryFetchPolicy } from '@apollo/client';
import { useLazyQuery } from '@apollo/client/react';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { type ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
@@ -43,6 +44,7 @@ export const useLazyFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
findOneRecordQuery,
{
client: apolloCoreClient,
fetchPolicy,
},
);
@@ -51,20 +53,19 @@ export const useLazyFindOneRecord = <T extends ObjectRecord = ObjectRecord>({
objectRecordId,
onCompleted,
}: FindOneRecordParams<T>) => {
await findOneRecord({
const result = await findOneRecord({
variables: { objectRecordId },
fetchPolicy,
onCompleted: (data) => {
const record = getRecordFromRecordNode<T>({
recordNode: data[objectNameSingular],
});
onCompleted?.(record);
},
});
}).retain();
if (result.data) {
const record = getRecordFromRecordNode<T>({
recordNode: (result.data as Record<string, any>)[objectNameSingular],
});
onCompleted?.(record);
}
},
called,
error,
loading,
record: data?.[objectNameSingular] || undefined,
record: (data as Record<string, any>)?.[objectNameSingular] || undefined,
};
};
@@ -10,7 +10,7 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
import { getMergeManyRecordsMutationResponseField } from '@/object-record/utils/getMergeManyRecordsMutationResponseField';
import { getOperationName } from '@apollo/client/utilities';
import { getOperationName } from '~/utils/getOperationName';
import { type RecordGqlOperationGqlRecordFields } from 'twenty-shared/types';
export type MergeManySettings = {
@@ -110,7 +110,10 @@ export const useMergeManyRecords = <
});
}
return mergedObject.data?.[mutationResponseField] ?? null;
return (
(mergedObject.data as Record<string, any>)?.[mutationResponseField] ??
null
);
} catch (error) {
setLoading(false);
throw error;
@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type ObjectPermissions } from 'twenty-shared/types';
@@ -10,26 +12,31 @@ type useObjectPermissionsReturnType = {
>;
};
const EMPTY_PERMISSIONS: Record<
string,
ObjectPermissions & { objectMetadataId: string }
> = {};
export const useObjectPermissions = (): useObjectPermissionsReturnType => {
const currentUserWorkspace = useAtomStateValue(currentUserWorkspaceState);
const objectsPermissions = currentUserWorkspace?.objectsPermissions;
if (!isDefined(objectsPermissions)) {
return {
objectPermissionsByObjectMetadataId: {},
};
}
const objectPermissionsByObjectMetadataId = useMemo(() => {
if (!isDefined(objectsPermissions)) {
return EMPTY_PERMISSIONS;
}
const objectPermissionsByObjectMetadataId = objectsPermissions?.reduce(
(
acc: Record<string, ObjectPermissions & { objectMetadataId: string }>,
objectPermission,
) => {
acc[objectPermission.objectMetadataId] = objectPermission;
return acc;
},
{},
);
return objectsPermissions.reduce(
(
acc: Record<string, ObjectPermissions & { objectMetadataId: string }>,
objectPermission,
) => {
acc[objectPermission.objectMetadataId] = objectPermission;
return acc;
},
{},
);
}, [objectsPermissions]);
return {
objectPermissionsByObjectMetadataId,
@@ -2,24 +2,22 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useDoObjectMetadataItemsExist } from '@/object-metadata/hooks/useDoObjectMetadataItemsExist';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useSnackBarOnQueryError } from '@/apollo/hooks/useSnackBarOnQueryError';
import { type WatchQueryFetchPolicy } from '@apollo/client';
import { useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import {
type ObjectRecordFilterInput,
type SearchQuery,
useSearchQuery,
SearchDocument,
} from '~/generated/graphql';
import { logError } from '~/utils/logError';
// maybe we should look at ObjectMetadataItemIdentifier to update the API even though there are many location to update
export type UseSearchRecordsParams = {
objectNameSingulars: string[];
limit?: number;
onError?: (error?: Error) => void;
onCompleted?: (data: SearchQuery) => void;
skip?: boolean;
fetchPolicy?: WatchQueryFetchPolicy;
searchInput?: string;
@@ -30,7 +28,6 @@ export const useObjectRecordSearchRecords = ({
objectNameSingulars,
searchInput,
limit,
onCompleted,
skip,
filter,
fetchPolicy,
@@ -38,10 +35,9 @@ export const useObjectRecordSearchRecords = ({
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const areDefined = useDoObjectMetadataItemsExist(objectNameSingulars);
const { enqueueErrorSnackBar } = useSnackBar();
const apolloCoreClient = useApolloCoreClient();
const { data, loading, error, previousData } = useSearchQuery({
const { data, loading, error, previousData } = useQuery(SearchDocument, {
skip:
skip || !areDefined || !currentWorkspaceMember || !isDefined(searchInput),
variables: {
@@ -52,22 +48,14 @@ export const useObjectRecordSearchRecords = ({
},
fetchPolicy: fetchPolicy,
client: apolloCoreClient,
onCompleted: onCompleted,
onError: (error) => {
logError(
`useSearchRecords for "${objectNameSingulars.join(', ')}" error : ` +
error,
);
enqueueErrorSnackBar({
apolloError: error,
});
},
});
useSnackBarOnQueryError(error);
const effectiveData = loading ? previousData : data;
const searchRecords = useMemo(
() => effectiveData?.search.edges.map((edge) => edge.node) || [],
() => effectiveData?.search?.edges?.map((edge) => edge.node) || [],
[effectiveData],
);
@@ -195,7 +195,9 @@ export const useRestoreManyRecords = ({
});
const restoredRecordsForThisBatch =
restoredRecordsResponse.data?.[mutationResponseField] ?? [];
(restoredRecordsResponse.data as Record<string, any>)?.[
mutationResponseField
] ?? [];
restoredRecords.push(...restoredRecordsForThisBatch);
@@ -173,7 +173,7 @@ export const useUpdateOneRecord = () => {
input: sanitizedInput,
},
update: (cache, { data }) => {
const record = data?.[mutationResponseField];
const record = (data as Record<string, any>)?.[mutationResponseField];
if (!isDefined(record)) return;
const recordToUpsert = getRecordFromRecordNode({
@@ -246,7 +246,9 @@ export const useUpdateOneRecord = () => {
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
const resultRecord = updatedRecord?.data?.[mutationResponseField] ?? null;
const resultRecord =
(updatedRecord?.data as Record<string, any>)?.[mutationResponseField] ??
null;
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,