Refactored object operation dispatch for SSE (#17174)

This PR refactors object record operation dispatch through a browser
event instead of a state that was registering all operations.

This is a cleaner pattern as it is a synchronous event code path instead
of relying on a useEffect to watch state change, which is not ideal.

We introduce this change first to then rely on this new pattern to
dispatch SSE events in a following PR.
This commit is contained in:
Lucas Bordeau
2026-01-15 17:38:21 +01:00
committed by GitHub
parent 3f28fde03a
commit 2cff75d772
31 changed files with 380 additions and 482 deletions
@@ -0,0 +1,2 @@
export const OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME =
'object-record-operation-browser-event';
@@ -1,12 +1,12 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
import { useIncrementalUpdateManyRecords } from '@/object-record/hooks/useIncrementalUpdateManyRecords';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { renderHook } from '@testing-library/react';
jest.mock('@/object-metadata/hooks/useObjectMetadataItem');
jest.mock('@/object-record/hooks/useRegisterObjectOperation');
jest.mock('@/object-record/utils/dispatchObjectRecordOperationBrowserEvent');
jest.mock('@/object-record/hooks/useUpdateManyRecords', () => ({
useUpdateManyRecords: jest.fn(),
}));
@@ -18,7 +18,9 @@ jest.mock('@/object-record/hooks/useRefetchAggregateQueries', () => ({
jest.mock('@/object-record/hooks/useIncrementalFetchAndMutateRecords');
const mockUseObjectMetadataItem = jest.mocked(useObjectMetadataItem);
const mockUseRegisterObjectOperation = jest.mocked(useRegisterObjectOperation);
const mockDispatchObjectRecordOperationBrowserEvent = jest.mocked(
dispatchObjectRecordOperationBrowserEvent,
);
const mockUseUpdateManyRecords = jest.mocked(useUpdateManyRecords);
const mockUseIncrementalFetchAndMutateRecords = jest.mocked(
useIncrementalFetchAndMutateRecords,
@@ -26,7 +28,6 @@ const mockUseIncrementalFetchAndMutateRecords = jest.mocked(
describe('useIncrementalUpdateManyRecords', () => {
const mockUpdateManyRecords = jest.fn();
const mockRegisterObjectOperation = jest.fn();
const mockIncrementalFetchAndMutate = jest.fn();
const mockUpdateProgress = jest.fn();
@@ -39,9 +40,7 @@ describe('useIncrementalUpdateManyRecords', () => {
} as any,
});
mockUseRegisterObjectOperation.mockReturnValue({
registerObjectOperation: mockRegisterObjectOperation,
});
mockDispatchObjectRecordOperationBrowserEvent.mockImplementation(jest.fn());
mockUseUpdateManyRecords.mockReturnValue({
updateManyRecords: mockUpdateManyRecords,
@@ -85,18 +84,24 @@ describe('useIncrementalUpdateManyRecords', () => {
abortSignal: expect.any(AbortSignal),
});
expect(mockUpdateProgress).toHaveBeenCalledWith(2, 2);
expect(mockRegisterObjectOperation).toHaveBeenCalledWith(
expect.anything(),
{
expect(mockDispatchObjectRecordOperationBrowserEvent).toHaveBeenCalledWith({
objectMetadataItem: expect.anything(),
operation: {
type: 'update-many',
result: {
updateInputs: [
{ id: '1', name: 'New Name' },
{ id: '2', name: 'New Name' },
{
recordId: '1',
updatedFields: [{ name: 'New Name' }],
},
{
recordId: '2',
updatedFields: [{ name: 'New Name' }],
},
],
},
},
);
});
});
it('should pass abortSignal to updateManyRecords', async () => {
@@ -140,14 +145,14 @@ describe('useIncrementalUpdateManyRecords', () => {
result.current.incrementalUpdateManyRecords({ name: 'New Name' }),
).rejects.toThrow('Process failed');
expect(mockRegisterObjectOperation).toHaveBeenCalledWith(
expect.anything(),
{
expect(mockDispatchObjectRecordOperationBrowserEvent).toHaveBeenCalledWith({
objectMetadataItem: expect.anything(),
operation: {
type: 'update-many',
result: {
updateInputs: [],
},
},
);
});
});
});
@@ -6,8 +6,8 @@ import {
type useCreateManyRecordsProps,
} from '@/object-record/hooks/useCreateManyRecords';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
@@ -27,8 +27,6 @@ export const useBatchCreateManyRecords = <
setBatchedRecordsCount?: (count: number) => void;
abortController?: AbortController;
}) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { createManyRecords } = useCreateManyRecords({
objectNameSingular,
recordGqlFields,
@@ -100,7 +98,10 @@ export const useBatchCreateManyRecords = <
await refetchAggregateQueries();
registerObjectOperation(objectMetadataItem, { type: 'create-many' });
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: { type: 'create-many' },
});
return allCreatedRecords;
};
@@ -12,18 +12,18 @@ import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordF
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { type RecordGqlOperationGqlRecordFields } from 'twenty-shared/types';
import { useCreateManyRecordsMutation } from '@/object-record/hooks/useCreateManyRecordsMutation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { type FieldActorForInputValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getCreateManyRecordsMutationResponseField } from '@/object-record/utils/getCreateManyRecordsMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { useRecoilValue } from 'recoil';
import { type RecordGqlOperationGqlRecordFields } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type PartialObjectRecordWithId = Partial<ObjectRecord> & {
@@ -51,7 +51,6 @@ export const useCreateManyRecords = <
shouldMatchRootQueryFilter,
shouldRefetchAggregateQueries = true,
}: useCreateManyRecordsProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const apolloCoreClient = useApolloCoreClient();
@@ -243,7 +242,10 @@ export const useCreateManyRecords = <
await refetchAggregateQueries();
}
registerObjectOperation(objectMetadataItem, { type: 'create-many' });
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: { type: 'create-many' },
});
return createdObjects.data?.[mutationResponseField] ?? [];
};
@@ -13,18 +13,18 @@ import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename
import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { type RecordGqlOperationGqlRecordFields } from 'twenty-shared/types';
import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticCreateRecordBaseRecordInput } from '@/object-record/utils/computeOptimisticCreateRecordBaseRecordInput';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { useRecoilValue } from 'recoil';
import { type RecordGqlOperationGqlRecordFields } from 'twenty-shared/types';
import { CustomError, isDefined } from 'twenty-shared/utils';
type useCreateOneRecordProps = {
@@ -43,7 +43,6 @@ export const useCreateOneRecord = <
shouldMatchRootQueryFilter,
}: useCreateOneRecordProps) => {
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const { registerObjectOperation } = useRegisterObjectOperation();
const apolloCoreClient = useApolloCoreClient();
const [loading, setLoading] = useState(false);
@@ -187,7 +186,10 @@ export const useCreateOneRecord = <
await refetchAggregateQueries();
registerObjectOperation(objectMetadataItem, { type: 'create-one' });
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: { type: 'create-one' },
});
if (!isDefined(createdObject.data?.[mutationResponseField])) {
throw new CustomError('Failed to create record');
@@ -12,9 +12,9 @@ import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode'
import { useDeleteManyRecordsMutation } from '@/object-record/hooks/useDeleteManyRecordsMutation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@@ -34,7 +34,6 @@ export type DeleteManyRecordsProps = {
export const useDeleteManyRecords = ({
objectNameSingular,
}: useDeleteManyRecordProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const apiConfig = useRecoilValue(apiConfigState);
@@ -224,8 +223,11 @@ export const useDeleteManyRecords = ({
}
await refetchAggregateQueries();
registerObjectOperation(objectMetadataItem, {
type: 'delete-many',
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
type: 'delete-many',
},
});
return deletedRecords;
@@ -12,9 +12,9 @@ import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordF
import { useDeleteOneRecordMutation } from '@/object-record/hooks/useDeleteOneRecordMutation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/getDeleteOneRecordMutationResponseField';
import { isNull } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
@@ -26,7 +26,6 @@ type useDeleteOneRecordProps = {
export const useDeleteOneRecord = ({
objectNameSingular,
}: useDeleteOneRecordProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const apolloCoreClient = useApolloCoreClient();
@@ -165,8 +164,11 @@ export const useDeleteOneRecord = ({
await refetchAggregateQueries();
registerObjectOperation(objectMetadataItem, {
type: 'delete-one',
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
type: 'delete-one',
},
});
return deletedRecord.data?.[mutationResponseField] ?? null;
@@ -178,7 +180,6 @@ export const useDeleteOneRecord = ({
objectMetadataItems,
deleteOneRecordMutation,
refetchAggregateQueries,
registerObjectOperation,
mutationResponseField,
objectPermissionsByObjectMetadataId,
upsertRecordsInStore,
@@ -9,9 +9,9 @@ import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMu
import { useDestroyManyRecordsMutation } from '@/object-record/hooks/useDestroyManyRecordsMutation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared/utils';
@@ -31,7 +31,6 @@ export type DestroyManyRecordsProps = {
export const useDestroyManyRecords = ({
objectNameSingular,
}: useDestroyManyRecordProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const apiConfig = useRecoilValue(apiConfigState);
@@ -145,8 +144,11 @@ export const useDestroyManyRecords = ({
await refetchAggregateQueries();
registerObjectOperation(objectMetadataItem, {
type: 'destroy-many',
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
type: 'destroy-many',
},
});
return destroyedRecords;
@@ -8,8 +8,8 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { useDestroyOneRecordMutation } from '@/object-record/hooks/useDestroyOneRecordMutation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDestroyOneRecordMutationResponseField } from '@/object-record/utils/getDestroyOneRecordMutationResponseField';
import { capitalize, isDefined } from 'twenty-shared/utils';
@@ -21,7 +21,6 @@ type useDestroyOneRecordProps = {
export const useDestroyOneRecord = ({
objectNameSingular,
}: useDestroyOneRecordProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const apolloCoreClient = useApolloCoreClient();
@@ -89,8 +88,11 @@ export const useDestroyOneRecord = ({
throw error;
});
registerObjectOperation(objectMetadataItem, {
type: 'destroy-one',
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
type: 'destroy-one',
},
});
return deletedRecord.data?.[mutationResponseField] ?? null;
@@ -101,7 +103,6 @@ export const useDestroyOneRecord = ({
destroyOneRecordMutation,
mutationResponseField,
objectNameSingular,
registerObjectOperation,
objectMetadataItem,
objectMetadataItems,
upsertRecordsInStore,
@@ -13,9 +13,9 @@ import { type UseFindManyRecordsParams } from '@/object-record/hooks/useFetchMor
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { sleep } from '~/utils/sleep';
@@ -40,7 +40,6 @@ export const useIncrementalDeleteManyRecords = <T>({
delayInMsBetweenMutations = DEFAULT_DELAY_BETWEEN_MUTATIONS_MS,
skipOptimisticEffect = false,
}: UseIncrementalDeleteManyRecordsParams<T>) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const mutationPageSize = pageSize;
@@ -235,8 +234,11 @@ export const useIncrementalDeleteManyRecords = <T>({
await refetchAggregateQueries();
registerObjectOperation(objectMetadataItem, {
type: 'delete-many',
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
type: 'delete-many',
},
});
return totalDeletedCount;
@@ -10,9 +10,9 @@ import { type UseFindManyRecordsParams } from '@/object-record/hooks/useFetchMor
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { sleep } from '~/utils/sleep';
@@ -37,7 +37,6 @@ export const useIncrementalDestroyManyRecords = <T>({
delayInMsBetweenMutations = DEFAULT_DELAY_BETWEEN_MUTATIONS_MS,
skipOptimisticEffect = false,
}: UseIncrementalDestroyManyRecordsParams<T>) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const mutationPageSize = pageSize;
@@ -165,8 +164,11 @@ export const useIncrementalDestroyManyRecords = <T>({
await refetchAggregateQueries();
registerObjectOperation(objectMetadataItem, {
type: 'destroy-many',
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
type: 'destroy-many',
},
});
return totalDestroyedCount;
@@ -3,9 +3,10 @@ import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryP
import { type UseFindManyRecordsParams } from '@/object-record/hooks/useFetchMoreRecordsWithPagination';
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
const DEFAULT_DELAY_BETWEEN_MUTATIONS_MS = 50;
@@ -28,8 +29,6 @@ export const useIncrementalUpdateManyRecords = <
pageSize = DEFAULT_QUERY_PAGE_SIZE,
delayInMsBetweenMutations = DEFAULT_DELAY_BETWEEN_MUTATIONS_MS,
}: UseIncrementalUpdateManyRecordsParams<T>) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
@@ -83,13 +82,16 @@ export const useIncrementalUpdateManyRecords = <
} finally {
await refetchAggregateQueries();
registerObjectOperation(objectMetadataItem, {
type: 'update-many',
result: {
updateInputs: allUpdatedRecordIds.map((id) => ({
id,
...fieldsToUpdate,
})),
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
type: 'update-many',
result: {
updateInputs: allUpdatedRecordIds.map((recordId) => ({
recordId,
updatedFields: getUpdatedFieldsFromRecordInput(fieldsToUpdate),
})),
},
},
});
}
@@ -0,0 +1,57 @@
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/object-record/constants/ObjectRecordOperationBrowserEventName';
import { type ObjectRecordOperation } from '@/object-record/types/ObjectRecordOperation';
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
import { useEffect } from 'react';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
export const useListenToObjectRecordOperationBrowserEvent = ({
onObjectRecordOperationBrowserEvent,
objectMetadataItemId,
operationTypes,
}: {
onObjectRecordOperationBrowserEvent: (
detail: ObjectRecordOperationBrowserEventDetail,
) => void;
objectMetadataItemId?: string;
operationTypes?: ObjectRecordOperation['type'][];
}) => {
useEffect(() => {
const handleObjectRecordOperationEvent = (event: Event) => {
const detail = (
event as CustomEvent<ObjectRecordOperationBrowserEventDetail>
).detail;
if (
isDefined(objectMetadataItemId) &&
detail.objectMetadataItem.id !== objectMetadataItemId
) {
return;
}
if (
isNonEmptyArray(operationTypes) &&
!operationTypes.includes(detail.operation.type)
) {
return;
}
onObjectRecordOperationBrowserEvent(detail);
};
window.addEventListener(
OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME,
handleObjectRecordOperationEvent,
);
return () => {
window.removeEventListener(
OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME,
handleObjectRecordOperationEvent,
);
};
}, [
objectMetadataItemId,
onObjectRecordOperationBrowserEvent,
operationTypes,
]);
};
@@ -3,15 +3,15 @@ import { useCallback, useState } from 'react';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { type RecordGqlOperationGqlRecordFields } from 'twenty-shared/types';
import { useFindDuplicateRecordsQuery } from '@/object-record/hooks/useFindDuplicatesRecordsQuery';
import { useFindOneRecordQuery } from '@/object-record/hooks/useFindOneRecordQuery';
import { useMergeManyRecordsMutation } from '@/object-record/hooks/useMergeManyRecordsMutation';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getMergeManyRecordsMutationResponseField } from '@/object-record/utils/getMergeManyRecordsMutationResponseField';
import { getOperationName } from '@apollo/client/utilities';
import { type RecordGqlOperationGqlRecordFields } from 'twenty-shared/types';
export type MergeManySettings = {
conflictPriorityIndex: number;
@@ -28,7 +28,6 @@ export const useMergeManyRecords = <
objectNameSingular,
recordGqlFields,
}: UseMergeManyRecordsProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const apolloCoreClient = useApolloCoreClient();
const [loading, setLoading] = useState(false);
@@ -103,8 +102,11 @@ export const useMergeManyRecords = <
if (!preview) {
await refetchAggregateQueries();
registerObjectOperation(objectMetadataItem, {
type: 'merge-records',
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
type: 'merge-records',
},
});
}
@@ -121,7 +123,6 @@ export const useMergeManyRecords = <
findOneRecordQuery,
findDuplicateRecordsQuery,
refetchAggregateQueries,
registerObjectOperation,
],
);
@@ -1,32 +0,0 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
objectOperationsState,
type ObjectOperationData,
} from '@/object-record/states/objectOperationsComponentState';
import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid';
export const useRegisterObjectOperation = () => {
const registerObjectOperation = useRecoilCallback(
({ set }) =>
(objectMetadataItem: ObjectMetadataItem, data: ObjectOperationData) => {
set(objectOperationsState, (currentValue) => {
const newValue = currentValue.concat();
newValue.push({
id: v4(),
timestamp: +new Date(),
objectMetadataItemId: objectMetadataItem.id,
data,
});
return newValue;
});
},
[],
);
return {
registerObjectOperation,
};
};
@@ -9,10 +9,10 @@ import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNo
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { DEFAULT_MUTATION_BATCH_SIZE } from '@/object-record/constants/DefaultMutationBatchSize';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useRestoreManyRecordsMutation } from '@/object-record/hooks/useRestoreManyRecordsMutation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField';
import { useRecoilValue } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared/utils';
@@ -32,7 +32,6 @@ type RestoreManyRecordsProps = {
export const useRestoreManyRecords = ({
objectNameSingular,
}: useRestoreManyRecordProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const apiConfig = useRecoilValue(apiConfigState);
@@ -200,8 +199,11 @@ export const useRestoreManyRecords = ({
restoredRecords.push(...restoredRecordsForThisBatch);
registerObjectOperation(objectMetadataItem, {
type: 'restore-many',
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
type: 'restore-many',
},
});
if (isDefined(delayInMsBetweenRequests)) {
@@ -13,10 +13,11 @@ import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/
import { type RecordGqlNode } from '@/object-record/graphql/types/RecordGqlNode';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpdateManyRecordsMutation } from '@/object-record/hooks/useUpdateManyRecordsMutation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
import { getUpdateManyRecordsMutationResponseField } from '@/object-record/utils/getUpdateManyRecordsMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { useRecoilValue } from 'recoil';
@@ -42,7 +43,6 @@ export const useUpdateManyRecords = <T extends ObjectRecord = ObjectRecord>({
objectNameSingular,
recordGqlFields,
}: UseUpdateManyRecordsProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const apiConfig = useRecoilValue(apiConfigState);
@@ -265,13 +265,17 @@ export const useUpdateManyRecords = <T extends ObjectRecord = ObjectRecord>({
}
if (!skipRegisterObjectOperation) {
registerObjectOperation(objectMetadataItem, {
type: 'update-many',
result: {
updateInputs: recordIdsToUpdate.map((id) => ({
id,
...updateOneRecordInput,
})),
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
type: 'update-many',
result: {
updateInputs: recordIdsToUpdate.map((recordId) => ({
recordId,
updatedFields:
getUpdatedFieldsFromRecordInput(updateOneRecordInput),
})),
},
},
});
}
@@ -12,11 +12,12 @@ import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graph
import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromRecord';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isNull } from '@sniptt/guards';
@@ -39,7 +40,6 @@ export const useUpdateOneRecord = <
objectNameSingular,
recordGqlFields,
}: useUpdateOneRecordProps) => {
const { registerObjectOperation } = useRegisterObjectOperation();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const apolloCoreClient = useApolloCoreClient();
@@ -236,9 +236,18 @@ export const useUpdateOneRecord = <
const udpatedRecord = updatedRecord?.data?.[mutationResponseField] ?? null;
registerObjectOperation(objectMetadataItem, {
type: 'update-one',
result: { updateInput: updateOneRecordInput },
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
type: 'update-one',
result: {
updateInput: {
recordId: idToUpdate,
updatedFields:
getUpdatedFieldsFromRecordInput(updateOneRecordInput),
},
},
},
});
return udpatedRecord;
@@ -12,10 +12,12 @@ import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/
import { generateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromObject';
import { generateDepthRecordGqlFieldsFromRecord } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromRecord';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { isNull } from '@sniptt/guards';
@@ -33,7 +35,6 @@ type UpdateOneRecordArgs<UpdatedObjectRecord> = {
export const useUpdateOneRecordV2 = () => {
const apolloCoreClient = useApolloCoreClient();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const { registerObjectOperation } = useRegisterObjectOperation();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
@@ -238,9 +239,18 @@ export const useUpdateOneRecordV2 = () => {
throw error;
});
registerObjectOperation(objectMetadataItem, {
type: 'update-one',
result: { updateInput: updateOneRecordInput },
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
type: 'update-one',
result: {
updateInput: {
recordId: idToUpdate,
updatedFields:
getUpdatedFieldsFromRecordInput(updateOneRecordInput),
},
},
},
});
return updatedRecord?.data?.[mutationResponseField] ?? null;
@@ -9,7 +9,7 @@ import { RecordTableCellPortals } from '@/object-record/record-table/record-tabl
import { RecordTableAggregateFooter } from '@/object-record/record-table/record-table-footer/components/RecordTableAggregateFooter';
import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState';
import { RecordTableVirtualizedDataChangedEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedDataChangedEffect';
import { RecordTableVirtualizedOnObjectRecordEventsEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedOnObjectRecordEventsEffect';
import { RecordTableVirtualizedRowTreadmillEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedRowTreadmillEffect';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
@@ -38,7 +38,6 @@ export const RecordTableNoRecordGroupBody = () => {
)}
<RecordTableVirtualizedRowTreadmillEffect />
<RecordTableVirtualizedDataChangedEffect />
<RecordTableVirtualizedOnObjectRecordEventsEffect />
</RecordTableBodyNoRecordGroupDragDropContextProvider>
</RecordTableNoRecordGroupBodyContextProvider>
);
@@ -1,154 +1,50 @@
import { useActiveFieldMetadataItems } from '@/object-metadata/hooks/useActiveFieldMetadataItems';
import { useGetObjectMetadataItemById } from '@/object-metadata/hooks/useGetObjectMetadataItemById';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useGetShouldResetTableVirtualizationForUpdateInputs } from '@/object-record/record-table/virtualization/hooks/useGetShouldResetTableVirtualizationForUpdateInputs';
import { useResetVirtualizationBecauseDataChanged } from '@/object-record/record-table/virtualization/hooks/useResetVirtualizationBecauseDataChanged';
import { lastObjectOperationThatResettedVirtualizationComponentState } from '@/object-record/record-table/virtualization/states/lastObjectOperationThatResettedVirtualizationComponentState';
import { objectOperationsState } from '@/object-record/states/objectOperationsComponentState';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined, mapById } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated/graphql';
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
export const RecordTableVirtualizedDataChangedEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { objectNameSingular } = useRecordTableContextOrThrow();
const { getObjectMetadataItemById } = useGetObjectMetadataItemById();
const { resetVirtualizationBecauseDataChanged } =
useResetVirtualizationBecauseDataChanged(objectNameSingular);
const [
lastObjectOperationThatResettedVirtualization,
setLastObjectOperationThatResettedVirtualization,
] = useRecoilComponentState(
lastObjectOperationThatResettedVirtualizationComponentState,
);
const { getShouldResetTableVirtualizationForUpdateInputs } =
useGetShouldResetTableVirtualizationForUpdateInputs();
const { activeFieldMetadataItems } = useActiveFieldMetadataItems({
objectMetadataItem,
});
const handleObjectRecordOperation = (
objectRecordOperationEventDetail: ObjectRecordOperationBrowserEventDetail,
) => {
const objectRecordOperation = objectRecordOperationEventDetail.operation;
const currentRecordSorts = useRecoilComponentValue(
currentRecordSortsComponentState,
);
const isUpdateOperation =
objectRecordOperation.type === 'update-one' ||
objectRecordOperation.type === 'update-many';
const currentRecordFilters = useRecoilComponentValue(
currentRecordFiltersComponentState,
);
if (isUpdateOperation) {
const updateInputs =
objectRecordOperation.type === 'update-one'
? [objectRecordOperation.result.updateInput]
: objectRecordOperation.result.updateInputs;
const objectOperations = useRecoilValue(objectOperationsState);
const shouldResetForUpdateOperation =
getShouldResetTableVirtualizationForUpdateInputs(updateInputs);
useEffect(() => {
const lastObjectOperation = objectOperations.at(-1);
if (
!isDefined(lastObjectOperationThatResettedVirtualization) &&
!isDefined(lastObjectOperation)
) {
return;
}
if (
lastObjectOperation?.id !==
lastObjectOperationThatResettedVirtualization?.id
) {
setLastObjectOperationThatResettedVirtualization(lastObjectOperation);
if (isDefined(lastObjectOperation)) {
if (
lastObjectOperation.objectMetadataItemId !== objectMetadataItem.id
) {
return;
}
const updatedFieldNames = new Set<string>();
let thereIsAnUpdateOnAFilteredField = false;
let thereIsAnUpdateOnASortedField = false;
if (
lastObjectOperation.data.type === 'update-one' ||
lastObjectOperation.data.type === 'update-many'
) {
const updateInputs =
lastObjectOperation.data.type === 'update-one'
? [lastObjectOperation.data.result.updateInput]
: lastObjectOperation.data.result.updateInputs;
for (const updateInput of updateInputs) {
const fieldNamesForUpdateInput =
Object.keys(updateInput ?? {}) ?? [];
for (const fieldName of fieldNamesForUpdateInput) {
updatedFieldNames.add(fieldName);
}
const updatedFieldMetadataItems = activeFieldMetadataItems.filter(
(fieldMetadataItemToFilter) =>
fieldNamesForUpdateInput.includes(
fieldMetadataItemToFilter.name,
) ||
(fieldMetadataItemToFilter.type ===
FieldMetadataType.RELATION &&
fieldNamesForUpdateInput.includes(
`${fieldMetadataItemToFilter.name}Id`,
)),
);
const updatedFieldMetadataItemIds =
updatedFieldMetadataItems.map(mapById);
const updateOnAFilteredField = currentRecordFilters.some(
(recordFilter) =>
updatedFieldMetadataItemIds.includes(
recordFilter.fieldMetadataId,
),
);
const updateOnASortedField = currentRecordSorts.some((recordSort) =>
updatedFieldMetadataItemIds.includes(recordSort.fieldMetadataId),
);
if (updateOnAFilteredField) {
thereIsAnUpdateOnAFilteredField = true;
}
if (updateOnASortedField) {
thereIsAnUpdateOnASortedField = true;
}
}
} else {
resetVirtualizationBecauseDataChanged();
}
if (updatedFieldNames.has('position')) {
resetVirtualizationBecauseDataChanged();
} else if (
thereIsAnUpdateOnAFilteredField ||
thereIsAnUpdateOnASortedField
) {
resetVirtualizationBecauseDataChanged();
}
if (shouldResetForUpdateOperation) {
resetVirtualizationBecauseDataChanged();
}
} else {
resetVirtualizationBecauseDataChanged();
}
}, [
lastObjectOperationThatResettedVirtualization,
objectOperations,
setLastObjectOperationThatResettedVirtualization,
resetVirtualizationBecauseDataChanged,
activeFieldMetadataItems,
currentRecordFilters,
currentRecordSorts,
getObjectMetadataItemById,
objectMetadataItem.id,
]);
};
useListenToObjectRecordOperationBrowserEvent({
onObjectRecordOperationBrowserEvent: handleObjectRecordOperation,
objectMetadataItemId: objectMetadataItem.id,
});
return <></>;
};
@@ -1,165 +0,0 @@
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import {
computeRecordGqlOperationFilter,
isDefined,
isNonEmptyArray,
} from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
import { DatabaseEventAction } from '~/generated/graphql';
export const RecordTableVirtualizedOnObjectRecordEventsEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { objectNameSingular } = useRecordTableContextOrThrow();
const { registerObjectOperation } = useRegisterObjectOperation();
const { refetchAggregateQueries } = useRefetchAggregateQueries({
objectMetadataNamePlural: objectMetadataItem.namePlural,
});
const apolloCoreClient = useApolloCoreClient();
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular,
});
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const { objectMetadataItems } = useObjectMetadataItems();
const recordGqlFields = useRecordsFieldVisibleGqlFields({
objectMetadataItem,
});
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const debouncedRefetchAggregateQueries = useDebouncedCallback(
refetchAggregateQueries,
200,
);
const currentRecordFilters = useRecoilComponentValue(
currentRecordFiltersComponentState,
);
const currentRecordSorts = useRecoilComponentValue(
currentRecordSortsComponentState,
);
const currentRecordFilterGroups = useRecoilComponentValue(
currentRecordFilterGroupsComponentState,
);
const { filterValueDependencies } = useFilterValueDependencies();
const queryId = `record-table-virtualized-${objectMetadataItem.nameSingular}`;
useListenToObjectRecordEventsForQuery({
queryId,
operationSignature: {
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
}),
orderBy: turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts),
},
},
onObjectRecordEvents: (objectRecordEvents) => {
const cache = apolloCoreClient.cache;
const updateEvents = objectRecordEvents.filter(
(objectRecordEvent) =>
objectRecordEvent.action === DatabaseEventAction.UPDATED,
);
const updatedRecordsWithUpdatedFieldsOnly: ObjectRecord[] = [];
for (const updateEvent of updateEvents) {
const updatedRecord = updateEvent.properties.after;
upsertRecordsInStore({ partialRecords: [updatedRecord] });
const cachedRecord = getRecordFromCache<ObjectRecord>(updatedRecord.id);
const cachedRecordWithConnection =
getRecordNodeFromRecord<ObjectRecord>({
record: cachedRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: recordGqlFields,
computeReferences: false,
});
const computedOptimisticRecord = {
...updatedRecord,
id: updatedRecord.id,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
};
const computedOptimisticRecordWithConnection =
getRecordNodeFromRecord<ObjectRecord>({
record: computedOptimisticRecord,
objectMetadataItem,
objectMetadataItems,
recordGqlFields: recordGqlFields,
});
if (
!isDefined(cachedRecordWithConnection) ||
!isDefined(computedOptimisticRecordWithConnection)
) {
continue;
}
triggerUpdateRecordOptimisticEffect({
cache,
objectMetadataItem,
currentRecord: cachedRecordWithConnection,
updatedRecord: computedOptimisticRecordWithConnection,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
upsertRecordsInStore,
});
const updatedFields = updateEvent.properties?.updatedFields ?? [];
updatedRecordsWithUpdatedFieldsOnly.push({
...Object.fromEntries(
updatedFields.map((fieldName) => {
return [fieldName, updatedRecord[fieldName]];
}),
),
id: updatedRecord.id,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
});
}
registerObjectOperation(objectMetadataItem, {
type: 'update-many',
result: { updateInputs: updatedRecordsWithUpdatedFieldsOnly },
});
if (isNonEmptyArray(updateEvents)) {
debouncedRefetchAggregateQueries();
}
},
});
return null;
};
@@ -0,0 +1,81 @@
import { useActiveFieldMetadataItems } from '@/object-metadata/hooks/useActiveFieldMetadataItems';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { type ObjectRecordOperationUpdateInput } from '@/object-record/types/ObjectRecordOperationUpdateInput';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { FieldMetadataType } from 'twenty-shared/types';
import { mapById } from 'twenty-shared/utils';
export const useGetShouldResetTableVirtualizationForUpdateInputs = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { activeFieldMetadataItems } = useActiveFieldMetadataItems({
objectMetadataItem,
});
const currentRecordSorts = useRecoilComponentValue(
currentRecordSortsComponentState,
);
const currentRecordFilters = useRecoilComponentValue(
currentRecordFiltersComponentState,
);
const getShouldResetTableVirtualizationForUpdateInputs = (
updateInputs: ObjectRecordOperationUpdateInput[],
) => {
const updatedFieldNames = new Set<string>();
let thereIsAnUpdateOnAFilteredField = false;
let thereIsAnUpdateOnASortedField = false;
for (const updateInput of updateInputs) {
const fieldNamesForUpdateInput = updateInput.updatedFields.flatMap(
(updatedField) => Object.keys(updatedField ?? {}),
);
for (const fieldName of fieldNamesForUpdateInput) {
updatedFieldNames.add(fieldName);
}
const updatedFieldMetadataItems = activeFieldMetadataItems.filter(
(fieldMetadataItemToFilter) =>
fieldNamesForUpdateInput.includes(fieldMetadataItemToFilter.name) ||
(fieldMetadataItemToFilter.type === FieldMetadataType.RELATION &&
fieldNamesForUpdateInput.includes(
`${fieldMetadataItemToFilter.name}Id`,
)),
);
const updatedFieldMetadataItemIds =
updatedFieldMetadataItems.map(mapById);
const updateOnAFilteredField = currentRecordFilters.some((recordFilter) =>
updatedFieldMetadataItemIds.includes(recordFilter.fieldMetadataId),
);
const updateOnASortedField = currentRecordSorts.some((recordSort) =>
updatedFieldMetadataItemIds.includes(recordSort.fieldMetadataId),
);
if (updateOnAFilteredField) {
thereIsAnUpdateOnAFilteredField = true;
}
if (updateOnASortedField) {
thereIsAnUpdateOnASortedField = true;
}
}
if (updatedFieldNames.has('position')) {
return true;
}
return thereIsAnUpdateOnAFilteredField || thereIsAnUpdateOnASortedField;
};
return {
getShouldResetTableVirtualizationForUpdateInputs,
};
};
@@ -1,11 +0,0 @@
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { type ObjectOperation } from '@/object-record/states/objectOperationsComponentState';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { type Nullable } from 'twenty-shared/types';
export const lastObjectOperationThatResettedVirtualizationComponentState =
createComponentState<Nullable<ObjectOperation>>({
key: 'lastObjectOperationThatResettedVirtualizationComponentState',
componentInstanceContext: RecordTableComponentInstanceContext,
defaultValue: null,
});
@@ -1,39 +0,0 @@
import { createState } from 'twenty-ui/utilities';
export type ObjectOperationData =
| {
type: 'update-one';
result: {
updateInput: any;
};
}
| {
type: 'update-many';
result: {
updateInputs: any[];
};
}
| {
type:
| 'create-one'
| 'create-many'
| 'destroy-one'
| 'destroy-many'
| 'delete-one'
| 'delete-many'
| 'restore-one'
| 'restore-many'
| 'merge-records';
};
export type ObjectOperation = {
id: string;
objectMetadataItemId: string;
data: ObjectOperationData;
timestamp: number;
};
export const objectOperationsState = createState<ObjectOperation[]>({
key: 'objectOperationsState',
defaultValue: [],
});
@@ -0,0 +1,27 @@
import { type ObjectRecordOperationUpdateInput } from '@/object-record/types/ObjectRecordOperationUpdateInput';
export type ObjectRecordOperation =
| {
type: 'update-one';
result: {
updateInput: ObjectRecordOperationUpdateInput;
};
}
| {
type: 'update-many';
result: {
updateInputs: ObjectRecordOperationUpdateInput[];
};
}
| {
type:
| 'create-one'
| 'create-many'
| 'destroy-one'
| 'destroy-many'
| 'delete-one'
| 'delete-many'
| 'restore-one'
| 'restore-many'
| 'merge-records';
};
@@ -0,0 +1,7 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectRecordOperation } from '@/object-record/types/ObjectRecordOperation';
export type ObjectRecordOperationBrowserEventDetail = {
objectMetadataItem: ObjectMetadataItem;
operation: ObjectRecordOperation;
};
@@ -0,0 +1,4 @@
export type ObjectRecordOperationUpdateInput = {
recordId: string;
updatedFields: Record<string, unknown>[];
};
@@ -0,0 +1,12 @@
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/object-record/constants/ObjectRecordOperationBrowserEventName';
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
export const dispatchObjectRecordOperationBrowserEvent = (
detail: ObjectRecordOperationBrowserEventDetail,
) => {
window.dispatchEvent(
new CustomEvent(OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME, {
detail,
}),
);
};
@@ -0,0 +1,7 @@
export const getUpdatedFieldsFromRecordInput = (
recordInput: Record<string, unknown>,
): Record<string, unknown>[] => {
return Object.entries(recordInput)
.filter(([fieldName]) => fieldName !== 'id')
.map(([fieldName, fieldValue]) => ({ [fieldName]: fieldValue }));
};
@@ -1,7 +1,7 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useRegisterObjectOperation } from '@/object-record/hooks/useRegisterObjectOperation';
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
import { useStopWorkflowRunMutation } from '~/generated-metadata/graphql';
export const useStopWorkflowRun = () => {
@@ -9,7 +9,6 @@ export const useStopWorkflowRun = () => {
const [mutate] = useStopWorkflowRunMutation({
client: apolloCoreClient,
});
const { registerObjectOperation } = useRegisterObjectOperation();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
@@ -22,10 +21,16 @@ export const useStopWorkflowRun = () => {
},
});
registerObjectOperation(objectMetadataItem, {
type: 'update-one',
result: {
updateInput: { id: workflowRunId },
dispatchObjectRecordOperationBrowserEvent({
objectMetadataItem,
operation: {
type: 'update-one',
result: {
updateInput: {
recordId: workflowRunId,
updatedFields: [],
},
},
},
});
};