diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs index cf7dbc6fb15..958504f168c 100644 --- a/packages/twenty-front/jest.config.mjs +++ b/packages/twenty-front/jest.config.mjs @@ -61,9 +61,9 @@ const jestConfig = { extensionsToTreatAsEsm: ['.ts', '.tsx'], coverageThreshold: { global: { - statements: 53, - lines: 52, - functions: 42, + statements: 52, + lines: 51, + functions: 41, }, }, collectCoverageFrom: ['/src/**/*.ts'], diff --git a/packages/twenty-front/src/hooks/useHTMLElementByIdWhenAvailable.ts b/packages/twenty-front/src/hooks/useHTMLElementByIdWhenAvailable.ts index d88a8d48e0d..cbf97c3d842 100644 --- a/packages/twenty-front/src/hooks/useHTMLElementByIdWhenAvailable.ts +++ b/packages/twenty-front/src/hooks/useHTMLElementByIdWhenAvailable.ts @@ -14,6 +14,7 @@ export const useHTMLElementByIdWhenAvailable = (id: string) => { if (isDefined(elementFoundBeforeObservingMutation)) { setElement(elementFoundBeforeObservingMutation); + return; } diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts index ea30af60a88..6c0e00fb894 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts @@ -7,11 +7,12 @@ import { type TaskTarget } from '@/activities/types/TaskTarget'; import { getActivityTargetObjectRecords } from '@/activities/utils/getActivityTargetObjectRecords'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { type Nullable } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; export const useActivityTargetObjectRecords = ( activityRecordId?: string, - activityTargets?: NoteTarget[] | TaskTarget[], + activityTargets?: Nullable, ) => { const objectMetadataItems = useRecoilValue(objectMetadataItemsState); diff --git a/packages/twenty-front/src/modules/activities/utils/getActivityTargetObjectRecords.ts b/packages/twenty-front/src/modules/activities/utils/getActivityTargetObjectRecords.ts index f95eee76e16..8623abb8c5c 100644 --- a/packages/twenty-front/src/modules/activities/utils/getActivityTargetObjectRecords.ts +++ b/packages/twenty-front/src/modules/activities/utils/getActivityTargetObjectRecords.ts @@ -6,12 +6,13 @@ import { type TaskTarget } from '@/activities/types/TaskTarget'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { type Nullable } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; type GetActivityTargetObjectRecordsProps = { activityRecord: Note | Task; objectMetadataItems: ObjectMetadataItem[]; - activityTargets?: NoteTarget[] | TaskTarget[]; + activityTargets?: Nullable; }; export const getActivityTargetObjectRecords = ({ diff --git a/packages/twenty-front/src/modules/command-menu/pages/message-thread/hooks/__tests__/useEmailThreadInCommandMenu.test.tsx b/packages/twenty-front/src/modules/command-menu/pages/message-thread/hooks/__tests__/useEmailThreadInCommandMenu.test.tsx index 4399c1f75ee..bcc2d2ad34e 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/message-thread/hooks/__tests__/useEmailThreadInCommandMenu.test.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/message-thread/hooks/__tests__/useEmailThreadInCommandMenu.test.tsx @@ -41,12 +41,14 @@ const mocks = [ $orderBy: [MessageOrderByInput] $lastCursor: String $limit: Int + $offset: Int ) { messages( filter: $filter orderBy: $orderBy first: $limit after: $lastCursor + offset: $offset ) { edges { node { @@ -209,12 +211,14 @@ const mocks = [ $orderBy: [MessageParticipantOrderByInput] $lastCursor: String $limit: Int + $offset: Int ) { messageParticipants( filter: $filter orderBy: $orderBy first: $limit after: $lastCursor + offset: $offset ) { edges { node { diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFindManyRecordsQuery.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFindManyRecordsQuery.test.tsx index 0c7fd0afbeb..744e8d4f334 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFindManyRecordsQuery.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFindManyRecordsQuery.test.tsx @@ -1,13 +1,19 @@ import { renderHook } from '@testing-library/react'; -import { print } from 'graphql'; import { PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS } from '@/object-record/hooks/__mocks__/personFragments'; import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery'; +import { print } from 'graphql'; import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; const expectedQueryTemplate = ` - query FindManyPeople($filter: PersonFilterInput, $orderBy: [PersonOrderByInput], $lastCursor: String, $limit: Int) { - people(filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor) { + query FindManyPeople($filter: PersonFilterInput, $orderBy: [PersonOrderByInput], $lastCursor: String, $limit: Int, $offset: Int) { + people( + filter: $filter + orderBy: $orderBy + first: $limit + after: $lastCursor + offset: $offset + ) { edges { node { ${PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS} diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useLazyFetchAllRecords.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useLazyFetchAllRecords.test.tsx index 65e92c11dd8..b5c453f9343 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useLazyFetchAllRecords.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useLazyFetchAllRecords.test.tsx @@ -74,12 +74,14 @@ const mock: MockedResponse = { $orderBy: [PersonOrderByInput] $lastCursor: String $limit: Int + $offset: Int ) { people( filter: $filter orderBy: $orderBy first: $limit after: $lastCursor + offset: $offset ) { edges { node { diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useRecordIndexTableQuery.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useRecordIndexTableQuery.test.tsx index 2515979e87a..a9f26646c1c 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useRecordIndexTableQuery.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useRecordIndexTableQuery.test.tsx @@ -35,12 +35,14 @@ const mocks: MockedResponse[] = [ $orderBy: [PersonOrderByInput] $lastCursor: String $limit: Int + $offset: Int ) { people( filter: $filter orderBy: $orderBy first: $limit after: $lastCursor + offset: $offset ) { edges { node { diff --git a/packages/twenty-front/src/modules/object-record/hooks/useBatchCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useBatchCreateManyRecords.ts index 7e9b412081d..e1fdffcd2d0 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useBatchCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useBatchCreateManyRecords.ts @@ -6,6 +6,7 @@ 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 { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { ApolloError } from '@apollo/client'; @@ -26,6 +27,8 @@ export const useBatchCreateManyRecords = < setBatchedRecordsCount?: (count: number) => void; abortController?: AbortController; }) => { + const { registerObjectOperation } = useRegisterObjectOperation(); + const { createManyRecords } = useCreateManyRecords({ objectNameSingular, recordGqlFields, @@ -97,6 +100,8 @@ export const useBatchCreateManyRecords = < await refetchAggregateQueries(); + registerObjectOperation(objectNameSingular, { type: 'create-many' }); + return allCreatedRecords; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts index b40626f6d3b..c99e585c5ff 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts @@ -16,6 +16,7 @@ import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/ 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 { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput'; @@ -49,6 +50,8 @@ export const useCreateManyRecords = < shouldMatchRootQueryFilter, shouldRefetchAggregateQueries = true, }: useCreateManyRecordsProps) => { + const { registerObjectOperation } = useRegisterObjectOperation(); + const apolloCoreClient = useApolloCoreClient(); const { objectMetadataItem } = useObjectMetadataItem({ @@ -228,7 +231,11 @@ export const useCreateManyRecords = < throw error; }); - if (shouldRefetchAggregateQueries) await refetchAggregateQueries(); + if (shouldRefetchAggregateQueries) { + await refetchAggregateQueries(); + } + + registerObjectOperation(objectNameSingular, { type: 'create-many' }); return createdObjects.data?.[mutationResponseField] ?? []; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts index c69b275e69b..f62ba4f16a5 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts @@ -16,6 +16,7 @@ import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/ 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 { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { computeOptimisticCreateRecordBaseRecordInput } from '@/object-record/utils/computeOptimisticCreateRecordBaseRecordInput'; import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput'; @@ -38,6 +39,7 @@ export const useCreateOneRecord = < skipPostOptimisticEffect = false, shouldMatchRootQueryFilter, }: useCreateOneRecordProps) => { + const { registerObjectOperation } = useRegisterObjectOperation(); const apolloCoreClient = useApolloCoreClient(); const [loading, setLoading] = useState(false); @@ -174,6 +176,9 @@ export const useCreateOneRecord = < }); await refetchAggregateQueries(); + + registerObjectOperation(objectNameSingular, { type: 'create-one' }); + return createdObject.data?.[mutationResponseField] ?? null; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts index 3fad69e31ff..3630a1c263d 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts @@ -12,6 +12,7 @@ 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 { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField'; import { useRecoilValue } from 'recoil'; @@ -32,6 +33,7 @@ export type DeleteManyRecordsProps = { export const useDeleteManyRecords = ({ objectNameSingular, }: useDeleteManyRecordProps) => { + const { registerObjectOperation } = useRegisterObjectOperation(); const apiConfig = useRecoilValue(apiConfigState); const mutationPageSize = @@ -215,6 +217,11 @@ export const useDeleteManyRecords = ({ } } await refetchAggregateQueries(); + + registerObjectOperation(objectNameSingular, { + type: 'delete-many', + }); + return deletedRecords; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts index 738e2e6fc46..e5d8052c780 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts @@ -12,6 +12,7 @@ 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 { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/getDeleteOneRecordMutationResponseField'; import { isNull } from '@sniptt/guards'; @@ -24,6 +25,7 @@ type useDeleteOneRecordProps = { export const useDeleteOneRecord = ({ objectNameSingular, }: useDeleteOneRecordProps) => { + const { registerObjectOperation } = useRegisterObjectOperation(); const apolloCoreClient = useApolloCoreClient(); const { objectMetadataItem } = useObjectMetadataItem({ @@ -154,6 +156,11 @@ export const useDeleteOneRecord = ({ }); await refetchAggregateQueries(); + + registerObjectOperation(objectNameSingular, { + type: 'delete-one', + }); + return deletedRecord.data?.[mutationResponseField] ?? null; }, [ @@ -165,6 +172,8 @@ export const useDeleteOneRecord = ({ objectMetadataItems, objectPermissionsByObjectMetadataId, refetchAggregateQueries, + objectNameSingular, + registerObjectOperation, ], ); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts index cd7ec462687..fd42a39c1d2 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDestroyManyRecords.ts @@ -9,6 +9,7 @@ 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 { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField'; import { useRecoilValue } from 'recoil'; @@ -29,6 +30,7 @@ export type DestroyManyRecordsProps = { export const useDestroyManyRecords = ({ objectNameSingular, }: useDestroyManyRecordProps) => { + const { registerObjectOperation } = useRegisterObjectOperation(); const apiConfig = useRecoilValue(apiConfigState); const mutationPageSize = @@ -137,6 +139,11 @@ export const useDestroyManyRecords = ({ } await refetchAggregateQueries(); + + registerObjectOperation(objectNameSingular, { + type: 'destroy-many', + }); + return destroyedRecords; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDestroyOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useDestroyOneRecord.ts index 556b4a19b18..ec813cea4d5 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDestroyOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDestroyOneRecord.ts @@ -8,6 +8,7 @@ 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 { getDestroyOneRecordMutationResponseField } from '@/object-record/utils/getDestroyOneRecordMutationResponseField'; import { capitalize, isDefined } from 'twenty-shared/utils'; @@ -19,6 +20,8 @@ type useDestroyOneRecordProps = { export const useDestroyOneRecord = ({ objectNameSingular, }: useDestroyOneRecordProps) => { + const { registerObjectOperation } = useRegisterObjectOperation(); + const apolloCoreClient = useApolloCoreClient(); const { objectMetadataItem } = useObjectMetadataItem({ @@ -81,6 +84,10 @@ export const useDestroyOneRecord = ({ throw error; }); + registerObjectOperation(objectMetadataItem.nameSingular, { + type: 'destroy-one', + }); + return deletedRecord.data?.[mutationResponseField] ?? null; }, [ @@ -92,6 +99,7 @@ export const useDestroyOneRecord = ({ objectNameSingular, objectMetadataItems, objectPermissionsByObjectMetadataId, + registerObjectOperation, ], ); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useLazyFetchMoreRecordsWithPagination.ts b/packages/twenty-front/src/modules/object-record/hooks/useLazyFetchMoreRecordsWithPagination.ts index 6d13312c136..05e9c43ca53 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useLazyFetchMoreRecordsWithPagination.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useLazyFetchMoreRecordsWithPagination.ts @@ -24,6 +24,7 @@ import { type OnFindManyRecordsCompleted } from '@/object-record/types/OnFindMan import { filterUniqueRecordEdgesByCursor } from '@/object-record/utils/filterUniqueRecordEdgesByCursor'; import { getQueryIdentifier } from '@/object-record/utils/getQueryIdentifier'; +import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit'; import { capitalize, isDefined } from 'twenty-shared/utils'; import { cursorFamilyState } from '../states/cursorFamilyState'; import { hasNextPageFamilyState } from '../states/hasNextPageFamilyState'; @@ -88,7 +89,7 @@ export const useLazyFetchMoreRecordsWithPagination = < // This function is equivalent to merge function + read function in field policy const fetchMoreRecordsLazy = useRecoilCallback( ({ snapshot, set }) => - async () => { + async (limit = DEFAULT_SEARCH_REQUEST_LIMIT) => { const hasNextPageLocal = snapshot .getLoadable(hasNextPageFamilyState(queryIdentifier)) .getValue(); @@ -105,6 +106,7 @@ export const useLazyFetchMoreRecordsWithPagination = < try { const { data: fetchMoreDataResult } = await fetchMore({ variables: { + limit, filter, orderBy, lastCursor: isNonEmptyString(lastCursorLocal) diff --git a/packages/twenty-front/src/modules/object-record/hooks/useLazyFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useLazyFindManyRecords.ts index 402576cfd22..f9762663b74 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useLazyFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useLazyFindManyRecords.ts @@ -96,7 +96,7 @@ export const useLazyFindManyRecords = ({ return { data: null, - records: [], + records: null, totalCount: 0, hasNextPage: false, error: undefined, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useLazyFindManyRecordsWithOffset.ts b/packages/twenty-front/src/modules/object-record/hooks/useLazyFindManyRecordsWithOffset.ts new file mode 100644 index 00000000000..6267e2377d0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useLazyFindManyRecordsWithOffset.ts @@ -0,0 +1,105 @@ +import { useLazyQuery } from '@apollo/client'; +import { useRecoilCallback } from 'recoil'; + +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; +import { type RecordGqlOperationFindManyResult } from '@/object-record/graphql/types/RecordGqlOperationFindManyResult'; +import { type UseFindManyRecordsParams } from '@/object-record/hooks/useFindManyRecords'; +import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery'; +import { useHandleFindManyRecordsError } from '@/object-record/hooks/useHandleFindManyRecordsError'; +import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; + +import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields'; +import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/hooks/useFindManyRecordIndexTableParams'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; + +type UseLazyFindManyRecordsWithOffsetParams = Pick< + UseFindManyRecordsParams, + 'objectNameSingular' +>; + +export const useLazyFindManyRecordsWithOffset = ({ + objectNameSingular, +}: UseLazyFindManyRecordsWithOffsetParams) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const params = useFindManyRecordIndexTableParams(objectNameSingular); + + const recordGqlFields = useRecordsFieldVisibleGqlFields({ + objectMetadataItem, + }); + + const apolloCoreClient = useApolloCoreClient(); + + const { findManyRecordsQuery } = useFindManyRecordsQuery({ + objectNameSingular, + recordGqlFields, + }); + + const { handleFindManyRecordsError } = useHandleFindManyRecordsError({ + objectMetadataItem, + }); + + const objectPermissions = useObjectPermissionsForObject( + objectMetadataItem.id, + ); + + const hasReadPermission = objectPermissions.canReadObjectRecords; + + const [findManyRecords] = useLazyQuery( + findManyRecordsQuery, + { + variables: { + ...params, + }, + onError: handleFindManyRecordsError, + client: apolloCoreClient, + }, + ); + + const findManyRecordsLazyWithOffset = useRecoilCallback( + () => async (limit: number, offset: number) => { + if (!hasReadPermission) { + return { + data: null, + records: null, + totalCount: 0, + error: undefined, + }; + } + + const result = await findManyRecords({ + variables: { + limit, + offset, + }, + }); + + const records = getRecordsFromRecordConnection({ + recordConnection: { + edges: result?.data?.[objectMetadataItem.namePlural]?.edges ?? [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + }, + }); + + return { + data: result?.data, + records, + error: result?.error, + }; + }, + [hasReadPermission, findManyRecords, objectMetadataItem.namePlural], + ); + + return { + findManyRecordsLazyWithOffset, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useLoadSelectedRecordsInContextStore.tsx b/packages/twenty-front/src/modules/object-record/hooks/useLoadSelectedRecordsInContextStore.tsx index 65dd4cfdf3d..bee9b29e388 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useLoadSelectedRecordsInContextStore.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/useLoadSelectedRecordsInContextStore.tsx @@ -55,8 +55,9 @@ export const useLoadSelectedRecordsInContextStore = ({ objectRecordIds.length, ); - const records = await findManyRecordsLazy(); - upsertRecords(records.records); + const { records } = await findManyRecordsLazy(); + + upsertRecords(records ?? []); }; }, [objectRecordIds, objectMetadataItemId, findManyRecordsLazy, upsertRecords], diff --git a/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts index 4f8630cad38..688bb7285be 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts @@ -8,6 +8,7 @@ import { useFindDuplicateRecordsQuery } from '@/object-record/hooks/useFindDupli 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 { getMergeManyRecordsMutationResponseField } from '@/object-record/utils/getMergeManyRecordsMutationResponseField'; import { getOperationName } from '@apollo/client/utilities'; @@ -27,6 +28,7 @@ export const useMergeManyRecords = < objectNameSingular, recordGqlFields, }: UseMergeManyRecordsProps) => { + const { registerObjectOperation } = useRegisterObjectOperation(); const apolloCoreClient = useApolloCoreClient(); const [loading, setLoading] = useState(false); @@ -103,6 +105,10 @@ export const useMergeManyRecords = < await refetchAggregateQueries(); } + registerObjectOperation(objectNameSingular, { + type: 'merge-records', + }); + return mergedObject.data?.[mutationResponseField] ?? null; } catch (error) { setLoading(false); @@ -116,6 +122,8 @@ export const useMergeManyRecords = < mergeManyRecordsMutation, objectMetadataItem.namePlural, refetchAggregateQueries, + registerObjectOperation, + objectNameSingular, ], ); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useRegisterObjectOperation.ts b/packages/twenty-front/src/modules/object-record/hooks/useRegisterObjectOperation.ts new file mode 100644 index 00000000000..446ec1e5cca --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useRegisterObjectOperation.ts @@ -0,0 +1,35 @@ +import { + type ObjectOperationData, + objectOperationsByObjectNameSingularFamilyState, +} from '@/object-record/states/objectOperationsByObjectNameSingularFamilyState'; +import { useRecoilCallback } from 'recoil'; +import { v4 } from 'uuid'; + +export const useRegisterObjectOperation = () => { + const registerObjectOperation = useRecoilCallback( + ({ set }) => + (objectNameSingular: string, data: ObjectOperationData) => { + set( + objectOperationsByObjectNameSingularFamilyState({ + objectNameSingular, + }), + (currentValue) => { + const newValue = currentValue.concat(); + + newValue.push({ + id: v4(), + timestamp: +new Date(), + data, + }); + + return newValue; + }, + ); + }, + [], + ); + + return { + registerObjectOperation, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useRestoreManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useRestoreManyRecords.ts index e44d123c528..d7696b41e5f 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useRestoreManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useRestoreManyRecords.ts @@ -9,6 +9,7 @@ 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 { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField'; @@ -30,6 +31,8 @@ type RestoreManyRecordsProps = { export const useRestoreManyRecords = ({ objectNameSingular, }: useRestoreManyRecordProps) => { + const { registerObjectOperation } = useRegisterObjectOperation(); + const apiConfig = useRecoilValue(apiConfigState); const mutationPageSize = @@ -191,6 +194,10 @@ export const useRestoreManyRecords = ({ restoredRecords.push(...restoredRecordsForThisBatch); + registerObjectOperation(objectMetadataItem.nameSingular, { + type: 'restore-many', + }); + if (isDefined(delayInMsBetweenRequests)) { await sleep(delayInMsBetweenRequests); } diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts index c522706ce24..9b666f38ad8 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts @@ -11,6 +11,7 @@ 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 { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput'; @@ -36,6 +37,7 @@ export const useUpdateOneRecord = < objectNameSingular, recordGqlFields, }: useUpdateOneRecordProps) => { + const { registerObjectOperation } = useRegisterObjectOperation(); const apolloCoreClient = useApolloCoreClient(); const { objectMetadataItem } = useObjectMetadataItem({ @@ -217,7 +219,15 @@ export const useUpdateOneRecord = < }); await refetchAggregateQueries(); - return updatedRecord?.data?.[mutationResponseField] ?? null; + + const udpatedRecord = updatedRecord?.data?.[mutationResponseField] ?? null; + + registerObjectOperation(objectNameSingular, { + type: 'update-one', + result: { updatedRecord, updateInput: updateOneRecordInput }, + }); + + return udpatedRecord; }; return { diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useSelectAllCards.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useSelectAllCards.ts index cc67a385548..fde616066b8 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useSelectAllCards.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useSelectAllCards.ts @@ -3,7 +3,7 @@ import { useRecoilCallback } from 'recoil'; import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState'; import { allCardsSelectedStatusComponentSelector } from '@/object-record/record-board/states/selectors/allCardsSelectedStatusComponentSelector'; -import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { allRecordIdsOfAllRecordGroupsComponentSelector } from '@/object-record/record-index/states/selectors/allRecordIdsOfAllRecordGroupsComponentSelector'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; @@ -16,10 +16,11 @@ export const useSelectAllCards = (recordBoardId?: string) => { isRecordBoardCardSelectedComponentFamilyState, recordBoardId, ); - const recordIndexAllRecordIdsSelector = useRecoilComponentCallbackState( - recordIndexAllRecordIdsComponentSelector, - recordBoardId, - ); + const allRecordIdsOfAllRecordGroupsCallbackSelector = + useRecoilComponentCallbackState( + allRecordIdsOfAllRecordGroupsComponentSelector, + recordBoardId, + ); const { resetRecordSelection } = useRecordBoardSelection(recordBoardId); @@ -33,7 +34,7 @@ export const useSelectAllCards = (recordBoardId?: string) => { const allRecordIds = getSnapshotValue( snapshot, - recordIndexAllRecordIdsSelector, + allRecordIdsOfAllRecordGroupsCallbackSelector, ); if (allCardsSelectedStatus === 'all') { @@ -50,7 +51,7 @@ export const useSelectAllCards = (recordBoardId?: string) => { }, [ allCardsSelectedStatusSelector, - recordIndexAllRecordIdsSelector, + allRecordIdsOfAllRecordGroupsCallbackSelector, resetRecordSelection, isRecordBoardCardSelectedFamilyState, ], diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/allCardsSelectedStatusComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/allCardsSelectedStatusComponentSelector.ts index 0bcccdf934a..6d802878ec6 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/allCardsSelectedStatusComponentSelector.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/allCardsSelectedStatusComponentSelector.ts @@ -1,6 +1,6 @@ import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector'; -import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { allRecordIdsOfAllRecordGroupsComponentSelector } from '@/object-record/record-index/states/selectors/allRecordIdsOfAllRecordGroupsComponentSelector'; import { type AllRowsSelectedStatus } from '@/object-record/record-table/types/AllRowSelectedStatus'; import { createComponentSelector } from '@/ui/utilities/state/component-state/utils/createComponentSelector'; @@ -12,7 +12,7 @@ export const allCardsSelectedStatusComponentSelector = ({ instanceId }) => ({ get }) => { const allRecordIds = get( - recordIndexAllRecordIdsComponentSelector.selectorFamily({ + allRecordIdsOfAllRecordGroupsComponentSelector.selectorFamily({ instanceId, }), ); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/board/hooks/useRecordBoardDragOperations.ts b/packages/twenty-front/src/modules/object-record/record-drag/board/hooks/useRecordBoardDragOperations.ts index f0c97ccd932..6d93b2bd5a2 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/board/hooks/useRecordBoardDragOperations.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/board/hooks/useRecordBoardDragOperations.ts @@ -25,13 +25,12 @@ export const useRecordBoardDragOperations = () => { result, snapshot, selectedRecordIds, - selectFieldName: selectFieldMetadataItem.name, recordIdsByGroupFamilyState: recordIndexRecordIdsByGroupFamilyState, - onUpdateRecord: ({ recordId, position, groupValue }) => { + onUpdateRecord: ({ recordId, position }, targetRecordGroupValue) => { updateOneRecord({ idToUpdate: recordId, updateOneRecordInput: { - [selectFieldMetadataItem.name]: groupValue, + [selectFieldMetadataItem.name]: targetRecordGroupValue, position, }, }); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/calendar/hooks/useHandleDragOneCalendarCard.ts b/packages/twenty-front/src/modules/object-record/record-drag/calendar/hooks/useHandleDragOneCalendarCard.ts index be677c62a26..a807fb06d6b 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/calendar/hooks/useHandleDragOneCalendarCard.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/calendar/hooks/useHandleDragOneCalendarCard.ts @@ -4,10 +4,10 @@ import { useRecoilCallback } from 'recoil'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext'; import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector'; -import { calculateDragPositions } from '@/object-record/record-drag/shared/utils/calculateDragPositions'; import { extractRecordPositions } from '@/object-record/record-drag/shared/utils/extractRecordPositions'; import { isFieldDateTime } from '@/object-record/record-field/ui/types/guards/isFieldDateTime'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { computeNewPositionOfDraggedRecord } from '@/object-record/utils/computeNewPositionOfDraggedRecord'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; import { @@ -58,19 +58,18 @@ export const useHandleDragOneCalendarCard = () => { .getLoadable(calendarDayRecordIdsSelector(destinationDate)) .getValue() as string[]; - const recordPositionData = extractRecordPositions( + const recordsWithPosition = extractRecordPositions( destinationRecordIds, snapshot, ); - const positions = calculateDragPositions({ - recordIds: destinationRecordIds, - recordsToMove: [recordId], - destinationIndex, - recordPositionData, - }); + const targetRecordId = destinationRecordIds[destinationIndex]; - const newPosition = positions[recordId]; + const newPosition = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: recordsWithPosition, + idOfItemToMove: recordId, + idOfTargetItem: targetRecordId, + }); const targetDate = parse(destinationDate, 'yyyy-MM-dd', new Date()); const currentFieldValue = record[calendarFieldMetadata.name]; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/types/MultiDragResult.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/MultiDragResult.ts index 80280eb2ac6..0d09a6c8cab 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/types/MultiDragResult.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/MultiDragResult.ts @@ -1,5 +1,5 @@ -import { type RecordDragUpdate } from '@/object-record/record-drag/shared/types/RecordDragUpdate'; +import { type RecordWithPosition } from '@/object-record/utils/computeNewPositionOfDraggedRecord'; export type MultiDragResult = { - recordUpdates: RecordDragUpdate[]; + recordUpdates: RecordWithPosition[]; }; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragPositionData.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragPositionData.ts deleted file mode 100644 index 1be7b4a920f..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragPositionData.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type RecordDragPositionData = { - recordId: string; - position?: number; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragUpdate.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragUpdate.ts deleted file mode 100644 index 79f581a56d2..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/types/RecordDragUpdate.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type RecordDragUpdate = { - recordId: string; - position: number; - groupValue?: string | null; - selectFieldName?: string; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/calculateDragPositions.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/calculateDragPositions.test.ts deleted file mode 100644 index 666e15c0cde..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/calculateDragPositions.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { type RecordDragPositionData } from '@/object-record/record-drag/shared/types/RecordDragPositionData'; -import { calculateDragPositions } from '@/object-record/record-drag/shared/utils/calculateDragPositions'; - -describe('calculateDragPositions', () => { - const mockRecordPositionData: RecordDragPositionData[] = [ - { recordId: 'record-1', position: 1 }, - { recordId: 'record-2', position: 2 }, - { recordId: 'record-3', position: 3 }, - { recordId: 'record-4', position: 4 }, - { recordId: 'record-5', position: 5 }, - ]; - - describe('Single record drag', () => { - it('should calculate position when moving record forward', () => { - const result = calculateDragPositions({ - recordIds: ['record-1', 'record-2', 'record-3', 'record-4', 'record-5'], - recordsToMove: ['record-2'], - destinationIndex: 3, - recordPositionData: mockRecordPositionData, - }); - - expect(result['record-2']).toBeGreaterThan(3); - expect(result['record-2']).toBeLessThan(5); - }); - - it('should calculate position when moving record backward', () => { - const result = calculateDragPositions({ - recordIds: ['record-1', 'record-2', 'record-3', 'record-4'], - recordsToMove: ['record-4'], - destinationIndex: 1, - recordPositionData: mockRecordPositionData, - }); - - expect(result['record-4']).toBeGreaterThan(1); - expect(result['record-4']).toBeLessThan(2); - }); - - it('should handle moving to the beginning', () => { - const result = calculateDragPositions({ - recordIds: ['record-1', 'record-2', 'record-3'], - recordsToMove: ['record-3'], - destinationIndex: 0, - recordPositionData: mockRecordPositionData, - }); - - expect(result['record-3']).toBeLessThan(1); - }); - - it('should handle moving to the end', () => { - const result = calculateDragPositions({ - recordIds: ['record-1', 'record-2', 'record-3'], - recordsToMove: ['record-1'], - destinationIndex: 2, - recordPositionData: mockRecordPositionData, - }); - - expect(result['record-1']).toBeGreaterThan(3); - }); - }); - - describe('Multi record drag', () => { - it('should calculate positions for multiple records with proper spacing', () => { - const result = calculateDragPositions({ - recordIds: ['record-1', 'record-2', 'record-3', 'record-4', 'record-5'], - recordsToMove: ['record-1', 'record-3'], - destinationIndex: 3, - recordPositionData: mockRecordPositionData, - }); - - expect(result['record-1']).toBeGreaterThan(4); - expect(result['record-1']).toBeLessThan(5); - expect(result['record-3']).toBeGreaterThan(result['record-1']); - expect(result['record-3']).toBeLessThan(5); - }); - - it('should maintain order of dragged records', () => { - const result = calculateDragPositions({ - recordIds: ['record-1', 'record-2', 'record-3', 'record-4', 'record-5'], - recordsToMove: ['record-2', 'record-3', 'record-4'], - destinationIndex: 0, - recordPositionData: mockRecordPositionData, - }); - - expect(result['record-2']).toBeLessThan(result['record-3']); - expect(result['record-3']).toBeLessThan(result['record-4']); - expect(result['record-4']).toBeLessThan(1); - }); - - it('should handle moving multiple records to the end', () => { - const result = calculateDragPositions({ - recordIds: ['record-1', 'record-2', 'record-3', 'record-4'], - recordsToMove: ['record-1', 'record-2'], - destinationIndex: 2, - recordPositionData: mockRecordPositionData, - }); - - expect(typeof result['record-1']).toBe('number'); - expect(result['record-2']).toBeGreaterThan(result['record-1']); - }); - }); - - describe('Edge cases', () => { - it('should handle undefined positions', () => { - const dataWithUndefined: RecordDragPositionData[] = [ - { recordId: 'record-1', position: undefined }, - { recordId: 'record-2', position: 2 }, - { recordId: 'record-3', position: undefined }, - ]; - - const result = calculateDragPositions({ - recordIds: ['record-1', 'record-2', 'record-3'], - recordsToMove: ['record-1'], - destinationIndex: 1, - recordPositionData: dataWithUndefined, - }); - - expect(typeof result['record-1']).toBe('number'); - expect(isNaN(result['record-1'])).toBe(false); - }); - - it('should handle empty record arrays', () => { - const result = calculateDragPositions({ - recordIds: [], - recordsToMove: ['record-1'], - destinationIndex: 0, - recordPositionData: [], - }); - - expect(typeof result['record-1']).toBe('number'); - }); - - it('should handle negative positions', () => { - const negativeData: RecordDragPositionData[] = [ - { recordId: 'record-1', position: -5 }, - { recordId: 'record-2', position: -3 }, - { recordId: 'record-3', position: -1 }, - ]; - - const result = calculateDragPositions({ - recordIds: ['record-1', 'record-2', 'record-3'], - recordsToMove: ['record-1'], - destinationIndex: 2, - recordPositionData: negativeData, - }); - - expect(result['record-1']).toBeGreaterThan(-1); - }); - - it('should handle very small gaps between positions', () => { - const tightData: RecordDragPositionData[] = [ - { recordId: 'record-1', position: 1 }, - { recordId: 'record-2', position: 1.0001 }, - { recordId: 'record-3', position: 1.0002 }, - ]; - - const result = calculateDragPositions({ - recordIds: ['record-1', 'record-2', 'record-3'], - recordsToMove: ['record-3'], - destinationIndex: 0, - recordPositionData: tightData, - }); - - expect(result['record-3']).toBeLessThan(1); - }); - - it('should handle single record in list', () => { - const result = calculateDragPositions({ - recordIds: ['record-1'], - recordsToMove: ['record-1'], - destinationIndex: 0, - recordPositionData: [{ recordId: 'record-1', position: 1 }], - }); - - expect(typeof result['record-1']).toBe('number'); - }); - - it('should preserve relative order when moving multiple non-consecutive records', () => { - const result = calculateDragPositions({ - recordIds: ['record-1', 'record-2', 'record-3', 'record-4', 'record-5'], - recordsToMove: ['record-1', 'record-3', 'record-5'], - destinationIndex: 1, - recordPositionData: mockRecordPositionData, - }); - - const positions = ['record-1', 'record-3', 'record-5'].map( - (id) => result[id], - ); - - expect(positions[0]).toBeLessThan(positions[1]); - expect(positions[1]).toBeLessThan(positions[2]); - expect(typeof positions[2]).toBe('number'); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/extractRecordPositions.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/extractRecordPositions.test.ts index f5e789ab64c..63bcea6b3d2 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/extractRecordPositions.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/extractRecordPositions.test.ts @@ -36,9 +36,9 @@ describe('extractRecordPositions', () => { const result = extractRecordPositions(allRecordIds, snapshot); expect(result).toEqual([ - { recordId: 'record-1', position: 1 }, - { recordId: 'record-2', position: 2 }, - { recordId: 'record-3', position: 3 }, + { id: 'record-1', position: 1 }, + { id: 'record-2', position: 2 }, + { id: 'record-3', position: 3 }, ]); }); @@ -55,9 +55,9 @@ describe('extractRecordPositions', () => { const result = extractRecordPositions(allRecordIds, snapshot); expect(result).toEqual([ - { recordId: 'record-1', position: 1 }, - { recordId: 'record-2', position: undefined }, - { recordId: 'record-3', position: undefined }, + { id: 'record-1', position: 1 }, + { id: 'record-2', position: undefined }, + { id: 'record-3', position: undefined }, ]); }); @@ -72,9 +72,9 @@ describe('extractRecordPositions', () => { const result = extractRecordPositions(allRecordIds, snapshot); expect(result).toEqual([ - { recordId: 'record-1', position: 1 }, - { recordId: 'record-2', position: undefined }, - { recordId: 'non-existent', position: undefined }, + { id: 'record-1', position: 1 }, + { id: 'record-2', position: undefined }, + { id: 'non-existent', position: undefined }, ]); }); @@ -100,9 +100,9 @@ describe('extractRecordPositions', () => { const result = extractRecordPositions(allRecordIds, snapshot); expect(result).toEqual([ - { recordId: 'record-3', position: 75 }, - { recordId: 'record-1', position: 100 }, - { recordId: 'record-2', position: 50 }, + { id: 'record-3', position: 75 }, + { id: 'record-1', position: 100 }, + { id: 'record-2', position: 50 }, ]); }); @@ -118,8 +118,8 @@ describe('extractRecordPositions', () => { const result = extractRecordPositions(allRecordIds, snapshot); expect(result).toEqual([ - { recordId: 'record-1', position: null }, - { recordId: 'record-2', position: 2 }, + { id: 'record-1', position: null }, + { id: 'record-2', position: 2 }, ]); }); @@ -135,8 +135,8 @@ describe('extractRecordPositions', () => { const result = extractRecordPositions(allRecordIds, snapshot); expect(result).toEqual([ - { recordId: 'record-1', position: -5 }, - { recordId: 'record-2', position: -10 }, + { id: 'record-1', position: -5 }, + { id: 'record-2', position: -10 }, ]); }); @@ -152,8 +152,8 @@ describe('extractRecordPositions', () => { const result = extractRecordPositions(allRecordIds, snapshot); expect(result).toEqual([ - { recordId: 'record-1', position: 1.5 }, - { recordId: 'record-2', position: 2.75 }, + { id: 'record-1', position: 1.5 }, + { id: 'record-2', position: 2.75 }, ]); }); }); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/getDraggedRecordPosition.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/getDraggedRecordPosition.test.ts deleted file mode 100644 index dbac58d0fb9..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/getDraggedRecordPosition.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { getDraggedRecordPosition } from '@/object-record/record-drag/shared/utils/getDraggedRecordPosition'; - -describe('getDraggedRecordPosition', () => { - it('when both records defined and positive, should return the average of the two positions', () => { - expect(getDraggedRecordPosition(1, 3)).toBe(2); - }); - - it('when both records defined and negative, should return the average of the two positions', () => { - expect(getDraggedRecordPosition(-3, -1)).toBe(-2); - }); - - it('when both records defined and one negative, should return the average of the two positions', () => { - expect(getDraggedRecordPosition(-1, 3)).toBe(1); - }); - - it('when only record after, should return the position - 1', () => { - expect(getDraggedRecordPosition(undefined, 3)).toBe(2); - }); - - it('when only record before, should return the position + 1', () => { - expect(getDraggedRecordPosition(1, undefined)).toBe(2); - }); - - it('when both records undefined, should return 1', () => { - expect(getDraggedRecordPosition(undefined, undefined)).toBe(1); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processGroupDragOperation.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processGroupDragOperation.test.ts deleted file mode 100644 index dfa8a24f10c..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processGroupDragOperation.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { type DropResult } from '@hello-pangea/dnd'; -import { type Snapshot } from 'recoil'; - -import { processGroupDragOperation } from '../processGroupDragOperation'; - -jest.mock('../getDragOperationType'); -jest.mock('../extractRecordPositions'); -jest.mock('../processSingleDrag'); -jest.mock('../processMultiDrag'); -jest.mock('@/ui/utilities/state/utils/getSnapshotValue'); - -import { getDragOperationType } from '../getDragOperationType'; -import { extractRecordPositions } from '../extractRecordPositions'; -import { processSingleDrag } from '../processSingleDrag'; -import { processMultiDrag } from '../processMultiDrag'; -import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; - -const mockGetDragOperationType = getDragOperationType as jest.Mock; -const mockExtractRecordPositions = extractRecordPositions as jest.Mock; -const mockProcessSingleDrag = processSingleDrag as jest.Mock; -const mockProcessMultiDrag = processMultiDrag as jest.Mock; -const mockGetSnapshotValue = getSnapshotValue as jest.Mock; - -describe('processGroupDragOperation', () => { - const mockSnapshot = {} as Snapshot; - const mockOnUpdateRecord = jest.fn(); - const mockRecordIdsByGroupFamilyState = jest.fn(); - - const createDropResult = (destination?: { - droppableId: string; - index: number; - }): DropResult => ({ - draggableId: 'record-1', - type: 'DEFAULT', - source: { - droppableId: 'group-1', - index: 0, - }, - destination: destination ?? null, - reason: 'DROP', - mode: 'FLUID', - combine: null, - }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('Early returns', () => { - it('should return early when destination is not defined', () => { - const result = createDropResult(); - - processGroupDragOperation({ - result, - snapshot: mockSnapshot, - selectedRecordIds: ['record-1'], - selectFieldName: 'status', - recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, - onUpdateRecord: mockOnUpdateRecord, - }); - - expect(mockGetSnapshotValue).not.toHaveBeenCalled(); - expect(mockOnUpdateRecord).not.toHaveBeenCalled(); - }); - - it('should throw error when record group is not defined', () => { - const result = createDropResult({ droppableId: 'group-2', index: 1 }); - - mockGetSnapshotValue.mockReturnValueOnce(undefined); - - expect(() => { - processGroupDragOperation({ - result, - snapshot: mockSnapshot, - selectedRecordIds: ['record-1'], - selectFieldName: 'status', - recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, - onUpdateRecord: mockOnUpdateRecord, - }); - }).toThrow('Record group is not defined'); - }); - }); - - describe('Single drag operation', () => { - it('should process single drag operation successfully', () => { - const result = createDropResult({ droppableId: 'group-2', index: 1 }); - const mockRecordGroup = { - value: 'in-progress', - fieldMetadataId: 'field-1', - }; - const mockDestinationRecordIds = ['record-2', 'record-3']; - const mockRecordPositionData = [ - { recordId: 'record-2', position: 1 }, - { recordId: 'record-3', position: 2 }, - ]; - - mockGetSnapshotValue - .mockReturnValueOnce(mockRecordGroup) - .mockReturnValueOnce(mockDestinationRecordIds); - - mockExtractRecordPositions.mockReturnValue(mockRecordPositionData); - mockGetDragOperationType.mockReturnValue('single'); - mockProcessSingleDrag.mockReturnValue({ - recordId: 'record-1', - position: 1.5, - groupValue: 'in-progress', - }); - - processGroupDragOperation({ - result, - snapshot: mockSnapshot, - selectedRecordIds: ['record-1'], - selectFieldName: 'status', - recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, - onUpdateRecord: mockOnUpdateRecord, - }); - - expect(mockGetDragOperationType).toHaveBeenCalledWith({ - draggedRecordId: 'record-1', - selectedRecordIds: ['record-1'], - }); - - expect(mockProcessSingleDrag).toHaveBeenCalledWith({ - result, - recordPositionData: mockRecordPositionData, - recordIds: mockDestinationRecordIds, - groupValue: 'in-progress', - selectFieldName: 'status', - }); - - expect(mockOnUpdateRecord).toHaveBeenCalledWith({ - recordId: 'record-1', - position: 1.5, - groupValue: 'in-progress', - selectFieldName: 'status', - }); - }); - - it('should return early when single drag has no position', () => { - const result = createDropResult({ droppableId: 'group-2', index: 1 }); - const mockRecordGroup = { value: 'done', fieldMetadataId: 'field-1' }; - const mockDestinationRecordIds = ['record-2']; - - mockGetSnapshotValue - .mockReturnValueOnce(mockRecordGroup) - .mockReturnValueOnce(mockDestinationRecordIds); - - mockExtractRecordPositions.mockReturnValue([]); - mockGetDragOperationType.mockReturnValue('single'); - mockProcessSingleDrag.mockReturnValue({ - recordId: 'record-1', - position: undefined, - groupValue: 'done', - }); - - processGroupDragOperation({ - result, - snapshot: mockSnapshot, - selectedRecordIds: ['record-1'], - selectFieldName: 'status', - recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, - onUpdateRecord: mockOnUpdateRecord, - }); - - expect(mockOnUpdateRecord).not.toHaveBeenCalled(); - }); - }); - - describe('Multi drag operation', () => { - it('should process multi drag operation successfully', () => { - const result = createDropResult({ droppableId: 'group-2', index: 1 }); - const mockRecordGroup = { - value: 'in-review', - fieldMetadataId: 'field-1', - }; - const mockDestinationRecordIds = ['record-4', 'record-5']; - const mockRecordPositionData = [ - { recordId: 'record-4', position: 1 }, - { recordId: 'record-5', position: 2 }, - ]; - const selectedRecordIds = ['record-1', 'record-2', 'record-3']; - - mockGetSnapshotValue - .mockReturnValueOnce(mockRecordGroup) - .mockReturnValueOnce(mockDestinationRecordIds); - - mockExtractRecordPositions.mockReturnValue(mockRecordPositionData); - mockGetDragOperationType.mockReturnValue('multi'); - mockProcessMultiDrag.mockReturnValue({ - recordUpdates: [ - { recordId: 'record-1', position: 1.3 }, - { recordId: 'record-2', position: 1.6 }, - { recordId: 'record-3', position: 1.9 }, - ], - }); - - processGroupDragOperation({ - result, - snapshot: mockSnapshot, - selectedRecordIds, - selectFieldName: 'priority', - recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, - onUpdateRecord: mockOnUpdateRecord, - }); - - expect(mockGetDragOperationType).toHaveBeenCalledWith({ - draggedRecordId: 'record-1', - selectedRecordIds, - }); - - expect(mockProcessMultiDrag).toHaveBeenCalledWith({ - result, - selectedRecordIds, - recordPositionData: mockRecordPositionData, - recordIds: mockDestinationRecordIds, - groupValue: 'in-review', - selectFieldName: 'priority', - }); - - expect(mockOnUpdateRecord).toHaveBeenCalledTimes(3); - expect(mockOnUpdateRecord).toHaveBeenNthCalledWith(1, { - recordId: 'record-1', - position: 1.3, - groupValue: 'in-review', - selectFieldName: 'priority', - }); - expect(mockOnUpdateRecord).toHaveBeenNthCalledWith(2, { - recordId: 'record-2', - position: 1.6, - groupValue: 'in-review', - selectFieldName: 'priority', - }); - expect(mockOnUpdateRecord).toHaveBeenNthCalledWith(3, { - recordId: 'record-3', - position: 1.9, - groupValue: 'in-review', - selectFieldName: 'priority', - }); - }); - - it('should handle empty multi drag result', () => { - const result = createDropResult({ droppableId: 'group-2', index: 0 }); - const mockRecordGroup = { value: null, fieldMetadataId: 'field-1' }; - const mockDestinationRecordIds: string[] = []; - - mockGetSnapshotValue - .mockReturnValueOnce(mockRecordGroup) - .mockReturnValueOnce(mockDestinationRecordIds); - - mockExtractRecordPositions.mockReturnValue([]); - mockGetDragOperationType.mockReturnValue('multi'); - mockProcessMultiDrag.mockReturnValue({ - recordUpdates: [], - }); - - processGroupDragOperation({ - result, - snapshot: mockSnapshot, - selectedRecordIds: ['record-1', 'record-2'], - selectFieldName: 'category', - recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, - onUpdateRecord: mockOnUpdateRecord, - }); - - expect(mockOnUpdateRecord).not.toHaveBeenCalled(); - }); - }); - - describe('Group value handling', () => { - it('should handle null group value correctly', () => { - const result = createDropResult({ droppableId: 'no-group', index: 0 }); - const mockRecordGroup = { value: null, fieldMetadataId: 'field-1' }; - const mockDestinationRecordIds = ['record-1']; - - mockGetSnapshotValue - .mockReturnValueOnce(mockRecordGroup) - .mockReturnValueOnce(mockDestinationRecordIds); - - mockExtractRecordPositions.mockReturnValue([]); - mockGetDragOperationType.mockReturnValue('single'); - mockProcessSingleDrag.mockReturnValue({ - recordId: 'record-1', - position: 1, - groupValue: null, - }); - - processGroupDragOperation({ - result, - snapshot: mockSnapshot, - selectedRecordIds: ['record-1'], - selectFieldName: 'status', - recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, - onUpdateRecord: mockOnUpdateRecord, - }); - - expect(mockOnUpdateRecord).toHaveBeenCalledWith({ - recordId: 'record-1', - position: 1, - groupValue: null, - selectFieldName: 'status', - }); - }); - - it('should pass correct group value to processors', () => { - const result = createDropResult({ - droppableId: 'custom-group', - index: 2, - }); - const mockRecordGroup = { - value: 'custom-value', - fieldMetadataId: 'field-custom', - }; - const mockDestinationRecordIds = ['record-a', 'record-b', 'record-c']; - - mockGetSnapshotValue - .mockReturnValueOnce(mockRecordGroup) - .mockReturnValueOnce(mockDestinationRecordIds); - - mockExtractRecordPositions.mockReturnValue([]); - mockGetDragOperationType.mockReturnValue('single'); - mockProcessSingleDrag.mockReturnValue({ - recordId: 'record-1', - position: 2.5, - }); - - processGroupDragOperation({ - result, - snapshot: mockSnapshot, - selectedRecordIds: ['record-1'], - selectFieldName: 'customField', - recordIdsByGroupFamilyState: mockRecordIdsByGroupFamilyState, - onUpdateRecord: mockOnUpdateRecord, - }); - - expect(mockProcessSingleDrag).toHaveBeenCalledWith( - expect.objectContaining({ - groupValue: 'custom-value', - selectFieldName: 'customField', - }), - ); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processMultiDrag.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processMultiDrag.test.ts index 9b12ac53670..2360909d435 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processMultiDrag.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processMultiDrag.test.ts @@ -1,211 +1,69 @@ -import { type RecordDragPositionData } from '@/object-record/record-drag/shared/types/RecordDragPositionData'; import { processMultiDrag } from '@/object-record/record-drag/shared/utils/processMultiDrag'; -import { type DropResult } from '@hello-pangea/dnd'; - -jest.mock( - '@/object-record/record-drag/shared/utils/calculateDragPositions', - () => ({ - calculateDragPositions: jest.fn(({ recordsToMove }) => { - const positions: Record = {}; - recordsToMove.forEach((id: string, index: number) => { - positions[id] = 1500 + index * 500; - }); - return positions; - }), - }), -); +import { type RecordWithPosition } from '@/object-record/utils/computeNewPositionOfDraggedRecord'; describe('processMultiDrag', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const mockRecordPositionData: RecordDragPositionData[] = [ - { recordId: 'record-1', position: 1000 }, - { recordId: 'record-2', position: 2000 }, - { recordId: 'record-3', position: 3000 }, - { recordId: 'record-4', position: 4000 }, + const recordsWithPosition: RecordWithPosition[] = [ + { id: 'record-1', position: 1000 }, + { id: 'record-2', position: 2000 }, + { id: 'record-3', position: 3000 }, + { id: 'record-4', position: 4000 }, ]; - const createDropResult = ( - draggableId: string, - destinationIndex: number | null, - ): DropResult => ({ - draggableId, - type: 'record', - source: { - droppableId: 'source', - index: 0, - }, - destination: - destinationIndex !== null - ? { - droppableId: 'destination', - index: destinationIndex, - } - : null, - reason: 'DROP', - mode: 'FLUID', - combine: null, + it('should process multi drag for table and drop at the end', () => { + const selectedRecordIds = ['record-1', 'record-2', 'record-3']; + + const dragResult = processMultiDrag({ + draggedRecordId: 'record-2', + targetRecordId: 'record-4', + selectedRecordIds, + recordsWithPosition, + }); + + expect(dragResult.recordUpdates).toEqual([ + { id: 'record-1', position: 4001 }, + { id: 'record-2', position: 4002 }, + { id: 'record-3', position: 4003 }, + ]); }); - describe('Table context (without group fields)', () => { - it('should process multi drag for table', () => { - const result = createDropResult('record-2', 3); - const selectedRecordIds = ['record-1', 'record-2', 'record-3']; + it('should throw error when destination is null', () => { + const selectedRecordIds = ['record-1', 'record-2']; - const dragResult = processMultiDrag({ - result, + expect(() => { + processMultiDrag({ + draggedRecordId: 'record-1', + targetRecordId: '', selectedRecordIds, - recordPositionData: mockRecordPositionData, - recordIds: ['record-1', 'record-2', 'record-3', 'record-4'], + recordsWithPosition, }); - - expect(dragResult.recordUpdates).toEqual([ - { recordId: 'record-1', position: 1500 }, - { recordId: 'record-2', position: 2000 }, - { recordId: 'record-3', position: 2500 }, - ]); - - dragResult.recordUpdates.forEach((update) => { - expect(update).not.toHaveProperty('groupValue'); - expect(update).not.toHaveProperty('selectFieldName'); - }); - }); - - it('should throw error when destination is null', () => { - const result = createDropResult('record-1', null); - const selectedRecordIds = ['record-1', 'record-2']; - - expect(() => { - processMultiDrag({ - result, - selectedRecordIds, - recordPositionData: mockRecordPositionData, - recordIds: ['record-1', 'record-2', 'record-3'], - }); - }).toThrow('Destination is required for drag operation'); - }); + }).toThrow('Cannot find item to move for id : '); }); - describe('Board context (with group fields)', () => { - it('should process multi drag for board with group fields', () => { - const result = createDropResult('record-2', 3); - const selectedRecordIds = ['record-1', 'record-2', 'record-3']; + it('should handle single record in selection', () => { + const selectedRecordIds = ['record-1']; - const dragResult = processMultiDrag({ - result, - selectedRecordIds, - recordPositionData: mockRecordPositionData, - recordIds: ['record-1', 'record-2', 'record-3', 'record-4'], - groupValue: 'in-progress', - selectFieldName: 'status', - }); - - expect(dragResult.recordUpdates).toEqual([ - { - recordId: 'record-1', - position: 1500, - groupValue: 'in-progress', - selectFieldName: 'status', - }, - { - recordId: 'record-2', - position: 2000, - groupValue: 'in-progress', - selectFieldName: 'status', - }, - { - recordId: 'record-3', - position: 2500, - groupValue: 'in-progress', - selectFieldName: 'status', - }, - ]); + const dragResult = processMultiDrag({ + draggedRecordId: 'record-1', + targetRecordId: 'record-3', + selectedRecordIds, + recordsWithPosition, }); - it('should handle null group value', () => { - const result = createDropResult('record-1', 1); - const selectedRecordIds = ['record-1', 'record-2']; - - const dragResult = processMultiDrag({ - result, - selectedRecordIds, - recordPositionData: mockRecordPositionData, - recordIds: ['record-1', 'record-2', 'record-3'], - groupValue: null, - selectFieldName: 'status', - }); - - expect(dragResult.recordUpdates).toEqual([ - { - recordId: 'record-1', - position: 1500, - groupValue: null, - selectFieldName: 'status', - }, - { - recordId: 'record-2', - position: 2000, - groupValue: null, - selectFieldName: 'status', - }, - ]); - }); + expect(dragResult.recordUpdates).toEqual([ + { id: 'record-1', position: 3500 }, + ]); }); - describe('Edge cases', () => { - it('should handle single record in selection', () => { - const result = createDropResult('record-1', 2); - const selectedRecordIds = ['record-1']; + it('should handle empty selection', () => { + const selectedRecordIds: string[] = []; - const dragResult = processMultiDrag({ - result, - selectedRecordIds, - recordPositionData: mockRecordPositionData, - recordIds: ['record-1', 'record-2', 'record-3'], - }); - - expect(dragResult.recordUpdates).toEqual([ - { recordId: 'record-1', position: 1500 }, - ]); + const dragResult = processMultiDrag({ + draggedRecordId: 'record-1', + targetRecordId: 'record-3', + selectedRecordIds, + recordsWithPosition, }); - it('should handle empty selection', () => { - const result = createDropResult('record-1', 2); - const selectedRecordIds: string[] = []; - - const dragResult = processMultiDrag({ - result, - selectedRecordIds, - recordPositionData: mockRecordPositionData, - recordIds: ['record-1', 'record-2', 'record-3'], - }); - - expect(dragResult.recordUpdates).toEqual([]); - }); - - it('should not include group fields when only one is provided', () => { - const result = createDropResult('record-1', 2); - const selectedRecordIds = ['record-1', 'record-2']; - - const dragResult = processMultiDrag({ - result, - selectedRecordIds, - recordPositionData: mockRecordPositionData, - recordIds: ['record-1', 'record-2', 'record-3'], - groupValue: 'group-1', - }); - - expect(dragResult.recordUpdates).toEqual([ - { recordId: 'record-1', position: 1500 }, - { recordId: 'record-2', position: 2000 }, - ]); - - dragResult.recordUpdates.forEach((update) => { - expect(update).not.toHaveProperty('groupValue'); - expect(update).not.toHaveProperty('selectFieldName'); - }); - }); + expect(dragResult.recordUpdates).toEqual([]); }); }); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processSingleDrag.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processSingleDrag.test.ts index 9b1b2ae2be0..d38720e6570 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processSingleDrag.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/__tests__/processSingleDrag.test.ts @@ -1,156 +1,85 @@ -import { type RecordDragPositionData } from '@/object-record/record-drag/shared/types/RecordDragPositionData'; import { processSingleDrag } from '@/object-record/record-drag/shared/utils/processSingleDrag'; -import { type DropResult } from '@hello-pangea/dnd'; - -jest.mock( - '@/object-record/record-drag/shared/utils/calculateDragPositions', - () => ({ - calculateDragPositions: jest.fn(({ recordsToMove }) => { - const positions: Record = {}; - recordsToMove.forEach((id: string, index: number) => { - positions[id] = 1000 + index * 1000; - }); - return positions; - }), - }), -); +import { type RecordWithPosition } from '@/object-record/utils/computeNewPositionOfDraggedRecord'; describe('processSingleDrag', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const mockRecordPositionData: RecordDragPositionData[] = [ - { recordId: 'record-1', position: 1000 }, - { recordId: 'record-2', position: 2000 }, - { recordId: 'record-3', position: 3000 }, + const mockRecordPositionData: RecordWithPosition[] = [ + { id: 'record-1', position: 1000 }, + { id: 'record-2', position: 2000 }, + { id: 'record-3', position: 3000 }, ]; - const createDropResult = ( - draggableId: string, - destinationIndex: number | null, - ): DropResult => ({ - draggableId, - type: 'record', - source: { - droppableId: 'source', - index: 0, - }, - destination: - destinationIndex !== null - ? { - droppableId: 'destination', - index: destinationIndex, - } - : null, - reason: 'DROP', - mode: 'FLUID', - combine: null, - }); - - describe('Table context (without group fields)', () => { - it('should process single drag for table', () => { - const result = createDropResult('record-1', 2); - - const dragResult = processSingleDrag({ - result, - recordPositionData: mockRecordPositionData, - recordIds: ['record-1', 'record-2', 'record-3'], - }); - - expect(dragResult).toEqual({ - recordId: 'record-1', - position: 1000, - }); - expect(dragResult).not.toHaveProperty('groupValue'); - expect(dragResult).not.toHaveProperty('selectFieldName'); + it('should process single drag at end of list', () => { + const dragResult = processSingleDrag({ + sourceRecordId: 'record-1', + targetRecordId: 'record-3', + recordsWithPosition: mockRecordPositionData, }); - it('should throw error when destination is null', () => { - const result = createDropResult('record-1', null); - - expect(() => { - processSingleDrag({ - result, - recordPositionData: mockRecordPositionData, - recordIds: ['record-1', 'record-2', 'record-3'], - }); - }).toThrow('Destination is required for drag operation'); + expect(dragResult).toEqual({ + id: 'record-1', + position: 3001, }); }); - describe('Board context (with group fields)', () => { - it('should process single drag for board with group fields', () => { - const result = createDropResult('record-1', 2); - - const dragResult = processSingleDrag({ - result, - recordPositionData: mockRecordPositionData, - recordIds: ['record-1', 'record-2', 'record-3'], - groupValue: 'group-1', - selectFieldName: 'status', - }); - - expect(dragResult).toEqual({ - recordId: 'record-1', - position: 1000, - groupValue: 'group-1', - selectFieldName: 'status', - }); + it('should process single drag at beginning', () => { + const dragResult = processSingleDrag({ + sourceRecordId: 'record-3', + targetRecordId: 'record-1', + recordsWithPosition: mockRecordPositionData, }); - it('should handle null group value', () => { - const result = createDropResult('record-1', 2); - - const dragResult = processSingleDrag({ - result, - recordPositionData: mockRecordPositionData, - recordIds: ['record-1', 'record-2', 'record-3'], - groupValue: null, - selectFieldName: 'status', - }); - - expect(dragResult).toEqual({ - recordId: 'record-1', - position: 1000, - groupValue: null, - selectFieldName: 'status', - }); + expect(dragResult).toEqual({ + id: 'record-3', + position: 999, }); }); - describe('Edge cases', () => { - it('should handle empty record list', () => { - const result = createDropResult('record-1', 0); - - const dragResult = processSingleDrag({ - result, - recordPositionData: [], - recordIds: [], - }); - - expect(dragResult).toEqual({ - recordId: 'record-1', - position: 1000, - }); + it('should process single drag in between from before', () => { + const dragResult = processSingleDrag({ + sourceRecordId: 'record-1', + targetRecordId: 'record-2', + recordsWithPosition: mockRecordPositionData, }); - it('should not include group fields when only one is provided', () => { - const result = createDropResult('record-1', 2); - - const dragResult = processSingleDrag({ - result, - recordPositionData: mockRecordPositionData, - recordIds: ['record-1', 'record-2', 'record-3'], - groupValue: 'group-1', - }); - - expect(dragResult).toEqual({ - recordId: 'record-1', - position: 1000, - }); - expect(dragResult).not.toHaveProperty('groupValue'); - expect(dragResult).not.toHaveProperty('selectFieldName'); + expect(dragResult).toEqual({ + id: 'record-1', + position: 2500, }); }); + + it('should process single drag in between from after', () => { + const dragResult = processSingleDrag({ + sourceRecordId: 'record-3', + targetRecordId: 'record-2', + recordsWithPosition: mockRecordPositionData, + }); + + expect(dragResult).toEqual({ + id: 'record-3', + position: 1500, + }); + }); + + it('should process single drag from other list', () => { + const dragResult = processSingleDrag({ + sourceRecordId: 'record-4', + targetRecordId: 'record-2', + recordsWithPosition: mockRecordPositionData, + }); + + expect(dragResult).toEqual({ + id: 'record-4', + position: 1500, + }); + }); + + it('should handle empty record list', () => { + expect(() => + processSingleDrag({ + sourceRecordId: 'record-1', + targetRecordId: 'record-1', + recordsWithPosition: [], + }), + ).toThrowError('Cannot find item to move for id : record-1'); + }); }); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/calculateDragPositions.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/calculateDragPositions.ts deleted file mode 100644 index 6e4f021efdd..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/calculateDragPositions.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { type RecordDragPositionData } from '@/object-record/record-drag/shared/types/RecordDragPositionData'; -import { getDraggedRecordPosition } from '@/object-record/record-drag/shared/utils/getDraggedRecordPosition'; -import { getIndexNeighboursElementsFromArray } from '~/utils/array/getIndexNeighboursElementsFromArray'; - -type DragPositionCalculationParams = { - recordIds: string[]; - recordsToMove: string[]; - destinationIndex: number; - recordPositionData: RecordDragPositionData[]; -}; - -export const calculateDragPositions = ({ - recordIds, - recordsToMove, - destinationIndex, - recordPositionData, -}: DragPositionCalculationParams): Record => { - const otherRecordIds = recordIds.filter( - (recordId: string) => !recordsToMove.includes(recordId), - ); - - const filteredRecordIds = - recordsToMove.length === 1 - ? otherRecordIds - : recordIds.filter((recordId) => recordId !== recordsToMove[0]); - - const { before: recordBeforeId, after: recordAfterId } = - getIndexNeighboursElementsFromArray({ - index: destinationIndex, - array: filteredRecordIds, - }); - - const recordBefore = recordBeforeId - ? recordPositionData.find((r) => r.recordId === recordBeforeId) - : null; - - const recordAfter = recordAfterId - ? recordPositionData.find((r) => r.recordId === recordAfterId) - : null; - - const basePosition = getDraggedRecordPosition( - recordBefore?.position, - recordAfter?.position, - ); - - const positions: Record = {}; - - for (const [index, recordId] of recordsToMove.entries()) { - if (recordsToMove.length > 1) { - const availableSpace = recordAfter?.position - ? recordAfter.position - basePosition - : 1; - - const increment = availableSpace / (recordsToMove.length + 1); - positions[recordId] = basePosition + (index + 1) * increment; - } else { - positions[recordId] = basePosition; - } - } - - return positions; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/extractRecordPositions.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/extractRecordPositions.ts index 226f4266154..abbcf5160e3 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/extractRecordPositions.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/extractRecordPositions.ts @@ -1,16 +1,18 @@ import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { type RecordWithPosition } from '@/object-record/utils/computeNewPositionOfDraggedRecord'; import { type Snapshot } from 'recoil'; export const extractRecordPositions = ( recordIds: string[], snapshot: Snapshot, -) => { +): RecordWithPosition[] => { return recordIds.map((recordId) => { const record = snapshot .getLoadable(recordStoreFamilyState(recordId)) .getValue(); + return { - recordId, + id: recordId, position: record?.position, }; }); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/getDraggedRecordPosition.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/getDraggedRecordPosition.ts deleted file mode 100644 index b42c783ef13..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/getDraggedRecordPosition.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { isDefined } from 'twenty-shared/utils'; -export const getDraggedRecordPosition = ( - recordBeforePosition?: number, - recordAfterPosition?: number, -): number => { - if (isDefined(recordAfterPosition) && isDefined(recordBeforePosition)) { - return (recordBeforePosition + recordAfterPosition) / 2; - } - - if (isDefined(recordAfterPosition)) { - return recordAfterPosition - 1; - } - - if (isDefined(recordBeforePosition)) { - return recordBeforePosition + 1; - } - - return 1; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processGroupDragOperation.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processGroupDragOperation.ts index ada156be357..a525802ea7e 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processGroupDragOperation.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processGroupDragOperation.ts @@ -5,36 +5,34 @@ import { isDefined } from 'twenty-shared/utils'; import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { processSingleDrag } from '@/object-record/record-drag/shared/utils/processSingleDrag'; +import { type RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; import { extractRecordPositions } from './extractRecordPositions'; import { getDragOperationType } from './getDragOperationType'; import { processMultiDrag } from './processMultiDrag'; -import { processSingleDrag } from './processSingleDrag'; type ProcessGroupDragOperationParams = { result: DropResult; snapshot: Snapshot; selectedRecordIds: string[]; - selectFieldName: string; recordIdsByGroupFamilyState: any; - onUpdateRecord: (update: { - recordId: string; - position?: number; - groupValue?: string | null; - selectFieldName: string; - }) => void; + onUpdateRecord: ( + update: { recordId: string; position?: number }, + targetRecordGroupId: RecordGroupDefinition['value'], + ) => void; }; export const processGroupDragOperation = ({ result, snapshot, selectedRecordIds, - selectFieldName, recordIdsByGroupFamilyState, onUpdateRecord, }: ProcessGroupDragOperationParams) => { if (!result.destination) { return; } + const destinationGroupId = result.destination.droppableId; const recordGroup = getSnapshotValue( @@ -51,7 +49,7 @@ export const processGroupDragOperation = ({ recordIdsByGroupFamilyState(destinationGroupId), ) as string[]; - const recordPositionData = extractRecordPositions( + const recordsWithPosition = extractRecordPositions( destinationRecordIds, snapshot, ); @@ -62,42 +60,54 @@ export const processGroupDragOperation = ({ selectedRecordIds, }); + const destinationIndex = result.destination.index; + + const isDroppedAfterList = destinationIndex >= recordsWithPosition.length; + + const targetRecord = isDroppedAfterList + ? recordsWithPosition.at(-1) + : recordsWithPosition.at(result.destination.index); + + if (!isDefined(targetRecord)) { + throw new Error( + `targetRecord cannot be found in passed recordsWithPosition, this should not happen.`, + ); + } + if (dragOperationType === 'single') { const singleDragResult = processSingleDrag({ - result, - recordPositionData, - recordIds: destinationRecordIds, - groupValue: recordGroup.value, - selectFieldName, + sourceRecordId: draggedRecordId, + targetRecordId: targetRecord.id, + recordsWithPosition: recordsWithPosition, }); if (!isDefined(singleDragResult.position)) { return; } - onUpdateRecord({ - recordId: singleDragResult.recordId, - position: singleDragResult.position, - groupValue: recordGroup.value, - selectFieldName, - }); + onUpdateRecord( + { + recordId: singleDragResult.id, + position: singleDragResult.position, + }, + recordGroup.value, + ); } else { const multiDragResult = processMultiDrag({ - result, + draggedRecordId, selectedRecordIds, - recordPositionData, - recordIds: destinationRecordIds, - groupValue: recordGroup.value, - selectFieldName, + recordsWithPosition, + targetRecordId: targetRecord.id, }); for (const update of multiDragResult.recordUpdates) { - onUpdateRecord({ - recordId: update.recordId, - position: update.position, - groupValue: recordGroup.value, - selectFieldName, - }); + onUpdateRecord( + { + recordId: update.id, + position: update.position, + }, + recordGroup.value, + ); } } }; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processMultiDrag.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processMultiDrag.ts index 7ee1427113e..d09e4ca56ff 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processMultiDrag.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processMultiDrag.ts @@ -1,62 +1,28 @@ import { type MultiDragResult } from '@/object-record/record-drag/shared/types/MultiDragResult'; -import { type RecordDragPositionData } from '@/object-record/record-drag/shared/types/RecordDragPositionData'; -import { calculateDragPositions } from '@/object-record/record-drag/shared/utils/calculateDragPositions'; -import { type DropResult } from '@hello-pangea/dnd'; -import { isNull } from '@sniptt/guards'; -import { isDefined } from 'twenty-shared/utils'; +import { type RecordWithPosition } from '@/object-record/utils/computeNewPositionOfDraggedRecord'; +import { computeNewPositionsOfDraggedRecords } from '@/object-record/utils/computeNewPositionsOfDraggedRecords'; type MultiDragContext = { - result: DropResult; + draggedRecordId: string; + targetRecordId: string; selectedRecordIds: string[]; - recordPositionData: RecordDragPositionData[]; - recordIds: string[]; - groupValue?: string | null; - selectFieldName?: string; + recordsWithPosition: RecordWithPosition[]; }; export const processMultiDrag = ({ - result, + draggedRecordId, + targetRecordId, selectedRecordIds, - recordPositionData, - recordIds, - groupValue, - selectFieldName, + recordsWithPosition, }: MultiDragContext): MultiDragResult => { - if (!result.destination) { - throw new Error('Destination is required for drag operation'); - } - - const destinationIndex = result.destination.index; - - const positions = calculateDragPositions({ - recordIds, - recordsToMove: selectedRecordIds, - destinationIndex, - recordPositionData, - }); - - const recordUpdates = selectedRecordIds.map((recordId) => { - const baseUpdate = { - recordId, - position: positions[recordId], - }; - - const shouldIncludeGroupFields = - isDefined(selectFieldName) && - (isDefined(groupValue) || isNull(groupValue)); - - if (shouldIncludeGroupFields) { - return { - ...baseUpdate, - groupValue, - selectFieldName, - }; - } - - return baseUpdate; + const newPositionOfDraggedRecords = computeNewPositionsOfDraggedRecords({ + arrayOfRecordsWithPosition: recordsWithPosition, + draggedRecordId, + targetRecordId, + sourceRecordIds: selectedRecordIds, }); return { - recordUpdates, + recordUpdates: newPositionOfDraggedRecords ?? [], }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processSingleDrag.ts b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processSingleDrag.ts index bca129f5597..a53077c5499 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processSingleDrag.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/shared/utils/processSingleDrag.ts @@ -1,56 +1,27 @@ -import { type RecordDragPositionData } from '@/object-record/record-drag/shared/types/RecordDragPositionData'; -import { type RecordDragUpdate } from '@/object-record/record-drag/shared/types/RecordDragUpdate'; -import { calculateDragPositions } from '@/object-record/record-drag/shared/utils/calculateDragPositions'; -import { type DropResult } from '@hello-pangea/dnd'; -import { isNull } from '@sniptt/guards'; -import { isDefined } from 'twenty-shared/utils'; +import { + computeNewPositionOfDraggedRecord, + type RecordWithPosition, +} from '@/object-record/utils/computeNewPositionOfDraggedRecord'; type SingleDragContext = { - result: DropResult; - recordPositionData: RecordDragPositionData[]; - recordIds: string[]; - groupValue?: string | null; - selectFieldName?: string; + targetRecordId: string; + sourceRecordId: string; + recordsWithPosition: RecordWithPosition[]; }; export const processSingleDrag = ({ - result, - recordPositionData, - recordIds, - groupValue, - selectFieldName, -}: SingleDragContext): RecordDragUpdate => { - const draggedRecordId = result.draggableId; - - if (!result.destination) { - throw new Error('Destination is required for drag operation'); - } - - const destinationIndex = result.destination.index; - const recordsToMove = [draggedRecordId]; - - const positions = calculateDragPositions({ - recordIds, - recordsToMove, - destinationIndex, - recordPositionData, + targetRecordId, + sourceRecordId, + recordsWithPosition, +}: SingleDragContext): RecordWithPosition => { + const newPosition = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: recordsWithPosition, + idOfItemToMove: sourceRecordId, + idOfTargetItem: targetRecordId, }); - const baseResult = { - recordId: draggedRecordId, - position: positions[draggedRecordId], + return { + id: sourceRecordId, + position: newPosition, }; - - const shouldIncludeGroupFields = - isDefined(selectFieldName) && (isDefined(groupValue) || isNull(groupValue)); - - if (shouldIncludeGroupFields) { - return { - ...baseResult, - groupValue, - selectFieldName, - }; - } - - return baseResult; }; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableGroupDragOperations.ts b/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableGroupDragOperations.ts index 20e80021861..1c0c11feb90 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableGroupDragOperations.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableGroupDragOperations.ts @@ -80,7 +80,6 @@ export const useRecordTableGroupDragOperations = () => { result, snapshot, selectedRecordIds, - selectFieldName: fieldMetadata.name, recordIdsByGroupFamilyState: recordIdsByGroupFamilyState, onUpdateRecord: ({ recordId, position }) => { updateOneRow({ diff --git a/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableDragOperations.ts b/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableWithoutGroupDragOperations.ts similarity index 54% rename from packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableDragOperations.ts rename to packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableWithoutGroupDragOperations.ts index 0af406d3579..9e9f8f0243a 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableDragOperations.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/table/hooks/useRecordTableWithoutGroupDragOperations.ts @@ -3,7 +3,6 @@ import { type DropResult } from '@hello-pangea/dnd'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { getDragOperationType } from '@/object-record/record-drag/shared/utils/getDragOperationType'; import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId'; -import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; @@ -13,22 +12,26 @@ import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; import { isDefined } from 'twenty-shared/utils'; import { useRecordDragState } from '@/object-record/record-drag/shared/hooks/useRecordDragState'; -import { extractRecordPositions } from '@/object-record/record-drag/shared/utils/extractRecordPositions'; -import { processSingleDrag } from '@/object-record/record-drag/shared/utils/processSingleDrag'; import { processMultiDrag } from '@/object-record/record-drag/shared/utils/processMultiDrag'; + +import { processSingleDrag } from '@/object-record/record-drag/shared/utils/processSingleDrag'; +import { allRecordIdsWithoutGroupsComponentSelector } from '@/object-record/record-index/states/selectors/allRecordIdsWithoutGroupsComponentSelector'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector'; +import { useResetVirtualizationBecauseDataChanged } from '@/object-record/record-table/virtualization/hooks/useResetVirtualizationBecauseDataChanged'; +import { useTriggerFetchPages } from '@/object-record/record-table/virtualization/hooks/useTriggerFetchPages'; +import { type RecordWithPosition } from '@/object-record/utils/computeNewPositionOfDraggedRecord'; import { useRecoilCallback } from 'recoil'; -export const useRecordTableDragOperations = () => { +export const useRecordTableWithoutGroupDragOperations = () => { const { objectNameSingular, recordTableId } = useRecordTableContextOrThrow(); const { updateOneRecord: updateOneRow } = useUpdateOneRecord({ objectNameSingular, }); - const recordIndexAllRecordIdsSelector = useRecoilComponentCallbackState( - recordIndexAllRecordIdsComponentSelector, - ); + const allRecordIdsWithoutGroupCallbackSelector = + useRecoilComponentCallbackState(allRecordIdsWithoutGroupsComponentSelector); const selectedRowIdsSelector = useRecoilComponentCallbackState( selectedRowIdsComponentSelector, @@ -42,9 +45,14 @@ export const useRecordTableDragOperations = () => { const { openModal } = useModal(); const multiDragState = useRecordDragState('table', recordTableId); - const processDragOperation = useRecoilCallback( + const { resetVirtualization } = + useResetVirtualizationBecauseDataChanged(objectNameSingular); + + const { triggerFetchPagesWithoutDebounce } = useTriggerFetchPages(); + + const processDragOperationWithoutGroup = useRecoilCallback( ({ snapshot }) => - (result: DropResult) => { + async (result: DropResult) => { if (!result.destination) return; if (currentRecordSorts.length > 0) { @@ -52,9 +60,9 @@ export const useRecordTableDragOperations = () => { return; } - const allRecordIds = getSnapshotValue( + const allSparseRecordIds = getSnapshotValue( snapshot, - recordIndexAllRecordIdsSelector, + allRecordIdsWithoutGroupCallbackSelector, ); const draggedRecordId = result.draggableId; @@ -63,10 +71,17 @@ export const useRecordTableDragOperations = () => { selectedRowIdsSelector, ); - const recordPositionData = extractRecordPositions( - allRecordIds, - snapshot, - ); + const recordsWithPosition: RecordWithPosition[] = allSparseRecordIds + .filter(isDefined) + .map((recordId) => ({ + id: recordId, + position: + getSnapshotValue(snapshot, recordStoreFamilyState(recordId)) + ?.position ?? null, + })); + + const contiguousRecordsWithPosition = + recordsWithPosition.filter(isDefined); const dragOperationType = getDragOperationType({ draggedRecordId, @@ -74,10 +89,20 @@ export const useRecordTableDragOperations = () => { }); if (dragOperationType === 'single') { + const targetRecordId = allSparseRecordIds.at( + result.destination.index, + ); + + if (!isDefined(targetRecordId)) { + throw new Error( + `Target record id cannot be found, this should not happen`, + ); + } + const singleDragResult = processSingleDrag({ - result, - recordPositionData, - recordIds: allRecordIds, + sourceRecordId: draggedRecordId, + targetRecordId: targetRecordId ?? '', + recordsWithPosition: contiguousRecordsWithPosition, }); if (!isDefined(singleDragResult.position)) { @@ -85,28 +110,42 @@ export const useRecordTableDragOperations = () => { } updateOneRow({ - idToUpdate: singleDragResult.recordId, + idToUpdate: singleDragResult.id, updateOneRecordInput: { position: singleDragResult.position, }, }); } else { + const targetRecordId = allSparseRecordIds.at( + result.destination.index, + ); + + if (!isDefined(targetRecordId)) { + throw new Error( + `Target record id cannot be found, this should not happen`, + ); + } + const multiDragResult = processMultiDrag({ - result, + draggedRecordId, + targetRecordId: targetRecordId ?? '', selectedRecordIds: multiDragState.originalSelection, - recordPositionData, - recordIds: allRecordIds, + recordsWithPosition: contiguousRecordsWithPosition, }); for (const update of multiDragResult.recordUpdates) { updateOneRow({ - idToUpdate: update.recordId, + idToUpdate: update.id, updateOneRecordInput: { position: update.position, }, }); } } + + await resetVirtualization(); + + await triggerFetchPagesWithoutDebounce(); }, [ selectedRowIdsSelector, @@ -114,9 +153,11 @@ export const useRecordTableDragOperations = () => { openModal, currentRecordSorts, multiDragState.originalSelection, - recordIndexAllRecordIdsSelector, + allRecordIdsWithoutGroupCallbackSelector, + resetVirtualization, + triggerFetchPagesWithoutDebounce, ], ); - return { processDragOperation }; + return { processDragOperationWithoutGroup }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useReorderVisibleRecordFields.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useReorderVisibleRecordFields.ts index 7e5bb42da12..9f981f3090e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/useReorderVisibleRecordFields.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useReorderVisibleRecordFields.ts @@ -2,7 +2,7 @@ import { useUpdateRecordField } from '@/object-record/record-field/hooks/useUpda import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState'; import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; import { type RecordField } from '@/object-record/record-field/types/RecordField'; -import { computeNewPositionOfRecordWithPosition } from '@/object-record/utils/computeNewPositionOfRecordWithPosition'; +import { computeNewPositionOfDraggedRecord } from '@/object-record/utils/computeNewPositionOfDraggedRecord'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { useRecoilCallback } from 'recoil'; @@ -35,12 +35,11 @@ export const useReorderVisibleRecordFields = (recordTableId: string) => { const recordToMove = visibleRecordFields[fromIndex]; - const newPositionOfTargetRecord = - computeNewPositionOfRecordWithPosition({ - arrayOfRecordsWithPosition: currentRecordFields, - idOfItemToMove: idOfRecordToMove, - idOfTargetItem: idOfTargetRecord, - }); + const newPositionOfTargetRecord = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: currentRecordFields, + idOfItemToMove: idOfRecordToMove, + idOfTargetItem: idOfTargetRecord, + }); updateRecordField(recordToMove.fieldMetadataItemId, { position: newPositionOfTargetRecord, diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx index 5c91e1813ba..5eb35f31706 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RelationFromManyFieldDisplay.tsx @@ -9,6 +9,7 @@ import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/ui import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; import styled from '@emotion/styled'; +import { isArray } from '@sniptt/guards'; import { useContext } from 'react'; import { isDefined } from 'twenty-shared/utils'; @@ -38,7 +39,15 @@ export const RelationFromManyFieldDisplay = () => { fieldValue as NoteTarget[] | TaskTarget[], ); - if (!fieldValue || !relationObjectNameSingular) { + if (!isDefined(fieldValue)) { + return null; + } + + if (!isArray(fieldValue)) { + return null; + } + + if (!isDefined(!relationObjectNameSingular)) { return null; } @@ -65,7 +74,7 @@ export const RelationFromManyFieldDisplay = () => { return isFocused ? ( {fieldValue - .map((record) => { + ?.map((record) => { if (!isDefined(record) || !isDefined(record[relationFieldName])) { return undefined; } @@ -83,7 +92,7 @@ export const RelationFromManyFieldDisplay = () => { ) : ( {fieldValue - .map((record) => { + ?.map((record) => { if (!isDefined(record) || !isDefined(record[relationFieldName])) { return undefined; } @@ -115,7 +124,7 @@ export const RelationFromManyFieldDisplay = () => { } else { return ( - {fieldValue.filter(isDefined).map((record) => { + {fieldValue?.filter(isDefined).map((record) => { const recordChipData = generateRecordChipData(record); return ( ({ key: 'lastShowPageRecordIdState', defaultValue: null, diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts index 8e4adf6767c..242ee8c13f6 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableFetchMore.ts @@ -2,6 +2,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords'; import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields'; import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/hooks/useFindManyRecordIndexTableParams'; + export const useRecordIndexTableFetchMore = (objectNameSingular: string) => { const params = useFindManyRecordIndexTableParams(objectNameSingular); @@ -13,12 +14,14 @@ export const useRecordIndexTableFetchMore = (objectNameSingular: string) => { objectMetadataItem, }); - const { fetchMoreRecordsLazy, queryIdentifier } = useLazyFindManyRecords({ - ...params, - recordGqlFields, - }); + const { fetchMoreRecordsLazy, queryIdentifier, findManyRecordsLazy } = + useLazyFindManyRecords({ + ...params, + recordGqlFields, + }); return { + findManyRecordsLazy, fetchMoreRecordsLazy, queryIdentifier, }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableQuery.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableQuery.ts index 5ce6ce269f6..f234aed4362 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableQuery.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableQuery.ts @@ -17,18 +17,18 @@ export const useRecordIndexTableQuery = (objectNameSingular: string) => { objectMetadataItem, }); - const { records, hasNextPage, queryIdentifier, loading } = useFindManyRecords( - { + const { records, hasNextPage, queryIdentifier, loading, totalCount } = + useFindManyRecords({ ...params, recordGqlFields, skip: showAuthModal, - }, - ); + }); return { records: showAuthModal ? SIGN_IN_BACKGROUND_MOCK_COMPANIES : records, loading: showAuthModal ? false : loading, hasNextPage, queryIdentifier, + totalCount, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/selectors/allRecordIdsOfAllRecordGroupsComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-index/states/selectors/allRecordIdsOfAllRecordGroupsComponentSelector.ts new file mode 100644 index 00000000000..feae320b56f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/selectors/allRecordIdsOfAllRecordGroupsComponentSelector.ts @@ -0,0 +1,34 @@ +import { recordGroupIdsComponentState } from '@/object-record/record-group/states/recordGroupIdsComponentState'; +import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; +import { createComponentSelector } from '@/ui/utilities/state/component-state/utils/createComponentSelector'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; + +export const allRecordIdsOfAllRecordGroupsComponentSelector = + createComponentSelector({ + key: 'recordIndexAllRecordIdsComponentSelector', + componentInstanceContext: ViewComponentInstanceContext, + get: + ({ instanceId }) => + ({ get }) => { + const recordGroupIds = get( + recordGroupIdsComponentState.atomFamily({ + instanceId, + }), + ); + + if (recordGroupIds.length === 0) { + return []; + } + + return recordGroupIds.reduce((acc, recordGroupId) => { + const rowIds = get( + recordIndexRecordIdsByGroupComponentFamilyState.atomFamily({ + instanceId, + familyKey: recordGroupId, + }), + ); + + return [...acc, ...rowIds]; + }, []); + }, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/selectors/allRecordIdsWithoutGroupsComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-index/states/selectors/allRecordIdsWithoutGroupsComponentSelector.ts new file mode 100644 index 00000000000..ca6563f0a54 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/selectors/allRecordIdsWithoutGroupsComponentSelector.ts @@ -0,0 +1,21 @@ +import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; +import { NO_RECORD_GROUP_FAMILY_KEY } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { createComponentSelector } from '@/ui/utilities/state/component-state/utils/createComponentSelector'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; +import { type SparseArray } from '~/types/SparseArray'; + +export const allRecordIdsWithoutGroupsComponentSelector = + createComponentSelector>({ + key: 'allRecordIdsWithoutGroupsComponentSelector', + componentInstanceContext: ViewComponentInstanceContext, + get: + ({ instanceId }) => + ({ get }) => { + return get( + recordIndexRecordIdsByGroupComponentFamilyState.atomFamily({ + instanceId, + familyKey: NO_RECORD_GROUP_FAMILY_KEY, + }), + ); + }, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector.ts index c6e19338b57..d4b79a34d9c 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector.ts @@ -4,14 +4,10 @@ import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { createComponentSelector } from '@/ui/utilities/state/component-state/utils/createComponentSelector'; import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; -/** - * Do not use this key outside of this file. - * This is a temporary key to store the record ids for the default record group. - */ -const defaultFamilyKey = 'record-group-default-id'; +export const NO_RECORD_GROUP_FAMILY_KEY = 'record-group-default-id'; export const recordIndexAllRecordIdsComponentSelector = createComponentSelector< - ObjectRecord['id'][] + string[] >({ key: 'recordIndexAllRecordIdsComponentSelector', componentInstanceContext: ViewComponentInstanceContext, @@ -28,7 +24,7 @@ export const recordIndexAllRecordIdsComponentSelector = createComponentSelector< return get( recordIndexRecordIdsByGroupComponentFamilyState.atomFamily({ instanceId, - familyKey: defaultFamilyKey, + familyKey: NO_RECORD_GROUP_FAMILY_KEY, }), ); } @@ -53,7 +49,7 @@ export const recordIndexAllRecordIdsComponentSelector = createComponentSelector< set( recordIndexRecordIdsByGroupComponentFamilyState.atomFamily({ instanceId, - familyKey: defaultFamilyKey, + familyKey: NO_RECORD_GROUP_FAMILY_KEY, }), recordIds, ), diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/selectors/recordIndexHasRecordsComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-index/states/selectors/recordIndexHasRecordsComponentSelector.ts new file mode 100644 index 00000000000..e43ed84baba --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/selectors/recordIndexHasRecordsComponentSelector.ts @@ -0,0 +1,20 @@ +import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { createComponentSelector } from '@/ui/utilities/state/component-state/utils/createComponentSelector'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; + +export const recordIndexHasRecordsComponentSelector = + createComponentSelector({ + key: 'recordIndexHasRecordsComponentSelector', + componentInstanceContext: ViewComponentInstanceContext, + get: + ({ instanceId }) => + ({ get }) => { + const allRecordIds = get( + recordIndexAllRecordIdsComponentSelector.selectorFamily({ + instanceId, + }), + ); + + return allRecordIds.length > 0; + }, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx index 48783eeec1b..f7b5d9d3f19 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx @@ -3,7 +3,7 @@ import { useRef } from 'react'; import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; import { hasRecordGroupsComponentSelector } from '@/object-record/record-group/states/selectors/hasRecordGroupsComponentSelector'; -import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { recordIndexHasRecordsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexHasRecordsComponentSelector'; import { RecordTableBodyEffectsWrapper } from '@/object-record/record-table/components/RecordTableBodyEffectsWrapper'; import { RecordTableContent } from '@/object-record/record-table/components/RecordTableContent'; import { RecordTableEmpty } from '@/object-record/record-table/components/RecordTableEmpty'; @@ -35,8 +35,8 @@ export const RecordTable = () => { recordTableId, ); - const allRecordIds = useRecoilComponentValue( - recordIndexAllRecordIdsComponentSelector, + const recordTableHasRecords = useRecoilComponentValue( + recordIndexHasRecordsComponentSelector, recordTableId, ); @@ -48,7 +48,7 @@ export const RecordTable = () => { const { resetTableRowSelection } = useResetTableRowSelection(recordTableId); const recordTableIsEmpty = - !isRecordTableInitialLoading && allRecordIds.length === 0; + !isRecordTableInitialLoading && !recordTableHasRecords; if (!isNonEmptyString(objectNameSingular)) { return <>; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableAddNew.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableAddNew.tsx deleted file mode 100644 index 26202638540..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableAddNew.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; -import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView'; -import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; -import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord'; -import { RecordTableActionRow } from '@/object-record/record-table/record-table-row/components/RecordTableActionRow'; -import { hasRecordTableFetchedAllRecordsComponentState } from '@/object-record/record-table/states/hasRecordTableFetchedAllRecordsComponentState'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { t } from '@lingui/core/macro'; -import { IconPlus } from 'twenty-ui/display'; - -export const RecordTableAddNew = () => { - const { objectMetadataItem } = useRecordTableContextOrThrow(); - - const hasRecordTableFetchedAllRecords = useRecoilComponentValue( - hasRecordTableFetchedAllRecordsComponentState, - ); - - const { createNewIndexRecord } = useCreateNewIndexRecord({ - objectMetadataItem, - }); - - const objectPermissions = useObjectPermissionsForObject( - objectMetadataItem.id, - ); - - const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords; - - const hasAnySoftDeleteFilterOnView = useRecoilComponentValue( - hasAnySoftDeleteFilterOnViewComponentSelector, - ); - - if (hasAnySoftDeleteFilterOnView) { - return null; - } - - if (!hasObjectUpdatePermissions || !hasRecordTableFetchedAllRecords) { - return null; - } - - return ( - { - createNewIndexRecord({ - position: 'last', - }); - }} - LeftIcon={IconPlus} - text={t`Add New`} - /> - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyEffectsWrapper.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyEffectsWrapper.tsx index 2c679f1cd75..d62c85c7d2b 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyEffectsWrapper.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBodyEffectsWrapper.tsx @@ -2,8 +2,9 @@ import { RecordTableDeactivateRecordTableRowEffect } from '@/object-record/recor import { RecordTableBodyEscapeHotkeyEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyEscapeHotkeyEffect'; import { RecordTableBodyFocusClickOutsideEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyFocusClickOutsideEffect'; import { RecordTableBodyFocusKeyboardEffect } from '@/object-record/record-table/record-table-body/components/RecordTableBodyFocusKeyboardEffect'; -import { RecordTableNoRecordGroupBodyEffect } from '@/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBodyEffect'; import { RecordTableRecordGroupBodyEffects } from '@/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffects'; +import { RecordTableNoRecordGroupScrollToPreviousRecordEffect } from '@/object-record/record-table/virtualization/components/RecordTableNoRecordGroupScrollToPreviousRecordEffect'; +import { RecordTableVirtualizedInitialDataLoadEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect'; export interface RecordTableBodyEffectsWrapperProps { hasRecordGroups: boolean; @@ -19,7 +20,10 @@ export const RecordTableBodyEffectsWrapper = ({ {hasRecordGroups ? ( ) : ( - + <> + + + )} diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContent.tsx index cd0c34cd3a0..965fa5ee6e9 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableContent.tsx @@ -13,9 +13,10 @@ import { recordTableHoverPositionComponentState } from '@/object-record/record-t import { isSomeCellInEditModeComponentSelector } from '@/object-record/record-table/states/selectors/isSomeCellInEditModeComponentSelector'; import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { useRecoilComponentFamilyCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyCallbackState'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; import styled from '@emotion/styled'; import { useRef, useState } from 'react'; import { useRecoilCallback } from 'recoil'; @@ -52,6 +53,7 @@ export const RecordTableContent = ({ const handleDragEnd = () => { setIsDragging(false); + handleDragSelectionEnd(); }; @@ -76,15 +78,24 @@ export const RecordTableContent = ({ recordTableHoverPositionComponentState, ); - const isSomeCellInEditMode = useRecoilComponentValue( + const isSomeCellInEditModeCallbackState = useRecoilComponentCallbackState( isSomeCellInEditModeComponentSelector, ); - const handleMouseLeave = () => { - if (!isSomeCellInEditMode) { - setRecordTableHoverPosition(null); - } - }; + const handleMouseLeave = useRecoilCallback( + ({ snapshot }) => + () => { + const isSomeCellInEditMode = getSnapshotValue( + snapshot, + isSomeCellInEditModeCallbackState, + ); + + if (!isSomeCellInEditMode) { + setRecordTableHoverPosition(null); + } + }, + [isSomeCellInEditModeCallbackState, setRecordTableHoverPosition], + ); return ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupAddNew.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupAddNew.tsx new file mode 100644 index 00000000000..e61a75fbe0c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupAddNew.tsx @@ -0,0 +1,77 @@ +import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; +import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord'; +import { RecordTableActionRow } from '@/object-record/record-table/record-table-row/components/RecordTableActionRow'; +import { useAssignRecordsToStore } from '@/object-record/record-table/virtualization/hooks/useAssignRecordsToStore'; +import { useLoadRecordsToVirtualRows } from '@/object-record/record-table/virtualization/hooks/useLoadRecordsToVirtualRows'; +import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { t } from '@lingui/core/macro'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { IconPlus } from 'twenty-ui/display'; + +export const RecordTableNoRecordGroupAddNew = () => { + const { objectMetadataItem } = useRecordTableContextOrThrow(); + + const { createNewIndexRecord } = useCreateNewIndexRecord({ + objectMetadataItem, + }); + + const objectPermissions = useObjectPermissionsForObject( + objectMetadataItem.id, + ); + + const hasObjectUpdatePermissions = objectPermissions.canUpdateObjectRecords; + + const hasAnySoftDeleteFilterOnView = useRecoilComponentValue( + hasAnySoftDeleteFilterOnViewComponentSelector, + ); + + const totalNumberOfRecordsToVirtualize = useRecoilComponentValue( + totalNumberOfRecordsToVirtualizeComponentState, + ); + + const { loadRecordsToVirtualRows } = useLoadRecordsToVirtualRows(); + const { assignRecordsToStore } = useAssignRecordsToStore(); + + const handleButtonClick = useRecoilCallback( + () => async () => { + const createdRecord = await createNewIndexRecord({ + position: 'last', + }); + + assignRecordsToStore({ records: [createdRecord] }); + + if (isDefined(totalNumberOfRecordsToVirtualize)) { + loadRecordsToVirtualRows({ + records: [createdRecord], + startingRealIndex: totalNumberOfRecordsToVirtualize, + }); + } + }, + [ + createNewIndexRecord, + assignRecordsToStore, + loadRecordsToVirtualRows, + totalNumberOfRecordsToVirtualize, + ], + ); + + if (hasAnySoftDeleteFilterOnView) { + return null; + } + + if (!hasObjectUpdatePermissions) { + return null; + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupBodyContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupBodyContextProvider.tsx index de087f01778..d588fb00dd6 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupBodyContextProvider.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupBodyContextProvider.tsx @@ -8,8 +8,10 @@ import { useOpenRecordTableCell, } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCell'; import { useTriggerActionMenuDropdown } from '@/object-record/record-table/record-table-cell/hooks/useTriggerActionMenuDropdown'; +import { hasUserSelectedAllRowsComponentState } from '@/object-record/record-table/record-table-row/states/hasUserSelectedAllRowsFamilyState'; import { type MoveFocusDirection } from '@/object-record/record-table/types/MoveFocusDirection'; import { type TableCellPosition } from '@/object-record/record-table/types/TableCellPosition'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { type ReactNode } from 'react'; type RecordTableNoRecordGroupBodyContextProviderProps = { @@ -56,6 +58,10 @@ export const RecordTableNoRecordGroupBodyContextProvider = ({ triggerActionMenuDropdown(event, recordId); }; + const hasUserSelectedAllRows = useRecoilComponentValue( + hasUserSelectedAllRowsComponentState, + ); + return ( {children} diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx index 5abcf6261b2..c63f8777b2b 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableNoRecordGroupRows.tsx @@ -1,31 +1,37 @@ -import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; -import { RecordTableAddNew } from '@/object-record/record-table/components/RecordTableAddNew'; -import { RecordTableBodyDroppablePlaceholder } from '@/object-record/record-table/record-table-body/components/RecordTableBodyDroppablePlaceholder'; -import { RecordTableBodyFetchMoreLoader } from '@/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader'; -import { RecordTableRow } from '@/object-record/record-table/record-table-row/components/RecordTableRow'; +import { RecordTableNoRecordGroupAddNew } from '@/object-record/record-table/components/RecordTableNoRecordGroupAddNew'; +import { RecordTableRowVirtualizedContainer } from '@/object-record/record-table/virtualization/components/RecordTableRowVirtualizedContainer'; +import { RecordTableVirtualizedBodyPlaceholder } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedBodyPlaceholder'; +import { RecordTableVirtualizedDebugHelper } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedDebugHelper'; +import { NUMBER_OF_VIRTUALIZED_ROWS } from '@/object-record/record-table/virtualization/constants/NumberOfVirtualizedRows'; +import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { getContiguousIncrementalValues } from 'twenty-shared/utils'; export const RecordTableNoRecordGroupRows = () => { - const allRecordIds = useRecoilComponentValue( - recordIndexAllRecordIdsComponentSelector, + const totalNumberOfRecordsToVirtualize = + useRecoilComponentValue(totalNumberOfRecordsToVirtualizeComponentState) ?? + 0; + + const numberOfRows = Math.min( + totalNumberOfRecordsToVirtualize, + NUMBER_OF_VIRTUALIZED_ROWS, ); + const virtualRowIndices = getContiguousIncrementalValues(numberOfRows); + return ( <> - {allRecordIds.map((recordId, rowIndex) => { + + {virtualRowIndices.map((virtualRowIndex) => { return ( - ); })} - - - + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableStyleWrapper.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableStyleWrapper.tsx index 2b4b2d93b06..b8027d34fd9 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableStyleWrapper.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableStyleWrapper.tsx @@ -20,7 +20,7 @@ import { css, type Theme } from '@emotion/react'; import styled from '@emotion/styled'; import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; -const VerticalScrollBoxShadowCSS = ({ theme }: { theme: Theme }) => css` +export const VerticalScrollBoxShadowCSS = ({ theme }: { theme: Theme }) => css` &::before { bottom: -1px; box-shadow: @@ -38,7 +38,11 @@ const VerticalScrollBoxShadowCSS = ({ theme }: { theme: Theme }) => css` } `; -const HorizontalScrollBoxShadowCSS = ({ theme }: { theme: Theme }) => css` +export const HorizontalScrollBoxShadowCSS = ({ + theme, +}: { + theme: Theme; +}) => css` &::after { content: ''; position: absolute; @@ -72,6 +76,8 @@ const StyledTable = styled.div<{ flex-wrap: wrap; width: 100%; + position: relative; + div.header-cell { position: sticky; top: 0; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx index ed807001174..5a54176d696 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/__stories__/perf/RecordTableCell.perf.stories.tsx @@ -188,7 +188,6 @@ const meta: Meta = { mockPerformance.entityValue.__typename.toLocaleLowerCase(), }) + mockPerformance.recordId, isSelected: false, - inView: true, }} > void; + hasUserSelectedAllRows?: boolean; }; export const [ diff --git a/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableRowContext.ts b/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableRowContext.ts index cca01ee0d01..aef52858035 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableRowContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableRowContext.ts @@ -6,7 +6,6 @@ export type RecordTableRowContextValue = { recordId: string; rowIndex: number; isSelected: boolean; - inView: boolean; isRecordReadOnly?: boolean; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRecordTableData.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRecordTableData.ts index 170aa2f5005..8b8aea5889d 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRecordTableData.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRecordTableData.ts @@ -8,7 +8,9 @@ import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/use import { useUnfocusRecordTableCell } from '@/object-record/record-table/record-table-cell/hooks/useUnfocusRecordTableCell'; import { hasUserSelectedAllRowsComponentState } from '@/object-record/record-table/record-table-row/states/hasUserSelectedAllRowsFamilyState'; import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState'; +import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState'; import { recordTableHoverPositionComponentState } from '@/object-record/record-table/states/recordTableHoverPositionComponentState'; +import { recordIdByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { useRecoilComponentFamilyCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyCallbackState'; @@ -45,6 +47,18 @@ export const useSetRecordTableData = ({ recordTableId, ); + const isRecordTableInitialLoadingCallbackState = + useRecoilComponentCallbackState( + isRecordTableInitialLoadingComponentState, + recordTableId, + ); + + const recordIdByRealIndexCallbackState = + useRecoilComponentFamilyCallbackState( + recordIdByRealIndexComponentFamilyState, + recordTableId, + ); + const setRecordTableHoverPosition = useSetRecoilComponentState( recordTableHoverPositionComponentState, recordTableId, @@ -111,6 +125,24 @@ export const useSetRecordTableData = ({ } else { set(recordIndexAllRecordIdsSelector, recordIds); } + + const isTableInitialLoading = getSnapshotValue( + snapshot, + isRecordTableInitialLoadingCallbackState, + ); + + if (isTableInitialLoading) { + for (const [realIndex, recordId] of recordIds.entries()) { + const currentRecordIdAtRealIndex = getSnapshotValue( + snapshot, + recordIdByRealIndexCallbackState({ realIndex }), + ); + + if (recordId !== currentRecordIdAtRealIndex) { + set(recordIdByRealIndexCallbackState({ realIndex }), recordId); + } + } + } } }, [ @@ -121,6 +153,8 @@ export const useSetRecordTableData = ({ unfocusRecordTableRow, setRecordTableHoverPosition, isRowSelectedFamilyState, + recordIdByRealIndexCallbackState, + isRecordTableInitialLoadingCallbackState, ], ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts index 42a45767292..d2ed91a01fd 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts @@ -48,11 +48,12 @@ export const useCreateNewIndexRecord = ({ .getLoadable(recordIndexOpenRecordInState) .getValue(); - await createOneRecord({ + const createdRecord = await createOneRecord({ id: recordId, ...recordInputFromFilters, ...recordInput, }); + if ( recordIndexOpenRecordIn === ViewOpenRecordInType.SIDE_PANEL && canOpenObjectInSidePanel(objectMetadataItem.nameSingular) @@ -83,6 +84,8 @@ export const useCreateNewIndexRecord = ({ objectRecordId: recordId, }); } + + return createdRecord; }, [ buildRecordInputFromFilters, diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useScrollTableToPosition.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useScrollTableToPosition.ts new file mode 100644 index 00000000000..db6b2ab36ba --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useScrollTableToPosition.ts @@ -0,0 +1,34 @@ +import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; +import { useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +export const useScrollTableToPosition = () => { + const { getScrollWrapperElement } = useScrollWrapperHTMLElement(); + + const scrollTableToPosition = useCallback( + ({ + horizontalScrollInPx, + verticalScrollInPx, + }: { + verticalScrollInPx?: number; + horizontalScrollInPx?: number; + }) => { + const { scrollWrapperElement } = getScrollWrapperElement(); + + if (isDefined(verticalScrollInPx)) { + scrollWrapperElement?.scrollTo({ + top: verticalScrollInPx, + }); + } + + if (isDefined(horizontalScrollInPx)) { + scrollWrapperElement?.scrollTo({ + left: horizontalScrollInPx, + }); + } + }, + [getScrollWrapperElement], + ); + + return { scrollTableToPosition }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDroppable.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDroppable.tsx deleted file mode 100644 index e9c7a9b526e..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyDroppable.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody'; -import { RecordTableBodyDroppableContextProvider } from '@/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext'; -import { Droppable } from '@hello-pangea/dnd'; -import { type ReactNode, useState } from 'react'; -import { v4 } from 'uuid'; - -type RecordTableBodyDroppableProps = { - children: ReactNode; - recordGroupId?: string; - isDropDisabled?: boolean; -}; - -export const RecordTableBodyDroppable = ({ - children, - recordGroupId, - isDropDisabled, -}: RecordTableBodyDroppableProps) => { - const [v4Persistable] = useState(v4()); - - return ( - - {(provided) => ( - <> - - - {children} - - - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx deleted file mode 100644 index f8dc527e0fa..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyFetchMoreLoader.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import styled from '@emotion/styled'; -import { useInView } from 'react-intersection-observer'; -import { useRecoilState } from 'recoil'; - -import { useRecordIndexTableFetchMore } from '@/object-record/record-index/hooks/useRecordIndexTableFetchMore'; -import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight'; -import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; -import { hasRecordTableFetchedAllRecordsComponentState } from '@/object-record/record-table/states/hasRecordTableFetchedAllRecordsComponentState'; -import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState'; -import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { GRAY_SCALE } from 'twenty-ui/theme'; - -const StyledText = styled.div` - align-items: center; - box-shadow: none; - color: ${GRAY_SCALE.gray40}; - display: flex; - height: ${RECORD_TABLE_ROW_HEIGHT}px; - margin-left: ${({ theme }) => theme.spacing(8)}; - padding-left: ${({ theme }) => theme.spacing(2)}; -`; - -export const RecordTableBodyFetchMoreLoader = () => { - const { recordTableId, objectNameSingular } = useRecordTableContextOrThrow(); - - const { fetchMoreRecordsLazy } = - useRecordIndexTableFetchMore(objectNameSingular); - - const [isFetchingMoreRecords, setIsFetchingMoreRecords] = useRecoilState( - isFetchingMoreRecordsFamilyState(recordTableId), - ); - - const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement(); - - const hasRecordTableFetchedAllRecordsComponents = useRecoilComponentValue( - hasRecordTableFetchedAllRecordsComponentState, - ); - - const showLoadingMoreRow = !hasRecordTableFetchedAllRecordsComponents; - - const { ref: tbodyRef } = useInView({ - onChange: async (inView) => { - if (isFetchingMoreRecords || !inView) { - return; - } - - setIsFetchingMoreRecords(true); - await fetchMoreRecordsLazy(); - setIsFetchingMoreRecords(false); - }, - delay: 1000, - rootMargin: '1000px', - root: scrollWrapperHTMLElement, - }); - - if (!showLoadingMoreRow) { - return <>; - } - // TODO: fix here styling - - return ( -
-
- Loading more... -
-
-
- ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyLoading.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyLoading.tsx index 66491a033dd..90810758ac1 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyLoading.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyLoading.tsx @@ -14,7 +14,7 @@ export const RecordTableBodyLoading = () => { return ( - {Array.from({ length: 50 }).map((_, rowIndex) => ( + {Array.from({ length: 80 }).map((_, rowIndex) => ( { recordId: `${rowIndex}`, rowIndex, isSelected: false, - inView: true, }} > @@ -46,10 +47,10 @@ export const RecordTableBodyDragDropContextProvider = ({ const handleDragEnd = useRecoilCallback( () => (result: DropResult) => { - processDragOperation(result); + processDragOperationWithoutGroup(result); endDrag(); }, - [endDrag, processDragOperation], + [endDrag, processDragOperationWithoutGroup], ); return ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDroppable.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDroppable.tsx new file mode 100644 index 00000000000..49bb98e63c4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDroppable.tsx @@ -0,0 +1,47 @@ +import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody'; +import { RecordTableBodyVirtualizedDraggableClone } from '@/object-record/record-table/record-table-body/components/RecordTableBodyVirtualizedDraggableClone'; +import { RecordTableBodyDroppableContextProvider } from '@/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext'; +import { Droppable } from '@hello-pangea/dnd'; +import { type ReactNode, useState } from 'react'; +import { v4 } from 'uuid'; + +type RecordTableBodyNoRecordGroupDroppableProps = { + children: ReactNode; + isDropDisabled?: boolean; +}; + +export const RecordTableBodyNoRecordGroupDroppable = ({ + children, + isDropDisabled, +}: RecordTableBodyNoRecordGroupDroppableProps) => { + const [v4Persistable] = useState(v4()); + + return ( + ( + + )} + > + {(provided) => ( + + + {children} + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable.tsx new file mode 100644 index 00000000000..e61547fa961 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable.tsx @@ -0,0 +1,38 @@ +import { RecordTableBody } from '@/object-record/record-table/record-table-body/components/RecordTableBody'; +import { RecordTableBodyDroppableContextProvider } from '@/object-record/record-table/record-table-body/contexts/RecordTableBodyDroppableContext'; +import { Droppable } from '@hello-pangea/dnd'; +import { type ReactNode } from 'react'; + +type RecordTableBodyRecordGroupDroppableProps = { + children: ReactNode; + recordGroupId: string; + isDropDisabled?: boolean; +}; + +export const RecordTableBodyRecordGroupDroppable = ({ + children, + recordGroupId, + isDropDisabled, +}: RecordTableBodyRecordGroupDroppableProps) => { + return ( + + {(provided) => ( + + + {children} + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyVirtualizedDraggableClone.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyVirtualizedDraggableClone.tsx new file mode 100644 index 00000000000..b7d64e39a17 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableBodyVirtualizedDraggableClone.tsx @@ -0,0 +1,218 @@ +import { useRecordDragState } from '@/object-record/record-drag/shared/hooks/useRecordDragState'; +import { type RecordField } from '@/object-record/record-field/types/RecordField'; +import { HorizontalScrollBoxShadowCSS } from '@/object-record/record-table/components/RecordTableStyleWrapper'; +import { RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnAddColumnButtonWidth'; +import { RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH_CLASS_NAME } from '@/object-record/record-table/constants/RecordTableColumnAddColumnButtonWidthClassName'; +import { RECORD_TABLE_COLUMN_CHECKBOX_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnCheckboxWidth'; +import { RECORD_TABLE_COLUMN_CHECKBOX_WIDTH_CLASS_NAME } from '@/object-record/record-table/constants/RecordTableColumnCheckboxWidthClassName'; +import { RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnDragAndDropWidth'; +import { RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH_CLASS_NAME } from '@/object-record/record-table/constants/RecordTableColumnDragAndDropWidthClassName'; +import { RECORD_TABLE_COLUMN_LAST_EMPTY_COLUMN_WIDTH_CLASS_NAME } from '@/object-record/record-table/constants/RecordTableColumnLastEmptyColumnWidthClassName'; +import { RECORD_TABLE_COLUMN_LAST_EMPTY_COLUMN_WIDTH_VARIABLE_NAME } from '@/object-record/record-table/constants/RecordTableColumnLastEmptyColumnWidthVariableName'; +import { RECORD_TABLE_COLUMN_WITH_GROUP_LAST_EMPTY_COLUMN_WIDTH_VARIABLE_NAME } from '@/object-record/record-table/constants/RecordTableColumnWithGroupLastEmptyColumnWidthVariableName'; +import { RECORD_TABLE_LABEL_IDENTIFIER_COLUMN_WIDTH_ON_MOBILE } from '@/object-record/record-table/constants/RecordTableLabelIdentifierColumnWidthOnMobile'; +import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { RecordTableRowDraggableContextProvider } from '@/object-record/record-table/contexts/RecordTableRowDraggableContext'; +import { useRecordTableLastColumnWidthToFill } from '@/object-record/record-table/hooks/useRecordTableLastColumnWidthToFill'; +import { RecordTableCellCheckbox } from '@/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox'; +import { RecordTableCellDragAndDrop } from '@/object-record/record-table/record-table-cell/components/RecordTableCellDragAndDrop'; +import { RecordTableLastEmptyCell } from '@/object-record/record-table/record-table-cell/components/RecordTableLastEmptyCell'; +import { RecordTablePlusButtonCellPlaceholder } from '@/object-record/record-table/record-table-cell/components/RecordTablePlusButtonCellPlaceholder'; +import { RecordTableFieldsCells } from '@/object-record/record-table/record-table-row/components/RecordTableFieldsCells'; + +import { RecordTableRowMultiDragPreview } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview'; +import { RecordTableTr } from '@/object-record/record-table/record-table-row/components/RecordTableTr'; +import { getRecordTableColumnFieldWidthClassName } from '@/object-record/record-table/utils/getRecordTableColumnFieldWidthClassName'; +import { getRecordTableColumnFieldWidthCSSVariableName } from '@/object-record/record-table/utils/getRecordTableColumnFieldWidthCSSVariableName'; +import { recordIdByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState'; +import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { + type DraggableProvided, + type DraggableRubric, + type DraggableStateSnapshot, +} from '@hello-pangea/dnd'; +import { isDefined } from 'twenty-shared/utils'; +import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; + +// TODO: see how we can merge this with RecordTableStyleWrapper, +// because we have not decided a strategy for sharing CSS bits yet +const StyledRowDraggableCloneCSSBridge = styled.div<{ + visibleRecordFields: RecordField[]; + lastColumnWidth: number; +}>` + div.table-cell:nth-of-type(1) { + position: sticky; + left: 0px; + z-index: ${TABLE_Z_INDEX.cell.withGroups.sticky}; + } + + div.table-cell:nth-of-type(2) { + position: sticky; + left: ${RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH}px; + z-index: ${TABLE_Z_INDEX.cell.withoutGroups.sticky}; + } + + div.table-cell-0-0 { + position: sticky; + left: ${RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH + + RECORD_TABLE_COLUMN_CHECKBOX_WIDTH}px; + + ${HorizontalScrollBoxShadowCSS} + } + + div.table-cell:nth-of-type(3) { + position: sticky; + left: ${RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH + + RECORD_TABLE_COLUMN_CHECKBOX_WIDTH}px; + z-index: ${TABLE_Z_INDEX.cell.withoutGroups.sticky}; + + ${HorizontalScrollBoxShadowCSS} + } + + div.${RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH_CLASS_NAME} { + width: ${RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH}px; + min-width: ${RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH}px; + max-width: ${RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH}px; + } + + div.${RECORD_TABLE_COLUMN_CHECKBOX_WIDTH_CLASS_NAME} { + width: ${RECORD_TABLE_COLUMN_CHECKBOX_WIDTH}px; + min-width: ${RECORD_TABLE_COLUMN_CHECKBOX_WIDTH}px; + max-width: ${RECORD_TABLE_COLUMN_CHECKBOX_WIDTH}px; + } + + div.${RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH_CLASS_NAME} { + width: ${RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH}px; + min-width: ${RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH}px; + max-width: ${RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH}px; + } + + ${({ visibleRecordFields, lastColumnWidth }) => { + let returnedCSS = ''; + + for (let i = 0; i < visibleRecordFields.length; i++) { + returnedCSS += `--record-table-column-field-${i}: ${visibleRecordFields[i].size}px; \n`; + } + + for (let i = 0; i < visibleRecordFields.length; i++) { + returnedCSS += `div.${getRecordTableColumnFieldWidthClassName(i)} { + width: var(${getRecordTableColumnFieldWidthCSSVariableName(i)}); + min-width: var(${getRecordTableColumnFieldWidthCSSVariableName(i)}); + max-width: var(${getRecordTableColumnFieldWidthCSSVariableName(i)}); + } \n`; + + const isLabelIdentifierColumn = i === 0; + + if (isLabelIdentifierColumn) { + returnedCSS += `div.${getRecordTableColumnFieldWidthClassName(i)} { + @media (max-width: ${MOBILE_VIEWPORT}px) { + width: ${RECORD_TABLE_LABEL_IDENTIFIER_COLUMN_WIDTH_ON_MOBILE}px; + max-width: ${RECORD_TABLE_LABEL_IDENTIFIER_COLUMN_WIDTH_ON_MOBILE}px; + min-width: ${RECORD_TABLE_LABEL_IDENTIFIER_COLUMN_WIDTH_ON_MOBILE}px; + } + } \n`; + } + } + + returnedCSS += `${RECORD_TABLE_COLUMN_LAST_EMPTY_COLUMN_WIDTH_VARIABLE_NAME}: ${lastColumnWidth}px;`; + returnedCSS += `${RECORD_TABLE_COLUMN_WITH_GROUP_LAST_EMPTY_COLUMN_WIDTH_VARIABLE_NAME}: ${lastColumnWidth}px;`; + + return returnedCSS; + }}; + + div.${RECORD_TABLE_COLUMN_LAST_EMPTY_COLUMN_WIDTH_CLASS_NAME} { + width: var(${RECORD_TABLE_COLUMN_LAST_EMPTY_COLUMN_WIDTH_VARIABLE_NAME}); + min-width: var( + ${RECORD_TABLE_COLUMN_LAST_EMPTY_COLUMN_WIDTH_VARIABLE_NAME} + ); + max-width: var( + ${RECORD_TABLE_COLUMN_LAST_EMPTY_COLUMN_WIDTH_VARIABLE_NAME} + ); + } +`; + +export const RecordTableBodyVirtualizedDraggableClone = ({ + draggableProvided, + draggableSnapshot, + rubric, +}: { + draggableProvided: DraggableProvided; + draggableSnapshot: DraggableStateSnapshot; + rubric: DraggableRubric; +}) => { + const realIndex = rubric.source.index; + + const { recordTableId } = useRecordTableContextOrThrow(); + + const multiDragState = useRecordDragState('table', recordTableId); + + const theme = useTheme(); + + const recordId = useRecoilComponentFamilyValue( + recordIdByRealIndexComponentFamilyState, + { realIndex }, + ); + + const { lastColumnWidth } = useRecordTableLastColumnWidthToFill(); + + const { visibleRecordFields } = useRecordTableContextOrThrow(); + + if (!isDefined(recordId)) { + return null; + } + + const isSecondaryDragged = + multiDragState?.isDragging && + multiDragState.originalSelection.includes(recordId) && + recordId !== multiDragState.primaryDraggedRecordId; + + return ( + + {}} + isFirstRowOfGroup={false} + > + + + + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx index 31a0511bd7d..d364f17fe9e 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBody.tsx @@ -1,38 +1,43 @@ -import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { recordIndexHasRecordsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexHasRecordsComponentSelector'; import { RecordTableNoRecordGroupBodyContextProvider } from '@/object-record/record-table/components/RecordTableNoRecordGroupBodyContextProvider'; import { RecordTableNoRecordGroupRows } from '@/object-record/record-table/components/RecordTableNoRecordGroupRows'; -import { RecordTableBodyDragDropContextProvider } from '@/object-record/record-table/record-table-body/components/RecordTableBodyDragDropContextProvider'; -import { RecordTableBodyDroppable } from '@/object-record/record-table/record-table-body/components/RecordTableBodyDroppable'; + import { RecordTableBodyLoading } from '@/object-record/record-table/record-table-body/components/RecordTableBodyLoading'; +import { RecordTableBodyNoRecordGroupDragDropContextProvider } from '@/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDragDropContextProvider'; +import { RecordTableBodyNoRecordGroupDroppable } from '@/object-record/record-table/record-table-body/components/RecordTableBodyNoRecordGroupDroppable'; import { RecordTableCellPortals } from '@/object-record/record-table/record-table-cell/components/RecordTableCellPortals'; 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 { RecordTableVirtualizedRowTreadmillEffect } from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedRowTreadmillEffect'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; export const RecordTableNoRecordGroupBody = () => { - const allRecordIds = useRecoilComponentValue( - recordIndexAllRecordIdsComponentSelector, + const recordTableHasRecords = useRecoilComponentValue( + recordIndexHasRecordsComponentSelector, ); const isRecordTableInitialLoading = useRecoilComponentValue( isRecordTableInitialLoadingComponentState, ); - if (isRecordTableInitialLoading && allRecordIds.length === 0) { + if (isRecordTableInitialLoading && !recordTableHasRecords) { return ; } return ( - - + + - - {!isRecordTableInitialLoading && allRecordIds.length > 0 && ( + + {!isRecordTableInitialLoading && recordTableHasRecords && ( )} - + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBodyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBodyEffect.tsx deleted file mode 100644 index ccf1d67dfd6..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableNoRecordGroupBodyEffect.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useRecoilState, useRecoilValue } from 'recoil'; - -import { lastShowPageRecordIdState } from '@/object-record/record-field/ui/states/lastShowPageRecordId'; -import { useRecordIndexTableQuery } from '@/object-record/record-index/hooks/useRecordIndexTableQuery'; -import { ROW_HEIGHT } from '@/object-record/record-table/constants/RowHeight'; -import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; -import { useSetRecordTableData } from '@/object-record/record-table/hooks/internal/useSetRecordTableData'; -import { hasRecordTableFetchedAllRecordsComponentState } from '@/object-record/record-table/states/hasRecordTableFetchedAllRecordsComponentState'; -import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState'; -import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState'; -import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal'; -import { useScrollToPosition } from '@/ui/utilities/scroll/hooks/useScrollToPosition'; -import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; -import { isNonEmptyString } from '@sniptt/guards'; - -export const RecordTableNoRecordGroupBodyEffect = () => { - const { objectNameSingular, recordTableId } = useRecordTableContextOrThrow(); - - const { records, loading, hasNextPage } = - useRecordIndexTableQuery(objectNameSingular); - - const setRecordTableData = useSetRecordTableData({ - recordTableId, - }); - - const showAuthModal = useShowAuthModal(); - - const [hasInitializedScroll, setHasInitializedScroll] = useState(false); - - const setHasRecordTableFetchedAllRecordsComponents = - useSetRecoilComponentState(hasRecordTableFetchedAllRecordsComponentState); - const setIsRecordTableInitialLoading = useSetRecoilComponentState( - isRecordTableInitialLoadingComponentState, - ); - const isFetchingMoreRecords = useRecoilValue( - isFetchingMoreRecordsFamilyState(recordTableId), - ); - - const [lastShowPageRecordId] = useRecoilState(lastShowPageRecordIdState); - - const { scrollToPosition } = useScrollToPosition(); - - useEffect(() => { - if (!loading && !isFetchingMoreRecords) { - setRecordTableData({ - records, - }); - setHasRecordTableFetchedAllRecordsComponents(!hasNextPage); - setIsRecordTableInitialLoading(false); - } - }, [ - hasNextPage, - isFetchingMoreRecords, - loading, - records, - setHasRecordTableFetchedAllRecordsComponents, - setIsRecordTableInitialLoading, - setRecordTableData, - showAuthModal, - ]); - - useEffect(() => { - if (hasInitializedScroll) { - return; - } - - if (isNonEmptyString(lastShowPageRecordId)) { - const isRecordAlreadyFetched = records.some( - (record) => record.id === lastShowPageRecordId, - ); - - if (isRecordAlreadyFetched) { - const recordPosition = records.findIndex( - (record) => record.id === lastShowPageRecordId, - ); - - const positionInPx = recordPosition * ROW_HEIGHT; - - scrollToPosition(positionInPx); - - setHasInitializedScroll(true); - } - } - }, [hasInitializedScroll, lastShowPageRecordId, records, scrollToPosition]); - - return <>; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffect.tsx index c094429661d..a6e9322f7a0 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupBodyEffect.tsx @@ -5,7 +5,8 @@ import { lastShowPageRecordIdState } from '@/object-record/record-field/ui/state import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId'; import { useRecordIndexTableQuery } from '@/object-record/record-index/hooks/useRecordIndexTableQuery'; import { recordIndexHasFetchedAllRecordsByGroupComponentState } from '@/object-record/record-index/states/recordIndexHasFetchedAllRecordsByGroupComponentState'; -import { ROW_HEIGHT } from '@/object-record/record-table/constants/RowHeight'; + +import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { useSetRecordTableData } from '@/object-record/record-table/hooks/internal/useSetRecordTableData'; import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState'; @@ -67,7 +68,7 @@ export const RecordTableRecordGroupBodyEffect = () => { ); if (recordPosition !== -1) { - const positionInPx = recordPosition * ROW_HEIGHT; + const positionInPx = recordPosition * RECORD_TABLE_ROW_HEIGHT; scrollToPosition(positionInPx); } diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx index 270e1bb3c1e..002ee278d11 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx @@ -3,9 +3,9 @@ import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/re import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; import { RecordTableRecordGroupBodyContextProvider } from '@/object-record/record-table/components/RecordTableRecordGroupBodyContextProvider'; import { RecordTableRecordGroupRows } from '@/object-record/record-table/components/RecordTableRecordGroupRows'; -import { RecordTableBodyDroppable } from '@/object-record/record-table/record-table-body/components/RecordTableBodyDroppable'; import { RecordTableBodyLoading } from '@/object-record/record-table/record-table-body/components/RecordTableBodyLoading'; import { RecordTableBodyRecordGroupDragDropContextProvider } from '@/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider'; +import { RecordTableBodyRecordGroupDroppable } from '@/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable'; import { RecordTableCellPortals } from '@/object-record/record-table/record-table-cell/components/RecordTableCellPortals'; import { RecordTableRecordGroupSection } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection'; import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState'; @@ -40,11 +40,13 @@ export const RecordTableRecordGroupsBody = () => { recordGroupId={recordGroupId} > - + {index === 0 && } - + ))} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckboxPlaceholder.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckboxPlaceholder.tsx new file mode 100644 index 00000000000..c200d0cea72 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellCheckboxPlaceholder.tsx @@ -0,0 +1,40 @@ +import styled from '@emotion/styled'; + +import { RECORD_TABLE_COLUMN_CHECKBOX_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnCheckboxWidth'; +import { RECORD_TABLE_COLUMN_CHECKBOX_WIDTH_CLASS_NAME } from '@/object-record/record-table/constants/RecordTableColumnCheckboxWidthClassName'; +import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight'; +import { useRecordTableBodyContextOrThrow } from '@/object-record/record-table/contexts/RecordTableBodyContext'; +import { RecordTableCellStyleWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper'; +import { Checkbox } from 'twenty-ui/input'; + +const StyledContainer = styled.div` + align-items: center; + cursor: pointer; + display: flex; + height: ${RECORD_TABLE_ROW_HEIGHT}px; + justify-content: center; + min-width: ${RECORD_TABLE_COLUMN_CHECKBOX_WIDTH}; + width: ${RECORD_TABLE_COLUMN_CHECKBOX_WIDTH}; + padding-right: ${({ theme }) => theme.spacing(1)}; +`; + +// TODO: refactor +const StyledRecordTableTd = styled(RecordTableCellStyleWrapper)` + border-left: 1px solid transparent; +`; + +export const RecordTableCellCheckboxPlaceholder = () => { + const { hasUserSelectedAllRows } = useRecordTableBodyContextOrThrow(); + + return ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellLoading.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellLoading.tsx index 913f8729e28..8a2ac1daee2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellLoading.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellLoading.tsx @@ -1,17 +1,30 @@ -import { RecordTableCellSkeletonLoader } from '@/object-record/record-table/record-table-cell/components/RecordTableCellSkeletonLoader'; import { RecordTableCellStyleWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper'; import { getRecordTableColumnFieldWidthClassName } from '@/object-record/record-table/utils/getRecordTableColumnFieldWidthClassName'; +import { type Theme, useTheme } from '@emotion/react'; +import { styled } from '@linaria/react'; + +const StyledStaticCellSkeleton = styled.div<{ theme: Theme }>` + background-color: ${({ theme }) => theme.background.tertiary}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + padding: 8px; + margin: 8px; +`; export const RecordTableCellLoading = ({ recordFieldIndex, + isSelected = false, }: { recordFieldIndex: number; + isSelected?: boolean; }) => { + const theme = useTheme(); + return ( - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalContexts.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalContexts.tsx index 822b5d4b578..9847fdb7404 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalContexts.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellPortalContexts.tsx @@ -50,7 +50,6 @@ export const RecordTableCellPortalContexts = ({ recordId, rowIndex: position.row, isSelected: false, - inView: true, pathToShowPage: getBasePathToShowPage({ objectNameSingular: objectMetadataItem.nameSingular, diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper.tsx index 5361fdc0842..93276b08912 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper.tsx @@ -17,8 +17,8 @@ export const StyledCell = styled.div<{ hasBottomBorder && !isDragging ? borderColor : 'transparent'}; color: ${({ fontColor }) => fontColor}; - border-right: ${({ borderColor, hasRightBorder, isDragging }) => - hasRightBorder && !isDragging ? `1px solid ${borderColor}` : 'none'}; + border-right: ${({ borderColor, hasRightBorder }) => + hasRightBorder ? `1px solid ${borderColor}` : 'none'}; padding: 0; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTablePlusButtonCellPlaceholderSkeleton.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTablePlusButtonCellPlaceholderSkeleton.tsx new file mode 100644 index 00000000000..1cbb81cad85 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTablePlusButtonCellPlaceholderSkeleton.tsx @@ -0,0 +1,15 @@ +import { RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH_CLASS_NAME } from '@/object-record/record-table/constants/RecordTableColumnAddColumnButtonWidthClassName'; +import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext'; +import { RecordTableCellStyleWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper'; + +export const RecordTablePlusButtonCellPlaceholder = () => { + const { isSelected } = useRecordTableRowContextOrThrow(); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts index a09b668b8aa..d3c967642c2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts @@ -8,7 +8,6 @@ export const recordTableRowContextValue: RecordTableRowContextValue = { recordId: 'recordId', pathToShowPage: '/', objectNameSingular: 'objectNameSingular', - inView: true, }; export const recordTableRowDraggableContextValue: RecordTableRowDraggableContextValue = { diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableCells.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableCells.tsx deleted file mode 100644 index 60a282a67a9..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableCells.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useRecordTableRowContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowContext'; -import { useRecordTableRowDraggableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableRowDraggableContext'; -import { RecordTableCellsEmpty } from '@/object-record/record-table/record-table-row/components/RecordTableCellsEmpty'; -import { RecordTableCellsVisible } from '@/object-record/record-table/record-table-row/components/RecordTableCellsVisible'; - -export const RecordTableCells = () => { - const { inView } = useRecordTableRowContextOrThrow(); - - const { isDragging } = useRecordTableRowDraggableContextOrThrow(); - - const areCellsVisible = inView || isDragging; - - return areCellsVisible ? ( - - ) : ( - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx index 2bcbea01596..5e77634ec9d 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableDraggableTr.tsx @@ -7,7 +7,6 @@ import { useRecordTableContextOrThrow } from '@/object-record/record-table/conte import { RecordTableRowDraggableContextProvider } from '@/object-record/record-table/contexts/RecordTableRowDraggableContext'; import { RecordTableRowMultiDragPreview } from '@/object-record/record-table/record-table-row/components/RecordTableRowMultiDragPreview'; import { RecordTableTr } from '@/object-record/record-table/record-table-row/components/RecordTableTr'; -import { RecordTableTrEffect } from '@/object-record/record-table/record-table-row/components/RecordTableTrEffect'; type RecordTableDraggableTrProps = { className?: string; @@ -45,7 +44,6 @@ export const RecordTableDraggableTr = ({ > {(draggableProvided, draggableSnapshot) => ( <> - {(draggableProvided, draggableSnapshot) => ( <> - { +export const RecordTableFieldsCells = () => { const { isSelected, rowIndex } = useRecordTableRowContextOrThrow(); const { isDragging } = useRecordTableRowDraggableContextOrThrow(); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRow.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRow.tsx index 6584e5a4949..5a948743fb0 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRow.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableRow.tsx @@ -3,9 +3,10 @@ import { RecordTableCellCheckbox } from '@/object-record/record-table/record-tab import { RecordTableCellDragAndDrop } from '@/object-record/record-table/record-table-cell/components/RecordTableCellDragAndDrop'; import { RecordTableLastEmptyCell } from '@/object-record/record-table/record-table-cell/components/RecordTableLastEmptyCell'; import { RecordTablePlusButtonCellPlaceholder } from '@/object-record/record-table/record-table-cell/components/RecordTablePlusButtonCellPlaceholder'; -import { RecordTableCells } from '@/object-record/record-table/record-table-row/components/RecordTableCells'; + import { RecordTableDraggableTr } from '@/object-record/record-table/record-table-row/components/RecordTableDraggableTr'; import { RecordTableDraggableTrFirstRowOfGroup } from '@/object-record/record-table/record-table-row/components/RecordTableDraggableTrFirstRowOfGroup'; +import { RecordTableFieldsCells } from '@/object-record/record-table/record-table-row/components/RecordTableFieldsCells'; import { RecordTableRowArrowKeysEffect } from '@/object-record/record-table/record-table-row/components/RecordTableRowArrowKeysEffect'; import { RecordTableRowHotkeyEffect } from '@/object-record/record-table/record-table-row/components/RecordTableRowHotkeyEffect'; import { isRecordTableRowFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableRowFocusActiveComponentState'; @@ -54,7 +55,7 @@ export const RecordTableRow = ({ )} - + - + theme.border.color.medium}; - border-top: 1px solid ${({ theme }) => theme.border.color.medium}; border-color: ${({ theme }) => theme.border.color.medium}; background-color: ${({ theme }) => theme.background.tertiary}; } &:nth-of-type(2) { border-left: 1px solid ${({ theme }) => theme.border.color.medium}; - border-radius: ${({ theme }) => theme.border.radius.sm} 0 0 - ${({ theme }) => theme.border.radius.sm}; margin-left: -1px; @@ -70,44 +61,6 @@ const StyledTr = styled.div<{ } } } - - &[data-active='true'] { - div.table-cell, - div.table-cell-0-0 { - &:not(:first-of-type) { - border-bottom: 1px solid ${({ theme }) => theme.adaptiveColors.blue3}; - border-top: 1px solid ${({ theme }) => theme.adaptiveColors.blue3}; - background-color: ${({ theme }) => theme.accent.quaternary}; - - z-index: var(--z-index-for-normal-cells); - } - &:nth-of-type(2) { - border-left: 1px solid ${({ theme }) => theme.adaptiveColors.blue3}; - border-radius: ${({ theme }) => theme.border.radius.sm} 0 0 - ${({ theme }) => theme.border.radius.sm}; - - margin-left: -1px; - - div { - margin-left: -1px; - } - - z-index: var(--z-index-for-sticky-cells); - } - &:nth-of-type(3) { - z-index: var(--z-index-for-sticky-cells); - } - &:nth-of-type(1) { - z-index: var(--z-index-for-sticky-cells); - } - &:last-of-type { - border-right: 1px solid ${({ theme }) => theme.adaptiveColors.blue3}; - border-radius: 0 ${({ theme }) => theme.border.radius.sm} - ${({ theme }) => theme.border.radius.sm} 0; - z-index: var(--z-index-for-normal-cells); - } - } - } `; export const RecordTableRowDiv = StyledTr; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTr.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTr.tsx index d643403be40..0ca227a6172 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTr.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTr.tsx @@ -1,16 +1,15 @@ import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly'; -import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableRowContextProvider } from '@/object-record/record-table/contexts/RecordTableRowContext'; import { RecordTableFirstRowOfGroup } from '@/object-record/record-table/record-table-row/components/RecordTableFirstRowOfGroup'; import { RecordTableRowDiv } from '@/object-record/record-table/record-table-row/components/RecordTableRowDiv'; import { isRecordIdFirstOfGroupComponentFamilySelector } from '@/object-record/record-table/record-table-row/states/isRecordIdFirstOfGroupComponentFamilySelector'; import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState'; -import { isRowVisibleComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowVisibleComponentFamilyState'; import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState'; import { isRecordTableRowFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableRowFocusActiveComponentState'; import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState'; +import { recordIdByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState'; import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { forwardRef, type ReactNode } from 'react'; @@ -45,11 +44,6 @@ export const RecordTableTr = forwardRef( recordId, ); - const isRowVisible = useRecoilComponentFamilyValue( - isRowVisibleComponentFamilyState, - recordId, - ); - const isActive = useRecoilComponentFamilyValue( isRecordTableRowActiveComponentFamilyState, focusIndex, @@ -60,15 +54,14 @@ export const RecordTableTr = forwardRef( focusIndex + 1, ); - const allRecordIds = useRecoilComponentValue( - recordIndexAllRecordIdsComponentSelector, + const nextRecordId = useRecoilComponentFamilyValue( + recordIdByRealIndexComponentFamilyState, + { realIndex: focusIndex + 1 }, ); - const nextRecordId = allRecordIds[focusIndex + 1]; - const isNextRecordIdFirstOfGroup = useRecoilComponentFamilyValue( isRecordIdFirstOfGroupComponentFamilySelector, - { recordId: nextRecordId }, + { recordId: nextRecordId ?? '' }, ); const isFocused = useRecoilComponentFamilyValue( @@ -105,7 +98,6 @@ export const RecordTableTr = forwardRef( }) + recordId, objectNameSingular: objectMetadataItem.nameSingular, isSelected: currentRowSelected, - inView: isRowVisible, isRecordReadOnly, }} > diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTrEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTrEffect.tsx deleted file mode 100644 index ffbec3676a9..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/components/RecordTableTrEffect.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { isRowVisibleComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowVisibleComponentFamilyState'; -import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; -import { useSetRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentFamilyState'; -import { useEffect } from 'react'; - -type RecordTableTrEffectProps = { - recordId: string; -}; - -export const RecordTableTrEffect = ({ recordId }: RecordTableTrEffectProps) => { - const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement(); - - const setIsRowVisible = useSetRecoilComponentFamilyState( - isRowVisibleComponentFamilyState, - recordId, - ); - - useEffect(() => { - const options = { - root: scrollWrapperHTMLElement, - rootMargin: '1000px', - threshold: 0.1, - }; - - const callback = (entries: IntersectionObserverEntry[]) => { - entries.forEach((entry) => { - const isIntersecting = entry.isIntersecting; - - if (isIntersecting) { - setIsRowVisible(true); - } - - if (!isIntersecting) { - setIsRowVisible(false); - } - }); - }; - - const observer = new IntersectionObserver(callback, options); - - observer.observe( - document.querySelector( - `[data-virtualized-id="${recordId}"]`, - ) as HTMLElement, - ); - - return () => { - observer.disconnect(); - }; - }, [recordId, scrollWrapperHTMLElement, setIsRowVisible]); - - return <>; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/isRowVisibleComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/isRowVisibleComponentFamilyState.ts deleted file mode 100644 index 592ee66dd26..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/isRowVisibleComponentFamilyState.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; -import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; - -export const isRowVisibleComponentFamilyState = createComponentFamilyState< - boolean, - string ->({ - key: 'isRowVisibleComponentFamilyState', - defaultValue: true, - componentInstanceContext: RecordTableComponentInstanceContext, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableNoRecordGroupScrollToPreviousRecordEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableNoRecordGroupScrollToPreviousRecordEffect.tsx new file mode 100644 index 00000000000..b7742c694a6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableNoRecordGroupScrollToPreviousRecordEffect.tsx @@ -0,0 +1,95 @@ +import { lastShowPageRecordIdState } from '@/object-record/record-field/ui/states/lastShowPageRecordId'; +import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight'; +import { useScrollTableToPosition } from '@/object-record/record-table/hooks/useScrollTableToPosition'; +import { useProcessTreadmillScrollTop } from '@/object-record/record-table/virtualization/hooks/useProcessTreadmillScrollTop'; +import { useTriggerFetchPages } from '@/object-record/record-table/virtualization/hooks/useTriggerFetchPages'; +import { useTriggerInitialRecordTableDataLoad } from '@/object-record/record-table/virtualization/hooks/useTriggerInitialRecordTableDataLoad'; +import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useEffect, useState } from 'react'; +import { useRecoilState } from 'recoil'; + +export const RecordTableNoRecordGroupScrollToPreviousRecordEffect = () => { + const { getScrollWrapperElement } = useScrollWrapperHTMLElement(); + + const allRecordIds = useRecoilComponentValue( + recordIndexAllRecordIdsComponentSelector, + ); + + const [lastShowPageRecordId, setLastShowPageRecordId] = useRecoilState( + lastShowPageRecordIdState, + ); + + const [hasInitializedScroll, setHasInitializedScroll] = useState(false); + + const { scrollTableToPosition } = useScrollTableToPosition(); + + const { triggerInitialRecordTableDataLoad } = + useTriggerInitialRecordTableDataLoad(); + + const { processTreadmillScrollTop } = useProcessTreadmillScrollTop(); + + const { triggerFetchPagesWithoutDebounce } = useTriggerFetchPages(); + + useEffect(() => { + const run = async () => { + setLastShowPageRecordId(null); + + const recordPosition = allRecordIds.findIndex( + (recordId) => recordId === lastShowPageRecordId, + ); + + await triggerInitialRecordTableDataLoad(); + + const { scrollWrapperElement } = getScrollWrapperElement(); + + const tableScrollWrapperHeight = scrollWrapperElement?.clientHeight ?? 0; + + const numberOfRowsDisplayedInTable = Math.min( + Math.floor(tableScrollWrapperHeight / (RECORD_TABLE_ROW_HEIGHT + 1)), + 30, + ); + + const halfNumberOfRowsVisible = Math.floor( + numberOfRowsDisplayedInTable / 2, + ); + + const recordPositionInPx = recordPosition * (RECORD_TABLE_ROW_HEIGHT + 1); + + const targetScrollPositionInPx = Math.max( + 0, + recordPositionInPx - + halfNumberOfRowsVisible * (RECORD_TABLE_ROW_HEIGHT + 1), + ); + + scrollTableToPosition({ + horizontalScrollInPx: 0, + verticalScrollInPx: targetScrollPositionInPx, + }); + + processTreadmillScrollTop(targetScrollPositionInPx); + + setHasInitializedScroll(true); + + await triggerFetchPagesWithoutDebounce(); + }; + + if (isNonEmptyString(lastShowPageRecordId)) { + run(); + } + }, [ + hasInitializedScroll, + lastShowPageRecordId, + scrollTableToPosition, + allRecordIds, + setLastShowPageRecordId, + triggerInitialRecordTableDataLoad, + processTreadmillScrollTop, + getScrollWrapperElement, + triggerFetchPagesWithoutDebounce, + ]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedContainer.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedContainer.tsx new file mode 100644 index 00000000000..7a2f1ca70b6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedContainer.tsx @@ -0,0 +1,56 @@ +import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight'; +import { RecordTableRowVirtualizedDebugRowHelper } from '@/object-record/record-table/virtualization/components/RecordTableRowVirtualizedDebugRowHelper'; +import { RecordTableRowVirtualizedRouterLevel1 } from '@/object-record/record-table/virtualization/components/RecordTableRowVirtualizedRouterLevel1'; +import { TABLE_VIRTUALIZATION_DEBUG_ACTIVATED } from '@/object-record/record-table/virtualization/constants/TableVirtualizationDebugActivated'; + +import { realIndexByVirtualIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/realIndexByVirtualIndexComponentFamilyState'; +import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState'; + +import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { styled } from '@linaria/react'; +import { isDefined } from 'twenty-shared/utils'; + +const StyledVirtualizedRowContainer = styled.div<{ + pixelsFromTop: number; +}>` + height: ${RECORD_TABLE_ROW_HEIGHT + 1}; + position: absolute; + top: ${({ pixelsFromTop }) => pixelsFromTop}px; +`; + +type RecordTableRowVirtualizedContainerProps = { + virtualIndex: number; +}; + +export const RecordTableRowVirtualizedContainer = ({ + virtualIndex, +}: RecordTableRowVirtualizedContainerProps) => { + const realIndex = useRecoilComponentFamilyValue( + realIndexByVirtualIndexComponentFamilyState, + { virtualIndex }, + ); + + const totalNumberOfRecordsToVirtualize = + useRecoilComponentValue(totalNumberOfRecordsToVirtualizeComponentState) ?? + 0; + + if (!isDefined(realIndex) || realIndex >= totalNumberOfRecordsToVirtualize) { + return null; + } + + const pixelsFromTop = + realIndex * (RECORD_TABLE_ROW_HEIGHT + 1) + (RECORD_TABLE_ROW_HEIGHT + 1); + + return ( + + {TABLE_VIRTUALIZATION_DEBUG_ACTIVATED && ( + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedDebugRowHelper.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedDebugRowHelper.tsx new file mode 100644 index 00000000000..c0b6753546a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedDebugRowHelper.tsx @@ -0,0 +1,96 @@ +import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; +import { getLabelIdentifierFieldValue } from '@/object-metadata/utils/getLabelIdentifierFieldValue'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { realIndexByVirtualIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/realIndexByVirtualIndexComponentFamilyState'; +import { recordIdByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState'; + +import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +const StyledDebugColumn = styled.div<{ width: number }>` + height: 25px; + max-height: 25px; + min-width: ${({ width }) => width}px; + max-width: ${({ width }) => width}px; + overflow: scroll; + + display: flex; + text-wrap-mode: nowrap; + padding-right: 2px; + padding-left: 2px; +`; + +type RecordTableRowVirtualizedDebugRowHelperProps = { + virtualIndex: number; +}; + +export const RecordTableRowVirtualizedDebugRowHelper = ({ + virtualIndex, +}: RecordTableRowVirtualizedDebugRowHelperProps) => { + const realIndex = useRecoilComponentFamilyValue( + realIndexByVirtualIndexComponentFamilyState, + { virtualIndex }, + ); + + const recordId = useRecoilComponentFamilyValue( + recordIdByRealIndexComponentFamilyState, + { realIndex }, + ); + + const pixelsFromTop = + (realIndex ?? 0) * (RECORD_TABLE_ROW_HEIGHT + 1) + + (RECORD_TABLE_ROW_HEIGHT + 1); + + const record = useRecoilValue(recordStoreFamilyState(recordId ?? '')); + const { objectMetadataItem, objectNameSingular } = + useRecordTableContextOrThrow(); + const labelIdentifierFieldMetadataItem = + getLabelIdentifierFieldMetadataItem(objectMetadataItem); + + const labelIdentifier = isDefined(record) + ? getLabelIdentifierFieldValue( + record, + labelIdentifierFieldMetadataItem, + objectNameSingular, + ) + : '-'; + + const position = record?.position; + + return ( +
+ virtual :{virtualIndex} + real :{realIndex} + pos :{position} + + px: + {pixelsFromTop} + + + {labelIdentifier} + + + id: + {recordId} + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedFullData.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedFullData.tsx new file mode 100644 index 00000000000..052085c89ea --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedFullData.tsx @@ -0,0 +1,77 @@ +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; + +import { RecordTableCellCheckbox } from '@/object-record/record-table/record-table-cell/components/RecordTableCellCheckbox'; +import { RecordTableCellDragAndDrop } from '@/object-record/record-table/record-table-cell/components/RecordTableCellDragAndDrop'; +import { RecordTableLastEmptyCell } from '@/object-record/record-table/record-table-cell/components/RecordTableLastEmptyCell'; +import { RecordTablePlusButtonCellPlaceholder } from '@/object-record/record-table/record-table-cell/components/RecordTablePlusButtonCellPlaceholder'; +import { RecordTableDraggableTr } from '@/object-record/record-table/record-table-row/components/RecordTableDraggableTr'; +import { RecordTableFieldsCells } from '@/object-record/record-table/record-table-row/components/RecordTableFieldsCells'; +import { RecordTableRowArrowKeysEffect } from '@/object-record/record-table/record-table-row/components/RecordTableRowArrowKeysEffect'; +import { RecordTableRowHotkeyEffect } from '@/object-record/record-table/record-table-row/components/RecordTableRowHotkeyEffect'; +import { isRecordTableRowFocusActiveComponentState } from '@/object-record/record-table/states/isRecordTableRowFocusActiveComponentState'; +import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowFocusedComponentFamilyState'; +import { RecordTableRowVirtualizedSkeleton } from '@/object-record/record-table/virtualization/components/RecordTableRowVirtualizedSkeleton'; +import { recordIdByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState'; + +import { ListenRecordUpdatesEffect } from '@/subscription/components/ListenRecordUpdatesEffect'; +import { getDefaultRecordFieldsToListen } from '@/subscription/utils/getDefaultRecordFieldsToListen.util'; +import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { isDefined } from 'twenty-shared/utils'; + +type RecordTableRowVirtualizedFullDataProps = { + realIndex: number; +}; + +// TODO: Full Data will take its full meaning when we'll have different levels of data : with relations, only identifiers, etc. +export const RecordTableRowVirtualizedFullData = ({ + realIndex, +}: RecordTableRowVirtualizedFullDataProps) => { + const { objectNameSingular } = useRecordIndexContextOrThrow(); + const listenedFields = getDefaultRecordFieldsToListen({ + objectNameSingular, + }); + + const isFocused = useRecoilComponentFamilyValue( + isRecordTableRowFocusedComponentFamilyState, + realIndex ?? 0, + ); + + const isRowFocusActive = useRecoilComponentValue( + isRecordTableRowFocusActiveComponentState, + ); + + const recordId = useRecoilComponentFamilyValue( + recordIdByRealIndexComponentFamilyState, + { realIndex }, + ); + + if (!isDefined(recordId)) { + return ; + } + + return ( + + {isRowFocusActive && isFocused && ( + <> + + + + )} + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedRouterLevel1.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedRouterLevel1.tsx new file mode 100644 index 00000000000..4c5d8d55b34 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedRouterLevel1.tsx @@ -0,0 +1,23 @@ +import { RecordTableRowVirtualizedRouterLevel2 } from '@/object-record/record-table/virtualization/components/RecordTableRowVirtualizedRouterLevel2'; +import { RecordTableRowVirtualizedSkeleton } from '@/object-record/record-table/virtualization/components/RecordTableRowVirtualizedSkeleton'; +import { lowDetailsActivatedComponentState } from '@/object-record/record-table/virtualization/states/lowDetailsActivatedComponentState'; + +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; + +type RecordTableRowVirtualizedRouterLevel1Props = { + realIndex: number; +}; + +export const RecordTableRowVirtualizedRouterLevel1 = ({ + realIndex, +}: RecordTableRowVirtualizedRouterLevel1Props) => { + const lowDetailsActivated = useRecoilComponentValue( + lowDetailsActivatedComponentState, + ); + + if (lowDetailsActivated) { + return ; + } + + return ; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedRouterLevel2.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedRouterLevel2.tsx new file mode 100644 index 00000000000..5d18058e038 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedRouterLevel2.tsx @@ -0,0 +1,25 @@ +import { RecordTableRowVirtualizedFullData } from '@/object-record/record-table/virtualization/components/RecordTableRowVirtualizedFullData'; + +import { RecordTableRowVirtualizedSkeleton } from '@/object-record/record-table/virtualization/components/RecordTableRowVirtualizedSkeleton'; +import { dataLoadingStatusByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/dataLoadingStatusByRealIndexComponentFamilyState'; + +import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; + +type RecordTableRowVirtualizedRouterLevel2Props = { + realIndex: number; +}; + +export const RecordTableRowVirtualizedRouterLevel2 = ({ + realIndex, +}: RecordTableRowVirtualizedRouterLevel2Props) => { + const dataLoadingStatus = useRecoilComponentFamilyValue( + dataLoadingStatusByRealIndexComponentFamilyState, + { realIndex }, + ); + + if (dataLoadingStatus === null) { + return ; + } + + return ; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedSkeleton.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedSkeleton.tsx new file mode 100644 index 00000000000..c6a902409fb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedSkeleton.tsx @@ -0,0 +1,38 @@ +import { RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH_CLASS_NAME } from '@/object-record/record-table/constants/RecordTableColumnAddColumnButtonWidthClassName'; +import { RECORD_TABLE_COLUMN_LAST_EMPTY_COLUMN_WIDTH_CLASS_NAME } from '@/object-record/record-table/constants/RecordTableColumnLastEmptyColumnWidthClassName'; +import { useRecordTableBodyContextOrThrow } from '@/object-record/record-table/contexts/RecordTableBodyContext'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { RecordTableCellCheckboxPlaceholder } from '@/object-record/record-table/record-table-cell/components/RecordTableCellCheckboxPlaceholder'; +import { RecordTableCellLoading } from '@/object-record/record-table/record-table-cell/components/RecordTableCellLoading'; +import { RecordTableCellStyleWrapper } from '@/object-record/record-table/record-table-cell/components/RecordTableCellStyleWrapper'; +import { RecordTableDragAndDropPlaceholderCell } from '@/object-record/record-table/record-table-cell/components/RecordTableDragAndDropPlaceholderCell'; +import { RecordTableRowDiv } from '@/object-record/record-table/record-table-row/components/RecordTableRowDiv'; + +export const RecordTableRowVirtualizedSkeleton = () => { + const { visibleRecordFields } = useRecordTableContextOrThrow(); + const { hasUserSelectedAllRows } = useRecordTableBodyContextOrThrow(); + + return ( + + + + {visibleRecordFields.map((recordField, index) => ( + + ))} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedBodyPlaceholder.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedBodyPlaceholder.tsx new file mode 100644 index 00000000000..553b397def1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedBodyPlaceholder.tsx @@ -0,0 +1,54 @@ +import { RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnAddColumnButtonWidth'; +import { RECORD_TABLE_COLUMN_CHECKBOX_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnCheckboxWidth'; +import { RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnDragAndDropWidth'; +import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight'; + +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { useRecordTableLastColumnWidthToFill } from '@/object-record/record-table/hooks/useRecordTableLastColumnWidthToFill'; +import { computeVisibleRecordFieldsWidthOnTable } from '@/object-record/record-table/utils/computeVisibleRecordFieldsWidthOnTable'; +import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { styled } from '@linaria/react'; +import { isDefined } from 'twenty-shared/utils'; +import { useIsMobile } from 'twenty-ui/utilities'; + +const StyledVirtualizationContainer = styled.div<{ + width: number; + height: number; +}>` + height: ${({ height }) => height}px; + width: ${({ width }) => width}px; +`; + +export const RecordTableVirtualizedBodyPlaceholder = () => { + const { visibleRecordFields } = useRecordTableContextOrThrow(); + + const isMobile = useIsMobile(); + + const { visibleRecordFieldsWidth } = computeVisibleRecordFieldsWidthOnTable({ + isMobile, + visibleRecordFields, + }); + + const totalNumberOfRecordsToVirtualize = useRecoilComponentValue( + totalNumberOfRecordsToVirtualizeComponentState, + ); + + const { lastColumnWidth } = useRecordTableLastColumnWidthToFill(); + + const totalWidth = + visibleRecordFieldsWidth + + RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH + + RECORD_TABLE_COLUMN_CHECKBOX_WIDTH + + RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH + + lastColumnWidth + + visibleRecordFields.length; + + const totalHeight = isDefined(totalNumberOfRecordsToVirtualize) + ? totalNumberOfRecordsToVirtualize * (RECORD_TABLE_ROW_HEIGHT + 1) + : 0; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedDataChangedEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedDataChangedEffect.tsx new file mode 100644 index 00000000000..a2ee74e724f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedDataChangedEffect.tsx @@ -0,0 +1,55 @@ +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { useResetVirtualizationBecauseDataChanged } from '@/object-record/record-table/virtualization/hooks/useResetVirtualizationBecauseDataChanged'; +import { lastObjectOperationThatResettedVirtualizationComponentState } from '@/object-record/record-table/virtualization/states/lastObjectOperationThatResettedVirtualizationComponentState'; +import { objectOperationsByObjectNameSingularFamilyState } from '@/object-record/states/objectOperationsByObjectNameSingularFamilyState'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const RecordTableVirtualizedDataChangedEffect = () => { + const { objectNameSingular } = useRecordTableContextOrThrow(); + + const { resetVirtualizationBecauseDataChanged } = + useResetVirtualizationBecauseDataChanged(objectNameSingular); + + const [ + lastObjectOperationThatResettedVirtualization, + setLastObjectOperationThatResettedVirtualization, + ] = useRecoilComponentState( + lastObjectOperationThatResettedVirtualizationComponentState, + ); + + const objectOperationsForObjectNameSingular = useRecoilValue( + objectOperationsByObjectNameSingularFamilyState({ objectNameSingular }), + ); + + useEffect(() => { + const lastObjectOperation = objectOperationsForObjectNameSingular.at(-1); + + if ( + !isDefined(lastObjectOperationThatResettedVirtualization) && + !isDefined(lastObjectOperation) + ) { + return; + } + + if ( + lastObjectOperation?.id !== + lastObjectOperationThatResettedVirtualization?.id + ) { + setLastObjectOperationThatResettedVirtualization(lastObjectOperation); + + if (isDefined(lastObjectOperation)) { + resetVirtualizationBecauseDataChanged(lastObjectOperation); + } + } + }, [ + lastObjectOperationThatResettedVirtualization, + objectOperationsForObjectNameSingular, + setLastObjectOperationThatResettedVirtualization, + resetVirtualizationBecauseDataChanged, + ]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedDebugHelper.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedDebugHelper.tsx new file mode 100644 index 00000000000..c634029771f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedDebugHelper.tsx @@ -0,0 +1,52 @@ +import { + SCROLL_SPEED_THRESHOLD_IN_ROWS_PER_SECOND_TO_ACTIVATE_LOW_DETAILS, + SCROLL_SPEED_THRESHOLD_IN_ROWS_PER_SECOND_TO_DEACTIVATE_LOW_DETAILS, +} from '@/object-record/record-table/virtualization/components/RecordTableVirtualizedRowTreadmillEffect'; +import { TABLE_VIRTUALIZATION_DEBUG_ACTIVATED } from '@/object-record/record-table/virtualization/constants/TableVirtualizationDebugActivated'; +import { lowDetailsActivatedComponentState } from '@/object-record/record-table/virtualization/states/lowDetailsActivatedComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { createPortal } from 'react-dom'; + +export const RecordTableVirtualizedDebugHelper = () => { + const lowDetailsActivated = useRecoilComponentValue( + lowDetailsActivatedComponentState, + ); + + if (!TABLE_VIRTUALIZATION_DEBUG_ACTIVATED) { + return; + } + + return ( + <> + {createPortal( +
+ Virtualize debug helper + + - Low details activated : {lowDetailsActivated ? 'Yes' : 'No'} + + + - Rows / sec threshold to activate low details : + {SCROLL_SPEED_THRESHOLD_IN_ROWS_PER_SECOND_TO_ACTIVATE_LOW_DETAILS} + and to deactivate : + { + SCROLL_SPEED_THRESHOLD_IN_ROWS_PER_SECOND_TO_DEACTIVATE_LOW_DETAILS + } + +
, + document.body, + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect.tsx new file mode 100644 index 00000000000..3c5de007592 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedInitialDataLoadEffect.tsx @@ -0,0 +1,74 @@ +import { useRecoilValue } from 'recoil'; + +import { useRecordIndexTableFetchMore } from '@/object-record/record-index/hooks/useRecordIndexTableFetchMore'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; + +import { useTriggerInitialRecordTableDataLoad } from '@/object-record/record-table/virtualization/hooks/useTriggerInitialRecordTableDataLoad'; +import { isInitializingVirtualTableDataLoadingComponentState } from '@/object-record/record-table/virtualization/states/isInitializingVirtualTableDataLoadingComponentState'; +import { lastContextStoreVirtualizedViewIdComponentState } from '@/object-record/record-table/virtualization/states/lastContextStoreVirtualizedViewIdComponentState'; +import { lastRecordTableQueryIdentifierComponentState } from '@/object-record/record-table/virtualization/states/lastRecordTableQueryIdentifierComponentState'; +import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetchingMoreRecordsFamilyState'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; +import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; +import { useEffect } from 'react'; + +// TODO: see if we can merge the initial and load more processes, to have only one load at scroll index effect +export const RecordTableVirtualizedInitialDataLoadEffect = () => { + const { recordTableId, objectNameSingular } = useRecordTableContextOrThrow(); + + const { queryIdentifier } = useRecordIndexTableFetchMore(objectNameSingular); + + const [lastRecordTableQueryIdentifier, setLastRecordTableQueryIdentifier] = + useRecoilComponentState(lastRecordTableQueryIdentifierComponentState); + + const [isInitializingVirtualTableDataLoading] = useRecoilComponentState( + isInitializingVirtualTableDataLoadingComponentState, + ); + + const isFetchingMoreRecords = useRecoilValue( + isFetchingMoreRecordsFamilyState(recordTableId), + ); + + const { triggerInitialRecordTableDataLoad } = + useTriggerInitialRecordTableDataLoad(); + + const [ + lastContextStoreVirtualizedViewId, + setLastContextStoreVirtualizedViewId, + ] = useRecoilComponentState(lastContextStoreVirtualizedViewIdComponentState); + + const { currentView } = useGetCurrentViewOnly(); + + useEffect(() => { + if (isInitializingVirtualTableDataLoading) { + return; + } + + (async () => { + if ((currentView?.id ?? null) !== lastContextStoreVirtualizedViewId) { + setLastContextStoreVirtualizedViewId(currentView?.id ?? null); + + await triggerInitialRecordTableDataLoad(); + } else if ( + queryIdentifier !== lastRecordTableQueryIdentifier && + !isFetchingMoreRecords + ) { + setLastRecordTableQueryIdentifier(queryIdentifier); + + await triggerInitialRecordTableDataLoad(); + } + })(); + }, [ + queryIdentifier, + lastRecordTableQueryIdentifier, + triggerInitialRecordTableDataLoad, + setLastRecordTableQueryIdentifier, + isFetchingMoreRecords, + isInitializingVirtualTableDataLoading, + currentView, + lastContextStoreVirtualizedViewId, + setLastContextStoreVirtualizedViewId, + ]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedRowTreadmillEffect.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedRowTreadmillEffect.tsx new file mode 100644 index 00000000000..bfe2cec2770 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableVirtualizedRowTreadmillEffect.tsx @@ -0,0 +1,255 @@ +import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight'; +import { NUMBER_OF_VIRTUALIZED_ROWS } from '@/object-record/record-table/virtualization/constants/NumberOfVirtualizedRows'; +import { useProcessTreadmillScrollTop } from '@/object-record/record-table/virtualization/hooks/useProcessTreadmillScrollTop'; +import { useTriggerFetchPages } from '@/object-record/record-table/virtualization/hooks/useTriggerFetchPages'; +import { lastScrollMeasurementsComponentState } from '@/object-record/record-table/virtualization/states/lastScrollMeasurementsComponentState'; +import { lowDetailsActivatedComponentState } from '@/object-record/record-table/virtualization/states/lowDetailsActivatedComponentState'; +import { scrollAtRealIndexComponentState } from '@/object-record/record-table/virtualization/states/scrollAtRealIndexComponentState'; +import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState'; +import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useCallback, useEffect } from 'react'; +import { useRecoilCallback } from 'recoil'; +import { useDebouncedCallback } from 'use-debounce'; + +// TODO: export those constants +// TODO: break down this effect into multiple smaller effect components +export const SCROLL_SPEED_THRESHOLD_IN_ROWS_PER_SECOND_TO_ACTIVATE_LOW_DETAILS = 120; +export const SCROLL_SPEED_THRESHOLD_IN_ROWS_PER_SECOND_TO_DEACTIVATE_LOW_DETAILS = 30; + +export const TIME_BEFORE_DEACTIVATING_LOW_DETAILS = 20; +export const NUMBER_OF_EVENTS_TO_COMPUTE_AVERAGE = 10; + +const TIME_BETWEEN_TWO_SCROLL_HANDLING = 20; +const LAST_SCROLL_DEBOUNCE_TIME = 300; + +export const RecordTableVirtualizedRowTreadmillEffect = () => { + const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement(); + + const scrollAtRealIndexCallbackState = useRecoilComponentCallbackState( + scrollAtRealIndexComponentState, + ); + + const lowDetailsActivatedCallbackState = useRecoilComponentCallbackState( + lowDetailsActivatedComponentState, + ); + + const lastScrollMeasurementsCallbackState = useRecoilComponentCallbackState( + lastScrollMeasurementsComponentState, + ); + + const totalNumberOfRecordsToVirtualizeCallbackState = + useRecoilComponentCallbackState( + totalNumberOfRecordsToVirtualizeComponentState, + ); + + const { triggerFetchPages } = useTriggerFetchPages(); + + const { processTreadmillScrollTop } = useProcessTreadmillScrollTop(); + + const handleScrollDebounced = useDebouncedCallback( + processTreadmillScrollTop, + TIME_BETWEEN_TWO_SCROLL_HANDLING, + { + leading: true, + trailing: true, + maxWait: TIME_BETWEEN_TWO_SCROLL_HANDLING, + }, + ); + + const deactivateLowDetails = useRecoilCallback( + ({ snapshot, set }) => + () => { + const currentLowDetailsActivated = getSnapshotValue( + snapshot, + lowDetailsActivatedCallbackState, + ); + + if (currentLowDetailsActivated !== false) { + set(lowDetailsActivatedCallbackState, false); + } + }, + [lowDetailsActivatedCallbackState], + ); + + const deactivateLowDetailsDebounced = useDebouncedCallback( + deactivateLowDetails, + TIME_BEFORE_DEACTIVATING_LOW_DETAILS, + { + maxWait: TIME_BEFORE_DEACTIVATING_LOW_DETAILS, + }, + ); + + const activateLowDetails = useRecoilCallback( + ({ snapshot, set }) => + () => { + deactivateLowDetailsDebounced.cancel(); + + const currentLowDetailsActivated = getSnapshotValue( + snapshot, + lowDetailsActivatedCallbackState, + ); + + const scrollAtRealIndex = getSnapshotValue( + snapshot, + scrollAtRealIndexCallbackState, + ); + + const totalNumberOfRecordsToVirtualize = + getSnapshotValue( + snapshot, + totalNumberOfRecordsToVirtualizeCallbackState, + ) ?? NUMBER_OF_VIRTUALIZED_ROWS; + + if ( + scrollAtRealIndex < 100 || + scrollAtRealIndex > totalNumberOfRecordsToVirtualize - 100 + ) { + return; + } + + if (currentLowDetailsActivated !== true) { + set(lowDetailsActivatedCallbackState, true); + } + }, + [ + lowDetailsActivatedCallbackState, + deactivateLowDetailsDebounced, + scrollAtRealIndexCallbackState, + totalNumberOfRecordsToVirtualizeCallbackState, + ], + ); + + const handleAfterLastScroll = useRecoilCallback( + ({ set }) => + (scrollEvent: Event) => { + deactivateLowDetails(); + + processTreadmillScrollTop((scrollEvent.target as any).scrollTop); + + triggerFetchPages(); + + set(lastScrollMeasurementsCallbackState, []); + }, + [ + processTreadmillScrollTop, + triggerFetchPages, + deactivateLowDetails, + lastScrollMeasurementsCallbackState, + ], + ); + + const handleAfterLastScrollDebounced = useDebouncedCallback( + handleAfterLastScroll, + LAST_SCROLL_DEBOUNCE_TIME, + ); + + const handleScrollData = useRecoilCallback( + ({ snapshot, set }) => + (scrollEvent: Event) => { + const distanceFromTop = (scrollEvent.target as any).scrollTop; + + const lastScrollMeasurements = getSnapshotValue( + snapshot, + lastScrollMeasurementsCallbackState, + ).concat(); + + lastScrollMeasurements.push({ + scrollToTop: distanceFromTop, + timestamp: scrollEvent.timeStamp, + }); + + set(lastScrollMeasurementsCallbackState, lastScrollMeasurements); + + if (lastScrollMeasurements.length > 1) { + const scrollMeasurementsForAverage = lastScrollMeasurements.slice( + -NUMBER_OF_EVENTS_TO_COMPUTE_AVERAGE, + ); + + const scrollSpeedInPixelsPerSecondSum = + scrollMeasurementsForAverage.reduce( + (sum, scrollMeasurement, currentIndex, allMeasurements) => { + if (currentIndex === 0) { + return sum; + } + + const previousMeasurement = allMeasurements[currentIndex - 1]; + + const secondsDifferenceWithPreviousMeasurement = + (scrollMeasurement.timestamp - + previousMeasurement.timestamp) / + 1_000; + + const scrollDifferenceWithPreviousMeasurement = Math.abs( + scrollMeasurement.scrollToTop - + previousMeasurement.scrollToTop, + ); + + const scrollSpeedInPixelsPerSecond = + scrollDifferenceWithPreviousMeasurement / + secondsDifferenceWithPreviousMeasurement; + + sum += scrollSpeedInPixelsPerSecond; + + return sum; + }, + 0, + ); + + const averageScrollSpeedInPixelsPerSecond = + scrollSpeedInPixelsPerSecondSum / + scrollMeasurementsForAverage.length; + + const averageScrollSpeedInRowsPerSecond = + averageScrollSpeedInPixelsPerSecond / (RECORD_TABLE_ROW_HEIGHT + 1); + + if ( + averageScrollSpeedInRowsPerSecond > + SCROLL_SPEED_THRESHOLD_IN_ROWS_PER_SECOND_TO_ACTIVATE_LOW_DETAILS + ) { + activateLowDetails(); + handleAfterLastScrollDebounced.cancel(); + } else if ( + averageScrollSpeedInRowsPerSecond < + SCROLL_SPEED_THRESHOLD_IN_ROWS_PER_SECOND_TO_DEACTIVATE_LOW_DETAILS + ) { + deactivateLowDetailsDebounced(); + + triggerFetchPages(); + } + } else { + triggerFetchPages(); + } + }, + [ + lastScrollMeasurementsCallbackState, + triggerFetchPages, + activateLowDetails, + deactivateLowDetailsDebounced, + handleAfterLastScrollDebounced, + ], + ); + + const handleScrollEvent = useCallback( + (scrollEvent: Event) => { + handleScrollData(scrollEvent); + handleScrollDebounced((scrollEvent.target as any).scrollTop); + handleAfterLastScrollDebounced(scrollEvent); + }, + [handleScrollDebounced, handleScrollData, handleAfterLastScrollDebounced], + ); + + useEffect(() => { + scrollWrapperHTMLElement?.addEventListener('scroll', handleScrollEvent); + + return () => { + scrollWrapperHTMLElement?.removeEventListener( + 'scroll', + handleScrollEvent, + ); + }; + }, [scrollWrapperHTMLElement, handleScrollEvent]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/constants/NumberOfVirtualizedRows.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/constants/NumberOfVirtualizedRows.ts new file mode 100644 index 00000000000..0b874e408a1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/constants/NumberOfVirtualizedRows.ts @@ -0,0 +1 @@ +export const NUMBER_OF_VIRTUALIZED_ROWS = 200; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/constants/TableVirtualizationDebugActivated.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/constants/TableVirtualizationDebugActivated.ts new file mode 100644 index 00000000000..bc312672e62 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/constants/TableVirtualizationDebugActivated.ts @@ -0,0 +1 @@ +export const TABLE_VIRTUALIZATION_DEBUG_ACTIVATED = false; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/constants/VirtualizationScrollDebounceTime.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/constants/VirtualizationScrollDebounceTime.ts new file mode 100644 index 00000000000..fe1e2b5ffe9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/constants/VirtualizationScrollDebounceTime.ts @@ -0,0 +1 @@ +export const VIRTUALIZATION_SCROLL_DEBOUNCE_TIME = 20; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useAssignRecordsToStore.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useAssignRecordsToStore.ts new file mode 100644 index 00000000000..c103f074b8c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useAssignRecordsToStore.ts @@ -0,0 +1,32 @@ +import { useRecoilCallback } from 'recoil'; + +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +export const useAssignRecordsToStore = () => { + const assignRecordsToStore = useRecoilCallback( + ({ set, snapshot }) => + ({ records }: { records: T[] }) => { + for (const record of records) { + const currentRecord = snapshot + .getLoadable(recordStoreFamilyState(record.id)) + .getValue(); + + if (!isDeeplyEqual(currentRecord, record)) { + const newRecord = { + ...currentRecord, + ...record, + }; + + set(recordStoreFamilyState(record.id), newRecord); + } + } + }, + [], + ); + + return { + assignRecordsToStore, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useLoadRecordsToVirtualRows.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useLoadRecordsToVirtualRows.ts new file mode 100644 index 00000000000..cb42ebc7d8c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useLoadRecordsToVirtualRows.ts @@ -0,0 +1,99 @@ +import { useRecoilCallback } from 'recoil'; + +import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { hasUserSelectedAllRowsComponentState } from '@/object-record/record-table/record-table-row/states/hasUserSelectedAllRowsFamilyState'; +import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState'; +import { dataLoadingStatusByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/dataLoadingStatusByRealIndexComponentFamilyState'; +import { recordIdByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilComponentFamilyCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyCallbackState'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; + +export const useLoadRecordsToVirtualRows = () => { + const recordIdByRealIndexCallbackState = + useRecoilComponentFamilyCallbackState( + recordIdByRealIndexComponentFamilyState, + ); + + const dataLoadingStatusByRealIndexCallbackState = + useRecoilComponentCallbackState( + dataLoadingStatusByRealIndexComponentFamilyState, + ); + + const recordIndexAllRecordIdsSelector = useRecoilComponentCallbackState( + recordIndexAllRecordIdsComponentSelector, + ); + + const hasUserSelectedAllRowsCallbackState = useRecoilComponentCallbackState( + hasUserSelectedAllRowsComponentState, + ); + + const isRowSelectedCallbackState = useRecoilComponentCallbackState( + isRowSelectedComponentFamilyState, + ); + + const loadRecordsToVirtualRows = useRecoilCallback( + ({ set, snapshot }) => + ({ + records, + startingRealIndex, + }: { + records: ObjectRecord[]; + startingRealIndex: number; + }) => { + const hasUserSelectedAllRows = getSnapshotValue( + snapshot, + hasUserSelectedAllRowsCallbackState, + ); + + for (const [recordIndex, record] of records.entries()) { + const realIndex = startingRealIndex + recordIndex; + + const currentRecordIdAtRealIndex = getSnapshotValue( + snapshot, + recordIdByRealIndexCallbackState({ realIndex }), + ); + + if (record.id !== currentRecordIdAtRealIndex) { + set(recordIdByRealIndexCallbackState({ realIndex }), record.id); + } + + set( + dataLoadingStatusByRealIndexCallbackState({ realIndex }), + 'loaded', + ); + } + + const currentAllRecordIds = getSnapshotValue( + snapshot, + recordIndexAllRecordIdsSelector, + ); + + const recordIds = records.map((record) => record.id); + + const newAllRecordIds = currentAllRecordIds.concat(); + + for (let i = 0; i < records.length; i++) { + newAllRecordIds[i + startingRealIndex] = recordIds[i]; + + if (hasUserSelectedAllRows) { + set(isRowSelectedCallbackState(recordIds[i]), true); + } + } + + set(recordIndexAllRecordIdsSelector, newAllRecordIds); + }, + [ + recordIdByRealIndexCallbackState, + dataLoadingStatusByRealIndexCallbackState, + recordIndexAllRecordIdsSelector, + isRowSelectedCallbackState, + hasUserSelectedAllRowsCallbackState, + ], + ); + + return { + loadRecordsToVirtualRows, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useProcessTreadmillScrollTop.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useProcessTreadmillScrollTop.ts new file mode 100644 index 00000000000..643833d8715 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useProcessTreadmillScrollTop.ts @@ -0,0 +1,149 @@ +import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight'; +import { NUMBER_OF_VIRTUALIZED_ROWS } from '@/object-record/record-table/virtualization/constants/NumberOfVirtualizedRows'; +import { lastRealIndexSetComponentState } from '@/object-record/record-table/virtualization/states/lastRealIndexSetComponentState'; +import { lastScrollPositionComponentState } from '@/object-record/record-table/virtualization/states/lastScrollPositionComponentState'; +import { realIndexByVirtualIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/realIndexByVirtualIndexComponentFamilyState'; +import { scrollAtRealIndexComponentState } from '@/object-record/record-table/virtualization/states/scrollAtRealIndexComponentState'; +import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useRecoilCallback } from 'recoil'; + +export const useProcessTreadmillScrollTop = () => { + const { getScrollWrapperElement } = useScrollWrapperHTMLElement(); + + const lastScrollPositionCallbackState = useRecoilComponentCallbackState( + lastScrollPositionComponentState, + ); + + const lastRealIndexSetCallbackState = useRecoilComponentCallbackState( + lastRealIndexSetComponentState, + ); + + const scrollAtRealIndexCallbackState = useRecoilComponentCallbackState( + scrollAtRealIndexComponentState, + ); + + const virtualizedRowRealIndexByVirtualIndexCallbackState = + useRecoilComponentCallbackState( + realIndexByVirtualIndexComponentFamilyState, + ); + + const processTreadmillScrollTop = useRecoilCallback( + ({ snapshot, set }) => + (scrollTop: number) => { + const distanceFromTop = scrollTop; + + const { scrollWrapperElement } = getScrollWrapperElement(); + + const tableScrollWrapperHeight = + scrollWrapperElement?.clientHeight ?? 0; + + const lastScrollPosition = getSnapshotValue( + snapshot, + lastScrollPositionCallbackState, + ); + + set(lastScrollPositionCallbackState, distanceFromTop); + + const scrollDirection = + distanceFromTop > lastScrollPosition ? 'downward' : 'upward'; + + const numberOfRowsDisplayedInTable = Math.min( + Math.floor(tableScrollWrapperHeight / (RECORD_TABLE_ROW_HEIGHT + 1)), + 30, + ); + + const halfNumberOfRowsVisible = Math.floor( + numberOfRowsDisplayedInTable / 2, + ); + + const realIndexAtTheMiddleOfTheTable = + Math.floor(distanceFromTop / (RECORD_TABLE_ROW_HEIGHT + 1)) + + halfNumberOfRowsVisible; + + const lastCorrectlySetRealIndex = + getSnapshotValue(snapshot, lastRealIndexSetCallbackState) ?? + numberOfRowsDisplayedInTable; + + set(scrollAtRealIndexCallbackState, realIndexAtTheMiddleOfTheTable); + + if (scrollDirection === 'downward') { + const newTargetRealIndexToReachAtTheBottomOfTheOverscan = + realIndexAtTheMiddleOfTheTable + + Math.floor(NUMBER_OF_VIRTUALIZED_ROWS / 2); + + const numberOfRealIndexToSet = + newTargetRealIndexToReachAtTheBottomOfTheOverscan - + lastCorrectlySetRealIndex; + + if (numberOfRealIndexToSet <= 0) { + return; + } + + for (let i = 0; i < numberOfRealIndexToSet + 1; i++) { + const realIndexThatWillBeSet = lastCorrectlySetRealIndex + i; + + const correspondingVirtualIndex = + realIndexThatWillBeSet % NUMBER_OF_VIRTUALIZED_ROWS; + + set( + virtualizedRowRealIndexByVirtualIndexCallbackState({ + virtualIndex: correspondingVirtualIndex, + }), + realIndexThatWillBeSet, + ); + } + + set( + lastRealIndexSetCallbackState, + lastCorrectlySetRealIndex + numberOfRealIndexToSet, + ); + } else { + const newTargetRealIndexToReachAtTheTopOfTheOverscan = Math.max( + 0, + realIndexAtTheMiddleOfTheTable - + Math.floor(NUMBER_OF_VIRTUALIZED_ROWS / 2), + ); + + const numberOfRealIndexToSet = + lastCorrectlySetRealIndex - + newTargetRealIndexToReachAtTheTopOfTheOverscan; + + if (numberOfRealIndexToSet <= 0) { + return; + } + + for (let i = 0; i <= numberOfRealIndexToSet; i++) { + const realIndexThatWillBeSet = lastCorrectlySetRealIndex - i; + + const correspondingVirtualIndex = + realIndexThatWillBeSet % NUMBER_OF_VIRTUALIZED_ROWS; + + set( + virtualizedRowRealIndexByVirtualIndexCallbackState({ + virtualIndex: correspondingVirtualIndex, + }), + realIndexThatWillBeSet, + ); + } + + set( + lastRealIndexSetCallbackState, + lastCorrectlySetRealIndex - numberOfRealIndexToSet, + ); + } + }, + [ + lastRealIndexSetCallbackState, + virtualizedRowRealIndexByVirtualIndexCallbackState, + lastScrollPositionCallbackState, + scrollAtRealIndexCallbackState, + getScrollWrapperElement, + ], + ); + + return { + processTreadmillScrollTop, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useReapplyRowSelection.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useReapplyRowSelection.ts new file mode 100644 index 00000000000..cbd077c2f04 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useReapplyRowSelection.ts @@ -0,0 +1,21 @@ +import { useSelectAllRows } from '@/object-record/record-table/hooks/internal/useSelectAllRows'; +import { hasUserSelectedAllRowsComponentState } from '@/object-record/record-table/record-table-row/states/hasUserSelectedAllRowsFamilyState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; + +export const useReapplyRowSelection = () => { + const { selectAllRows } = useSelectAllRows(); + + const hasUserSelectedAllRows = useRecoilComponentValue( + hasUserSelectedAllRowsComponentState, + ); + + const reapplyRowSelection = () => { + if (hasUserSelectedAllRows) { + selectAllRows(); + } + }; + + return { + reapplyRowSelection, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useResetNumberOfRecordsToVirtualize.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useResetNumberOfRecordsToVirtualize.ts new file mode 100644 index 00000000000..d31559ea632 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useResetNumberOfRecordsToVirtualize.ts @@ -0,0 +1,44 @@ +import { NUMBER_OF_VIRTUALIZED_ROWS } from '@/object-record/record-table/virtualization/constants/NumberOfVirtualizedRows'; +import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useRecoilCallback } from 'recoil'; + +export const useResetNumberOfRecordsToVirtualize = () => { + const totalNumberOfRecordsToVirtualizeCallbackState = + useRecoilComponentCallbackState( + totalNumberOfRecordsToVirtualizeComponentState, + ); + + const resetNumberOfRecordsToVirtualize = useRecoilCallback( + ({ snapshot, set }) => + ({ + records, + totalCount, + }: { + records: ObjectRecord[]; + totalCount: number; + }) => { + const totalNumberOfRecordsToVirtualize = getSnapshotValue( + snapshot, + totalNumberOfRecordsToVirtualizeCallbackState, + ); + + if (totalCount > NUMBER_OF_VIRTUALIZED_ROWS) { + if (totalNumberOfRecordsToVirtualize !== totalCount) { + set(totalNumberOfRecordsToVirtualizeCallbackState, totalCount); + } + } else { + if (totalNumberOfRecordsToVirtualize !== records.length) { + set(totalNumberOfRecordsToVirtualizeCallbackState, records.length); + } + } + }, + [totalNumberOfRecordsToVirtualizeCallbackState], + ); + + return { + resetNumberOfRecordsToVirtualize, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useResetTableFocuses.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useResetTableFocuses.ts new file mode 100644 index 00000000000..d5b2ff26a01 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useResetTableFocuses.ts @@ -0,0 +1,24 @@ +import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow'; +import { useUnfocusRecordTableCell } from '@/object-record/record-table/record-table-cell/hooks/useUnfocusRecordTableCell'; +import { recordTableHoverPositionComponentState } from '@/object-record/record-table/states/recordTableHoverPositionComponentState'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; + +export const useResetTableFocuses = (recordTableId: string) => { + const { unfocusRecordTableCell } = useUnfocusRecordTableCell(recordTableId); + const { unfocusRecordTableRow } = useFocusedRecordTableRow(recordTableId); + + const setRecordTableHoverPosition = useSetRecoilComponentState( + recordTableHoverPositionComponentState, + recordTableId, + ); + + const resetTableFocuses = () => { + unfocusRecordTableCell(); + unfocusRecordTableRow(); + setRecordTableHoverPosition(null); + }; + + return { + resetTableFocuses, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useResetVirtualizationBecauseDataChanged.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useResetVirtualizationBecauseDataChanged.ts new file mode 100644 index 00000000000..a53bdad24a5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useResetVirtualizationBecauseDataChanged.ts @@ -0,0 +1,139 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords'; +import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields'; + +import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/hooks/useFindManyRecordIndexTableParams'; +import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { useTriggerFetchPages } from '@/object-record/record-table/virtualization/hooks/useTriggerFetchPages'; +import { dataLoadingStatusByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/dataLoadingStatusByRealIndexComponentFamilyState'; +import { dataPagesLoadedComponentState } from '@/object-record/record-table/virtualization/states/dataPagesLoadedComponentState'; +import { recordIdByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState'; +import { tableHasAnyFilterOrSortComponentSelector } from '@/object-record/record-table/virtualization/states/tableHasAnyFilterOrSortComponentSelector'; +import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState'; +import { type ObjectOperation } from '@/object-record/states/objectOperationsByObjectNameSingularFamilyState'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilComponentFamilyCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyCallbackState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useCallback } from 'react'; +import { useRecoilCallback } from 'recoil'; + +export const useResetVirtualizationBecauseDataChanged = ( + objectNameSingular: string, +) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const params = useFindManyRecordIndexTableParams(objectNameSingular); + + // TODO: we could optimize this by using an aggregate or using only id: true in recordGqlFields + const recordGqlFields = useRecordsFieldVisibleGqlFields({ + objectMetadataItem, + }); + + const { findManyRecordsLazy } = useLazyFindManyRecords({ + ...params, + recordGqlFields, + fetchPolicy: 'network-only', + limit: 1, + }); + + const totalNumberOfRecordsToVirtualizeCallbackState = + useRecoilComponentCallbackState( + totalNumberOfRecordsToVirtualizeComponentState, + ); + + const dataPagesLoadedCallbackState = useRecoilComponentCallbackState( + dataPagesLoadedComponentState, + ); + + const recordIdByRealIndexCallbackState = + useRecoilComponentFamilyCallbackState( + recordIdByRealIndexComponentFamilyState, + ); + + const dataLoadingStatusByRealIndexCallbackState = + useRecoilComponentCallbackState( + dataLoadingStatusByRealIndexComponentFamilyState, + ); + + const recordIndexAllRecordIdsSelector = useRecoilComponentCallbackState( + recordIndexAllRecordIdsComponentSelector, + ); + + const { triggerFetchPagesWithoutDebounce } = useTriggerFetchPages(); + + const resetVirtualization = useRecoilCallback( + ({ set, snapshot }) => + async () => { + const { totalCount } = await findManyRecordsLazy(); + + const currentRecordIds = getSnapshotValue( + snapshot, + recordIndexAllRecordIdsSelector, + ); + + for (const [index] of currentRecordIds.entries()) { + set( + dataLoadingStatusByRealIndexCallbackState({ + realIndex: index, + }), + null, + ); + + set( + recordIdByRealIndexCallbackState({ + realIndex: index, + }), + null, + ); + } + + set( + recordIndexAllRecordIdsSelector, + currentRecordIds.slice(0, totalCount), + ); + set(dataPagesLoadedCallbackState, []); + set(totalNumberOfRecordsToVirtualizeCallbackState, totalCount); + }, + [ + dataPagesLoadedCallbackState, + recordIdByRealIndexCallbackState, + dataLoadingStatusByRealIndexCallbackState, + recordIndexAllRecordIdsSelector, + findManyRecordsLazy, + totalNumberOfRecordsToVirtualizeCallbackState, + ], + ); + + const tableHasAnyFilterOrSort = useRecoilComponentValue( + tableHasAnyFilterOrSortComponentSelector, + ); + + const resetVirtualizationBecauseDataChanged = useCallback( + async (objectOperation: ObjectOperation) => { + if (!objectOperation.data.type.startsWith('update')) { + await resetVirtualization(); + + await triggerFetchPagesWithoutDebounce(); + } else { + if (tableHasAnyFilterOrSort) { + await resetVirtualization(); + + await triggerFetchPagesWithoutDebounce(); + } + } + }, + [ + resetVirtualization, + triggerFetchPagesWithoutDebounce, + tableHasAnyFilterOrSort, + ], + ); + + return { + resetVirtualizationBecauseDataChanged, + resetVirtualization, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useResetVirtualizedRowTreadmill.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useResetVirtualizedRowTreadmill.ts new file mode 100644 index 00000000000..4dfea232c9d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useResetVirtualizedRowTreadmill.ts @@ -0,0 +1,34 @@ +import { NUMBER_OF_VIRTUALIZED_ROWS } from '@/object-record/record-table/virtualization/constants/NumberOfVirtualizedRows'; +import { realIndexByVirtualIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/realIndexByVirtualIndexComponentFamilyState'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilCallback } from 'recoil'; +import { getContiguousIncrementalValues } from 'twenty-shared/utils'; + +export const useResetVirtualizedRowTreadmill = () => { + const realIndexByVirtualIndexCallbackState = useRecoilComponentCallbackState( + realIndexByVirtualIndexComponentFamilyState, + ); + + const resetVirtualizedRowTreadmill = useRecoilCallback( + ({ set }) => + () => { + const virtualIndices = getContiguousIncrementalValues( + NUMBER_OF_VIRTUALIZED_ROWS, + ); + + for (const virtualIndex of virtualIndices) { + const realIndex = virtualIndex; + + set( + realIndexByVirtualIndexCallbackState({ + virtualIndex: virtualIndex, + }), + realIndex, + ); + } + }, + [realIndexByVirtualIndexCallbackState], + ); + + return { resetVirtualizedRowTreadmill }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerFetchPages.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerFetchPages.ts new file mode 100644 index 00000000000..e8fb99535cf --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerFetchPages.ts @@ -0,0 +1,232 @@ +import { useLazyFindManyRecordsWithOffset } from '@/object-record/hooks/useLazyFindManyRecordsWithOffset'; +import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { useAssignRecordsToStore } from '@/object-record/record-table/virtualization/hooks/useAssignRecordsToStore'; +import { useLoadRecordsToVirtualRows } from '@/object-record/record-table/virtualization/hooks/useLoadRecordsToVirtualRows'; + +import { dataPagesLoadedComponentState } from '@/object-record/record-table/virtualization/states/dataPagesLoadedComponentState'; +import { lastScrollPositionComponentState } from '@/object-record/record-table/virtualization/states/lastScrollPositionComponentState'; +import { lowDetailsActivatedComponentState } from '@/object-record/record-table/virtualization/states/lowDetailsActivatedComponentState'; +import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState'; +import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useRecoilCallback } from 'recoil'; +import { getContiguousIncrementalValues, isDefined } from 'twenty-shared/utils'; +import { useDebouncedCallback } from 'use-debounce'; + +// TODO: Those parameters allow the UI to not freeze while it can take time to fetch data. +// We should work on two additional optimization rounds : +// - A row take too much time to render +// - Requests are too eagerly loading relationships. +const TIME_BETWEEN_UI_BATCH_UPDATE = 25; +const PAGING_FOR_UI_UPDATE = 10; +const TIME_BETWEEN_TWO_REQUETS = 25; + +const DATA_PAGE_SIZE = 10; +const DATA_PAGE_OVERSCAN = 3; + +export const useTriggerFetchPages = () => { + const { objectNameSingular } = useRecordTableContextOrThrow(); + const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement(); + + const { assignRecordsToStore } = useAssignRecordsToStore(); + + const { loadRecordsToVirtualRows } = useLoadRecordsToVirtualRows(); + + const totalNumberOfRecordsToVirtualizeCallbackState = + useRecoilComponentCallbackState( + totalNumberOfRecordsToVirtualizeComponentState, + ); + + const lastScrollPositionCallbackState = useRecoilComponentCallbackState( + lastScrollPositionComponentState, + ); + + const dataPagesLoadedCallbackState = useRecoilComponentCallbackState( + dataPagesLoadedComponentState, + ); + + const { findManyRecordsLazyWithOffset } = useLazyFindManyRecordsWithOffset({ + objectNameSingular, + }); + + const lowDetailsActivatedCallbackState = useRecoilComponentCallbackState( + lowDetailsActivatedComponentState, + ); + + const triggerFetchPagesWithoutDebounce = useRecoilCallback( + ({ set, snapshot }) => + async () => { + const lowDetailsActivated = getSnapshotValue( + snapshot, + lowDetailsActivatedCallbackState, + ); + + if (lowDetailsActivated) { + return; + } + + const tableScrollWrapperHeight = + scrollWrapperHTMLElement?.clientHeight ?? 0; + + const lastScrollPosition = getSnapshotValue( + snapshot, + lastScrollPositionCallbackState, + ); + + const numberOfRowsDisplayedInTable = Math.min( + Math.floor(tableScrollWrapperHeight / (RECORD_TABLE_ROW_HEIGHT + 1)), + 30, + ); + + const halfNumberOfRowsVisible = Math.floor( + numberOfRowsDisplayedInTable / 2, + ); + + const realIndexAtTheMiddleOfTheTable = + Math.floor(lastScrollPosition / (RECORD_TABLE_ROW_HEIGHT + 1)) + + halfNumberOfRowsVisible; + + const pageForRealIndex = Math.ceil( + realIndexAtTheMiddleOfTheTable / DATA_PAGE_SIZE, + ); + + const totalNumberOfRealIndices = + getSnapshotValue( + snapshot, + totalNumberOfRecordsToVirtualizeCallbackState, + ) ?? 0; + + const maxPage = Math.ceil(totalNumberOfRealIndices / DATA_PAGE_SIZE); + + const overscanPageAtTop = Math.max( + 0, + pageForRealIndex - DATA_PAGE_OVERSCAN, + ); + + const overscanPageAtBottom = Math.min( + maxPage, + pageForRealIndex + DATA_PAGE_OVERSCAN, + ); + + const pagesAlreadyLoaded = getSnapshotValue( + snapshot, + dataPagesLoadedCallbackState, + ); + + const numberOfPages = overscanPageAtBottom - overscanPageAtTop; + + const pagesToFetchInitial = getContiguousIncrementalValues( + numberOfPages, + overscanPageAtTop, + ); + + const pagesToFetch = pagesToFetchInitial.filter( + (pageNumber) => !pagesAlreadyLoaded.includes(pageNumber), + ); + + if (pagesToFetch.length === 0) { + return; + } + + let pagesAreContiguous = true; + + for (const [index, pageNumber] of pagesToFetch.entries()) { + const isLastElementOfArray = index === pagesToFetch.length - 1; + + if (isLastElementOfArray) { + continue; + } + + const nextPageNumberIsNotContiguous = + pagesToFetch.at(index + 1) !== pageNumber + 1; + + if (nextPageNumberIsNotContiguous) { + pagesAreContiguous = false; + break; + } + } + + if (pagesAreContiguous) { + const startingRealIndexToFetch = + (pagesToFetch.at(0) ?? 0) * DATA_PAGE_SIZE; + + const endingRealIndexToFetch = + (pagesToFetch.at(-1) ?? 0) * DATA_PAGE_SIZE + DATA_PAGE_SIZE; + + const numberOfRecordsToFetch = + endingRealIndexToFetch - startingRealIndexToFetch; + + if (numberOfRecordsToFetch > 0) { + const fetchResult = await findManyRecordsLazyWithOffset( + numberOfRecordsToFetch, + startingRealIndexToFetch, + ); + + const records = fetchResult.records; + + if (isDefined(records)) { + const pagingForUIUpdate = PAGING_FOR_UI_UPDATE; + + const pages = Math.ceil(records.length / pagingForUIUpdate); + + for (let page = 0; page < pages; page++) { + await new Promise((res) => + setTimeout(() => res(), TIME_BETWEEN_UI_BATCH_UPDATE), + ); + + const startingRealIndexInThisUIPage = + startingRealIndexToFetch + page * pagingForUIUpdate; + + const startingSliceIndexInThisPage = page * pagingForUIUpdate; + const endingSliceIndexInThisPage = + startingSliceIndexInThisPage + pagingForUIUpdate; + + const recordsSlice = records.slice( + startingSliceIndexInThisPage, + endingSliceIndexInThisPage, + ); + + loadRecordsToVirtualRows({ + records: recordsSlice, + startingRealIndex: startingRealIndexInThisUIPage, + }); + + assignRecordsToStore({ records: recordsSlice }); + } + } + + set(dataPagesLoadedCallbackState, (currentLoadedPages) => + currentLoadedPages.concat(pagesToFetch).toSorted(), + ); + } + } else { + // TODO fetch page by page (will be optmized later) + } + }, + [ + dataPagesLoadedCallbackState, + findManyRecordsLazyWithOffset, + totalNumberOfRecordsToVirtualizeCallbackState, + loadRecordsToVirtualRows, + assignRecordsToStore, + lastScrollPositionCallbackState, + scrollWrapperHTMLElement, + lowDetailsActivatedCallbackState, + ], + ); + + const triggerFetchPages = useDebouncedCallback( + triggerFetchPagesWithoutDebounce, + TIME_BETWEEN_TWO_REQUETS, + { + maxWait: 2000, + }, + ); + + return { + triggerFetchPages, + triggerFetchPagesWithoutDebounce, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerInitialRecordTableDataLoad.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerInitialRecordTableDataLoad.ts new file mode 100644 index 00000000000..20fb392df59 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/hooks/useTriggerInitialRecordTableDataLoad.ts @@ -0,0 +1,222 @@ +import { useRecordIndexTableFetchMore } from '@/object-record/record-index/hooks/useRecordIndexTableFetchMore'; +import { recordIndexAllRecordIdsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexAllRecordIdsComponentSelector'; +import { RECORD_TABLE_HORIZONTAL_SCROLL_SHADOW_VISIBILITY_CSS_VARIABLE_NAME } from '@/object-record/record-table/constants/RecordTableHorizontalScrollShadowVisibilityCssVariableName'; +import { RECORD_TABLE_VERTICAL_SCROLL_SHADOW_VISIBILITY_CSS_VARIABLE_NAME } from '@/object-record/record-table/constants/RecordTableVerticalScrollShadowVisibilityCssVariableName'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { useScrollTableToPosition } from '@/object-record/record-table/hooks/useScrollTableToPosition'; +import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState'; +import { isRecordTableScrolledHorizontallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledHorizontallyComponentState'; +import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState'; +import { updateRecordTableCSSVariable } from '@/object-record/record-table/utils/updateRecordTableCSSVariable'; +import { useAssignRecordsToStore } from '@/object-record/record-table/virtualization/hooks/useAssignRecordsToStore'; +import { useLoadRecordsToVirtualRows } from '@/object-record/record-table/virtualization/hooks/useLoadRecordsToVirtualRows'; +import { useReapplyRowSelection } from '@/object-record/record-table/virtualization/hooks/useReapplyRowSelection'; +import { useResetNumberOfRecordsToVirtualize } from '@/object-record/record-table/virtualization/hooks/useResetNumberOfRecordsToVirtualize'; +import { useResetTableFocuses } from '@/object-record/record-table/virtualization/hooks/useResetTableFocuses'; +import { useResetVirtualizedRowTreadmill } from '@/object-record/record-table/virtualization/hooks/useResetVirtualizedRowTreadmill'; +import { dataLoadingStatusByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/dataLoadingStatusByRealIndexComponentFamilyState'; +import { dataPagesLoadedComponentState } from '@/object-record/record-table/virtualization/states/dataPagesLoadedComponentState'; +import { isInitializingVirtualTableDataLoadingComponentState } from '@/object-record/record-table/virtualization/states/isInitializingVirtualTableDataLoadingComponentState'; +import { lastRealIndexSetComponentState } from '@/object-record/record-table/virtualization/states/lastRealIndexSetComponentState'; +import { lastScrollPositionComponentState } from '@/object-record/record-table/virtualization/states/lastScrollPositionComponentState'; +import { recordIdByRealIndexComponentFamilyState } from '@/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState'; +import { scrollAtRealIndexComponentState } from '@/object-record/record-table/virtualization/states/scrollAtRealIndexComponentState'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { SIGN_IN_BACKGROUND_MOCK_COMPANIES } from '@/sign-in-background-mock/constants/SignInBackgroundMockCompanies'; +import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilComponentFamilyCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyCallbackState'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useTriggerInitialRecordTableDataLoad = () => { + const { recordTableId, objectNameSingular } = useRecordTableContextOrThrow(); + + const showAuthModal = useShowAuthModal(); + + const { findManyRecordsLazy } = + useRecordIndexTableFetchMore(objectNameSingular); + + const isInitializingVirtualTableDataLoadingCallbackState = + useRecoilComponentCallbackState( + isInitializingVirtualTableDataLoadingComponentState, + ); + + const dataPagesLoadedCallbackState = useRecoilComponentCallbackState( + dataPagesLoadedComponentState, + ); + + const isRecordTableInitialLoadingCallbackState = + useRecoilComponentCallbackState(isRecordTableInitialLoadingComponentState); + + const recordIndexAllRecordIdsSelector = useRecoilComponentCallbackState( + recordIndexAllRecordIdsComponentSelector, + ); + + const recordIdByRealIndexCallbackState = + useRecoilComponentFamilyCallbackState( + recordIdByRealIndexComponentFamilyState, + ); + + const dataLoadingStatusByRealIndexCallbackState = + useRecoilComponentCallbackState( + dataLoadingStatusByRealIndexComponentFamilyState, + ); + + const setIsRecordTableScrolledHorizontally = useSetRecoilComponentState( + isRecordTableScrolledHorizontallyComponentState, + ); + + const setIsRecordTableScrolledVertically = useSetRecoilComponentState( + isRecordTableScrolledVerticallyComponentState, + ); + + const lastScrollPositionCallbackState = useRecoilComponentCallbackState( + lastScrollPositionComponentState, + ); + + const lastRealIndexSetCallbackState = useRecoilComponentCallbackState( + lastRealIndexSetComponentState, + ); + + const scrollAtRealIndexCallbackState = useRecoilComponentCallbackState( + scrollAtRealIndexComponentState, + ); + + const { scrollTableToPosition } = useScrollTableToPosition(); + + const { resetVirtualizedRowTreadmill } = useResetVirtualizedRowTreadmill(); + const { resetNumberOfRecordsToVirtualize } = + useResetNumberOfRecordsToVirtualize(); + + const { resetTableFocuses } = useResetTableFocuses(recordTableId); + const { assignRecordsToStore } = useAssignRecordsToStore(); + + const { loadRecordsToVirtualRows } = useLoadRecordsToVirtualRows(); + + const { reapplyRowSelection } = useReapplyRowSelection(); + + const triggerInitialRecordTableDataLoad = useRecoilCallback( + ({ snapshot, set }) => + async () => { + const isInitializingVirtualTableDataLoading = getSnapshotValue( + snapshot, + isInitializingVirtualTableDataLoadingCallbackState, + ); + + if (isInitializingVirtualTableDataLoading) { + return; + } + + set(isInitializingVirtualTableDataLoadingCallbackState, true); + + resetTableFocuses(); + + resetVirtualizedRowTreadmill(); + + updateRecordTableCSSVariable( + RECORD_TABLE_VERTICAL_SCROLL_SHADOW_VISIBILITY_CSS_VARIABLE_NAME, + 'hidden', + ); + + updateRecordTableCSSVariable( + RECORD_TABLE_HORIZONTAL_SCROLL_SHADOW_VISIBILITY_CSS_VARIABLE_NAME, + 'hidden', + ); + + const currentRecordIds = getSnapshotValue( + snapshot, + recordIndexAllRecordIdsSelector, + ); + + let records: ObjectRecord[] | null = null; + let totalCount = 0; + + if (showAuthModal) { + records = SIGN_IN_BACKGROUND_MOCK_COMPANIES; + totalCount = SIGN_IN_BACKGROUND_MOCK_COMPANIES.length; + } else { + for (const [index] of currentRecordIds.entries()) { + set( + dataLoadingStatusByRealIndexCallbackState({ + realIndex: index, + }), + null, + ); + + set( + recordIdByRealIndexCallbackState({ + realIndex: index, + }), + null, + ); + } + const { records: findManyRecords, totalCount: findManyTotalCount } = + await findManyRecordsLazy(); + records = findManyRecords; + totalCount = findManyTotalCount; + } + + if (isDefined(records)) { + resetNumberOfRecordsToVirtualize({ + records, + totalCount, + }); + + assignRecordsToStore({ records }); + + loadRecordsToVirtualRows({ + records, + startingRealIndex: 0, + }); + + reapplyRowSelection(); + } + + set(dataPagesLoadedCallbackState, []); + + set(isInitializingVirtualTableDataLoadingCallbackState, false); + set(isRecordTableInitialLoadingCallbackState, false); + + set(lastScrollPositionCallbackState, 0); + set(lastRealIndexSetCallbackState, null); + set(scrollAtRealIndexCallbackState, 0); + + setIsRecordTableScrolledHorizontally(false); + setIsRecordTableScrolledVertically(false); + + scrollTableToPosition({ + horizontalScrollInPx: 0, + verticalScrollInPx: 0, + }); + }, + [ + isInitializingVirtualTableDataLoadingCallbackState, + resetTableFocuses, + resetVirtualizedRowTreadmill, + recordIndexAllRecordIdsSelector, + showAuthModal, + dataPagesLoadedCallbackState, + isRecordTableInitialLoadingCallbackState, + lastScrollPositionCallbackState, + lastRealIndexSetCallbackState, + scrollAtRealIndexCallbackState, + setIsRecordTableScrolledHorizontally, + setIsRecordTableScrolledVertically, + scrollTableToPosition, + findManyRecordsLazy, + dataLoadingStatusByRealIndexCallbackState, + recordIdByRealIndexCallbackState, + resetNumberOfRecordsToVirtualize, + assignRecordsToStore, + loadRecordsToVirtualRows, + reapplyRowSelection, + ], + ); + + return { + triggerInitialRecordTableDataLoad, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/dataLoadingStatusByRealIndexComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/dataLoadingStatusByRealIndexComponentFamilyState.ts new file mode 100644 index 00000000000..d080af94f8d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/dataLoadingStatusByRealIndexComponentFamilyState.ts @@ -0,0 +1,9 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; + +export const dataLoadingStatusByRealIndexComponentFamilyState = + createComponentFamilyState<'loaded' | null, { realIndex: number | null }>({ + key: 'dataLoadingStatusByRealIndexComponentFamilyState', + componentInstanceContext: RecordTableComponentInstanceContext, + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/dataPagesLoadedComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/dataPagesLoadedComponentState.ts new file mode 100644 index 00000000000..1c763bd1da7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/dataPagesLoadedComponentState.ts @@ -0,0 +1,8 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const dataPagesLoadedComponentState = createComponentState({ + key: 'dataPagesLoadedComponentState', + componentInstanceContext: RecordTableComponentInstanceContext, + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/hasRecordTableFetchedAllRecordsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/isInitializingVirtualTableDataLoadingComponentState.ts similarity index 73% rename from packages/twenty-front/src/modules/object-record/record-table/states/hasRecordTableFetchedAllRecordsComponentState.ts rename to packages/twenty-front/src/modules/object-record/record-table/virtualization/states/isInitializingVirtualTableDataLoadingComponentState.ts index f1858dca395..80bfb099574 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/states/hasRecordTableFetchedAllRecordsComponentState.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/isInitializingVirtualTableDataLoadingComponentState.ts @@ -1,9 +1,9 @@ import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; -export const hasRecordTableFetchedAllRecordsComponentState = +export const isInitializingVirtualTableDataLoadingComponentState = createComponentState({ - key: 'hasRecordTableFetchedAllRecordsComponentState', + key: 'isInitializingVirtualTableDataLoadingComponentState', componentInstanceContext: RecordTableComponentInstanceContext, defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastContextStoreVirtualizedViewIdComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastContextStoreVirtualizedViewIdComponentState.ts new file mode 100644 index 00000000000..5fd4ed4981c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastContextStoreVirtualizedViewIdComponentState.ts @@ -0,0 +1,9 @@ +import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const lastContextStoreVirtualizedViewIdComponentState = + createComponentState({ + key: 'lastContextStoreVirtualizedViewIdComponentState', + componentInstanceContext: ContextStoreComponentInstanceContext, + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastObjectOperationThatResettedVirtualizationComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastObjectOperationThatResettedVirtualizationComponentState.ts new file mode 100644 index 00000000000..2f7fd890342 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastObjectOperationThatResettedVirtualizationComponentState.ts @@ -0,0 +1,11 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { type ObjectOperation } from '@/object-record/states/objectOperationsByObjectNameSingularFamilyState'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; +import { type Nullable } from 'twenty-shared/types'; + +export const lastObjectOperationThatResettedVirtualizationComponentState = + createComponentState>({ + key: 'lastObjectOperationThatResettedVirtualizationComponentState', + componentInstanceContext: RecordTableComponentInstanceContext, + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastRealIndexSetComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastRealIndexSetComponentState.ts new file mode 100644 index 00000000000..faa2e21c534 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastRealIndexSetComponentState.ts @@ -0,0 +1,10 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const lastRealIndexSetComponentState = createComponentState< + number | null +>({ + key: 'lastRealIndexSetComponentState', + componentInstanceContext: RecordTableComponentInstanceContext, + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastRecordTableQueryIdentifierComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastRecordTableQueryIdentifierComponentState.ts new file mode 100644 index 00000000000..9ade277ba4c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastRecordTableQueryIdentifierComponentState.ts @@ -0,0 +1,9 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const lastRecordTableQueryIdentifierComponentState = + createComponentState({ + key: 'lastRecordTableQueryIdentifierComponentState', + componentInstanceContext: RecordTableComponentInstanceContext, + defaultValue: '', + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastScrollMeasurementsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastScrollMeasurementsComponentState.ts new file mode 100644 index 00000000000..8b1c80c7618 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastScrollMeasurementsComponentState.ts @@ -0,0 +1,15 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export type ScrollMeasurement = { + scrollToTop: number; + timestamp: number; +}; + +export const lastScrollMeasurementsComponentState = createComponentState< + ScrollMeasurement[] +>({ + key: 'lastScrollMeasurementsComponentState', + componentInstanceContext: RecordTableComponentInstanceContext, + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastScrollPositionComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastScrollPositionComponentState.ts new file mode 100644 index 00000000000..490e0bcaf05 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lastScrollPositionComponentState.ts @@ -0,0 +1,8 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const lastScrollPositionComponentState = createComponentState({ + key: 'lastScrollPositionComponentState', + componentInstanceContext: RecordTableComponentInstanceContext, + defaultValue: 0, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lowDetailsActivatedComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lowDetailsActivatedComponentState.ts new file mode 100644 index 00000000000..9ca899983c8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/lowDetailsActivatedComponentState.ts @@ -0,0 +1,8 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const lowDetailsActivatedComponentState = createComponentState({ + key: 'lowDetailsActivatedComponentState', + componentInstanceContext: RecordTableComponentInstanceContext, + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/realIndexByVirtualIndexComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/realIndexByVirtualIndexComponentFamilyState.ts new file mode 100644 index 00000000000..f24c4e22eed --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/realIndexByVirtualIndexComponentFamilyState.ts @@ -0,0 +1,9 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; + +export const realIndexByVirtualIndexComponentFamilyState = + createComponentFamilyState({ + key: 'realIndexByVirtualIndexComponentFamilyState', + componentInstanceContext: RecordTableComponentInstanceContext, + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState.ts new file mode 100644 index 00000000000..fab769404db --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/recordIdByRealIndexComponentFamilyState.ts @@ -0,0 +1,9 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; + +export const recordIdByRealIndexComponentFamilyState = + createComponentFamilyState({ + key: 'recordIdByRealIndexComponentFamilyState', + componentInstanceContext: RecordTableComponentInstanceContext, + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/scrollAtRealIndexComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/scrollAtRealIndexComponentState.ts new file mode 100644 index 00000000000..a5adedbf88b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/scrollAtRealIndexComponentState.ts @@ -0,0 +1,8 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const scrollAtRealIndexComponentState = createComponentState({ + key: 'scrollAtRealIndexComponentState', + componentInstanceContext: RecordTableComponentInstanceContext, + defaultValue: 0, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/tableHasAnyFilterOrSortComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/tableHasAnyFilterOrSortComponentSelector.ts new file mode 100644 index 00000000000..674b6118f1e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/tableHasAnyFilterOrSortComponentSelector.ts @@ -0,0 +1,26 @@ +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentSelector } from '@/ui/utilities/state/component-state/utils/createComponentSelector'; + +export const tableHasAnyFilterOrSortComponentSelector = + createComponentSelector({ + key: 'tableHasAnyFilterOrSortComponentSelector', + componentInstanceContext: RecordTableComponentInstanceContext, + get: + ({ instanceId }) => + ({ get }) => { + const currentRecordFilters = get( + currentRecordFiltersComponentState.atomFamily({ instanceId }), + ); + + const currentRecordSorts = get( + currentRecordSortsComponentState.atomFamily({ instanceId }), + ); + + const tableHasAnyFilterOrSort = + currentRecordFilters.length > 0 || currentRecordSorts.length > 0; + + return tableHasAnyFilterOrSort; + }, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState.ts new file mode 100644 index 00000000000..caacc259feb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState.ts @@ -0,0 +1,9 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const totalNumberOfRecordsToVirtualizeComponentState = + createComponentState({ + key: 'totalNumberOfRecordsToVirtualizeComponentState', + componentInstanceContext: RecordTableComponentInstanceContext, + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/object-record/states/objectOperationsByObjectNameSingularFamilyState.ts b/packages/twenty-front/src/modules/object-record/states/objectOperationsByObjectNameSingularFamilyState.ts new file mode 100644 index 00000000000..7dff3d217cc --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/states/objectOperationsByObjectNameSingularFamilyState.ts @@ -0,0 +1,41 @@ +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; + +export type ObjectOperationData = + | { + type: 'update-one'; + result: { + updateInput: any; + updatedRecord: any; + }; + } + | { + type: 'update-many'; + result: { + updateInputs: any[]; + updatedRecords: 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; + data: ObjectOperationData; + timestamp: number; +}; + +export const objectOperationsByObjectNameSingularFamilyState = + createFamilyState({ + key: 'objectOperationsByObjectNameSingularFamilyState', + defaultValue: [], + }); diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/computeNewPositionOfRecordWithPosition.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/computeNewPositionOfRecordWithPosition.test.ts index e4be2aea4fc..7fbcb30e9f3 100644 --- a/packages/twenty-front/src/modules/object-record/utils/__tests__/computeNewPositionOfRecordWithPosition.test.ts +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/computeNewPositionOfRecordWithPosition.test.ts @@ -1,7 +1,7 @@ import { - computeNewPositionOfRecordWithPosition, + computeNewPositionOfDraggedRecord, type RecordWithPosition, -} from '@/object-record/utils/computeNewPositionOfRecordWithPosition'; +} from '@/object-record/utils/computeNewPositionOfDraggedRecord'; const mockRecordsWithPosition: RecordWithPosition[] = [ { @@ -24,7 +24,7 @@ const mockRecordsWithPosition: RecordWithPosition[] = [ describe('computeNewPositionOfRecordWithPosition', () => { it('should compute first position', () => { - const newPosition = computeNewPositionOfRecordWithPosition({ + const newPosition = computeNewPositionOfDraggedRecord({ arrayOfRecordsWithPosition: mockRecordsWithPosition, idOfItemToMove: 'B', idOfTargetItem: 'A', @@ -34,7 +34,7 @@ describe('computeNewPositionOfRecordWithPosition', () => { }); it('should compute last position', () => { - const newPosition = computeNewPositionOfRecordWithPosition({ + const newPosition = computeNewPositionOfDraggedRecord({ arrayOfRecordsWithPosition: mockRecordsWithPosition, idOfItemToMove: 'B', idOfTargetItem: 'D', @@ -44,7 +44,7 @@ describe('computeNewPositionOfRecordWithPosition', () => { }); it('should compute intermediary position after target item', () => { - const newPosition = computeNewPositionOfRecordWithPosition({ + const newPosition = computeNewPositionOfDraggedRecord({ arrayOfRecordsWithPosition: mockRecordsWithPosition, idOfItemToMove: 'A', idOfTargetItem: 'B', @@ -54,7 +54,7 @@ describe('computeNewPositionOfRecordWithPosition', () => { }); it('should compute intermediary position before target item', () => { - const newPosition = computeNewPositionOfRecordWithPosition({ + const newPosition = computeNewPositionOfDraggedRecord({ arrayOfRecordsWithPosition: mockRecordsWithPosition, idOfItemToMove: 'A', idOfTargetItem: 'C', diff --git a/packages/twenty-front/src/modules/object-record/utils/computeNewEvenlySpacedPositions.ts b/packages/twenty-front/src/modules/object-record/utils/computeNewEvenlySpacedPositions.ts new file mode 100644 index 00000000000..fc4504ad1c9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/computeNewEvenlySpacedPositions.ts @@ -0,0 +1,34 @@ +export const computeNewEvenlySpacedPositions = ({ + startingPosition, + endingPosition, + numberOfRecordsToInsertBetween, +}: { + startingPosition: number; + endingPosition: number; + numberOfRecordsToInsertBetween: number; +}) => { + const positionGapSize = endingPosition - startingPosition; + + if (positionGapSize === 0) { + return Array.from( + { length: numberOfRecordsToInsertBetween }, + () => endingPosition, + ); + } + + if (positionGapSize < 0) { + throw new Error( + `Cannot compute positions because starting position (${startingPosition}) is after ending position (${endingPosition})`, + ); + } + + const positionStep = positionGapSize / (numberOfRecordsToInsertBetween + 1); + + let newPositionsBetween: number[] = []; + + for (let i = 1; i <= numberOfRecordsToInsertBetween; i++) { + newPositionsBetween.push(startingPosition + positionStep * i); + } + + return newPositionsBetween; +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/computeNewPositionOfRecordWithPosition.ts b/packages/twenty-front/src/modules/object-record/utils/computeNewPositionOfDraggedRecord.ts similarity index 75% rename from packages/twenty-front/src/modules/object-record/utils/computeNewPositionOfRecordWithPosition.ts rename to packages/twenty-front/src/modules/object-record/utils/computeNewPositionOfDraggedRecord.ts index 88af56f5c65..0a22b4ea8c5 100644 --- a/packages/twenty-front/src/modules/object-record/utils/computeNewPositionOfRecordWithPosition.ts +++ b/packages/twenty-front/src/modules/object-record/utils/computeNewPositionOfDraggedRecord.ts @@ -6,7 +6,7 @@ export type RecordWithPosition = { position: number; }; -export const computeNewPositionOfRecordWithPosition = ({ +export const computeNewPositionOfDraggedRecord = ({ arrayOfRecordsWithPosition, idOfItemToMove, idOfTargetItem, @@ -15,24 +15,16 @@ export const computeNewPositionOfRecordWithPosition = ({ idOfItemToMove: string; idOfTargetItem: string; }) => { - const itemToMove = arrayOfRecordsWithPosition.find( - (recordToFind) => recordToFind.id === idOfItemToMove, - ); - const targetItem = arrayOfRecordsWithPosition.find( (recordToFind) => recordToFind.id === idOfTargetItem, ); - if (!isDefined(itemToMove)) { - throw new Error(`Cannot find item to move for id : ${idOfItemToMove}`); - } - if (!isDefined(targetItem)) { throw new Error(`Cannot find item to move for id : ${idOfTargetItem}`); } - if (itemToMove.id === targetItem.id) { - return itemToMove.position; + if (idOfItemToMove === idOfTargetItem) { + return targetItem.position; } const targetPosition = targetItem.position; @@ -44,23 +36,35 @@ export const computeNewPositionOfRecordWithPosition = ({ const indexOfItemToMove = sortedRecordsByAscendingPosition.findIndex( (recordToFind) => recordToFind.id === idOfItemToMove, ); + + const itemToMoveIsNotInTable = indexOfItemToMove === -1; + const indexOfTargetItem = sortedRecordsByAscendingPosition.findIndex( (recordToFind) => recordToFind.id === idOfTargetItem, ); const lastIndex = sortedRecordsByAscendingPosition.length - 1; - const shouldGoToFirstPosition = - indexOfItemToMove > 0 && indexOfTargetItem === 0; + const shouldGoToFirstPosition = indexOfTargetItem === 0; - const shouldGoToLastPosition = - indexOfItemToMove < lastIndex && indexOfTargetItem === lastIndex; + const shouldGoToLastPosition = indexOfTargetItem === lastIndex; if (shouldGoToFirstPosition) { return targetPosition - 1; } else if (shouldGoToLastPosition) { return targetPosition + 1; } else { + if (itemToMoveIsNotInTable) { + const itemBeforeTargetItem = + sortedRecordsByAscendingPosition[indexOfTargetItem - 1]; + + const intermediaryPosition = + targetItem.position - + (targetItem.position - itemBeforeTargetItem.position) / 2; + + return intermediaryPosition; + } + const shouldGoAfterTargetItem = indexOfItemToMove < indexOfTargetItem; if (shouldGoAfterTargetItem) { diff --git a/packages/twenty-front/src/modules/object-record/utils/computeNewPositionsOfDraggedRecords.ts b/packages/twenty-front/src/modules/object-record/utils/computeNewPositionsOfDraggedRecords.ts new file mode 100644 index 00000000000..49eba68400c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/computeNewPositionsOfDraggedRecords.ts @@ -0,0 +1,139 @@ +import { computeNewEvenlySpacedPositions } from '@/object-record/utils/computeNewEvenlySpacedPositions'; +import { type RecordWithPosition } from '@/object-record/utils/computeNewPositionOfDraggedRecord'; +import { isDefined } from 'twenty-shared/utils'; + +// TODO : refactor this +export const computeNewPositionsOfDraggedRecords = ({ + arrayOfRecordsWithPosition, + draggedRecordId, + targetRecordId, + sourceRecordIds, +}: { + arrayOfRecordsWithPosition: RecordWithPosition[]; + draggedRecordId: string; + targetRecordId: string; + sourceRecordIds: string[]; +}): RecordWithPosition[] | null => { + const targetItem = arrayOfRecordsWithPosition.find( + (recordToFind) => recordToFind.id === targetRecordId, + ); + + if (!isDefined(targetItem)) { + throw new Error(`Cannot find item to move for id : ${targetRecordId}`); + } + + if (targetRecordId === draggedRecordId) { + return null; + } + + const targetIsInSourceRecordIds = sourceRecordIds.includes(targetRecordId); + + if (targetIsInSourceRecordIds) { + return null; + } + + const targetPosition = targetItem.position; + + const indexOfItemToMove = arrayOfRecordsWithPosition.findIndex( + (recordToFind) => recordToFind.id === draggedRecordId, + ); + + const itemToMoveIsNotInTable = indexOfItemToMove === -1; + + const indexOfTargetItem = arrayOfRecordsWithPosition.findIndex( + (recordToFind) => recordToFind.id === targetRecordId, + ); + + const lastIndex = arrayOfRecordsWithPosition.length - 1; + + const shouldGoToFirstPosition = indexOfTargetItem === 0; + + const shouldGoToLastPosition = indexOfTargetItem === lastIndex; + + if (shouldGoToFirstPosition) { + const newPositions = computeNewEvenlySpacedPositions({ + startingPosition: targetPosition - 1, + endingPosition: targetPosition, + numberOfRecordsToInsertBetween: sourceRecordIds.length, + }); + + const newSourceRecordsWithPosition: RecordWithPosition[] = + sourceRecordIds.map((recordId, index) => ({ + id: recordId, + position: newPositions[index], + })); + + return newSourceRecordsWithPosition; + } else if (shouldGoToLastPosition) { + const newPositions = computeNewEvenlySpacedPositions({ + startingPosition: targetPosition, + endingPosition: targetPosition + sourceRecordIds.length + 1, + numberOfRecordsToInsertBetween: sourceRecordIds.length, + }); + + const newSourceRecordsWithPosition: RecordWithPosition[] = + sourceRecordIds.map((recordId, index) => ({ + id: recordId, + position: newPositions[index], + })); + + return newSourceRecordsWithPosition; + } else { + if (itemToMoveIsNotInTable) { + const itemBeforeTargetItem = + arrayOfRecordsWithPosition[indexOfTargetItem - 1]; + + const newPositions = computeNewEvenlySpacedPositions({ + startingPosition: itemBeforeTargetItem.position, + endingPosition: targetItem.position, + numberOfRecordsToInsertBetween: sourceRecordIds.length, + }); + + const newSourceRecordsWithPosition: RecordWithPosition[] = + sourceRecordIds.map((recordId, index) => ({ + id: recordId, + position: newPositions[index], + })); + + return newSourceRecordsWithPosition; + } + + const shouldGoAfterTargetItem = indexOfItemToMove < indexOfTargetItem; + + if (shouldGoAfterTargetItem) { + const itemAfterTargetItem = + arrayOfRecordsWithPosition[indexOfTargetItem + 1]; + + const newPositions = computeNewEvenlySpacedPositions({ + startingPosition: targetItem.position, + endingPosition: itemAfterTargetItem.position, + numberOfRecordsToInsertBetween: sourceRecordIds.length, + }); + + const newSourceRecordsWithPosition: RecordWithPosition[] = + sourceRecordIds.map((recordId, index) => ({ + id: recordId, + position: newPositions[index], + })); + + return newSourceRecordsWithPosition; + } else { + const itemBeforeTargetItem = + arrayOfRecordsWithPosition[indexOfTargetItem - 1]; + + const newPositions = computeNewEvenlySpacedPositions({ + startingPosition: itemBeforeTargetItem.position, + endingPosition: targetItem.position, + numberOfRecordsToInsertBetween: sourceRecordIds.length, + }); + + const newSourceRecordsWithPosition: RecordWithPosition[] = + sourceRecordIds.map((recordId, index) => ({ + id: recordId, + position: newPositions[index], + })); + + return newSourceRecordsWithPosition; + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/generateFindManyRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/utils/generateFindManyRecordsQuery.ts index ec5cc497ce0..19547e3ab2c 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateFindManyRecordsQuery.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateFindManyRecordsQuery.ts @@ -32,12 +32,12 @@ query FindMany${capitalize( objectMetadataItem.nameSingular, )}FilterInput, $orderBy: [${capitalize( objectMetadataItem.nameSingular, -)}OrderByInput], $lastCursor: String, $limit: Int) { +)}OrderByInput], $lastCursor: String, $limit: Int, $offset: Int) { ${objectMetadataItem.namePlural}(filter: $filter, orderBy: $orderBy, ${ cursorDirection === 'before' ? 'last: $limit, before: $lastCursor' : 'first: $limit, after: $lastCursor' - } ){ + }, offset: $offset){ edges { node ${mapObjectMetadataToGraphQLQuery({ objectMetadataItems, diff --git a/packages/twenty-front/src/modules/ui/utilities/scroll/hooks/useScrollToPosition.ts b/packages/twenty-front/src/modules/ui/utilities/scroll/hooks/useScrollToPosition.ts index 3eed8919e82..88991a3ab6d 100644 --- a/packages/twenty-front/src/modules/ui/utilities/scroll/hooks/useScrollToPosition.ts +++ b/packages/twenty-front/src/modules/ui/utilities/scroll/hooks/useScrollToPosition.ts @@ -6,7 +6,9 @@ export const useScrollToPosition = () => { const scrollToPosition = useCallback( (scrollPositionInPx: number) => { - scrollWrapperHTMLElement?.scrollTo({ top: scrollPositionInPx }); + scrollWrapperHTMLElement?.scrollTo({ + top: scrollPositionInPx, + }); }, [scrollWrapperHTMLElement], ); diff --git a/packages/twenty-front/src/modules/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement.ts b/packages/twenty-front/src/modules/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement.ts index a17085c0291..70b2a385c51 100644 --- a/packages/twenty-front/src/modules/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement.ts +++ b/packages/twenty-front/src/modules/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement.ts @@ -15,7 +15,16 @@ export const useScrollWrapperHTMLElement = ( const { element: scrollWrapperHTMLElement } = useHTMLElementByIdWhenAvailable(scrollWrapperId); + const getScrollWrapperElement = () => { + const scrollWrapperElement = document.getElementById(scrollWrapperId); + + return { + scrollWrapperElement, + }; + }; + return { scrollWrapperHTMLElement, + getScrollWrapperElement, }; }; diff --git a/packages/twenty-front/src/types/SparseArray.ts b/packages/twenty-front/src/types/SparseArray.ts new file mode 100644 index 00000000000..d75bd0bc944 --- /dev/null +++ b/packages/twenty-front/src/types/SparseArray.ts @@ -0,0 +1 @@ +export type SparseArray = Array; diff --git a/packages/twenty-front/vite.config.ts b/packages/twenty-front/vite.config.ts index f05c7c78835..e3a50020833 100644 --- a/packages/twenty-front/vite.config.ts +++ b/packages/twenty-front/vite.config.ts @@ -46,7 +46,7 @@ export default defineConfig(({ command, mode }) => { // Please don't increase this limit for main index chunk // If it gets too big then find modules in the code base // that can be loaded lazily, there are more! - const MAIN_CHUNK_SIZE_LIMIT = 5.5 * 1024 * 1024; // 5.5MB for main index chunk + const MAIN_CHUNK_SIZE_LIMIT = 5.7 * 1024 * 1024; // 5.5MB for main index chunk const OTHER_CHUNK_SIZE_LIMIT = 5 * 1024 * 1024; // 5MB for other chunks const checkers: Checkers = { @@ -151,6 +151,9 @@ export default defineConfig(({ command, mode }) => { '**/EmailsDisplay.tsx', '**/PhonesDisplay.tsx', '**/MultiSelectDisplay.tsx', + '**/RecordTableRowVirtualizedContainer.tsx', + '**/RecordTableVirtualizedBodyPlaceholder.tsx', + '**/RecordTableCellLoading.tsx', ], babelOptions: { presets: ['@babel/preset-typescript', '@babel/preset-react'], diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts index 03eae939efa..647c7b80dee 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts @@ -129,6 +129,10 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve objectMetadataMaps, }); + if (isDefined(executionArgs.args.offset)) { + queryBuilder.skip(executionArgs.args.offset); + } + const objectRecords = (await queryBuilder .setFindOptions({ select: columnsToSelect, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts index 4e61a333cb8..4f82cb3ace2 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts @@ -38,6 +38,7 @@ export interface FindManyResolverArgs< after?: string; filter?: Filter; orderBy?: OrderBy; + offset?: number; } export interface FindOneResolverArgs { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/__tests__/get-resolver-args.spec.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/__tests__/get-resolver-args.spec.ts index 7239b5e50bf..df1ad3639d7 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/__tests__/get-resolver-args.spec.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/__tests__/get-resolver-args.spec.ts @@ -19,6 +19,7 @@ describe('getResolverArgs', () => { isNullable: true, isArray: true, }, + offset: { type: GraphQLInt, isNullable: true }, }, findOne: { filter: { kind: GqlInputTypeDefinitionKind.Filter, isNullable: false }, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts index 6ee8c334895..f1034c7f9a2 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts @@ -20,6 +20,10 @@ export const getResolverArgs = ( type: GraphQLInt, isNullable: true, }, + offset: { + type: GraphQLInt, + isNullable: true, + }, before: { type: GraphQLString, isNullable: true, diff --git a/packages/twenty-shared/src/utils/array/getContiguousIncrementalValues.ts b/packages/twenty-shared/src/utils/array/getContiguousIncrementalValues.ts new file mode 100644 index 00000000000..4669c12f28b --- /dev/null +++ b/packages/twenty-shared/src/utils/array/getContiguousIncrementalValues.ts @@ -0,0 +1,9 @@ +export const getContiguousIncrementalValues = ( + numberOfValues: number, + startingValue = 0, +) => { + return Array.from( + { length: numberOfValues }, + (_, index) => startingValue + index, + ); +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 0e9a90d5fa2..b25f0066bca 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -12,6 +12,7 @@ export { filterOutByProperty } from './array/filterOutByProperty'; export { findById } from './array/findById'; export { findByProperty } from './array/findByProperty'; export { findOrThrow } from './array/findOrThrow'; +export { getContiguousIncrementalValues } from './array/getContiguousIncrementalValues'; export { isNonEmptyArray } from './array/isNonEmptyArray'; export { sumByProperty } from './array/sumByProperty'; export { assertUnreachable } from './assertUnreachable';