🦣🦣🦣 Table virtualization (#14743)

This big PR implements table virtualization with an offset paging,
allowing a way more fluid UX.

It is a v1 that should be improved in the future with partial data
loading and optimization of the browser display performance of a row.

But with this PR we have the solid enough technical foundation, both
frontend and backend, to get to a smooth table UX.

Fixes and improvements after first successful round of development
(needed to have main clean) :

- [x] Delete should refresh virtualized portion only and reset all table
- [x] Fix add new : top and bottom
- [x] Table empty shouldn’t show when first loading
- [x] Fix d&d
- [x]  Fix sorts
- [x] Fix drag when scrolling after a full virtual page (it throws an
error)
- [x] Si update mais qu’on a un sort ou filter, alors il faut trigger le
refresh
- [x]  Reset scroll position between tables
- [x]  Reset scroll shadows between tables
- [x] Setup d&n for virtual list :
https://github.com/hello-pangea/dnd/blob/main/docs/patterns/virtual-lists.md
- [x]  Full table re-render when entering edit mode
- [x] Clean code and prepare for merge

Fixes https://github.com/twentyhq/core-team-issues/issues/1613 that
contains other bugs to be fixed before merge

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2025-10-11 12:08:10 +02:00
committed by GitHub
co-authored by Charles Bochet
parent 720e93a68a
commit 68c86871dd
150 changed files with 3615 additions and 1712 deletions
+3 -3
View File
@@ -61,9 +61,9 @@ const jestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageThreshold: {
global: {
statements: 53,
lines: 52,
functions: 42,
statements: 52,
lines: 51,
functions: 41,
},
},
collectCoverageFrom: ['<rootDir>/src/**/*.ts'],
@@ -14,6 +14,7 @@ export const useHTMLElementByIdWhenAvailable = (id: string) => {
if (isDefined(elementFoundBeforeObservingMutation)) {
setElement(elementFoundBeforeObservingMutation);
return;
}
@@ -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<NoteTarget[] | TaskTarget[]>,
) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
@@ -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<NoteTarget[] | TaskTarget[]>;
};
export const getActivityTargetObjectRecords = ({
@@ -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 {
@@ -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}
@@ -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 {
@@ -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 {
@@ -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;
};
@@ -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] ?? [];
};
@@ -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;
};
@@ -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;
};
@@ -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,
],
);
@@ -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;
};
@@ -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,
],
);
@@ -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)
@@ -96,7 +96,7 @@ export const useLazyFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
return {
data: null,
records: [],
records: null,
totalCount: 0,
hasNextPage: false,
error: undefined,
@@ -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<ObjectRecord>,
'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<RecordGqlOperationFindManyResult>(
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,
};
};
@@ -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],
@@ -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,
],
);
@@ -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,
};
};
@@ -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);
}
@@ -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 {
@@ -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,
],
@@ -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,
}),
);
@@ -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,
},
});
@@ -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];
@@ -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[];
};
@@ -1,4 +0,0 @@
export type RecordDragPositionData = {
recordId: string;
position?: number;
};
@@ -1,6 +0,0 @@
export type RecordDragUpdate = {
recordId: string;
position: number;
groupValue?: string | null;
selectFieldName?: string;
};
@@ -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');
});
});
});
@@ -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 },
]);
});
});
@@ -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);
});
});
@@ -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',
}),
);
});
});
});
@@ -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<string, number> = {};
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([]);
});
});
@@ -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<string, number> = {};
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');
});
});
@@ -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<string, number> => {
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<string, number> = {};
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;
};
@@ -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,
};
});
@@ -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;
};
@@ -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,
);
}
}
};
@@ -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 ?? [],
};
};
@@ -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;
};
@@ -80,7 +80,6 @@ export const useRecordTableGroupDragOperations = () => {
result,
snapshot,
selectedRecordIds,
selectFieldName: fieldMetadata.name,
recordIdsByGroupFamilyState: recordIdsByGroupFamilyState,
onUpdateRecord: ({ recordId, position }) => {
updateOneRow({
@@ -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 };
};
@@ -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,
@@ -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 ? (
<ExpandableList isChipCountDisplayed={isFocused}>
{fieldValue
.map((record) => {
?.map((record) => {
if (!isDefined(record) || !isDefined(record[relationFieldName])) {
return undefined;
}
@@ -83,7 +92,7 @@ export const RelationFromManyFieldDisplay = () => {
) : (
<StyledContainer>
{fieldValue
.map((record) => {
?.map((record) => {
if (!isDefined(record) || !isDefined(record[relationFieldName])) {
return undefined;
}
@@ -115,7 +124,7 @@ export const RelationFromManyFieldDisplay = () => {
} else {
return (
<ExpandableList isChipCountDisplayed={isFocused}>
{fieldValue.filter(isDefined).map((record) => {
{fieldValue?.filter(isDefined).map((record) => {
const recordChipData = generateRecordChipData(record);
return (
<RecordChip
@@ -1,4 +1,5 @@
import { createState } from 'twenty-ui/utilities';
export const lastShowPageRecordIdState = createState<string | null>({
key: 'lastShowPageRecordIdState',
defaultValue: null,
@@ -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,
};
@@ -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,
};
};
@@ -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<string[]>({
key: 'recordIndexAllRecordIdsComponentSelector',
componentInstanceContext: ViewComponentInstanceContext,
get:
({ instanceId }) =>
({ get }) => {
const recordGroupIds = get(
recordGroupIdsComponentState.atomFamily({
instanceId,
}),
);
if (recordGroupIds.length === 0) {
return [];
}
return recordGroupIds.reduce<string[]>((acc, recordGroupId) => {
const rowIds = get(
recordIndexRecordIdsByGroupComponentFamilyState.atomFamily({
instanceId,
familyKey: recordGroupId,
}),
);
return [...acc, ...rowIds];
}, []);
},
});
@@ -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<SparseArray<string>>({
key: 'allRecordIdsWithoutGroupsComponentSelector',
componentInstanceContext: ViewComponentInstanceContext,
get:
({ instanceId }) =>
({ get }) => {
return get(
recordIndexRecordIdsByGroupComponentFamilyState.atomFamily({
instanceId,
familyKey: NO_RECORD_GROUP_FAMILY_KEY,
}),
);
},
});
@@ -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,
),
@@ -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<boolean>({
key: 'recordIndexHasRecordsComponentSelector',
componentInstanceContext: ViewComponentInstanceContext,
get:
({ instanceId }) =>
({ get }) => {
const allRecordIds = get(
recordIndexAllRecordIdsComponentSelector.selectorFamily({
instanceId,
}),
);
return allRecordIds.length > 0;
},
});
@@ -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 <></>;
@@ -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 (
<RecordTableActionRow
onClick={() => {
createNewIndexRecord({
position: 'last',
});
}}
LeftIcon={IconPlus}
text={t`Add New`}
/>
);
};
@@ -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 ? (
<RecordTableRecordGroupBodyEffects />
) : (
<RecordTableNoRecordGroupBodyEffect />
<>
<RecordTableNoRecordGroupScrollToPreviousRecordEffect />
<RecordTableVirtualizedInitialDataLoadEffect />
</>
)}
<RecordTableBodyEscapeHotkeyEffect />
<RecordTableBodyFocusKeyboardEffect />
@@ -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 (
<StyledTableContainer ref={containerRef}>
@@ -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 (
<RecordTableActionRow
onClick={handleButtonClick}
LeftIcon={IconPlus}
text={t`Add New`}
/>
);
};
@@ -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 (
<RecordTableBodyContextProvider
value={{
@@ -64,6 +70,7 @@ export const RecordTableNoRecordGroupBodyContextProvider = ({
onCloseTableCell: handleCloseTableCell,
onMoveHoverToCurrentCell: handleMoveHoverToCurrentCell,
onActionMenuDropdownOpened: handleActionMenuDropdown,
hasUserSelectedAllRows,
}}
>
{children}
@@ -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) => {
<RecordTableVirtualizedBodyPlaceholder />
{virtualRowIndices.map((virtualRowIndex) => {
return (
<RecordTableRow
key={recordId}
recordId={recordId}
rowIndexForFocus={rowIndex}
rowIndexForDrag={rowIndex}
isFirstRowOfGroup={rowIndex === 0}
<RecordTableRowVirtualizedContainer
key={virtualRowIndex}
virtualIndex={virtualRowIndex}
/>
);
})}
<RecordTableBodyFetchMoreLoader />
<RecordTableBodyDroppablePlaceholder />
<RecordTableAddNew />
<RecordTableNoRecordGroupAddNew />
<RecordTableVirtualizedDebugHelper />
</>
);
};
@@ -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;
@@ -188,7 +188,6 @@ const meta: Meta = {
mockPerformance.entityValue.__typename.toLocaleLowerCase(),
}) + mockPerformance.recordId,
isSelected: false,
inView: true,
}}
>
<RecordTableRowDraggableContextProvider
@@ -1 +0,0 @@
export const ROW_HEIGHT = 32;
@@ -15,6 +15,7 @@ export type RecordTableBodyContextProps = {
event: React.MouseEvent,
recordId: string,
) => void;
hasUserSelectedAllRows?: boolean;
};
export const [
@@ -6,7 +6,6 @@ export type RecordTableRowContextValue = {
recordId: string;
rowIndex: number;
isSelected: boolean;
inView: boolean;
isRecordReadOnly?: boolean;
};
@@ -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,
],
);
};
@@ -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,
@@ -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 };
};
@@ -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 (
<Droppable
droppableId={recordGroupId ?? v4Persistable}
isDropDisabled={isDropDisabled}
>
{(provided) => (
<>
<RecordTableBody
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
<RecordTableBodyDroppableContextProvider
value={{ droppablePlaceholder: provided.placeholder }}
>
{children}
</RecordTableBodyDroppableContextProvider>
</RecordTableBody>
</>
)}
</Droppable>
);
};
@@ -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 (
<div ref={tbodyRef}>
<div>
<StyledText>Loading more...</StyledText>
</div>
<div />
</div>
);
};
@@ -14,7 +14,7 @@ export const RecordTableBodyLoading = () => {
return (
<RecordTableBody>
{Array.from({ length: 50 }).map((_, rowIndex) => (
{Array.from({ length: 80 }).map((_, rowIndex) => (
<RecordTableRowContextProvider
key={rowIndex}
value={{
@@ -23,7 +23,6 @@ export const RecordTableBodyLoading = () => {
recordId: `${rowIndex}`,
rowIndex,
isSelected: false,
inView: true,
}}
>
<RecordTableRowDraggableContextProvider
@@ -12,10 +12,10 @@ import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useEndRecordDrag } from '@/object-record/record-drag/shared/hooks/useEndRecordDrag';
import { useStartRecordDrag } from '@/object-record/record-drag/shared/hooks/useStartRecordDrag';
import { useRecordTableDragOperations } from '@/object-record/record-drag/table/hooks/useRecordTableDragOperations';
import { useRecordTableWithoutGroupDragOperations } from '@/object-record/record-drag/table/hooks/useRecordTableWithoutGroupDragOperations';
import { selectedRowIdsComponentSelector } from '../../states/selectors/selectedRowIdsComponentSelector';
export const RecordTableBodyDragDropContextProvider = ({
export const RecordTableBodyNoRecordGroupDragDropContextProvider = ({
children,
}: {
children: ReactNode;
@@ -29,7 +29,8 @@ export const RecordTableBodyDragDropContextProvider = ({
const { startDrag } = useStartRecordDrag('table', recordTableId);
const { endDrag } = useEndRecordDrag('table', recordTableId);
const { processDragOperation } = useRecordTableDragOperations();
const { processDragOperationWithoutGroup } =
useRecordTableWithoutGroupDragOperations();
const handleDragStart = useRecoilCallback(
({ snapshot }) =>
@@ -46,10 +47,10 @@ export const RecordTableBodyDragDropContextProvider = ({
const handleDragEnd = useRecoilCallback(
() => (result: DropResult) => {
processDragOperation(result);
processDragOperationWithoutGroup(result);
endDrag();
},
[endDrag, processDragOperation],
[endDrag, processDragOperationWithoutGroup],
);
return (
@@ -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 (
<Droppable
droppableId={v4Persistable}
isDropDisabled={isDropDisabled}
mode={'virtual'}
renderClone={(draggableProvided, draggableSnapshot, rubric) => (
<RecordTableBodyVirtualizedDraggableClone
draggableProvided={draggableProvided}
draggableSnapshot={draggableSnapshot}
rubric={rubric}
/>
)}
>
{(provided) => (
<RecordTableBody
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
<RecordTableBodyDroppableContextProvider
value={{ droppablePlaceholder: provided.placeholder }}
>
{children}
</RecordTableBodyDroppableContextProvider>
</RecordTableBody>
)}
</Droppable>
);
};
@@ -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 (
<Droppable
droppableId={recordGroupId}
isDropDisabled={isDropDisabled}
mode={'standard'}
>
{(provided) => (
<RecordTableBody
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
<RecordTableBodyDroppableContextProvider
value={{ droppablePlaceholder: provided.placeholder }}
>
{children}
</RecordTableBodyDroppableContextProvider>
</RecordTableBody>
)}
</Droppable>
);
};
@@ -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 (
<StyledRowDraggableCloneCSSBridge
lastColumnWidth={lastColumnWidth}
visibleRecordFields={visibleRecordFields}
>
<RecordTableTr
recordId={recordId}
focusIndex={realIndex}
ref={draggableProvided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
style={{
...draggableProvided.draggableProps.style,
background: draggableSnapshot.isDragging
? theme.background.transparent.light
: undefined,
borderColor: draggableSnapshot.isDragging
? `${theme.border.color.medium}`
: 'transparent',
opacity: isSecondaryDragged ? 0.3 : undefined,
}}
isDragging={draggableSnapshot.isDragging}
data-testid={`row-id-${recordId}`}
data-virtualized-id={recordId}
data-selectable-id={recordId}
onClick={() => {}}
isFirstRowOfGroup={false}
>
<RecordTableRowDraggableContextProvider
value={{
isDragging: draggableSnapshot.isDragging,
dragHandleProps: draggableProvided.dragHandleProps,
}}
>
<RecordTableCellDragAndDrop />
<RecordTableCellCheckbox />
<RecordTableFieldsCells />
<RecordTablePlusButtonCellPlaceholder />
<RecordTableLastEmptyCell />
<RecordTableRowMultiDragPreview
isDragging={draggableSnapshot.isDragging}
/>
</RecordTableRowDraggableContextProvider>
</RecordTableTr>
</StyledRowDraggableCloneCSSBridge>
);
};
@@ -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 <RecordTableBodyLoading />;
}
return (
<RecordTableNoRecordGroupBodyContextProvider>
<RecordTableBodyDragDropContextProvider>
<RecordTableBodyDroppable>
<RecordTableBodyNoRecordGroupDragDropContextProvider>
<RecordTableBodyNoRecordGroupDroppable>
<RecordTableNoRecordGroupRows />
<RecordTableCellPortals />
</RecordTableBodyDroppable>
{!isRecordTableInitialLoading && allRecordIds.length > 0 && (
</RecordTableBodyNoRecordGroupDroppable>
{!isRecordTableInitialLoading && recordTableHasRecords && (
<RecordTableAggregateFooter />
)}
</RecordTableBodyDragDropContextProvider>
<RecordTableVirtualizedRowTreadmillEffect />
<RecordTableVirtualizedDataChangedEffect />
</RecordTableBodyNoRecordGroupDragDropContextProvider>
</RecordTableNoRecordGroupBodyContextProvider>
);
};
@@ -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 <></>;
};
@@ -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);
}
@@ -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}
>
<RecordGroupContext.Provider value={{ recordGroupId }}>
<RecordTableBodyDroppable recordGroupId={recordGroupId}>
<RecordTableBodyRecordGroupDroppable
recordGroupId={recordGroupId}
>
<RecordTableRecordGroupSection />
<RecordTableRecordGroupRows />
{index === 0 && <RecordTableCellPortals />}
</RecordTableBodyDroppable>
</RecordTableBodyRecordGroupDroppable>
</RecordGroupContext.Provider>
</RecordTableRecordGroupBodyContextProvider>
))}
@@ -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 (
<StyledRecordTableTd
isSelected={hasUserSelectedAllRows}
hasRightBorder={false}
widthClassName={RECORD_TABLE_COLUMN_CHECKBOX_WIDTH_CLASS_NAME}
>
<StyledContainer data-select-disable>
<Checkbox hoverable checked={hasUserSelectedAllRows === true} />
</StyledContainer>
</StyledRecordTableTd>
);
};
@@ -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 (
<RecordTableCellStyleWrapper
widthClassName={getRecordTableColumnFieldWidthClassName(recordFieldIndex)}
isSelected={isSelected}
>
<RecordTableCellSkeletonLoader />
<StyledStaticCellSkeleton theme={theme} />
</RecordTableCellStyleWrapper>
);
};
@@ -50,7 +50,6 @@ export const RecordTableCellPortalContexts = ({
recordId,
rowIndex: position.row,
isSelected: false,
inView: true,
pathToShowPage:
getBasePathToShowPage({
objectNameSingular: objectMetadataItem.nameSingular,
@@ -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;
@@ -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 (
<RecordTableCellStyleWrapper
isSelected={isSelected}
hasRightBorder={false}
widthClassName={RECORD_TABLE_COLUMN_ADD_COLUMN_BUTTON_WIDTH_CLASS_NAME}
/>
);
};
@@ -8,7 +8,6 @@ export const recordTableRowContextValue: RecordTableRowContextValue = {
recordId: 'recordId',
pathToShowPage: '/',
objectNameSingular: 'objectNameSingular',
inView: true,
};
export const recordTableRowDraggableContextValue: RecordTableRowDraggableContextValue = {
@@ -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 ? (
<RecordTableCellsVisible />
) : (
<RecordTableCellsEmpty />
);
};
@@ -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) => (
<>
<RecordTableTrEffect recordId={recordId} />
<RecordTableTr
recordId={recordId}
focusIndex={focusIndex}
@@ -7,7 +7,7 @@ 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';
import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
@@ -51,7 +51,6 @@ export const RecordTableDraggableTrFirstRowOfGroup = ({
>
{(draggableProvided, draggableSnapshot) => (
<>
<RecordTableTrEffect recordId={recordId} />
<RecordTableTr
recordId={recordId}
focusIndex={focusIndex}
@@ -10,7 +10,7 @@ import { getRecordTableColumnFieldWidthClassName } from '@/object-record/record-
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
export const RecordTableCellsVisible = () => {
export const RecordTableFieldsCells = () => {
const { isSelected, rowIndex } = useRecordTableRowContextOrThrow();
const { isDragging } = useRecordTableRowDraggableContextOrThrow();
@@ -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 = ({
)}
<RecordTableCellDragAndDrop />
<RecordTableCellCheckbox />
<RecordTableCells />
<RecordTableFieldsCells />
<RecordTablePlusButtonCellPlaceholder />
<RecordTableLastEmptyCell />
<ListenRecordUpdatesEffect
@@ -77,7 +78,7 @@ export const RecordTableRow = ({
)}
<RecordTableCellDragAndDrop />
<RecordTableCellCheckbox />
<RecordTableCells />
<RecordTableFieldsCells />
<RecordTablePlusButtonCellPlaceholder />
<RecordTableLastEmptyCell />
<ListenRecordUpdatesEffect
@@ -36,26 +36,17 @@ const StyledTr = styled.div<{
display: flex;
flex-direction: row;
&[data-next-row-active-or-focused='true'] {
div.table-cell,
div.table-cell-0-0 {
border-bottom: none;
}
}
&[data-focused='true'] {
&[data-focused='true'],
&[data-active='true'] {
div.table-cell,
div.table-cell-0-0 {
&:not(:first-of-type) {
border-bottom: 1px solid ${({ theme }) => 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;
@@ -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<HTMLDivElement, RecordTableTrProps>(
recordId,
);
const isRowVisible = useRecoilComponentFamilyValue(
isRowVisibleComponentFamilyState,
recordId,
);
const isActive = useRecoilComponentFamilyValue(
isRecordTableRowActiveComponentFamilyState,
focusIndex,
@@ -60,15 +54,14 @@ export const RecordTableTr = forwardRef<HTMLDivElement, RecordTableTrProps>(
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<HTMLDivElement, RecordTableTrProps>(
}) + recordId,
objectNameSingular: objectMetadataItem.nameSingular,
isSelected: currentRowSelected,
inView: isRowVisible,
isRecordReadOnly,
}}
>
@@ -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 <></>;
};
@@ -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,
});
@@ -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 <></>;
};
@@ -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 (
<StyledVirtualizedRowContainer
id={`row-virtual-index-${virtualIndex}`}
pixelsFromTop={pixelsFromTop}
>
{TABLE_VIRTUALIZATION_DEBUG_ACTIVATED && (
<RecordTableRowVirtualizedDebugRowHelper virtualIndex={virtualIndex} />
)}
<RecordTableRowVirtualizedRouterLevel1 realIndex={realIndex} />
</StyledVirtualizedRowContainer>
);
};
@@ -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 (
<div
style={{
position: 'absolute',
left: 250,
top: 5,
zIndex: 20,
color: 'darkblue',
backgroundColor: 'white',
border: '1px solid blue',
padding: 2,
display: 'flex',
maxHeight: 16,
overflow: 'clip',
}}
>
<StyledDebugColumn width={70}>virtual :{virtualIndex}</StyledDebugColumn>
<StyledDebugColumn width={70}>real :{realIndex}</StyledDebugColumn>
<StyledDebugColumn width={100}>pos :{position}</StyledDebugColumn>
<StyledDebugColumn width={80}>
px:
{pixelsFromTop}
</StyledDebugColumn>
<StyledDebugColumn width={150}>
<b>{labelIdentifier}</b>
</StyledDebugColumn>
<StyledDebugColumn width={300}>
id:
{recordId}
</StyledDebugColumn>
</div>
);
};
@@ -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 <RecordTableRowVirtualizedSkeleton />;
}
return (
<RecordTableDraggableTr
recordId={recordId}
draggableIndex={realIndex}
focusIndex={realIndex}
>
{isRowFocusActive && isFocused && (
<>
<RecordTableRowHotkeyEffect />
<RecordTableRowArrowKeysEffect />
</>
)}
<RecordTableCellDragAndDrop />
<RecordTableCellCheckbox />
<RecordTableFieldsCells />
<RecordTablePlusButtonCellPlaceholder />
<RecordTableLastEmptyCell />
<ListenRecordUpdatesEffect
objectNameSingular={objectNameSingular}
recordId={recordId}
listenedFields={listenedFields}
/>
</RecordTableDraggableTr>
);
};
@@ -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 <RecordTableRowVirtualizedSkeleton />;
}
return <RecordTableRowVirtualizedRouterLevel2 realIndex={realIndex} />;
};
@@ -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 <RecordTableRowVirtualizedSkeleton />;
}
return <RecordTableRowVirtualizedFullData realIndex={realIndex} />;
};

Some files were not shown because too many files have changed in this diff Show More