From 2691222d5fba9f340fe18b93c50ca9363d552dc8 Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Tue, 2 Dec 2025 11:09:29 +0100 Subject: [PATCH] =?UTF-8?q?Improve=20board=20experience=20=F0=9F=96=BC?= =?UTF-8?q?=EF=B8=8F=20(#16063)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR improves the general UX and DX of boards, by modifying the query effect to only use paged group by queries. In this PR we implement two more things in the backend for group by queries : - Fixed ORDER BY in the PARTITION BY sub-query (this wasn't working because it was applied in the main query, so it sorted randomly picked records, which was a correct sort on an incorrect dataset returned by the sub-query) - Added offset paging in PARTITION BY Miscellaneous, various bug fixes and improvements along the way : - Throttled loading of cards to avoid React freeze - Handling of drag & drop - Handling of create / delete / update - Reworked skeleton (the library slows down a lot with hundreds of skeleton for a spinning effect that is hardly noticed) - Fixed refetch of aggregate queries (I included the new group by aggregates query we use in the existing refetch mechanism) - Re-trigger queries on filters and sorts changes - Unselect all record ids when deleting / restoring / detroying - Fetch only groups that still have records to lighten the group by query. # What remains to be done This is still a naïve fetch more implementation that will work for a few fetch more rounds, but if you scroll and load say 200 cards per column on a board, React will re-render all 200 cards of each column each time. We would probably need to virtualize the board with paged queries as we did for the table, this could be done after this PR but seems less urgent. What's nice is that this new query pattern is well designed for virtualization also, drawing from our experience with table virtualization, and adapted to a multi-column request pattern, like a 2:2 matrix of records, for our boards. So the remaining work would be to design a UI solution for virtualizing this matrix of records, which could be quite different from our table virtualization mechanism. --- .../DeleteMultipleRecordsAction.tsx | 5 + .../DestroyMultipleRecordsAction.tsx | 4 + .../RestoreMultipleRecordsAction.tsx | 4 + .../components/DeleteSingleRecordAction.tsx | 6 + .../components/DestroySingleRecordAction.tsx | 5 + .../components/RestoreSingleRecordAction.tsx | 4 + .../effect-components/PageChangeEffect.tsx | 9 +- .../graphql/types/RecordGqlConnection.ts | 6 +- .../useRefetchAggregateQueries.test.tsx | 6 +- .../hooks/useRefetchAggregateQueries.ts | 7 +- .../ObjectOptionsDropdownCustomView.tsx | 4 +- ...tionsDropdownHiddenRecordGroupsContent.tsx | 4 +- .../ObjectOptionsDropdownLayoutContent.tsx | 4 +- ...ptionsDropdownRecordGroupFieldsContent.tsx | 4 +- ...jectOptionsDropdownRecordGroupsContent.tsx | 4 +- .../utils/generateGroupByAggregateQuery.ts | 7 +- .../generateGroupsRecordsGroupByQuery.ts | 62 +++++ .../utils/getGroupByAggregateQueryName.ts | 9 + .../record-board/components/RecordBoard.tsx | 35 +-- .../RecordBoardBodyEscapeHotkeyEffect.tsx | 9 +- .../RecordBoardClickOutsideEffect.tsx | 7 +- .../components/RecordBoardColumns.tsx | 31 ++- .../components/RecordBoardContainer.tsx} | 27 +- .../components/RecordBoardDragDropContext.tsx | 1 - .../components/RecordBoardEffects.tsx | 19 ++ ...rdBoardFetchMoreInViewTriggerComponent.tsx | 76 ++++++ .../components/RecordBoardQueryEffect.tsx | 88 +++++++ .../RecordBoardSelectRecordsEffect.tsx} | 9 +- .../RecordBoardColumnPaddingAndBorderWidth.ts | 1 + .../constants/RecordBoardColumnWidth.ts | 1 + ...aitTimeInMillisecondsToAvoidReactFreeze.ts | 1 + .../constants/RecordBoardQueryPageSize.ts | 1 + .../hooks/useRecordBoardCardHotkeys.ts | 9 +- .../hooks/useRecordBoardSelection.ts | 23 -- ...useRemoveSelectedRecordsFromRecordBoard.ts | 101 ++++++++ .../hooks/useResetRecordBoardSelection.ts | 60 +++++ .../record-board/hooks/useSelectAllCards.ts | 9 +- .../hooks/useSetRecordIdsForColumn.ts | 4 +- .../hooks/useTriggerRecordBoardFetchMore.ts | 231 ++++++++++++++++++ .../useTriggerRecordBoardInitialQuery.ts | 183 ++++++++++++++ ...BoardColumnCardContainerSkeletonLoader.tsx | 63 +++-- .../RecordBoardColumnCardsContainer.tsx | 70 ++---- .../RecordBoardColumnFetchMoreLoader.tsx | 44 ---- .../components/RecordBoardColumnHeader.tsx | 69 +++++- .../RecordBoardColumnLoadingSkeletonCards.tsx | 27 ++ ...umberOfCardsPerColumnForSkeletonLoading.ts | 13 - ...ecordBoardQueryIdentifierComponentState.ts | 9 + ...CurrentGroupByQueryOffsetComponentState.ts | 9 + ...recordBoardIsFetchingMoreComponentState.ts | 9 + ...ecordBoardShouldFetchMoreComponentState.ts | 9 + .../hooks/useProcessBoardCardDrop.ts | 32 +-- .../hooks/useProcessCalendarCardDrop.ts | 1 + .../useProcessTableWithoutGroupRecordDrop.ts | 6 + .../hooks/useUpdateDroppedRecordOnBoard.ts | 170 +++++++++++++ .../utils/__tests__/processMultiDrag.test.ts | 4 + .../utils/__tests__/processSingleDrag.test.ts | 6 + .../record-drag/utils/processGroupDrop.ts | 2 + .../record-drag/utils/processMultiDrag.ts | 3 + .../record-drag/utils/processSingleDrag.ts | 3 + .../hooks/useReorderVisibleRecordFields.ts | 3 + .../hooks/useRecordGroupActions.ts | 12 +- .../record-group/hooks/useSetRecordGroups.ts | 8 +- .../recordGroupFieldMetadataComponentState.ts | 11 - .../RecordIndexBoardColumnLoaderEffect.tsx | 72 ------ .../components/RecordIndexBoardDataLoader.tsx | 47 ---- .../components/RecordIndexContainer.tsx | 13 +- .../RecordIndexGroupAggregateQueryEffect.tsx | 37 ++- .../RecordIndexGroupAggregatesDataLoader.tsx | 2 +- .../hooks/useRecordIndexLazyFetchRecords.ts | 4 +- .../hooks/useLoadRecordIndexBoardColumn.ts | 131 ---------- .../hooks/useLoadRecordIndexStates.ts | 2 +- ...useRecordIndexGroupCommonQueryVariables.ts | 69 ++++++ .../useRecordIndexGroupsRecordsLazyGroupBy.ts | 71 ++++++ ...dIndexGroupFieldMetadataComponentState.ts} | 8 +- ...GroupsAreInInitialLoadingComponentState.ts | 9 + .../components/RecordTableWithWrappers.tsx | 2 + .../generateGroupByQuery.test.ts.snap | 6 +- ...uteNewPositionOfRecordWithPosition.test.ts | 115 +++++++-- .../computeNewPositionOfDraggedRecord.ts | 12 +- .../computeNewPositionsOfDraggedRecords.ts | 8 +- .../common-group-by-query-runner.service.ts | 1 + .../common/types/common-query-args.type.ts | 1 + .../graphql-query.parser.ts | 32 +++ .../services/group-by-with-records.service.ts | 84 ++++++- .../workspace-resolvers-builder.interface.ts | 1 + .../utils/get-resolver-args.util.ts | 4 + 86 files changed, 1782 insertions(+), 612 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-aggregate/utils/generateGroupsRecordsGroupByQuery.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-aggregate/utils/getGroupByAggregateQueryName.ts rename packages/twenty-front/src/modules/object-record/{record-index/components/RecordIndexBoardContainer.tsx => record-board/components/RecordBoardContainer.tsx} (81%) create mode 100644 packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardEffects.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardFetchMoreInViewTriggerComponent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardQueryEffect.tsx rename packages/twenty-front/src/modules/object-record/{record-index/components/RecordIndexBoardDataLoaderEffect.tsx => record-board/components/RecordBoardSelectRecordsEffect.tsx} (84%) create mode 100644 packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardColumnPaddingAndBorderWidth.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardColumnWidth.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardFetchMoreThrottlingWaitTimeInMillisecondsToAvoidReactFreeze.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardQueryPageSize.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useResetRecordBoardSelection.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardFetchMore.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnLoadingSkeletonCards.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/getNumberOfCardsPerColumnForSkeletonLoading.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/lastRecordBoardQueryIdentifierComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/recordBoardCurrentGroupByQueryOffsetComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/recordBoardIsFetchingMoreComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/states/recordBoardShouldFetchMoreComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-drag/hooks/useUpdateDroppedRecordOnBoard.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-group/states/recordGroupFieldMetadataComponentState.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoader.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupsRecordsLazyGroupBy.ts rename packages/twenty-front/src/modules/object-record/record-index/states/{recordIndexGroupFieldMetadataItemComponentState.ts => recordIndexGroupFieldMetadataComponentState.ts} (54%) create mode 100644 packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupsAreInInitialLoadingComponentState.ts diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/components/DeleteMultipleRecordsAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/components/DeleteMultipleRecordsAction.tsx index 0f7deb15ac..83b4697f30 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/components/DeleteMultipleRecordsAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/components/DeleteMultipleRecordsAction.tsx @@ -10,6 +10,7 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters'; import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; import { useIncrementalDeleteManyRecords } from '@/object-record/hooks/useIncrementalDeleteManyRecords'; +import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; @@ -47,6 +48,9 @@ export const DeleteMultipleRecordsAction = () => { contextStoreAnyFieldFilterValueComponentState, ); + const { removeSelectedRecordsFromRecordBoard } = + useRemoveSelectedRecordsFromRecordBoard(recordIndexId); + const { filterValueDependencies } = useFilterValueDependencies(); const graphqlFilter = computeContextStoreFilters({ @@ -85,6 +89,7 @@ export const DeleteMultipleRecordsAction = () => { }; const handleDeleteClick = async () => { + removeSelectedRecordsFromRecordBoard(); resetTableRowSelection(); await incrementalDeleteManyRecords(); }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/components/DestroyMultipleRecordsAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/components/DestroyMultipleRecordsAction.tsx index ba69537ebb..e2de61d2df 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/components/DestroyMultipleRecordsAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/components/DestroyMultipleRecordsAction.tsx @@ -10,6 +10,7 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters'; import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; import { useIncrementalDestroyManyRecords } from '@/object-record/hooks/useIncrementalDestroyManyRecords'; +import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; @@ -32,6 +33,8 @@ export const DestroyMultipleRecordsAction = () => { } const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + const { removeSelectedRecordsFromRecordBoard } = + useRemoveSelectedRecordsFromRecordBoard(recordIndexId); const contextStoreTargetedRecordsRule = useRecoilComponentValue( contextStoreTargetedRecordsRuleComponentState, @@ -93,6 +96,7 @@ export const DestroyMultipleRecordsAction = () => { }; const handleDestroyClick = async () => { + removeSelectedRecordsFromRecordBoard(); resetTableRowSelection(); await incrementalDestroyManyRecords(); }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/components/RestoreMultipleRecordsAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/components/RestoreMultipleRecordsAction.tsx index 2e045d1eef..37975f9ea5 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/components/RestoreMultipleRecordsAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/components/RestoreMultipleRecordsAction.tsx @@ -8,6 +8,7 @@ import { computeContextStoreFilters } from '@/context-store/utils/computeContext import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords'; +import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; @@ -28,6 +29,8 @@ export const RestoreMultipleRecordsAction = () => { } const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + const { removeSelectedRecordsFromRecordBoard } = + useRemoveSelectedRecordsFromRecordBoard(recordIndexId); const { restoreManyRecords } = useRestoreManyRecords({ objectNameSingular: objectMetadataItem.nameSingular, @@ -75,6 +78,7 @@ export const RestoreMultipleRecordsAction = () => { }); const handleRestoreClick = async () => { + removeSelectedRecordsFromRecordBoard(); const recordsToRestore = await fetchAllRecordIds(); const recordIdsToRestore = recordsToRestore.map((record) => record.id); diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/DeleteSingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/DeleteSingleRecordAction.tsx index 99597b27bc..4564f16a3c 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/DeleteSingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/DeleteSingleRecordAction.tsx @@ -3,6 +3,7 @@ import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite'; import { useFavorites } from '@/favorites/hooks/useFavorites'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; +import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; import { isDefined } from 'twenty-shared/utils'; @@ -15,6 +16,9 @@ export const DeleteSingleRecordAction = () => { const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + const { removeSelectedRecordsFromRecordBoard } = + useRemoveSelectedRecordsFromRecordBoard(recordIndexId); + const { deleteOneRecord } = useDeleteOneRecord({ objectNameSingular: objectMetadataItem.nameSingular, }); @@ -23,6 +27,8 @@ export const DeleteSingleRecordAction = () => { const { deleteFavorite } = useDeleteFavorite(); const handleDeleteClick = async () => { + removeSelectedRecordsFromRecordBoard(); + resetTableRowSelection(); const foundFavorite = favorites?.find( diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/DestroySingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/DestroySingleRecordAction.tsx index 1e89f27750..0f59ffe752 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/DestroySingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/DestroySingleRecordAction.tsx @@ -1,6 +1,7 @@ import { ActionModal } from '@/action-menu/actions/components/ActionModal'; import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow'; import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord'; +import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; import { t } from '@lingui/core/macro'; @@ -17,11 +18,15 @@ export const DestroySingleRecordAction = () => { const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + const { removeSelectedRecordsFromRecordBoard } = + useRemoveSelectedRecordsFromRecordBoard(recordIndexId); + const { destroyOneRecord } = useDestroyOneRecord({ objectNameSingular: objectMetadataItem.nameSingular, }); const handleDeleteClick = async () => { + removeSelectedRecordsFromRecordBoard(); resetTableRowSelection(); await destroyOneRecord(recordId); diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/RestoreSingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/RestoreSingleRecordAction.tsx index d1341c0f3a..2cf123bc5c 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/RestoreSingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/components/RestoreSingleRecordAction.tsx @@ -1,6 +1,7 @@ import { ActionModal } from '@/action-menu/actions/components/ActionModal'; import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow'; import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords'; +import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard'; import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; import { t } from '@lingui/core/macro'; @@ -12,12 +13,15 @@ export const RestoreSingleRecordAction = () => { const recordId = useSelectedRecordIdOrThrow(); const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId); + const { removeSelectedRecordsFromRecordBoard } = + useRemoveSelectedRecordsFromRecordBoard(recordIndexId); const { restoreManyRecords } = useRestoreManyRecords({ objectNameSingular: objectMetadataItem.nameSingular, }); const handleRestoreClick = async () => { + removeSelectedRecordsFromRecordBoard(); resetTableRowSelection(); await restoreManyRecords({ diff --git a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx index 3bcfe8c126..369b9c2990 100644 --- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx @@ -24,7 +24,7 @@ import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural'; import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard'; import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard'; -import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; +import { useResetRecordBoardSelection } from '@/object-record/record-board/hooks/useResetRecordBoardSelection'; import { useResetFocusStackToRecordIndex } from '@/object-record/record-index/hooks/useResetFocusStackToRecordIndex'; import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection'; import { useActiveRecordTableRow } from '@/object-record/record-table/hooks/useActiveRecordTableRow'; @@ -82,7 +82,8 @@ export const PageChangeEffect = () => { const { unfocusRecordTableRow } = useFocusedRecordTableRow(recordIndexId); const { deactivateRecordTableRow } = useActiveRecordTableRow(recordIndexId); - const { resetRecordSelection } = useRecordBoardSelection(recordIndexId); + const { resetRecordBoardSelection } = + useResetRecordBoardSelection(recordIndexId); const { deactivateBoardCard } = useActiveRecordBoardCard(recordIndexId); const { unfocusBoardCard } = useFocusedRecordBoardCard(recordIndexId); @@ -141,7 +142,7 @@ export const PageChangeEffect = () => { deactivateRecordTableRow(); } if (contextStoreCurrentViewType === ContextStoreViewType.Kanban) { - resetRecordSelection(); + resetRecordBoardSelection(); deactivateBoardCard(); unfocusBoardCard(); } @@ -304,7 +305,7 @@ export const PageChangeEffect = () => { resetTableRowSelection, unfocusRecordTableRow, deactivateRecordTableRow, - resetRecordSelection, + resetRecordBoardSelection, deactivateBoardCard, unfocusBoardCard, resetFocusStackToRecordIndex, diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts index 742e90701f..00f3bf870b 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlConnection.ts @@ -5,9 +5,9 @@ export type RecordGqlConnection = { __typename?: string; edges: RecordGqlEdge[]; pageInfo: { - __typename?: string; - hasNextPage?: boolean; - hasPreviousPage?: boolean; + __typename?: Nullable; + hasNextPage?: Nullable; + hasPreviousPage?: Nullable; startCursor?: Nullable; endCursor?: Nullable; totalCount?: number; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useRefetchAggregateQueries.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useRefetchAggregateQueries.test.tsx index c99bc51c7d..a429b42aff 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useRefetchAggregateQueries.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useRefetchAggregateQueries.test.tsx @@ -1,5 +1,6 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries'; +import { getGroupByAggregateQueryName } from '@/object-record/record-aggregate/utils/getGroupByAggregateQueryName'; import { getAggregateQueryName } from '@/object-record/utils/getAggregateQueryName'; import { renderHook } from '@testing-library/react'; @@ -22,6 +23,9 @@ describe('useRefetchAggregateQueries', () => { // Arrange const objectMetadataNamePlural = 'opportunities'; const expectedQueryName = getAggregateQueryName(objectMetadataNamePlural); + const expectedQueryNameGroupBy = getGroupByAggregateQueryName({ + objectMetadataNamePlural, + }); // Act const { result } = renderHook(() => @@ -32,7 +36,7 @@ describe('useRefetchAggregateQueries', () => { // Assert expect(mockRefetchQueries).toHaveBeenCalledTimes(1); expect(mockRefetchQueries).toHaveBeenCalledWith({ - include: [expectedQueryName], + include: [expectedQueryName, expectedQueryNameGroupBy], }); }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useRefetchAggregateQueries.ts b/packages/twenty-front/src/modules/object-record/hooks/useRefetchAggregateQueries.ts index ff41297296..8b667e3da3 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useRefetchAggregateQueries.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useRefetchAggregateQueries.ts @@ -1,4 +1,5 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { getGroupByAggregateQueryName } from '@/object-record/record-aggregate/utils/getGroupByAggregateQueryName'; import { getAggregateQueryName } from '@/object-record/utils/getAggregateQueryName'; export const useRefetchAggregateQueries = ({ @@ -11,8 +12,12 @@ export const useRefetchAggregateQueries = ({ const refetchAggregateQueries = async () => { const queryName = getAggregateQueryName(objectMetadataNamePlural); + const groupByAggregateQueryName = getGroupByAggregateQueryName({ + objectMetadataNamePlural, + }); + await apolloCoreClient.refetchQueries({ - include: [queryName], + include: [queryName, groupByAggregateQueryName], }); }; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCustomView.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCustomView.tsx index e3eae83640..bb10ee9965 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCustomView.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCustomView.tsx @@ -2,8 +2,8 @@ import { ObjectOptionsDropdownMenuViewName } from '@/object-record/object-option import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId'; import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown'; import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard'; -import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; @@ -55,7 +55,7 @@ export const ObjectOptionsDropdownCustomView = ({ : null; const recordGroupFieldMetadata = useRecoilComponentValue( - recordGroupFieldMetadataComponentState, + recordIndexGroupFieldMetadataItemComponentState, ); const calendarFieldMetadata = currentView?.calendarFieldMetadataId diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenRecordGroupsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenRecordGroupsContent.tsx index c5aab81541..fe9af1a410 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenRecordGroupsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenRecordGroupsContent.tsx @@ -5,8 +5,8 @@ import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObje import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown'; import { RecordGroupsVisibilityDropdownSection } from '@/object-record/record-group/components/RecordGroupsVisibilityDropdownSection'; import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility'; -import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; import { hiddenRecordGroupIdsComponentSelector } from '@/object-record/record-group/states/selectors/hiddenRecordGroupIdsComponentSelector'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; @@ -32,7 +32,7 @@ export const ObjectOptionsDropdownHiddenRecordGroupsContent = () => { } = useObjectOptionsDropdown(); const recordGroupFieldMetadata = useRecoilComponentValue( - recordGroupFieldMetadataComponentState, + recordIndexGroupFieldMetadataItemComponentState, ); const hiddenRecordGroupIds = useRecoilComponentValue( diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx index bd42b0fd5e..275a37b397 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownLayoutContent.tsx @@ -1,8 +1,8 @@ import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId'; import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown'; import { useSetViewTypeFromLayoutOptionsMenu } from '@/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu'; -import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; @@ -65,7 +65,7 @@ export const ObjectOptionsDropdownLayoutContent = () => { recordIndexCalendarLayoutState, ); const recordGroupFieldMetadata = useRecoilComponentValue( - recordGroupFieldMetadataComponentState, + recordIndexGroupFieldMetadataItemComponentState, ); const calendarFieldMetadata = currentView?.calendarFieldMetadataId diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupFieldsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupFieldsContent.tsx index c262b3a8ec..95b67ca757 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupFieldsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupFieldsContent.tsx @@ -5,9 +5,9 @@ import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObje import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown'; import { useSearchRecordGroupField } from '@/object-record/object-options-dropdown/hooks/useSearchRecordGroupField'; -import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; import { hiddenRecordGroupIdsComponentSelector } from '@/object-record/record-group/states/selectors/hiddenRecordGroupIdsComponentSelector'; import { useHandleRecordGroupField } from '@/object-record/record-index/hooks/useHandleRecordGroupField'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; @@ -52,7 +52,7 @@ export const ObjectOptionsDropdownRecordGroupFieldsContent = () => { ); const recordGroupFieldMetadata = useRecoilComponentValue( - recordGroupFieldMetadataComponentState, + recordIndexGroupFieldMetadataItemComponentState, ); const { diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx index 6c51f6b163..b65f13d01c 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx @@ -4,9 +4,9 @@ import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropd import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown'; import { RecordGroupsVisibilityDropdownSection } from '@/object-record/record-group/components/RecordGroupsVisibilityDropdownSection'; import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility'; -import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; import { hiddenRecordGroupIdsComponentSelector } from '@/object-record/record-group/states/selectors/hiddenRecordGroupIdsComponentSelector'; import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState'; import { recordIndexShouldHideEmptyRecordGroupsComponentState } from '@/object-record/record-index/states/recordIndexShouldHideEmptyRecordGroupsComponentState'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; @@ -47,7 +47,7 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => { const { currentView } = useGetCurrentViewOnly(); const recordGroupFieldMetadata = useRecoilComponentValue( - recordGroupFieldMetadataComponentState, + recordIndexGroupFieldMetadataItemComponentState, ); const visibleRecordGroupIds = useRecoilComponentFamilyValue( diff --git a/packages/twenty-front/src/modules/object-record/record-aggregate/utils/generateGroupByAggregateQuery.ts b/packages/twenty-front/src/modules/object-record/record-aggregate/utils/generateGroupByAggregateQuery.ts index f6c74c30ff..45f2217663 100644 --- a/packages/twenty-front/src/modules/object-record/record-aggregate/utils/generateGroupByAggregateQuery.ts +++ b/packages/twenty-front/src/modules/object-record/record-aggregate/utils/generateGroupByAggregateQuery.ts @@ -1,8 +1,9 @@ import gql from 'graphql-tag'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getGroupByAggregateQueryName } from '@/object-record/record-aggregate/utils/getGroupByAggregateQueryName'; +import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName'; import { capitalize } from 'twenty-shared/utils'; -import { getGroupByQueryResultGqlFieldName } from '../../../page-layout/utils/getGroupByQueryResultGqlFieldName'; export const generateGroupByAggregateQuery = ({ objectMetadataItem, @@ -12,7 +13,9 @@ export const generateGroupByAggregateQuery = ({ aggregateOperationGqlFields: string[]; }) => { const capitalizedSingular = capitalize(objectMetadataItem.nameSingular); - const queryName = `${capitalize(objectMetadataItem.namePlural)}GroupBy`; + const queryName = getGroupByAggregateQueryName({ + objectMetadataNamePlural: objectMetadataItem.namePlural, + }); const queryFieldName = getGroupByQueryResultGqlFieldName(objectMetadataItem); return gql` diff --git a/packages/twenty-front/src/modules/object-record/record-aggregate/utils/generateGroupsRecordsGroupByQuery.ts b/packages/twenty-front/src/modules/object-record/record-aggregate/utils/generateGroupsRecordsGroupByQuery.ts new file mode 100644 index 0000000000..efed78be06 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-aggregate/utils/generateGroupsRecordsGroupByQuery.ts @@ -0,0 +1,62 @@ +import gql from 'graphql-tag'; + +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { type RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; +import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName'; +import { type ObjectPermissions } from 'twenty-shared/types'; +import { capitalize } from 'twenty-shared/utils'; + +export const generateGroupsRecordsGroupByQuery = ({ + objectMetadataItems, + objectMetadataItem, + recordGqlFields, + computeReferences, + objectPermissionsByObjectMetadataId, +}: { + objectMetadataItems: ObjectMetadataItem[]; + objectMetadataItem: ObjectMetadataItem; + recordGqlFields: RecordGqlOperationGqlRecordFields; + computeReferences?: boolean; + objectPermissionsByObjectMetadataId: Record< + string, + ObjectPermissions & { objectMetadataId: string } + >; +}) => { + const capitalizedSingular = capitalize(objectMetadataItem.nameSingular); + const queryName = `${capitalize(objectMetadataItem.namePlural)}GroupByRecords`; + const queryFieldName = getGroupByQueryResultGqlFieldName(objectMetadataItem); + + return gql` + query ${queryName}( + $groupBy: [${capitalizedSingular}GroupByInput!]! + $filter: ${capitalizedSingular}FilterInput + $orderByForRecords: [${capitalizedSingular}OrderByInput!] + $limit: Int + $offsetForRecords: Int + ) { + ${queryFieldName}( + groupBy: $groupBy + filter: $filter + orderByForRecords: $orderByForRecords + limit: $limit + offsetForRecords: $offsetForRecords + ) { + groupByDimensionValues + edges { + node ${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems, + objectMetadataItem, + recordGqlFields, + computeReferences, + objectPermissionsByObjectMetadataId, + })} + } + pageInfo { + endCursor + hasNextPage + } + } + } + `; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-aggregate/utils/getGroupByAggregateQueryName.ts b/packages/twenty-front/src/modules/object-record/record-aggregate/utils/getGroupByAggregateQueryName.ts new file mode 100644 index 0000000000..63540472f0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-aggregate/utils/getGroupByAggregateQueryName.ts @@ -0,0 +1,9 @@ +import { capitalize } from 'twenty-shared/utils'; + +export const getGroupByAggregateQueryName = ({ + objectMetadataNamePlural, +}: { + objectMetadataNamePlural: string; +}) => { + return `${capitalize(objectMetadataNamePlural)}GroupByAggregates`; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx index 8eb191d3d9..084af0b214 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx @@ -1,35 +1,24 @@ import styled from '@emotion/styled'; -// Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350 + import { useContext, useRef } from 'react'; -import { RecordBoardClickOutsideEffect } from '@/object-record/record-board/components/RecordBoardClickOutsideEffect'; import { RecordBoardColumns } from '@/object-record/record-board/components/RecordBoardColumns'; import { RecordBoardDragDropContext } from '@/object-record/record-board/components/RecordBoardDragDropContext'; import { RecordBoardDragSelect } from '@/object-record/record-board/components/RecordBoardDragSelect'; +import { RecordBoardEffects } from '@/object-record/record-board/components/RecordBoardEffects'; +import { RecordBoardFetchMoreInViewTriggerComponent } from '@/object-record/record-board/components/RecordBoardFetchMoreInViewTriggerComponent'; import { RecordBoardHeader } from '@/object-record/record-board/components/RecordBoardHeader'; -import { RecordBoardScrollToFocusedCardEffect } from '@/object-record/record-board/components/RecordBoardScrollToFocusedCardEffect'; -import { RecordBoardStickyHeaderEffect } from '@/object-record/record-board/components/RecordBoardStickyHeaderEffect'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; -import { RecordBoardDeactivateBoardCardEffect } from '@/object-record/record-board/record-board-card/components/RecordBoardDeactivateBoardCardEffect'; -import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; const StyledContainer = styled.div` display: flex; flex: 1; - flex-direction: row; + flex-direction: column; min-height: 100%; position: relative; `; -const StyledColumnContainer = styled.div` - display: flex; - - & > *:not(:first-of-type) { - border-left: 1px solid ${({ theme }) => theme.border.color.light}; - } -`; - const StyledContainerContainer = styled.div` display: flex; flex-direction: column; @@ -48,30 +37,24 @@ export const RecordBoard = () => { const boardRef = useRef(null); return ( - + <> - - - + - - - + + - - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect.tsx index ff2adc4caa..287c58c0f3 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect.tsx @@ -3,7 +3,7 @@ import { Key } from 'ts-key-enum'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard'; -import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; +import { useResetRecordBoardSelection } from '@/object-record/record-board/hooks/useResetRecordBoardSelection'; import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector'; import { useResetFocusStackToRecordIndex } from '@/object-record/record-index/hooks/useResetFocusStackToRecordIndex'; import { PageFocusId } from '@/types/PageFocusId'; @@ -13,7 +13,8 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho export const RecordBoardBodyEscapeHotkeyEffect = () => { const { recordBoardId } = useContext(RecordBoardContext); - const { resetRecordSelection } = useRecordBoardSelection(recordBoardId); + const { resetRecordBoardSelection } = + useResetRecordBoardSelection(recordBoardId); const { unfocusBoardCard } = useFocusedRecordBoardCard(recordBoardId); const { resetFocusStackToRecordIndex } = useResetFocusStackToRecordIndex(); @@ -26,9 +27,11 @@ export const RecordBoardBodyEscapeHotkeyEffect = () => { const handleEscape = () => { unfocusBoardCard(); + if (isAtLeastOneRecordSelected) { - resetRecordSelection(); + resetRecordBoardSelection(); } + resetFocusStackToRecordIndex(); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardClickOutsideEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardClickOutsideEffect.tsx index c6c9c3162d..2b8d77ee13 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardClickOutsideEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardClickOutsideEffect.tsx @@ -4,7 +4,7 @@ import { RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-b import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard'; import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard'; -import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; +import { useResetRecordBoardSelection } from '@/object-record/record-board/hooks/useResetRecordBoardSelection'; import { RECORD_BOARD_CARD_CLICK_OUTSIDE_ID } from '@/object-record/record-board/record-board-card/constants/RecordBoardCardClickOutsideId'; import { isDraggingRecordComponentState } from '@/object-record/record-drag/states/isDraggingRecordComponentState'; @@ -25,7 +25,8 @@ export const RecordBoardClickOutsideEffect = () => { const { deactivateBoardCard } = useActiveRecordBoardCard(recordBoardId); const { unfocusBoardCard } = useFocusedRecordBoardCard(recordBoardId); - const { resetRecordSelection } = useRecordBoardSelection(recordBoardId); + const { resetRecordBoardSelection } = + useResetRecordBoardSelection(recordBoardId); useListenClickOutside({ excludedClickOutsideIds: [ @@ -40,7 +41,7 @@ export const RecordBoardClickOutsideEffect = () => { refs: [], callback: () => { if (!isDraggingRecord) { - resetRecordSelection(); + resetRecordBoardSelection(); deactivateBoardCard(); unfocusBoardCard(); } diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardColumns.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardColumns.tsx index fdd9ce7a55..89b0f7ee31 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardColumns.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardColumns.tsx @@ -2,6 +2,15 @@ import { RecordBoardColumn } from '@/object-record/record-board/record-board-col import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; import { ViewType } from '@/views/types/ViewType'; +import styled from '@emotion/styled'; + +const StyledColumnContainer = styled.div` + display: flex; + + & > *:not(:first-of-type) { + border-left: 1px solid ${({ theme }) => theme.border.color.light}; + } +`; export const RecordBoardColumns = () => { const visibleRecordGroupIds = useRecoilComponentFamilyValue( @@ -9,13 +18,17 @@ export const RecordBoardColumns = () => { ViewType.Kanban, ); - return visibleRecordGroupIds.map((recordGroupId, index) => { - return ( - - ); - }); + return ( + + {visibleRecordGroupIds.map((recordGroupId, index) => { + return ( + + ); + })} + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardContainer.tsx similarity index 81% rename from packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx rename to packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardContainer.tsx index 76ac90f68b..10afdca139 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardContainer.tsx @@ -7,23 +7,26 @@ import { RecordBoard } from '@/object-record/record-board/components/RecordBoard import { RecordBoardBodyEscapeHotkeyEffect } from '@/object-record/record-board/components/RecordBoardBodyEscapeHotkeyEffect'; import { RecordBoardHotkeyEffect } from '@/object-record/record-board/components/RecordBoardHotkeyEffect'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; + import { RecordIndexRemoveSortingModal } from '@/object-record/record-index/components/RecordIndexRemoveSortingModal'; import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId'; -import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataItemComponentState'; + import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { isDefined } from 'twenty-shared/utils'; -type RecordIndexBoardContainerProps = { +type RecordBoardContainerProps = { recordBoardId: string; viewBarId: string; objectNameSingular: string; }; -export const RecordIndexBoardContainer = ({ +export const RecordBoardContainer = ({ recordBoardId, objectNameSingular, -}: RecordIndexBoardContainerProps) => { +}: RecordBoardContainerProps) => { const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); @@ -64,12 +67,16 @@ export const RecordIndexBoardContainer = ({ objectPermissions, }} > - - {isRecordIndexRemoveSortingModalOpened && ( - - )} - - + + + {isRecordIndexRemoveSortingModalOpened && ( + + )} + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDragDropContext.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDragDropContext.tsx index 9a0cb7ff74..e38da043f9 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDragDropContext.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDragDropContext.tsx @@ -38,7 +38,6 @@ export const RecordBoardDragDropContext = ({ const { startRecordDrag } = useStartRecordDrag(); const { endRecordDrag } = useEndRecordDrag(); - // const multiDragState = useRecordDragState('board', recordBoardId); const { processBoardCardDrop } = useProcessBoardCardDrop(); diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardEffects.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardEffects.tsx new file mode 100644 index 0000000000..87c82fa808 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardEffects.tsx @@ -0,0 +1,19 @@ +import { RecordBoardClickOutsideEffect } from '@/object-record/record-board/components/RecordBoardClickOutsideEffect'; +import { RecordBoardQueryEffect } from '@/object-record/record-board/components/RecordBoardQueryEffect'; +import { RecordBoardScrollToFocusedCardEffect } from '@/object-record/record-board/components/RecordBoardScrollToFocusedCardEffect'; +import { RecordBoardSelectRecordsEffect } from '@/object-record/record-board/components/RecordBoardSelectRecordsEffect'; +import { RecordBoardStickyHeaderEffect } from '@/object-record/record-board/components/RecordBoardStickyHeaderEffect'; +import { RecordBoardDeactivateBoardCardEffect } from '@/object-record/record-board/record-board-card/components/RecordBoardDeactivateBoardCardEffect'; + +export const RecordBoardEffects = () => { + return ( + <> + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardFetchMoreInViewTriggerComponent.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardFetchMoreInViewTriggerComponent.tsx new file mode 100644 index 0000000000..1554ec00be --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardFetchMoreInViewTriggerComponent.tsx @@ -0,0 +1,76 @@ +import styled from '@emotion/styled'; +import { useEffect } from 'react'; +import { useInView } from 'react-intersection-observer'; + +import { RECORD_BOARD_COLUMN_PADDING_AND_BORDER_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnPaddingAndBorderWidth'; + +import { RECORD_BOARD_COLUMN_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnWidth'; +import { recordBoardIsFetchingMoreComponentState } from '@/object-record/record-board/states/recordBoardIsFetchingMoreComponentState'; +import { recordBoardShouldFetchMoreComponentState } from '@/object-record/record-board/states/recordBoardShouldFetchMoreComponentState'; +import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; +import { recordIndexRecordGroupsAreInInitialLoadingComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupsAreInInitialLoadingComponentState'; +import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; +import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { ViewType } from '@/views/types/ViewType'; + +const StyledFetchMoreTriggerDiv = styled.div<{ width: number }>` + max-width: ${({ width }) => width}px; + min-width: ${({ width }) => width}px; +`; + +export const RecordBoardFetchMoreInViewTriggerComponent = () => { + const [shouldFetchMore, setShouldFetchMore] = useRecoilComponentState( + recordBoardShouldFetchMoreComponentState, + ); + + const isInitialLoading = useRecoilComponentValue( + recordIndexRecordGroupsAreInInitialLoadingComponentState, + ); + + const isFetchingMore = useRecoilComponentValue( + recordBoardIsFetchingMoreComponentState, + ); + + const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement(); + + const { ref, inView } = useInView({ + rootMargin: '1600px', + root: scrollWrapperHTMLElement, + }); + + const visibleRecordGroupIds = useRecoilComponentFamilyValue( + visibleRecordGroupIdsComponentFamilySelector, + ViewType.Kanban, + ); + + const componentWidth = + visibleRecordGroupIds.length * RECORD_BOARD_COLUMN_WIDTH + + visibleRecordGroupIds.length * + RECORD_BOARD_COLUMN_PADDING_AND_BORDER_WIDTH - + 1; + + useEffect(() => { + if (!isInitialLoading && !isFetchingMore) { + const newShouldFetchMore = inView; + + if (shouldFetchMore !== newShouldFetchMore) { + setShouldFetchMore(newShouldFetchMore); + } + } + }, [ + shouldFetchMore, + setShouldFetchMore, + inView, + isInitialLoading, + isFetchingMore, + ]); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardQueryEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardQueryEffect.tsx new file mode 100644 index 0000000000..f162185b2a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardQueryEffect.tsx @@ -0,0 +1,88 @@ +import { useTriggerRecordBoardFetchMore } from '@/object-record/record-board/hooks/useTriggerRecordBoardFetchMore'; +import { useTriggerRecordBoardInitialQuery } from '@/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery'; +import { lastRecordBoardQueryIdentifierComponentState } from '@/object-record/record-board/states/lastRecordBoardQueryIdentifierComponentState'; +import { recordBoardCurrentGroupByQueryOffsetComponentState } from '@/object-record/record-board/states/recordBoardCurrentGroupByQueryOffsetComponentState'; +import { recordBoardIsFetchingMoreComponentState } from '@/object-record/record-board/states/recordBoardIsFetchingMoreComponentState'; +import { recordBoardShouldFetchMoreComponentState } from '@/object-record/record-board/states/recordBoardShouldFetchMoreComponentState'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { useRecordIndexGroupCommonQueryVariables } from '@/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables'; + +import { recordIndexRecordGroupsAreInInitialLoadingComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupsAreInInitialLoadingComponentState'; +import { getQueryIdentifier } from '@/object-record/utils/getQueryIdentifier'; +import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; + +import { useEffect } from 'react'; + +export const RecordBoardQueryEffect = () => { + const { objectMetadataItem } = useRecordIndexContextOrThrow(); + + const [lastRecordBoardQueryIdentifier, setLastRecordBoardQueryIdentifier] = + useRecoilComponentState(lastRecordBoardQueryIdentifierComponentState); + + const [recordIndexRecordGroupsAreInInitialLoading] = useRecoilComponentState( + recordIndexRecordGroupsAreInInitialLoadingComponentState, + ); + + const setRecordBoardCurrentGroupByQueryOffset = useSetRecoilComponentState( + recordBoardCurrentGroupByQueryOffsetComponentState, + ); + + const { combinedFilters, orderBy } = + useRecordIndexGroupCommonQueryVariables(); + + const queryIdentifier = getQueryIdentifier({ + objectNameSingular: objectMetadataItem.nameSingular, + filter: combinedFilters, + orderBy, + }); + + const queryIdentifierHasChanged = + queryIdentifier !== lastRecordBoardQueryIdentifier; + + const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement(); + + const [shouldFetchMore] = useRecoilComponentState( + recordBoardShouldFetchMoreComponentState, + ); + + const recordBoardIsFetchingMore = useRecoilComponentValue( + recordBoardIsFetchingMoreComponentState, + ); + + const { triggerRecordBoardFetchMore } = useTriggerRecordBoardFetchMore(); + + const { triggerRecordBoardInitialQuery } = + useTriggerRecordBoardInitialQuery(); + + useEffect(() => { + if ( + !recordIndexRecordGroupsAreInInitialLoading && + queryIdentifierHasChanged + ) { + triggerRecordBoardInitialQuery(); + } else if ( + !recordIndexRecordGroupsAreInInitialLoading && + shouldFetchMore && + !queryIdentifierHasChanged && + !recordBoardIsFetchingMore + ) { + triggerRecordBoardFetchMore(); + } + }, [ + triggerRecordBoardInitialQuery, + queryIdentifierHasChanged, + setLastRecordBoardQueryIdentifier, + queryIdentifier, + setRecordBoardCurrentGroupByQueryOffset, + scrollWrapperHTMLElement, + recordIndexRecordGroupsAreInInitialLoading, + shouldFetchMore, + recordBoardIsFetchingMore, + triggerRecordBoardFetchMore, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardSelectRecordsEffect.tsx similarity index 84% rename from packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx rename to packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardSelectRecordsEffect.tsx index 2b908df7cc..f511d4564b 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardSelectRecordsEffect.tsx @@ -5,16 +5,9 @@ import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/r import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; -type RecordIndexBoardDataLoaderEffectProps = { - recordBoardId: string; -}; - -export const RecordIndexBoardDataLoaderEffect = ({ - recordBoardId, -}: RecordIndexBoardDataLoaderEffectProps) => { +export const RecordBoardSelectRecordsEffect = () => { const selectedRecordIds = useRecoilComponentValue( recordBoardSelectedRecordIdsComponentSelector, - recordBoardId, ); const setContextStoreTargetedRecords = useSetRecoilComponentState( diff --git a/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardColumnPaddingAndBorderWidth.ts b/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardColumnPaddingAndBorderWidth.ts new file mode 100644 index 0000000000..937633478b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardColumnPaddingAndBorderWidth.ts @@ -0,0 +1 @@ +export const RECORD_BOARD_COLUMN_PADDING_AND_BORDER_WIDTH = 17; diff --git a/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardColumnWidth.ts b/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardColumnWidth.ts new file mode 100644 index 0000000000..3e2a2fcec9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardColumnWidth.ts @@ -0,0 +1 @@ +export const RECORD_BOARD_COLUMN_WIDTH = 200; diff --git a/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardFetchMoreThrottlingWaitTimeInMillisecondsToAvoidReactFreeze.ts b/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardFetchMoreThrottlingWaitTimeInMillisecondsToAvoidReactFreeze.ts new file mode 100644 index 0000000000..96b948576a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardFetchMoreThrottlingWaitTimeInMillisecondsToAvoidReactFreeze.ts @@ -0,0 +1 @@ +export const RECORD_BOARD_FETCH_MORE_THROTTLING_WAIT_TIME_IN_MILLISECONDS_TO_AVOID_REACT_FREEZE = 50; diff --git a/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardQueryPageSize.ts b/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardQueryPageSize.ts new file mode 100644 index 0000000000..f57b6424d5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardQueryPageSize.ts @@ -0,0 +1 @@ +export const RECORD_BOARD_QUERY_PAGE_SIZE = 10; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardCardHotkeys.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardCardHotkeys.ts index c0422967d9..e0abe4dc31 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardCardHotkeys.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardCardHotkeys.ts @@ -4,6 +4,7 @@ import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/use import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard'; import { useRecordBoardSelectAllHotkeys } from '@/object-record/record-board/hooks/useRecordBoardSelectAllHotkeys'; import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; +import { useResetRecordBoardSelection } from '@/object-record/record-board/hooks/useResetRecordBoardSelection'; import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext'; import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState'; import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector'; @@ -21,8 +22,9 @@ export const useRecordBoardCardHotkeys = (focusId: string) => { const { openRecordInCommandMenu } = useOpenRecordInCommandMenu(); const { activateBoardCard } = useActiveRecordBoardCard(); - const { setRecordAsSelected, resetRecordSelection } = - useRecordBoardSelection(); + const { setRecordAsSelected } = useRecordBoardSelection(); + + const { resetRecordBoardSelection } = useResetRecordBoardSelection(); const { unfocusBoardCard } = useFocusedRecordBoardCard(recordBoardId); const isRecordBoardCardSelected = useRecoilComponentFamilyValue( @@ -56,8 +58,9 @@ export const useRecordBoardCardHotkeys = (focusId: string) => { const handleEscape = () => { unfocusBoardCard(); + if (isAtLeastOneRecordSelected) { - resetRecordSelection(); + resetRecordBoardSelection(); } }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts index e807c68618..0cd8adb552 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts @@ -32,28 +32,6 @@ export const useRecordBoardSelection = (recordBoardId?: string) => { getActionMenuIdFromRecordIndexId(instanceIdFromProps), ); - const resetRecordSelection = useRecoilCallback( - ({ snapshot, set }) => - () => { - closeDropdown(dropdownId); - - const recordIds = getSnapshotValue( - snapshot, - recordBoardSelectedRecordIdsSelector, - ); - - for (const recordId of recordIds) { - set(isRecordBoardCardSelectedFamilyState(recordId), false); - } - }, - [ - closeDropdown, - dropdownId, - recordBoardSelectedRecordIdsSelector, - isRecordBoardCardSelectedFamilyState, - ], - ); - const setRecordAsSelected = useRecoilCallback( ({ snapshot, set }) => (recordId: string, isSelected: boolean) => { @@ -85,7 +63,6 @@ export const useRecordBoardSelection = (recordBoardId?: string) => { ); return { - resetRecordSelection, setRecordAsSelected, checkIfLastUnselectAndCloseDropdown, }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard.ts new file mode 100644 index 0000000000..f676172116 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard.ts @@ -0,0 +1,101 @@ +import { useResetRecordBoardSelection } from '@/object-record/record-board/hooks/useResetRecordBoardSelection'; +import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector'; +import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; +import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useRecoilCallback } from 'recoil'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; + +export const useRemoveSelectedRecordsFromRecordBoard = ( + recordBoardIndexId: string, +) => { + const recordGroupDefinitions = useRecoilComponentValue( + recordGroupDefinitionsComponentSelector, + recordBoardIndexId, + ); + + const groupByFieldMetadataItem = useRecoilComponentValue( + recordIndexGroupFieldMetadataItemComponentState, + recordBoardIndexId, + ); + + const recordIndexRecordIdsByGroupCallbackState = + useRecoilComponentCallbackState( + recordIndexRecordIdsByGroupComponentFamilyState, + recordBoardIndexId, + ); + + const recordBoardSelectedRecordIdsCallbackSelector = + useRecoilComponentCallbackState( + recordBoardSelectedRecordIdsComponentSelector, + recordBoardIndexId, + ); + + const { resetRecordBoardSelection } = + useResetRecordBoardSelection(recordBoardIndexId); + + const removeSelectedRecordsFromRecordBoard = useRecoilCallback( + ({ snapshot, set }) => + () => { + const deletedRecordIds = getSnapshotValue( + snapshot, + recordBoardSelectedRecordIdsCallbackSelector, + ); + + if ( + !isDefined(groupByFieldMetadataItem) || + !isNonEmptyArray(recordGroupDefinitions) || + !isNonEmptyArray(deletedRecordIds) + ) { + return; + } + + for (const recordGroup of recordGroupDefinitions) { + const currentRecordIds = getSnapshotValue( + snapshot, + recordIndexRecordIdsByGroupCallbackState(recordGroup.id), + ); + + let groupRecordIdsUpdated = [...currentRecordIds]; + + for (const deletedRecordId of deletedRecordIds) { + const indexOfDeletedRecordIdInGroupRecordIds = + groupRecordIdsUpdated.findIndex( + (recordIdInRecordGroup) => + recordIdInRecordGroup === deletedRecordId, + ); + + if (indexOfDeletedRecordIdInGroupRecordIds > -1) { + groupRecordIdsUpdated = groupRecordIdsUpdated.toSpliced( + indexOfDeletedRecordIdInGroupRecordIds, + 1, + ); + } + } + + if (groupRecordIdsUpdated.length !== currentRecordIds.length) { + set( + recordIndexRecordIdsByGroupCallbackState(recordGroup.id), + groupRecordIdsUpdated, + ); + } + } + + resetRecordBoardSelection(); + }, + [ + groupByFieldMetadataItem, + recordIndexRecordIdsByGroupCallbackState, + recordGroupDefinitions, + recordBoardSelectedRecordIdsCallbackSelector, + resetRecordBoardSelection, + ], + ); + + return { + removeSelectedRecordsFromRecordBoard, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useResetRecordBoardSelection.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useResetRecordBoardSelection.ts new file mode 100644 index 0000000000..e33a7f9b57 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useResetRecordBoardSelection.ts @@ -0,0 +1,60 @@ +import { useRecoilCallback } from 'recoil'; + +import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId'; +import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId'; +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState'; +import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; + +export const useResetRecordBoardSelection = (recordBoardId?: string) => { + const instanceIdFromProps = useAvailableComponentInstanceIdOrThrow( + RecordBoardComponentInstanceContext, + recordBoardId, + ); + + const isRecordBoardCardSelectedFamilyState = useRecoilComponentCallbackState( + isRecordBoardCardSelectedComponentFamilyState, + recordBoardId, + ); + + const recordBoardSelectedRecordIdsSelector = useRecoilComponentCallbackState( + recordBoardSelectedRecordIdsComponentSelector, + recordBoardId, + ); + + const { closeDropdown } = useCloseDropdown(); + + const dropdownId = getActionMenuDropdownIdFromActionMenuId( + getActionMenuIdFromRecordIndexId(instanceIdFromProps), + ); + + const resetRecordBoardSelection = useRecoilCallback( + ({ snapshot, set }) => + () => { + closeDropdown(dropdownId); + + const recordIds = getSnapshotValue( + snapshot, + recordBoardSelectedRecordIdsSelector, + ); + + for (const recordId of recordIds) { + set(isRecordBoardCardSelectedFamilyState(recordId), false); + } + }, + [ + closeDropdown, + dropdownId, + recordBoardSelectedRecordIdsSelector, + isRecordBoardCardSelectedFamilyState, + ], + ); + + return { + resetRecordBoardSelection, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useSelectAllCards.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useSelectAllCards.ts index fde616066b..42ec66541f 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useSelectAllCards.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useSelectAllCards.ts @@ -1,6 +1,6 @@ import { useRecoilCallback } from 'recoil'; -import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; +import { useResetRecordBoardSelection } from '@/object-record/record-board/hooks/useResetRecordBoardSelection'; import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState'; import { allCardsSelectedStatusComponentSelector } from '@/object-record/record-board/states/selectors/allCardsSelectedStatusComponentSelector'; import { allRecordIdsOfAllRecordGroupsComponentSelector } from '@/object-record/record-index/states/selectors/allRecordIdsOfAllRecordGroupsComponentSelector'; @@ -22,7 +22,8 @@ export const useSelectAllCards = (recordBoardId?: string) => { recordBoardId, ); - const { resetRecordSelection } = useRecordBoardSelection(recordBoardId); + const { resetRecordBoardSelection } = + useResetRecordBoardSelection(recordBoardId); const selectAllCards = useRecoilCallback( ({ set, snapshot }) => @@ -38,7 +39,7 @@ export const useSelectAllCards = (recordBoardId?: string) => { ); if (allCardsSelectedStatus === 'all') { - resetRecordSelection(); + resetRecordBoardSelection(); } for (const recordId of allRecordIds) { @@ -52,7 +53,7 @@ export const useSelectAllCards = (recordBoardId?: string) => { [ allCardsSelectedStatusSelector, allRecordIdsOfAllRecordGroupsCallbackSelector, - resetRecordSelection, + resetRecordBoardSelection, isRecordBoardCardSelectedFamilyState, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useSetRecordIdsForColumn.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useSetRecordIdsForColumn.ts index e0154977ca..428783d127 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useSetRecordIdsForColumn.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useSetRecordIdsForColumn.ts @@ -2,7 +2,7 @@ import { useRecoilCallback } from 'recoil'; import { emptyRecordGroupByIdComponentFamilyState } from '@/object-record/record-group/states/emptyRecordGroupByIdComponentFamilyState'; import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; -import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; @@ -12,7 +12,7 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; export const useSetRecordIdsForColumn = (recordBoardId?: string) => { const recordGroupFieldMetadataState = useRecoilComponentCallbackState( - recordGroupFieldMetadataComponentState, + recordIndexGroupFieldMetadataItemComponentState, recordBoardId, ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardFetchMore.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardFetchMore.ts new file mode 100644 index 0000000000..fbd8bbcc51 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardFetchMore.ts @@ -0,0 +1,231 @@ +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; +import { RECORD_BOARD_FETCH_MORE_THROTTLING_WAIT_TIME_IN_MILLISECONDS_TO_AVOID_REACT_FREEZE } from '@/object-record/record-board/constants/RecordBoardFetchMoreThrottlingWaitTimeInMillisecondsToAvoidReactFreeze'; +import { RECORD_BOARD_QUERY_PAGE_SIZE } from '@/object-record/record-board/constants/RecordBoardQueryPageSize'; + +import { recordBoardCurrentGroupByQueryOffsetComponentState } from '@/object-record/record-board/states/recordBoardCurrentGroupByQueryOffsetComponentState'; +import { recordBoardIsFetchingMoreComponentState } from '@/object-record/record-board/states/recordBoardIsFetchingMoreComponentState'; +import { recordBoardShouldFetchMoreInColumnComponentFamilyState } from '@/object-record/record-board/states/recordBoardShouldFetchMoreInColumnComponentFamilyState'; +import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { useRecordIndexGroupCommonQueryVariables } from '@/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables'; +import { useRecordIndexGroupsRecordsLazyGroupBy } from '@/object-record/record-index/hooks/useRecordIndexGroupsRecordsLazyGroupBy'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; +import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; +import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { isNonEmptyArray } from '@sniptt/guards'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { sortByProperty } from '~/utils/array/sortByProperty'; +import { sleep } from '~/utils/sleep'; + +export const useTriggerRecordBoardFetchMore = () => { + const recordGroupDefinitions = useRecoilComponentValue( + recordGroupDefinitionsComponentSelector, + ); + + const { objectMetadataItem } = useRecordIndexContextOrThrow(); + + const recordIndexGroupFieldMetadataItem = useRecoilComponentValue( + recordIndexGroupFieldMetadataItemComponentState, + ); + + const recordBoardShouldFetchMoreInColumnFamilyCallbackState = + useRecoilComponentCallbackState( + recordBoardShouldFetchMoreInColumnComponentFamilyState, + ); + + const { combinedFilters } = useRecordIndexGroupCommonQueryVariables(); + + const { upsertRecordsInStore } = useUpsertRecordsInStore(); + + const recordBoardCurrentGroupByQueryOffsetCallbackState = + useRecoilComponentCallbackState( + recordBoardCurrentGroupByQueryOffsetComponentState, + ); + + const { executeRecordIndexGroupsRecordsLazyGroupBy } = + useRecordIndexGroupsRecordsLazyGroupBy({ + groupByFieldMetadataItem: recordIndexGroupFieldMetadataItem, + objectMetadataItem, + }); + + const recordIndexRecordIdsByGroupCallbackState = + useRecoilComponentCallbackState( + recordIndexRecordIdsByGroupComponentFamilyState, + ); + + const recordBoardIsFetchingMoreCallbackState = + useRecoilComponentCallbackState(recordBoardIsFetchingMoreComponentState); + + const triggerRecordBoardFetchMore = useRecoilCallback( + ({ set, snapshot }) => + async () => { + const isAlreadyFetchingMore = getSnapshotValue( + snapshot, + recordBoardIsFetchingMoreCallbackState, + ); + + const cleanStateBeforeExit = () => { + set(recordBoardIsFetchingMoreCallbackState, false); + }; + + if (isAlreadyFetchingMore) { + return; + } + + set(recordBoardIsFetchingMoreCallbackState, true); + + const currentOffset = getSnapshotValue( + snapshot, + recordBoardCurrentGroupByQueryOffsetCallbackState, + ); + + const newOffset = currentOffset + RECORD_BOARD_QUERY_PAGE_SIZE; + + const recordGroupValuesThatShouldBeFetched = recordGroupDefinitions + .filter((recordGroupDefinition) => { + return getSnapshotValue( + snapshot, + recordBoardShouldFetchMoreInColumnFamilyCallbackState( + recordGroupDefinition.id, + ), + ); + }) + .map((recordGroupDefinition) => recordGroupDefinition.value) + .filter(isDefined); + + if (!isNonEmptyArray(recordGroupValuesThatShouldBeFetched)) { + cleanStateBeforeExit(); + + return; + } + + const recordIndexGroupsRecordsGroupByLazyQueryResult = + await executeRecordIndexGroupsRecordsLazyGroupBy({ + variables: { + offsetForRecords: newOffset, + filter: { + ...combinedFilters, + [recordIndexGroupFieldMetadataItem?.name ?? '']: { + in: [...recordGroupValuesThatShouldBeFetched], + }, + }, + }, + }); + + set(recordBoardCurrentGroupByQueryOffsetCallbackState, newOffset); + + if (!isDefined(recordIndexGroupsRecordsGroupByLazyQueryResult)) { + cleanStateBeforeExit(); + + return; + } + + const queryFieldName = + getGroupByQueryResultGqlFieldName(objectMetadataItem); + + const groups = + recordIndexGroupsRecordsGroupByLazyQueryResult.data?.[queryFieldName]; + + if (!isDefined(groups)) { + cleanStateBeforeExit(); + + return; + } + + const sortedRecordGroupDefinitions = recordGroupDefinitions.toSorted( + sortByProperty('position'), + ); + + for (const recordGroupDefinition of sortedRecordGroupDefinitions) { + const foundGroupInResult = groups.find( + (recordGroup: any) => + (recordGroup.groupByDimensionValues[0] as string) === + recordGroupDefinition.value, + ); + + if (!isDefined(foundGroupInResult)) { + set( + recordBoardShouldFetchMoreInColumnFamilyCallbackState( + recordGroupDefinition.id, + ), + false, + ); + continue; + } + + const newRecords = getRecordsFromRecordConnection({ + recordConnection: foundGroupInResult, + }); + + if (!isNonEmptyArray(newRecords)) { + set( + recordBoardShouldFetchMoreInColumnFamilyCallbackState( + recordGroupDefinition.id, + ), + false, + ); + continue; + } + + const currentRecordIds = getSnapshotValue( + snapshot, + recordIndexRecordIdsByGroupCallbackState(recordGroupDefinition.id), + ); + + const newRecordIds = currentRecordIds.concat( + newRecords.map((record) => record.id), + ); + + set( + recordIndexRecordIdsByGroupCallbackState(recordGroupDefinition.id), + newRecordIds, + ); + + upsertRecordsInStore(newRecords); + + if (newRecords.length < RECORD_BOARD_QUERY_PAGE_SIZE) { + set( + recordBoardShouldFetchMoreInColumnFamilyCallbackState( + recordGroupDefinition.id, + ), + false, + ); + } else { + set( + recordBoardShouldFetchMoreInColumnFamilyCallbackState( + recordGroupDefinition.id, + ), + true, + ); + } + + await sleep( + RECORD_BOARD_FETCH_MORE_THROTTLING_WAIT_TIME_IN_MILLISECONDS_TO_AVOID_REACT_FREEZE, + ); + } + + cleanStateBeforeExit(); + }, + [ + objectMetadataItem, + recordGroupDefinitions, + upsertRecordsInStore, + executeRecordIndexGroupsRecordsLazyGroupBy, + recordIndexRecordIdsByGroupCallbackState, + recordBoardIsFetchingMoreCallbackState, + recordBoardCurrentGroupByQueryOffsetCallbackState, + recordBoardShouldFetchMoreInColumnFamilyCallbackState, + combinedFilters, + recordIndexGroupFieldMetadataItem, + ], + ); + + return { + triggerRecordBoardFetchMore, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery.ts new file mode 100644 index 0000000000..6a58df0e54 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery.ts @@ -0,0 +1,183 @@ +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; +import { RECORD_BOARD_QUERY_PAGE_SIZE } from '@/object-record/record-board/constants/RecordBoardQueryPageSize'; +import { useSetRecordIdsForColumn } from '@/object-record/record-board/hooks/useSetRecordIdsForColumn'; +import { lastRecordBoardQueryIdentifierComponentState } from '@/object-record/record-board/states/lastRecordBoardQueryIdentifierComponentState'; +import { recordBoardCurrentGroupByQueryOffsetComponentState } from '@/object-record/record-board/states/recordBoardCurrentGroupByQueryOffsetComponentState'; +import { recordBoardShouldFetchMoreInColumnComponentFamilyState } from '@/object-record/record-board/states/recordBoardShouldFetchMoreInColumnComponentFamilyState'; +import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { useRecordIndexGroupCommonQueryVariables } from '@/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables'; +import { useRecordIndexGroupsRecordsLazyGroupBy } from '@/object-record/record-index/hooks/useRecordIndexGroupsRecordsLazyGroupBy'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; +import { recordIndexRecordGroupsAreInInitialLoadingComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupsAreInInitialLoadingComponentState'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; +import { getQueryIdentifier } from '@/object-record/utils/getQueryIdentifier'; +import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName'; +import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { isNonEmptyArray } from '@sniptt/guards'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useTriggerRecordBoardInitialQuery = () => { + const recordGroupDefinitions = useRecoilComponentValue( + recordGroupDefinitionsComponentSelector, + ); + + const { objectMetadataItem } = useRecordIndexContextOrThrow(); + + const recordIndexGroupFieldMetadataItem = useRecoilComponentValue( + recordIndexGroupFieldMetadataItemComponentState, + ); + + const setLastRecordBoardQueryIdentifier = useSetRecoilComponentState( + lastRecordBoardQueryIdentifierComponentState, + ); + + const recordBoardShouldFetchMoreInColumnFamilyCallbackState = + useRecoilComponentCallbackState( + recordBoardShouldFetchMoreInColumnComponentFamilyState, + ); + + const recordIndexRecordGroupsAreInInitialLoadingCallbackState = + useRecoilComponentCallbackState( + recordIndexRecordGroupsAreInInitialLoadingComponentState, + ); + + const setRecordBoardCurrentGroupByQueryOffset = useSetRecoilComponentState( + recordBoardCurrentGroupByQueryOffsetComponentState, + ); + + const { combinedFilters, orderBy } = + useRecordIndexGroupCommonQueryVariables(); + + const queryIdentifier = getQueryIdentifier({ + objectNameSingular: objectMetadataItem.nameSingular, + filter: combinedFilters, + orderBy, + }); + + const { setRecordIdsForColumn } = useSetRecordIdsForColumn(); + const { upsertRecordsInStore } = useUpsertRecordsInStore(); + + const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement(); + + const { executeRecordIndexGroupsRecordsLazyGroupBy } = + useRecordIndexGroupsRecordsLazyGroupBy({ + groupByFieldMetadataItem: recordIndexGroupFieldMetadataItem, + objectMetadataItem, + }); + + const triggerRecordBoardInitialQuery = useRecoilCallback( + ({ set }) => + async () => { + set(recordIndexRecordGroupsAreInInitialLoadingCallbackState, true); + + const cleanStateBeforeExit = () => { + set(recordIndexRecordGroupsAreInInitialLoadingCallbackState, false); + + setLastRecordBoardQueryIdentifier(queryIdentifier); + + setRecordBoardCurrentGroupByQueryOffset(0); + + scrollWrapperHTMLElement?.scrollTo({ top: 0, left: 0 }); + }; + + const recordIndexGroupsRecordsGroupByLazyQueryResult = + await executeRecordIndexGroupsRecordsLazyGroupBy(); + + if (!isDefined(recordIndexGroupsRecordsGroupByLazyQueryResult)) { + cleanStateBeforeExit(); + + return; + } + + const queryFieldName = + getGroupByQueryResultGqlFieldName(objectMetadataItem); + + const groups = + recordIndexGroupsRecordsGroupByLazyQueryResult.data?.[queryFieldName]; + + if (!isDefined(groups)) { + cleanStateBeforeExit(); + + return; + } + + for (const recordGroupDefinition of recordGroupDefinitions) { + const foundGroupInResult = groups?.find( + (recordGroup: any) => + (recordGroup.groupByDimensionValues[0] as string) === + recordGroupDefinition.value, + ); + + if (!isDefined(foundGroupInResult)) { + setRecordIdsForColumn(recordGroupDefinition.id, []); + set( + recordBoardShouldFetchMoreInColumnFamilyCallbackState( + recordGroupDefinition.id, + ), + false, + ); + continue; + } + + const records = getRecordsFromRecordConnection({ + recordConnection: foundGroupInResult, + }); + + if (!isNonEmptyArray(records)) { + setRecordIdsForColumn(recordGroupDefinition.id, []); + set( + recordBoardShouldFetchMoreInColumnFamilyCallbackState( + recordGroupDefinition.id, + ), + false, + ); + continue; + } + + upsertRecordsInStore(records); + + setRecordIdsForColumn(recordGroupDefinition.id, records); + + if (records.length < RECORD_BOARD_QUERY_PAGE_SIZE) { + set( + recordBoardShouldFetchMoreInColumnFamilyCallbackState( + recordGroupDefinition.id, + ), + false, + ); + } else { + set( + recordBoardShouldFetchMoreInColumnFamilyCallbackState( + recordGroupDefinition.id, + ), + true, + ); + } + } + + cleanStateBeforeExit(); + }, + [ + recordIndexRecordGroupsAreInInitialLoadingCallbackState, + executeRecordIndexGroupsRecordsLazyGroupBy, + objectMetadataItem, + setLastRecordBoardQueryIdentifier, + queryIdentifier, + setRecordBoardCurrentGroupByQueryOffset, + scrollWrapperHTMLElement, + recordGroupDefinitions, + upsertRecordsInStore, + setRecordIdsForColumn, + recordBoardShouldFetchMoreInColumnFamilyCallbackState, + ], + ); + + return { + triggerRecordBoardInitialQuery, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardContainerSkeletonLoader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardContainerSkeletonLoader.tsx index cdb3204dae..94008c863f 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardContainerSkeletonLoader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardContainerSkeletonLoader.tsx @@ -1,6 +1,6 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; +import { SkeletonTheme } from 'react-loading-skeleton'; import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader'; import { RecordCardBodyContainer } from '@/object-record/record-card/components/RecordCardBodyContainer'; @@ -8,17 +8,30 @@ import { RecordCardHeaderContainer } from '@/object-record/record-card/component import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; + const StyledSkeletonIconAndText = styled.div` display: flex; gap: ${({ theme }) => theme.spacing(1)}; `; const StyledSkeletonTitle = styled.div` - padding-left: ${({ theme }) => theme.spacing(2)}; + padding-left: ${({ theme }) => theme.spacing(1)}; `; -const StyledSeparator = styled.div` - height: ${({ theme }) => theme.spacing(2)}; +const StyledBodyContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(0.5)}; + padding-top: 4px; + padding-bottom: 4px; +`; + +const StyledStaticCellSkeleton = styled.div<{ width: number; height: number }>` + background-color: ${({ theme }) => theme.background.tertiary}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + + width: ${({ width }) => width}px; + height: ${({ height }) => height}px; `; export const RecordBoardColumnCardContainerSkeletonLoader = () => { @@ -32,7 +45,7 @@ export const RecordBoardColumnCardContainerSkeletonLoader = () => { visibleRecordFieldsComponentSelector, ); - const numberOfFields = visibleRecordFields.length; + const numberOfFields = visibleRecordFields.length - 1; const skeletonItems = Array.from({ length: numberOfFields }).map( (_, index) => ({ @@ -46,32 +59,30 @@ export const RecordBoardColumnCardContainerSkeletonLoader = () => { - + - - {!isCompactModeActive && - skeletonItems.map(({ id }) => ( - - - - - - - ))} + + {!isCompactModeActive && + skeletonItems.map(({ id }) => ( + + + + + + + ))} + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx index 2129d3c969..2d986eae4e 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer.tsx @@ -1,17 +1,18 @@ import styled from '@emotion/styled'; import { Draggable, type DroppableProvided } from '@hello-pangea/dnd'; import { useContext } from 'react'; -import { useRecoilValue } from 'recoil'; import { RecordBoardCardDraggableContainer } from '@/object-record/record-board/record-board-card/components/RecordBoardCardDraggableContainer'; -import { RecordBoardColumnCardContainerSkeletonLoader } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnCardContainerSkeletonLoader'; -import { RecordBoardColumnFetchMoreLoader } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader'; + import { RecordBoardColumnNewRecordButton } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnNewRecordButton'; import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; -import { getNumberOfCardsPerColumnForSkeletonLoading } from '@/object-record/record-board/record-board-column/utils/getNumberOfCardsPerColumnForSkeletonLoading'; + +import { RecordBoardColumnLoadingSkeletonCards } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnLoadingSkeletonCards'; +import { recordBoardShouldFetchMoreInColumnComponentFamilyState } from '@/object-record/record-board/states/recordBoardShouldFetchMoreInColumnComponentFamilyState'; +import { recordIndexRecordGroupsAreInInitialLoadingComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupsAreInInitialLoadingComponentState'; import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; -import { isRecordIndexBoardColumnLoadingFamilyState } from '@/object-record/states/isRecordBoardColumnLoadingFamilyState'; import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; const StyledColumnCardsContainer = styled.div` display: flex; @@ -22,17 +23,6 @@ const StyledColumnCardsContainer = styled.div` const StyledNewButtonContainer = styled.div` padding-bottom: ${({ theme }) => theme.spacing(4)}; `; -// eslint-disable-next-line @nx/workspace-no-hardcoded-colors -const StyledSkeletonCardContainer = styled.div` - background-color: ${({ theme }) => theme.background.secondary}; - border: 1px solid ${({ theme }) => theme.background.quaternary}; - border-radius: ${({ theme }) => theme.border.radius.md}; - box-shadow: - 0px 4px 8px 0px rgba(0, 0, 0, 0.08), - 0px 0px 4px 0px rgba(0, 0, 0, 0.08); - color: ${({ theme }) => theme.font.color.primary}; - margin-bottom: ${({ theme }) => theme.spacing(2)}; -`; type RecordBoardColumnCardsContainerProps = { recordBoardColumnId: string; @@ -45,15 +35,18 @@ export const RecordBoardColumnCardsContainer = ({ }: RecordBoardColumnCardsContainerProps) => { const { columnDefinition } = useContext(RecordBoardColumnContext); - const columnId = columnDefinition.id; - const recordIds = useRecoilComponentFamilyValue( recordIndexRecordIdsByGroupComponentFamilyState, recordBoardColumnId, ); - const isRecordIndexBoardColumnLoading = useRecoilValue( - isRecordIndexBoardColumnLoadingFamilyState(columnId), + const recordIndexRecordGroupsAreInInitialLoading = useRecoilComponentValue( + recordIndexRecordGroupsAreInInitialLoadingComponentState, + ); + + const recordBoardShouldFetchMoreInColumn = useRecoilComponentFamilyValue( + recordBoardShouldFetchMoreInColumnComponentFamilyState, + recordBoardColumnId, ); return ( @@ -62,29 +55,20 @@ export const RecordBoardColumnCardsContainer = ({ // eslint-disable-next-line react/jsx-props-no-spreading {...droppableProvided?.droppableProps} > - {isRecordIndexBoardColumnLoading - ? Array.from( - { - length: getNumberOfCardsPerColumnForSkeletonLoading( - columnDefinition.position, - ), - }, - (_, index) => ( - - - - ), - ) - : recordIds.map((recordId, index) => ( - - ))} - + {recordIndexRecordGroupsAreInInitialLoading ? ( + + ) : ( + recordIds.map((recordId, index) => ( + + )) + )} + {recordBoardShouldFetchMoreInColumn ? ( + + ) : null} theme.grayScale.gray9}; - display: flex; - height: 32px; - margin-left: ${({ theme }) => theme.spacing(8)}; - padding-left: ${({ theme }) => theme.spacing(2)}; -`; - -export const RecordBoardColumnFetchMoreLoader = () => { - const { columnDefinition } = useContext(RecordBoardColumnContext); - - const isFetchingRecord = useRecoilValue( - isRecordBoardFetchingRecordsByColumnFamilyState(columnDefinition.id), - ); - - const setShouldFetchMore = useSetRecoilComponentFamilyState( - recordBoardShouldFetchMoreInColumnComponentFamilyState, - columnDefinition.id, - ); - - const { ref, inView } = useInView(); - - useEffect(() => { - setShouldFetchMore(inView); - }, [setShouldFetchMore, inView]); - - return ( -
- {isFetchingRecord && Loading more...} -
- ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx index 4a3c7ab0fd..d6b10a589c 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx @@ -5,16 +5,25 @@ import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPe import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { RecordBoardColumnDropdownMenu } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu'; import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown'; + +import { RECORD_BOARD_COLUMN_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnWidth'; import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView'; +import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector'; import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition'; import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/record-index/states/recordIndexAggregateDisplayLabelComponentState'; import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } from '@/object-record/record-index/states/recordIndexAggregateDisplayValueForGroupValueComponentFamilyState'; +import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useRecoilCallback } from 'recoil'; +import { findByProperty, isDefined } from 'twenty-shared/utils'; import { Tag } from 'twenty-ui/components'; import { IconDotsVertical, IconPlus } from 'twenty-ui/display'; import { LightIconButton } from 'twenty-ui/input'; @@ -56,8 +65,8 @@ const StyledColumn = styled.div` background-color: ${({ theme }) => theme.background.primary}; display: flex; flex-direction: column; - max-width: 200px; - min-width: 200px; + max-width: ${RECORD_BOARD_COLUMN_WIDTH}px; + min-width: ${RECORD_BOARD_COLUMN_WIDTH}px; padding: ${({ theme }) => theme.spacing(2)}; @@ -95,15 +104,63 @@ export const RecordBoardColumnHeader = () => { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState, { groupValue: columnDefinition?.value ?? '' }, ); + const { upsertRecordsInStore } = useUpsertRecordsInStore(); + + const recordIndexRecordIdsByGroupCallbackState = + useRecoilComponentCallbackState( + recordIndexRecordIdsByGroupComponentFamilyState, + ); const recordIndexAggregateDisplayLabel = useRecoilComponentValue( recordIndexAggregateDisplayLabelComponentState, ); + const recordGroupDefinitions = useRecoilComponentValue( + recordGroupDefinitionsComponentSelector, + ); + const { toggleDropdown } = useToggleDropdown(); const dropdownId = `record-board-column-dropdown-${columnDefinition.id}`; + const handleCreateNewRecordClick = useRecoilCallback( + ({ set, snapshot }) => + async () => { + const createdRecord = await createNewIndexRecord({ + position: 'first', + [selectFieldMetadataItem.name]: columnDefinition.value, + }); + + const recordGroup = recordGroupDefinitions.find( + findByProperty('value', createdRecord[selectFieldMetadataItem.name]), + ); + + if (isDefined(recordGroup)) { + const currentRecordIds = getSnapshotValue( + snapshot, + recordIndexRecordIdsByGroupCallbackState(recordGroup.id), + ); + + const newRecordIds = [createdRecord.id, ...currentRecordIds]; + + set( + recordIndexRecordIdsByGroupCallbackState(recordGroup.id), + newRecordIds, + ); + } + + upsertRecordsInStore([createdRecord]); + }, + [ + createNewIndexRecord, + columnDefinition, + recordGroupDefinitions, + recordIndexRecordIdsByGroupCallbackState, + upsertRecordsInStore, + selectFieldMetadataItem, + ], + ); + return ( { { - createNewIndexRecord({ - position: 'first', - [selectFieldMetadataItem.name]: - columnDefinition.value, - }); - }} + onClick={handleCreateNewRecordClick} /> )} diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnLoadingSkeletonCards.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnLoadingSkeletonCards.tsx new file mode 100644 index 0000000000..0787d407a4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnLoadingSkeletonCards.tsx @@ -0,0 +1,27 @@ +import { RECORD_BOARD_QUERY_PAGE_SIZE } from '@/object-record/record-board/constants/RecordBoardQueryPageSize'; +import { RecordBoardColumnCardContainerSkeletonLoader } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnCardContainerSkeletonLoader'; +import styled from '@emotion/styled'; + +const StyledSkeletonCardContainer = styled.div` + background-color: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.background.quaternary}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + box-shadow: + 0px 4px 8px 0px ${({ theme }) => theme.color.gray2}, + 0px 0px 4px 0px ${({ theme }) => theme.color.gray2}; + color: ${({ theme }) => theme.font.color.primary}; + margin-bottom: ${({ theme }) => theme.spacing(2)}; +`; + +export const RecordBoardColumnLoadingSkeletonCards = () => { + return Array.from( + { + length: RECORD_BOARD_QUERY_PAGE_SIZE, + }, + (_, index) => ( + + + + ), + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/getNumberOfCardsPerColumnForSkeletonLoading.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/getNumberOfCardsPerColumnForSkeletonLoading.ts deleted file mode 100644 index 1f6fc58aaa..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/utils/getNumberOfCardsPerColumnForSkeletonLoading.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const getNumberOfCardsPerColumnForSkeletonLoading = ( - columnIndex: number, -): number => { - const skeletonCounts: Record = { - 0: 2, - 1: 1, - 2: 3, - 3: 0, - 4: 1, - }; - - return skeletonCounts[columnIndex] || 0; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/lastRecordBoardQueryIdentifierComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/lastRecordBoardQueryIdentifierComponentState.ts new file mode 100644 index 0000000000..26c522751c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/lastRecordBoardQueryIdentifierComponentState.ts @@ -0,0 +1,9 @@ +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const lastRecordBoardQueryIdentifierComponentState = + createComponentState({ + key: 'lastRecordBoardQueryIdentifierComponentState', + componentInstanceContext: RecordBoardComponentInstanceContext, + defaultValue: '', + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardCurrentGroupByQueryOffsetComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardCurrentGroupByQueryOffsetComponentState.ts new file mode 100644 index 0000000000..b2c631f7ef --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardCurrentGroupByQueryOffsetComponentState.ts @@ -0,0 +1,9 @@ +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordBoardCurrentGroupByQueryOffsetComponentState = + createComponentState({ + key: 'recordBoardCurrentGroupByQueryOffsetComponentState', + defaultValue: 0, + componentInstanceContext: RecordBoardComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardIsFetchingMoreComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardIsFetchingMoreComponentState.ts new file mode 100644 index 0000000000..415f080c25 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardIsFetchingMoreComponentState.ts @@ -0,0 +1,9 @@ +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordBoardIsFetchingMoreComponentState = + createComponentState({ + key: 'recordBoardIsFetchingMoreComponentState', + defaultValue: false, + componentInstanceContext: RecordBoardComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardShouldFetchMoreComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardShouldFetchMoreComponentState.ts new file mode 100644 index 0000000000..df2dbf3c55 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardShouldFetchMoreComponentState.ts @@ -0,0 +1,9 @@ +import { RecordBoardComponentInstanceContext } from '@/object-record/record-board/states/contexts/RecordBoardComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordBoardShouldFetchMoreComponentState = + createComponentState({ + key: 'recordBoardShouldFetchMoreComponentState', + defaultValue: false, + componentInstanceContext: RecordBoardComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessBoardCardDrop.ts b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessBoardCardDrop.ts index 3808934ce3..96a1fdb632 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessBoardCardDrop.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessBoardCardDrop.ts @@ -1,22 +1,24 @@ import { type DropResult } from '@hello-pangea/dnd'; -import { useContext } from 'react'; import { useRecoilCallback } from 'recoil'; -import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; - import { processGroupDrop } from '@/object-record/record-drag/utils/processGroupDrop'; + +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { useUpdateDroppedRecordOnBoard } from '@/object-record/record-drag/hooks/useUpdateDroppedRecordOnBoard'; import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useContext } from 'react'; export const useProcessBoardCardDrop = () => { - const { updateOneRecord, selectFieldMetadataItem } = - useContext(RecordBoardContext); + const { selectFieldMetadataItem } = useContext(RecordBoardContext); - const recordIndexRecordIdsByGroupFamilyState = + const recordIndexRecordIdsByGroupCallbackFamilyState = useRecoilComponentCallbackState( recordIndexRecordIdsByGroupComponentFamilyState, ); + const { updateDroppedRecordOnBoard } = useUpdateDroppedRecordOnBoard(); + const processBoardCardDrop = useRecoilCallback( ({ snapshot }) => (boardCardDropResult: DropResult, selectedRecordIds: string[]) => { @@ -26,22 +28,20 @@ export const useProcessBoardCardDrop = () => { groupDropResult: boardCardDropResult, snapshot, selectedRecordIds, - recordIdsByGroupFamilyState: recordIndexRecordIdsByGroupFamilyState, + recordIdsByGroupFamilyState: + recordIndexRecordIdsByGroupCallbackFamilyState, onUpdateRecord: ({ recordId, position }, targetRecordGroupValue) => { - updateOneRecord({ - idToUpdate: recordId, - updateOneRecordInput: { - [selectFieldMetadataItem.name]: targetRecordGroupValue, - position, - }, - }); + updateDroppedRecordOnBoard( + { recordId, position }, + targetRecordGroupValue, + ); }, }); }, [ - updateOneRecord, selectFieldMetadataItem, - recordIndexRecordIdsByGroupFamilyState, + recordIndexRecordIdsByGroupCallbackFamilyState, + updateDroppedRecordOnBoard, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessCalendarCardDrop.ts b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessCalendarCardDrop.ts index 25977872ae..ef92f08460 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessCalendarCardDrop.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessCalendarCardDrop.ts @@ -91,6 +91,7 @@ export const useProcessCalendarCardDrop = () => { arrayOfRecordsWithPosition: recordsWithPosition, idOfItemToMove: recordId, idOfTargetItem: targetRecord.id, + isDroppedAfterList, }); } diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop.ts b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop.ts index d94dc4ec50..dd0a184f57 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithoutGroupRecordDrop.ts @@ -72,6 +72,10 @@ export const useProcessTableWithoutGroupRecordDrop = () => { selectedRowIdsSelector, ); + const isDroppedAfterList = + tableRecordDropResult.destination.index + 1 >= + allSparseRecordIds.length; + const recordsWithPosition: RecordWithPosition[] = allSparseRecordIds .filter(isDefined) .map((recordId) => ({ @@ -104,6 +108,7 @@ export const useProcessTableWithoutGroupRecordDrop = () => { sourceRecordId: draggedRecordId, targetRecordId: targetRecordId ?? '', recordsWithPosition: contiguousRecordsWithPosition, + isDroppedAfterList, }); if (!isDefined(singleDragResult.position)) { @@ -137,6 +142,7 @@ export const useProcessTableWithoutGroupRecordDrop = () => { targetRecordId: targetRecordId ?? '', selectedRecordIds: originalDragSelection, recordsWithPosition: contiguousRecordsWithPosition, + isDroppedAfterList, }); for (const update of multiDragResult.recordUpdates) { diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useUpdateDroppedRecordOnBoard.ts b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useUpdateDroppedRecordOnBoard.ts new file mode 100644 index 0000000000..c8a2f3002f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useUpdateDroppedRecordOnBoard.ts @@ -0,0 +1,170 @@ +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { extractRecordPositions } from '@/object-record/record-drag/utils/extractRecordPositions'; +import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector'; +import { type RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; +import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useContext } from 'react'; +import { useRecoilCallback } from 'recoil'; +import { findByProperty, isDefined } from 'twenty-shared/utils'; +import { sortByProperty } from '~/utils/array/sortByProperty'; + +export const useUpdateDroppedRecordOnBoard = () => { + const { updateOneRecord, selectFieldMetadataItem } = + useContext(RecordBoardContext); + + const recordIndexRecordIdsByGroupCallbackFamilyState = + useRecoilComponentCallbackState( + recordIndexRecordIdsByGroupComponentFamilyState, + ); + + const recordGroupDefinitions = useRecoilComponentValue( + recordGroupDefinitionsComponentSelector, + ); + + const { upsertRecordsInStore } = useUpsertRecordsInStore(); + + const updateDroppedRecordOnBoard = useRecoilCallback( + ({ snapshot, set }) => + ( + { + recordId, + position: newPosition, + }: { recordId: string; position?: number }, + targetRecordGroupValue: RecordGroupDefinition['value'], + ) => { + const initialRecord = getSnapshotValue( + snapshot, + recordStoreFamilyState(recordId), + ); + + if (!isDefined(newPosition)) { + return; + } + + if (!isDefined(initialRecord)) { + return; + } + + const initialRecordGroupValue = + initialRecord[selectFieldMetadataItem.name]; + + const initialRecordGroup = recordGroupDefinitions.find( + findByProperty('value', initialRecordGroupValue), + ); + + if (!isDefined(initialRecordGroup)) { + return; + } + + const targetRecordGroup = recordGroupDefinitions.find( + findByProperty('value', targetRecordGroupValue), + ); + + if (!isDefined(targetRecordGroup)) { + return; + } + + const initialRecordGroupId = initialRecordGroup.id; + const targetRecordGroupId = targetRecordGroup.id; + + const movingInsideSameRecordGroup = + initialRecordGroupId === targetRecordGroupId; + + const isSamePosition = initialRecord.position === newPosition; + + if (movingInsideSameRecordGroup && isSamePosition) { + return; + } + + const currentRecordIdsInInitialRecordGroup = getSnapshotValue( + snapshot, + recordIndexRecordIdsByGroupCallbackFamilyState(initialRecordGroupId), + ); + + const positionOfDroppedRecordInInitialRecordIds = + currentRecordIdsInInitialRecordGroup.findIndex( + (id) => id === recordId, + ); + + let currentRecordIdsInTargetRecordGroup = getSnapshotValue( + snapshot, + recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId), + ); + + if (positionOfDroppedRecordInInitialRecordIds === -1) { + throw new Error( + `Cannot find record id in initial record group ids on drop, this should not happen`, + ); + } + + const newInitialGroupRecordIds = + currentRecordIdsInInitialRecordGroup.toSpliced( + positionOfDroppedRecordInInitialRecordIds, + 1, + ); + + if (movingInsideSameRecordGroup) { + currentRecordIdsInTargetRecordGroup = newInitialGroupRecordIds; + } else { + set( + recordIndexRecordIdsByGroupCallbackFamilyState( + initialRecordGroupId, + ), + newInitialGroupRecordIds, + ); + } + + const targetGroupRecordsWithIds = extractRecordPositions( + currentRecordIdsInTargetRecordGroup, + snapshot, + ); + + const newTargetRecordGroupWithIds = [ + ...targetGroupRecordsWithIds, + { + id: recordId, + position: newPosition, + }, + ]; + + newTargetRecordGroupWithIds.sort(sortByProperty('position', 'asc')); + + set( + recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId), + newTargetRecordGroupWithIds.map((record) => record.id), + ); + + upsertRecordsInStore([ + { + ...initialRecord, + [selectFieldMetadataItem.name]: targetRecordGroupValue, + position: newPosition, + }, + ]); + + updateOneRecord({ + idToUpdate: initialRecord.id, + updateOneRecordInput: { + [selectFieldMetadataItem.name]: targetRecordGroupValue, + position: newPosition, + }, + }); + }, + [ + recordGroupDefinitions, + recordIndexRecordIdsByGroupCallbackFamilyState, + selectFieldMetadataItem, + upsertRecordsInStore, + updateOneRecord, + ], + ); + + return { + updateDroppedRecordOnBoard, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-drag/utils/__tests__/processMultiDrag.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/utils/__tests__/processMultiDrag.test.ts index dd9d756edb..5532e0388b 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/utils/__tests__/processMultiDrag.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/utils/__tests__/processMultiDrag.test.ts @@ -17,6 +17,7 @@ describe('processMultiDrag', () => { targetRecordId: 'record-4', selectedRecordIds, recordsWithPosition, + isDroppedAfterList: true, }); expect(dragResult.recordUpdates).toEqual([ @@ -35,6 +36,7 @@ describe('processMultiDrag', () => { targetRecordId: '', selectedRecordIds, recordsWithPosition, + isDroppedAfterList: false, }); }).toThrow('Cannot find item to move for id : '); }); @@ -47,6 +49,7 @@ describe('processMultiDrag', () => { targetRecordId: 'record-3', selectedRecordIds, recordsWithPosition, + isDroppedAfterList: false, }); expect(dragResult.recordUpdates).toEqual([ @@ -62,6 +65,7 @@ describe('processMultiDrag', () => { targetRecordId: 'record-3', selectedRecordIds, recordsWithPosition, + isDroppedAfterList: false, }); expect(dragResult.recordUpdates).toEqual([]); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/utils/__tests__/processSingleDrag.test.ts b/packages/twenty-front/src/modules/object-record/record-drag/utils/__tests__/processSingleDrag.test.ts index 689cdb5cc9..4cb8bdea44 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/utils/__tests__/processSingleDrag.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/utils/__tests__/processSingleDrag.test.ts @@ -13,6 +13,7 @@ describe('processSingleDrag', () => { sourceRecordId: 'record-1', targetRecordId: 'record-3', recordsWithPosition: mockRecordPositionData, + isDroppedAfterList: true, }); expect(dragResult).toEqual({ @@ -26,6 +27,7 @@ describe('processSingleDrag', () => { sourceRecordId: 'record-3', targetRecordId: 'record-1', recordsWithPosition: mockRecordPositionData, + isDroppedAfterList: false, }); expect(dragResult).toEqual({ @@ -39,6 +41,7 @@ describe('processSingleDrag', () => { sourceRecordId: 'record-1', targetRecordId: 'record-2', recordsWithPosition: mockRecordPositionData, + isDroppedAfterList: false, }); expect(dragResult).toEqual({ @@ -52,6 +55,7 @@ describe('processSingleDrag', () => { sourceRecordId: 'record-3', targetRecordId: 'record-2', recordsWithPosition: mockRecordPositionData, + isDroppedAfterList: false, }); expect(dragResult).toEqual({ @@ -65,6 +69,7 @@ describe('processSingleDrag', () => { sourceRecordId: 'record-4', targetRecordId: 'record-2', recordsWithPosition: mockRecordPositionData, + isDroppedAfterList: false, }); expect(dragResult).toEqual({ @@ -79,6 +84,7 @@ describe('processSingleDrag', () => { sourceRecordId: 'record-1', targetRecordId: 'record-1', recordsWithPosition: [], + isDroppedAfterList: false, }), ).toThrowError('Cannot find item to move for id : record-1'); }); diff --git a/packages/twenty-front/src/modules/object-record/record-drag/utils/processGroupDrop.ts b/packages/twenty-front/src/modules/object-record/record-drag/utils/processGroupDrop.ts index d8cae423f7..4169237287 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/utils/processGroupDrop.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/utils/processGroupDrop.ts @@ -106,6 +106,7 @@ export const processGroupDrop = ({ sourceRecordId: draggedRecordId, targetRecordId: targetRecord.id, recordsWithPosition: recordsWithPosition, + isDroppedAfterList, }); if (!isDefined(singleDragResult.position)) { @@ -125,6 +126,7 @@ export const processGroupDrop = ({ selectedRecordIds, recordsWithPosition, targetRecordId: targetRecord.id, + isDroppedAfterList, }); for (const update of multiDragResult.recordUpdates) { diff --git a/packages/twenty-front/src/modules/object-record/record-drag/utils/processMultiDrag.ts b/packages/twenty-front/src/modules/object-record/record-drag/utils/processMultiDrag.ts index 693aeffe43..77853ed9b1 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/utils/processMultiDrag.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/utils/processMultiDrag.ts @@ -7,6 +7,7 @@ type MultiDragContext = { targetRecordId: string; selectedRecordIds: string[]; recordsWithPosition: RecordWithPosition[]; + isDroppedAfterList: boolean; }; export const processMultiDrag = ({ @@ -14,12 +15,14 @@ export const processMultiDrag = ({ targetRecordId, selectedRecordIds, recordsWithPosition, + isDroppedAfterList, }: MultiDragContext): MultiDragResult => { const newPositionOfDraggedRecords = computeNewPositionsOfDraggedRecords({ arrayOfRecordsWithPosition: recordsWithPosition, draggedRecordId, targetRecordId, sourceRecordIds: selectedRecordIds, + isDroppedAfterList, }); return { diff --git a/packages/twenty-front/src/modules/object-record/record-drag/utils/processSingleDrag.ts b/packages/twenty-front/src/modules/object-record/record-drag/utils/processSingleDrag.ts index a53077c549..5cac8a3b47 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/utils/processSingleDrag.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/utils/processSingleDrag.ts @@ -7,17 +7,20 @@ type SingleDragContext = { targetRecordId: string; sourceRecordId: string; recordsWithPosition: RecordWithPosition[]; + isDroppedAfterList: boolean; }; export const processSingleDrag = ({ targetRecordId, sourceRecordId, recordsWithPosition, + isDroppedAfterList, }: SingleDragContext): RecordWithPosition => { const newPosition = computeNewPositionOfDraggedRecord({ arrayOfRecordsWithPosition: recordsWithPosition, idOfItemToMove: sourceRecordId, idOfTargetItem: targetRecordId, + isDroppedAfterList, }); return { diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useReorderVisibleRecordFields.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useReorderVisibleRecordFields.ts index 9f981f3090..c9bda7a5f1 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/useReorderVisibleRecordFields.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useReorderVisibleRecordFields.ts @@ -35,10 +35,13 @@ export const useReorderVisibleRecordFields = (recordTableId: string) => { const recordToMove = visibleRecordFields[fromIndex]; + const isDroppedAfterList = toIndex >= visibleRecordFields.length; + const newPositionOfTargetRecord = computeNewPositionOfDraggedRecord({ arrayOfRecordsWithPosition: currentRecordFields, idOfItemToMove: idOfRecordToMove, idOfTargetItem: idOfTargetRecord, + isDroppedAfterList, }); updateRecordField(recordToMove.fieldMetadataItemId, { diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts index 516e58933a..db8f659b79 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts @@ -2,11 +2,11 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility'; import { useReorderRecordGroups } from '@/object-record/record-group/hooks/useReorderRecordGroups'; -import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; import { type RecordGroupAction } from '@/object-record/record-group/types/RecordGroupActions'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; @@ -49,8 +49,8 @@ export const useRecordGroupActions = ({ objectNameSingular, }); - const recordGroupFieldMetadata = useRecoilComponentValue( - recordGroupFieldMetadataComponentState, + const recordIndexGroupFieldMetadataItem = useRecoilComponentValue( + recordIndexGroupFieldMetadataItemComponentState, ); const { handleVisibilityChange: handleRecordGroupVisibilityChange } = @@ -73,13 +73,13 @@ export const useRecordGroupActions = ({ const navigateToSelectSettings = useCallback(() => { setNavigationMemorizedUrl(location.pathname + location.search); - if (!isDefined(recordGroupFieldMetadata)) { + if (!isDefined(recordIndexGroupFieldMetadataItem)) { throw new Error('recordGroupFieldMetadata is not a non-empty string'); } navigate(SettingsPath.ObjectFieldEdit, { objectNamePlural: objectMetadataItem.namePlural, - fieldName: recordGroupFieldMetadata.name, + fieldName: recordIndexGroupFieldMetadataItem.name, }); }, [ setNavigationMemorizedUrl, @@ -87,7 +87,7 @@ export const useRecordGroupActions = ({ location.search, navigate, objectMetadataItem, - recordGroupFieldMetadata, + recordIndexGroupFieldMetadataItem, ]); const hasAccessToDataModelSettings = useHasPermissionFlag( diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useSetRecordGroups.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useSetRecordGroups.ts index bebae10d84..5f1db1fab5 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/hooks/useSetRecordGroups.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useSetRecordGroups.ts @@ -1,17 +1,17 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; -import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; import { recordGroupIdsComponentState } from '@/object-record/record-group/states/recordGroupIdsComponentState'; import { type RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId'; import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; import { type ViewGroup } from '@/views/types/ViewGroup'; import { mapViewGroupsToRecordGroupDefinitions } from '@/views/utils/mapViewGroupsToRecordGroupDefinitions'; import { useCallback } from 'react'; import { useRecoilCallback } from 'recoil'; -import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { isDefined } from 'twenty-shared/utils'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; export const useSetRecordGroups = () => { const setRecordGroups = useRecoilCallback( @@ -48,7 +48,7 @@ export const useSetRecordGroups = () => { : undefined; const currentFieldMetadata = getSnapshotValue( snapshot, - recordGroupFieldMetadataComponentState.atomFamily({ + recordIndexGroupFieldMetadataItemComponentState.atomFamily({ instanceId: recordIndexId, }), ); @@ -56,7 +56,7 @@ export const useSetRecordGroups = () => { // Set the field metadata linked to the record groups if (!isDeeplyEqual(fieldMetadata, currentFieldMetadata)) { set( - recordGroupFieldMetadataComponentState.atomFamily({ + recordIndexGroupFieldMetadataItemComponentState.atomFamily({ instanceId: recordIndexId, }), fieldMetadata, diff --git a/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupFieldMetadataComponentState.ts b/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupFieldMetadataComponentState.ts deleted file mode 100644 index 18b1998091..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupFieldMetadataComponentState.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; -import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; - -export const recordGroupFieldMetadataComponentState = createComponentState< - FieldMetadataItem | undefined ->({ - key: 'recordGroupFieldMetadataComponentState', - defaultValue: undefined, - componentInstanceContext: ViewComponentInstanceContext, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect.tsx deleted file mode 100644 index 5e615b2064..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect } from 'react'; -import { useRecoilState, useSetRecoilState } from 'recoil'; - -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { isRecordBoardFetchingRecordsByColumnFamilyState } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsByColumnFamilyState'; -import { recordBoardShouldFetchMoreInColumnComponentFamilyState } from '@/object-record/record-board/states/recordBoardShouldFetchMoreInColumnComponentFamilyState'; -import { useLoadRecordIndexBoardColumn } from '@/object-record/record-index/hooks/useLoadRecordIndexBoardColumn'; -import { isRecordIndexBoardColumnLoadingFamilyState } from '@/object-record/states/isRecordBoardColumnLoadingFamilyState'; -import { useRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyState'; - -export const RecordIndexBoardColumnLoaderEffect = ({ - objectNameSingular, - recordBoardId, - kanbanFieldMetadataItem, - columnId, -}: { - recordBoardId: string; - objectNameSingular: string; - kanbanFieldMetadataItem: FieldMetadataItem; - columnId: string; -}) => { - const [shouldFetchMore, setShouldFetchMore] = useRecoilComponentFamilyState( - recordBoardShouldFetchMoreInColumnComponentFamilyState, - columnId, - recordBoardId, - ); - - const [loadingRecordsForThisColumn, setLoadingRecordsForThisColumn] = - useRecoilState(isRecordBoardFetchingRecordsByColumnFamilyState(columnId)); - - const { fetchMoreRecords, loading, records, hasNextPage } = - useLoadRecordIndexBoardColumn({ - objectNameSingular, - recordBoardId, - kanbanFieldMetadataItem, - columnId, - }); - - const setIsRecordIndexLoading = useSetRecoilState( - isRecordIndexBoardColumnLoadingFamilyState(columnId), - ); - - useEffect(() => { - setIsRecordIndexLoading(loading && records.length === 0); - }, [records, loading, setIsRecordIndexLoading]); - - useEffect(() => { - const run = async () => { - if (!loading && shouldFetchMore && hasNextPage) { - setLoadingRecordsForThisColumn(true); - setShouldFetchMore(false); - - await fetchMoreRecords?.(); - - setLoadingRecordsForThisColumn(false); - } - }; - - run(); - }, [ - setShouldFetchMore, - fetchMoreRecords, - loading, - shouldFetchMore, - setLoadingRecordsForThisColumn, - loadingRecordsForThisColumn, - - hasNextPage, - ]); - - return <>; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoader.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoader.tsx deleted file mode 100644 index b957b46ca7..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoader.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { recordGroupIdsComponentState } from '@/object-record/record-group/states/recordGroupIdsComponentState'; -import { RecordIndexBoardColumnLoaderEffect } from '@/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect'; -import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataItemComponentState'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { isDefined } from 'twenty-shared/utils'; - -type RecordIndexBoardDataLoaderProps = { - objectNameSingular: string; - recordBoardId: string; -}; - -export const RecordIndexBoardDataLoader = ({ - objectNameSingular, - recordBoardId, -}: RecordIndexBoardDataLoaderProps) => { - const recordGroupIds = useRecoilComponentValue(recordGroupIdsComponentState); - - const recordIndexGroupFieldMetadataItem = useRecoilComponentValue( - recordIndexGroupFieldMetadataItemComponentState, - ); - - if (!isDefined(recordIndexGroupFieldMetadataItem)) { - return null; - } - - return ( - <> - {recordGroupIds.map((recordGroupId) => ( - - ))} - {recordIndexGroupFieldMetadataItem.isNullable === true && ( - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 423451be6c..ecebfa9421 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -2,9 +2,7 @@ import styled from '@emotion/styled'; import { useRecoilState } from 'recoil'; import { ObjectOptionsDropdown } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdown'; -import { RecordIndexBoardContainer } from '@/object-record/record-index/components/RecordIndexBoardContainer'; -import { RecordIndexBoardDataLoader } from '@/object-record/record-index/components/RecordIndexBoardDataLoader'; -import { RecordIndexBoardDataLoaderEffect } from '@/object-record/record-index/components/RecordIndexBoardDataLoaderEffect'; +import { RecordBoardContainer } from '@/object-record/record-board/components/RecordBoardContainer'; import { RecordIndexTableContainer } from '@/object-record/record-index/components/RecordIndexTableContainer'; import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect'; import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; @@ -15,7 +13,6 @@ import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/compone import { RecordIndexCalendarContainer } from '@/object-record/record-index/components/RecordIndexCalendarContainer'; import { RecordIndexFiltersToContextStoreEffect } from '@/object-record/record-index/components/RecordIndexFiltersToContextStoreEffect'; -import { RecordIndexTableContainerEffect } from '@/object-record/record-index/components/RecordIndexTableContainerEffect'; import { ViewBar } from '@/views/components/ViewBar'; import { ViewType } from '@/views/types/ViewType'; @@ -68,21 +65,15 @@ export const RecordIndexContainer = () => { {recordIndexViewType === ViewType.Table && ( <> - )} {recordIndexViewType === ViewType.Kanban && ( - - - )} {recordIndexViewType === ViewType.Calendar && ( diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexGroupAggregateQueryEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexGroupAggregateQueryEffect.tsx index e329876d93..69b46501a4 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexGroupAggregateQueryEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexGroupAggregateQueryEffect.tsx @@ -1,4 +1,5 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useAggregateGqlFieldsFromRecordIndexGroupAggregates } from '@/object-record/record-index/hooks/useAggregateGqlFieldsFromRecordIndexGroupAggregates'; import { useRecordIndexGroupsAggregatesGroupBy } from '@/object-record/record-index/hooks/useRecordIndexGroupsAggregatesGroupBy'; @@ -9,6 +10,7 @@ import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/ import { turnRecordIndexGroupByAggregateQueryResultIntoRecordAggregateValueByGroupValue } from '@/object-record/record-index/utils/turnRecordIndexGroupByAggregateQueryResultIntoRecordAggregateValueByGroupValue'; import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useEffect } from 'react'; import { type Nullable } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; @@ -49,6 +51,10 @@ export const RecordIndexGroupAggregateQueryEffect = ({ const { setRecordIndexAggregateDisplayValueForRecordGroupValue } = useSetRecordIndexAggregateDisplayValueForRecordGroupValue(); + const recordGroupDefinitions = useRecoilComponentValue( + recordGroupDefinitionsComponentSelector, + ); + useEffect(() => { if ( !loading && @@ -71,13 +77,29 @@ export const RecordIndexGroupAggregateQueryEffect = ({ recordIndexGroupAggregateFieldMetadataItem, ); - for (const recordAggregateValueByGroupValue of recordAggregateValueByGroupValueArray) { - setRecordIndexAggregateDisplayValueForRecordGroupValue( - recordIndexGroupAggregateOperation, - recordIndexGroupAggregateFieldMetadataItem, - recordAggregateValueByGroupValue.recordGroupValue, - recordAggregateValueByGroupValue.recordAggregateValue, - ); + for (const recordGroupDefinition of recordGroupDefinitions) { + const foundAggregateValueForGroup = + recordAggregateValueByGroupValueArray.find( + (recordAggregateValueByGroupValue) => + recordAggregateValueByGroupValue.recordGroupValue === + recordGroupDefinition.value, + ); + + if (isDefined(foundAggregateValueForGroup)) { + setRecordIndexAggregateDisplayValueForRecordGroupValue( + recordIndexGroupAggregateOperation, + recordIndexGroupAggregateFieldMetadataItem, + foundAggregateValueForGroup.recordGroupValue, + foundAggregateValueForGroup.recordAggregateValue, + ); + } else { + setRecordIndexAggregateDisplayValueForRecordGroupValue( + recordIndexGroupAggregateOperation, + recordIndexGroupAggregateFieldMetadataItem, + recordGroupDefinition.value ?? '', + 0, + ); + } } } } @@ -92,6 +114,7 @@ export const RecordIndexGroupAggregateQueryEffect = ({ recordAggregateGqlField, recordIndexAggregateDisplayLabelCallbackState, objectMetadataItem, + recordGroupDefinitions, ]); return null; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexGroupAggregatesDataLoader.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexGroupAggregatesDataLoader.tsx index 04faaf6430..b1e70e83f8 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexGroupAggregatesDataLoader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexGroupAggregatesDataLoader.tsx @@ -1,7 +1,7 @@ import { RecordIndexGroupAggregateQueryEffect } from '@/object-record/record-index/components/RecordIndexGroupAggregateQueryEffect'; import { recordIndexGroupAggregateFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateFieldMetadataItemComponentState'; import { recordIndexGroupAggregateOperationComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateOperationComponentState'; -import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataItemComponentState'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { isDefined } from 'twenty-shared/utils'; diff --git a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useRecordIndexLazyFetchRecords.ts b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useRecordIndexLazyFetchRecords.ts index 9d51ea2a7a..9282a6cd82 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useRecordIndexLazyFetchRecords.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useRecordIndexLazyFetchRecords.ts @@ -14,8 +14,8 @@ import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdow import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; import { type RecordField } from '@/object-record/record-field/types/RecordField'; import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; -import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/hooks/useFindManyRecordIndexTableParams'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { ViewType } from '@/views/types/ViewType'; import { isDefined } from 'twenty-shared/utils'; @@ -59,7 +59,7 @@ export const useRecordIndexLazyFetchRecords = ({ }); const recordGroupFieldMetadata = useRecoilComponentValue( - recordGroupFieldMetadataComponentState, + recordIndexGroupFieldMetadataItemComponentState, recordIndexId, ); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts deleted file mode 100644 index 5a20afa5da..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useEffect } from 'react'; -import { useRecoilValue } from 'recoil'; - -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; -import { useSetRecordIdsForColumn } from '@/object-record/record-board/hooks/useSetRecordIdsForColumn'; -import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; -import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; -import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; -import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; - -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; - -import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields'; -import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState'; -import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; -import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { - combineFilters, - computeRecordGqlOperationFilter, - isDefined, - turnAnyFieldFilterIntoRecordGqlFilter, -} from 'twenty-shared/utils'; - -type UseLoadRecordIndexBoardProps = { - objectNameSingular: string; - kanbanFieldMetadataItem: FieldMetadataItem; - recordBoardId: string; - columnId: string; -}; - -export const useLoadRecordIndexBoardColumn = ({ - objectNameSingular, - kanbanFieldMetadataItem, - recordBoardId, - columnId, -}: UseLoadRecordIndexBoardProps) => { - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular, - }); - const { setRecordIdsForColumn } = useSetRecordIdsForColumn(recordBoardId); - const { upsertRecordsInStore } = useUpsertRecordsInStore(); - - const recordGroupDefinition = useRecoilValue( - recordGroupDefinitionFamilyState(columnId), - ); - - const currentRecordFilterGroups = useRecoilComponentValue( - currentRecordFilterGroupsComponentState, - ); - - const currentRecordFilters = useRecoilComponentValue( - currentRecordFiltersComponentState, - ); - - const currentRecordSorts = useRecoilComponentValue( - currentRecordSortsComponentState, - ); - - const { filterValueDependencies } = useFilterValueDependencies(); - - const requestFilters = computeRecordGqlOperationFilter({ - filterValueDependencies, - recordFilters: currentRecordFilters, - recordFilterGroups: currentRecordFilterGroups, - fields: objectMetadataItem.fields, - }); - - const anyFieldFilterValue = useRecoilComponentValue( - anyFieldFilterValueComponentState, - ); - - const { recordGqlOperationFilter: anyFieldFilter } = - turnAnyFieldFilterIntoRecordGqlFilter({ - fields: objectMetadataItem.fields, - filterValue: anyFieldFilterValue, - }); - - const orderBy = turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts); - const recordGroupFieldMetadata = useRecoilComponentValue( - recordGroupFieldMetadataComponentState, - recordBoardId, - ); - - const recordGqlFields = useRecordsFieldVisibleGqlFields({ - objectMetadataItem, - additionalFieldMetadataId: recordGroupFieldMetadata?.id, - }); - - const recordIndexKanbanFieldMetadataFilterValue = isDefined( - recordGroupDefinition?.value, - ) - ? { in: [recordGroupDefinition?.value] } - : { is: 'NULL' }; - - const combinedFilters = combineFilters([ - anyFieldFilter, - requestFilters, - { - [kanbanFieldMetadataItem.name]: recordIndexKanbanFieldMetadataFilterValue, - }, - ]); - - const { records, loading, fetchMoreRecords, queryIdentifier, hasNextPage } = - useFindManyRecords({ - objectNameSingular, - filter: combinedFilters, - orderBy, - recordGqlFields, - limit: 10, - }); - - useEffect(() => { - setRecordIdsForColumn(columnId, records); - }, [records, setRecordIdsForColumn, columnId]); - - useEffect(() => { - upsertRecordsInStore(records); - }, [records, upsertRecordsInStore]); - - return { - records, - loading, - fetchMoreRecords, - queryIdentifier, - hasNextPage, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexStates.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexStates.ts index ad6f6bdbd3..c3eda9e8fa 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexStates.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexStates.ts @@ -6,12 +6,12 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRecordGroups'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; import { recordIndexGroupAggregateFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateFieldMetadataItemComponentState'; import { recordIndexGroupAggregateOperationComponentState } from '@/object-record/record-index/states/recordIndexGroupAggregateOperationComponentState'; -import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataItemComponentState'; import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState'; import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; import { viewFieldAggregateOperationState } from '@/object-record/record-table/record-table-footer/states/viewFieldAggregateOperationState'; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables.ts new file mode 100644 index 0000000000..b7591c308a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables.ts @@ -0,0 +1,69 @@ +import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; +import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/hooks/useRecordsFieldVisibleGqlFields'; +import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState'; +import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies'; +import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState'; +import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; +import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { + combineFilters, + computeRecordGqlOperationFilter, + turnAnyFieldFilterIntoRecordGqlFilter, +} from 'twenty-shared/utils'; + +export const useRecordIndexGroupCommonQueryVariables = () => { + const { objectMetadataItem } = useRecordIndexContextOrThrow(); + + const currentRecordFilterGroups = useRecoilComponentValue( + currentRecordFilterGroupsComponentState, + ); + + const currentRecordFilters = useRecoilComponentValue( + currentRecordFiltersComponentState, + ); + + const currentRecordSorts = useRecoilComponentValue( + currentRecordSortsComponentState, + ); + + const { filterValueDependencies } = useFilterValueDependencies(); + + const requestFilters = computeRecordGqlOperationFilter({ + filterValueDependencies, + recordFilters: currentRecordFilters, + recordFilterGroups: currentRecordFilterGroups, + fields: objectMetadataItem.fields, + }); + + const anyFieldFilterValue = useRecoilComponentValue( + anyFieldFilterValueComponentState, + ); + + const { recordGqlOperationFilter: anyFieldFilter } = + turnAnyFieldFilterIntoRecordGqlFilter({ + fields: objectMetadataItem.fields, + filterValue: anyFieldFilterValue, + }); + + const orderBy = turnSortsIntoOrderBy(objectMetadataItem, currentRecordSorts); + + const recordGroupFieldMetadata = useRecoilComponentValue( + recordIndexGroupFieldMetadataItemComponentState, + ); + + const recordGqlFields = useRecordsFieldVisibleGqlFields({ + objectMetadataItem, + additionalFieldMetadataId: recordGroupFieldMetadata?.id, + }); + + const combinedFilters = combineFilters([anyFieldFilter, requestFilters]); + + return { + combinedFilters, + recordGqlFields, + orderBy, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupsRecordsLazyGroupBy.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupsRecordsLazyGroupBy.ts new file mode 100644 index 0000000000..998d6554df --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupsRecordsLazyGroupBy.ts @@ -0,0 +1,71 @@ +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { type RecordGqlEdge } from '@/object-record/graphql/types/RecordGqlEdge'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { generateGroupsRecordsGroupByQuery } from '@/object-record/record-aggregate/utils/generateGroupsRecordsGroupByQuery'; +import { useRecordIndexGroupCommonQueryVariables } from '@/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables'; +import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject'; +import { useLazyQuery } from '@apollo/client'; +import { type Nullable } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { type PageInfo } from '~/generated/graphql'; + +export type GroupsRecordsGroupByLazyResult = { + [queryGqlFieldName: string]: Array<{ + groupByDimensionValues: string[]; + edges: RecordGqlEdge[]; + pageInfo: PageInfo; + __typename: string; + }>; +}; + +export const useRecordIndexGroupsRecordsLazyGroupBy = ({ + objectMetadataItem, + groupByFieldMetadataItem, +}: { + objectMetadataItem: ObjectMetadataItem; + groupByFieldMetadataItem: Nullable; +}) => { + const { objectMetadataItems } = useObjectMetadataItems(); + const apolloCoreClient = useApolloCoreClient(); + + const { combinedFilters, orderBy, recordGqlFields } = + useRecordIndexGroupCommonQueryVariables(); + + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const recordIndexGroupsRecordGroupsGroupByQuery = + generateGroupsRecordsGroupByQuery({ + objectMetadataItem, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + recordGqlFields, + }); + + const groupByGqlInput = isDefined(groupByFieldMetadataItem) + ? buildGroupByFieldObject({ + field: groupByFieldMetadataItem, + }) + : {}; + + const [executeRecordIndexGroupsRecordsLazyGroupBy] = + useLazyQuery( + recordIndexGroupsRecordGroupsGroupByQuery, + { + variables: { + filter: { ...combinedFilters }, + groupBy: { + ...groupByGqlInput, + }, + orderByForRecords: orderBy, + }, + client: apolloCoreClient, + }, + ); + + return { + executeRecordIndexGroupsRecordsLazyGroupBy, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexGroupFieldMetadataItemComponentState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState.ts similarity index 54% rename from packages/twenty-front/src/modules/object-record/record-index/states/recordIndexGroupFieldMetadataItemComponentState.ts rename to packages/twenty-front/src/modules/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState.ts index e672db6a61..96e0b8ec00 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexGroupFieldMetadataItemComponentState.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState.ts @@ -1,10 +1,10 @@ -import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; export const recordIndexGroupFieldMetadataItemComponentState = - createComponentState({ + createComponentState({ key: 'recordIndexGroupFieldMetadataItemComponentState', - defaultValue: null, - componentInstanceContext: ContextStoreComponentInstanceContext, + defaultValue: undefined, + componentInstanceContext: ViewComponentInstanceContext, }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupsAreInInitialLoadingComponentState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupsAreInInitialLoadingComponentState.ts new file mode 100644 index 0000000000..43c73df127 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexRecordGroupsAreInInitialLoadingComponentState.ts @@ -0,0 +1,9 @@ +import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordIndexRecordGroupsAreInInitialLoadingComponentState = + createComponentState({ + key: 'recordIndexRecordGroupsAreInInitialLoadingComponentState', + defaultValue: false, + componentInstanceContext: ContextStoreComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx index 4b54c98181..aba93ef692 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableWithWrappers.tsx @@ -1,4 +1,5 @@ import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; +import { RecordIndexTableContainerEffect } from '@/object-record/record-index/components/RecordIndexTableContainerEffect'; import { useOpenRecordFromIndexView } from '@/object-record/record-index/hooks/useOpenRecordFromIndexView'; import { RecordTable } from '@/object-record/record-table/components/RecordTable'; import { RecordTableComponentInstance } from '@/object-record/record-table/components/RecordTableComponentInstance'; @@ -67,6 +68,7 @@ export const RecordTableWithWrappers = ({ > + diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap b/packages/twenty-front/src/modules/object-record/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap index 2e4c896a78..b31a2f9c93 100644 --- a/packages/twenty-front/src/modules/object-record/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap @@ -2,7 +2,7 @@ exports[`generateGroupByQuery should generate valid GraphQL query for empty aggregate operations 1`] = ` " - query PeopleGroupBy( + query PeopleGroupByAggregates( $groupBy: [PersonGroupByInput!]! $filter: PersonFilterInput $orderBy: [PersonOrderByWithGroupByInput!] @@ -24,7 +24,7 @@ exports[`generateGroupByQuery should generate valid GraphQL query for empty aggr exports[`generateGroupByQuery should generate valid GraphQL query for multiple aggregate operations 1`] = ` " - query OpportunitiesGroupBy( + query OpportunitiesGroupByAggregates( $groupBy: [OpportunityGroupByInput!]! $filter: OpportunityFilterInput $orderBy: [OpportunityOrderByWithGroupByInput!] @@ -49,7 +49,7 @@ exports[`generateGroupByQuery should generate valid GraphQL query for multiple a exports[`generateGroupByQuery should generate valid GraphQL query for single aggregate operation 1`] = ` " - query OpportunitiesGroupBy( + query OpportunitiesGroupByAggregates( $groupBy: [OpportunityGroupByInput!]! $filter: OpportunityFilterInput $orderBy: [OpportunityOrderByWithGroupByInput!] diff --git a/packages/twenty-front/src/modules/object-record/utils/__tests__/computeNewPositionOfRecordWithPosition.test.ts b/packages/twenty-front/src/modules/object-record/utils/__tests__/computeNewPositionOfRecordWithPosition.test.ts index 7fbcb30e9f..fb608e8de3 100644 --- a/packages/twenty-front/src/modules/object-record/utils/__tests__/computeNewPositionOfRecordWithPosition.test.ts +++ b/packages/twenty-front/src/modules/object-record/utils/__tests__/computeNewPositionOfRecordWithPosition.test.ts @@ -23,43 +23,106 @@ const mockRecordsWithPosition: RecordWithPosition[] = [ ]; describe('computeNewPositionOfRecordWithPosition', () => { - it('should compute first position', () => { - const newPosition = computeNewPositionOfDraggedRecord({ - arrayOfRecordsWithPosition: mockRecordsWithPosition, - idOfItemToMove: 'B', - idOfTargetItem: 'A', + describe('dragging inside same list', () => { + it('should compute first position', () => { + const newPosition = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: mockRecordsWithPosition, + idOfItemToMove: 'B', + idOfTargetItem: 'A', + isDroppedAfterList: false, + }); + + expect(newPosition).toEqual(-1); }); - expect(newPosition).toEqual(-1); + it('should compute last position', () => { + const newPosition = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: mockRecordsWithPosition, + idOfItemToMove: 'B', + idOfTargetItem: 'D', + isDroppedAfterList: true, + }); + + expect(newPosition).toEqual(4); + }); + + it('should compute intermediary position after target item', () => { + const newPosition = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: mockRecordsWithPosition, + idOfItemToMove: 'A', + idOfTargetItem: 'B', + isDroppedAfterList: false, + }); + + expect(newPosition).toEqual(1.5); + }); + + it('should compute intermediary position before target item', () => { + const newPosition = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: mockRecordsWithPosition, + idOfItemToMove: 'A', + idOfTargetItem: 'C', + isDroppedAfterList: false, + }); + + expect(newPosition).toEqual(2.5); + }); }); - it('should compute last position', () => { - const newPosition = computeNewPositionOfDraggedRecord({ - arrayOfRecordsWithPosition: mockRecordsWithPosition, - idOfItemToMove: 'B', - idOfTargetItem: 'D', + describe('dragging from another list', () => { + it('should compute first position', () => { + const newPosition = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: mockRecordsWithPosition, + idOfItemToMove: 'Unknown', + idOfTargetItem: 'A', + isDroppedAfterList: false, + }); + + expect(newPosition).toEqual(-1); }); - expect(newPosition).toEqual(4); - }); + it('should compute last position', () => { + const newPosition = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: mockRecordsWithPosition, + idOfItemToMove: 'Unknown', + idOfTargetItem: 'D', + isDroppedAfterList: true, + }); - it('should compute intermediary position after target item', () => { - const newPosition = computeNewPositionOfDraggedRecord({ - arrayOfRecordsWithPosition: mockRecordsWithPosition, - idOfItemToMove: 'A', - idOfTargetItem: 'B', + expect(newPosition).toEqual(4); }); - expect(newPosition).toEqual(1.5); - }); + it('should compute position before last item', () => { + const newPosition = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: mockRecordsWithPosition, + idOfItemToMove: 'Unknown', + idOfTargetItem: 'D', + isDroppedAfterList: false, + }); - it('should compute intermediary position before target item', () => { - const newPosition = computeNewPositionOfDraggedRecord({ - arrayOfRecordsWithPosition: mockRecordsWithPosition, - idOfItemToMove: 'A', - idOfTargetItem: 'C', + expect(newPosition).toEqual(2.5); }); - expect(newPosition).toEqual(2.5); + it('should compute intermediary position after target item', () => { + const newPosition = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: mockRecordsWithPosition, + idOfItemToMove: 'Unknown', + idOfTargetItem: 'B', + isDroppedAfterList: false, + }); + + expect(newPosition).toEqual(0.5); + }); + + it('should compute intermediary position before target item', () => { + const newPosition = computeNewPositionOfDraggedRecord({ + arrayOfRecordsWithPosition: mockRecordsWithPosition, + idOfItemToMove: 'Unknown', + idOfTargetItem: 'C', + isDroppedAfterList: false, + }); + + expect(newPosition).toEqual(1.5); + }); }); }); diff --git a/packages/twenty-front/src/modules/object-record/utils/computeNewPositionOfDraggedRecord.ts b/packages/twenty-front/src/modules/object-record/utils/computeNewPositionOfDraggedRecord.ts index 0a22b4ea8c..716ab612a7 100644 --- a/packages/twenty-front/src/modules/object-record/utils/computeNewPositionOfDraggedRecord.ts +++ b/packages/twenty-front/src/modules/object-record/utils/computeNewPositionOfDraggedRecord.ts @@ -10,10 +10,12 @@ export const computeNewPositionOfDraggedRecord = ({ arrayOfRecordsWithPosition, idOfItemToMove, idOfTargetItem, + isDroppedAfterList, }: { arrayOfRecordsWithPosition: RecordWithPosition[]; idOfItemToMove: string; idOfTargetItem: string; + isDroppedAfterList: boolean; }) => { const targetItem = arrayOfRecordsWithPosition.find( (recordToFind) => recordToFind.id === idOfTargetItem, @@ -29,6 +31,10 @@ export const computeNewPositionOfDraggedRecord = ({ const targetPosition = targetItem.position; + if (isDroppedAfterList) { + return targetPosition + 1; + } + const sortedRecordsByAscendingPosition = arrayOfRecordsWithPosition.toSorted( sortByProperty('position'), ); @@ -43,16 +49,10 @@ export const computeNewPositionOfDraggedRecord = ({ (recordToFind) => recordToFind.id === idOfTargetItem, ); - const lastIndex = sortedRecordsByAscendingPosition.length - 1; - const shouldGoToFirstPosition = indexOfTargetItem === 0; - const shouldGoToLastPosition = indexOfTargetItem === lastIndex; - if (shouldGoToFirstPosition) { return targetPosition - 1; - } else if (shouldGoToLastPosition) { - return targetPosition + 1; } else { if (itemToMoveIsNotInTable) { const itemBeforeTargetItem = diff --git a/packages/twenty-front/src/modules/object-record/utils/computeNewPositionsOfDraggedRecords.ts b/packages/twenty-front/src/modules/object-record/utils/computeNewPositionsOfDraggedRecords.ts index 32f5502038..8c3fc805a7 100644 --- a/packages/twenty-front/src/modules/object-record/utils/computeNewPositionsOfDraggedRecords.ts +++ b/packages/twenty-front/src/modules/object-record/utils/computeNewPositionsOfDraggedRecords.ts @@ -8,11 +8,13 @@ export const computeNewPositionsOfDraggedRecords = ({ draggedRecordId, targetRecordId, sourceRecordIds, + isDroppedAfterList, }: { arrayOfRecordsWithPosition: RecordWithPosition[]; draggedRecordId: string; targetRecordId: string; sourceRecordIds: string[]; + isDroppedAfterList: boolean; }): RecordWithPosition[] | null => { const targetItem = arrayOfRecordsWithPosition.find( (recordToFind) => recordToFind.id === targetRecordId, @@ -38,12 +40,8 @@ export const computeNewPositionsOfDraggedRecords = ({ (recordToFind) => recordToFind.id === targetRecordId, ); - const lastIndex = arrayOfRecordsWithPosition.length - 1; - const shouldGoToFirstPosition = indexOfTargetItem === 0; - const shouldGoToLastPosition = indexOfTargetItem === lastIndex; - if (shouldGoToFirstPosition) { const newPositions = computeNewEvenlySpacedPositions({ startingPosition: targetPosition - 1, @@ -58,7 +56,7 @@ export const computeNewPositionsOfDraggedRecords = ({ })); return newSourceRecordsWithPosition; - } else if (shouldGoToLastPosition) { + } else if (isDroppedAfterList) { const newPositions = computeNewEvenlySpacedPositions({ startingPosition: targetPosition, endingPosition: targetPosition + sourceRecordIds.length + 1, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts index ffb3ac62f3..21747d7a03 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts @@ -158,6 +158,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic queryRunnerContext, orderByForRecords: args.orderByForRecords ?? [], groupLimit: args.limit, + offsetForRecords: args.offsetForRecords, }); } diff --git a/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts b/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts index 10c26de59e..ddb5c451ef 100644 --- a/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts +++ b/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts @@ -71,6 +71,7 @@ export interface GroupByQueryArgs { includeRecords?: boolean; selectedFields: CommonSelectedFields; limit?: number; + offsetForRecords?: number; } export interface DestroyOneQueryArgs { id: string; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts index 62d70c022e..27d953065a 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts @@ -1,3 +1,5 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'class-validator'; import { type OrderByWithGroupBy } from 'twenty-shared/types'; import { type FindOptionsWhere, type ObjectLiteral } from 'typeorm'; @@ -115,6 +117,36 @@ export class GraphqlQueryParser { return queryBuilder.orderBy(parsedOrderBys); } + public getOrderByRawSQL( + orderBy: ObjectRecordOrderBy | OrderByWithGroupBy, + objectNameSingular: string, + isForwardPagination = true, + ): string { + const parsedOrderBys = this.orderFieldParser.parse( + orderBy as ObjectRecordOrderBy, + objectNameSingular, + isForwardPagination, + ); + + const orderByRawSQLClauseArray = Object.entries(parsedOrderBys).map( + ([orderByField, orderByCondition]) => { + const nullsCondition = isDefined(orderByCondition.nulls) + ? ` ${orderByCondition.nulls}` + : ''; + + return `${orderByField} ${orderByCondition.order}${nullsCondition}`; + }, + ); + + const orderByRawSQLString = orderByRawSQLClauseArray.join(', '); + + const orderByCompleteSQLClause = isNonEmptyString(orderByRawSQLString) + ? `ORDER BY ${orderByRawSQLString}` + : ''; + + return orderByCompleteSQLClause; + } + public applyGroupByOrderToBuilder( // eslint-disable-next-line @typescript-eslint/no-explicit-any queryBuilder: WorkspaceSelectQueryBuilder, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts index 3cff2bfa5f..75c0d3059b 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts @@ -1,5 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; +import { isNonEmptyString } from '@sniptt/guards'; import isEmpty from 'lodash.isempty'; import { ObjectRecord } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; @@ -44,6 +45,7 @@ export class GroupByWithRecordsService { queryRunnerContext, orderByForRecords, groupLimit, + offsetForRecords, }: { queryBuilderWithGroupBy: WorkspaceSelectQueryBuilder; queryBuilderWithFiltersAndWithoutGroupBy: WorkspaceSelectQueryBuilder; @@ -52,6 +54,7 @@ export class GroupByWithRecordsService { queryRunnerContext: CommonExtendedQueryRunnerContext; orderByForRecords: ObjectRecordOrderBy; groupLimit?: number; + offsetForRecords?: number; }): Promise { const effectiveGroupLimit = getGroupLimit(groupLimit); @@ -91,16 +94,21 @@ export class GroupByWithRecordsService { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + offsetForRecords, }); const recordsResult = await queryBuilderWithPartitionBy.getRawMany(); + const allRecords = recordsResult + .flatMap((group) => group.records) + .filter(isDefined); + if (!isEmpty(selectedFieldsResult.relations)) { await this.processNestedRelationsHelper.processNestedRelations({ flatObjectMetadataMaps, flatFieldMetadataMaps, parentObjectMetadataItem: flatObjectMetadata, - parentObjectRecords: recordsResult.flatMap((group) => group.records), + parentObjectRecords: allRecords, parentObjectRecordsAggregatedValues: {}, relations: selectedFieldsResult.relations, aggregate: selectedFieldsResult.aggregate, @@ -141,6 +149,7 @@ export class GroupByWithRecordsService { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps, + offsetForRecords = 0, }: { queryBuilderForSubQuery: WorkspaceSelectQueryBuilder; columnsToSelect: Record; @@ -151,11 +160,8 @@ export class GroupByWithRecordsService { flatObjectMetadata: FlatObjectMetadata; flatObjectMetadataMaps: FlatEntityMaps; flatFieldMetadataMaps: FlatEntityMaps; + offsetForRecords?: number; }): WorkspaceSelectQueryBuilder { - const groupByExpressions = groupByDefinitions - .map((def) => def.expression) - .join(', '); - const groupByAliases = groupByDefinitions .map((def) => `"${def.alias}"`) .join(', '); @@ -179,9 +185,17 @@ export class GroupByWithRecordsService { const subQuery = queryBuilderForSubQuery .select(recordSelectWithAlias) .addSelect(groupBySelectWithAlias) - .addSelect(`ROW_NUMBER() OVER (PARTITION BY ${groupByExpressions})`, 'rn') .andWhere(groupConditions); + this.applyPartitionByToBuilder({ + groupByDefinitions, + flatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + orderByForRecords, + queryBuilder: subQuery, + }); + if (!isEmpty(orderByForRecords)) { const graphqlQueryParser = new GraphqlQueryParser( flatObjectMetadata, @@ -198,16 +212,17 @@ export class GroupByWithRecordsService { let mainQueryQueryBuilder = repository.createQueryBuilder(); + const pageStart = offsetForRecords; + const pageEnd = offsetForRecords + RECORDS_PER_GROUP_LIMIT; + const mainQuery = mainQueryQueryBuilder .from(`(${subQuery.getQuery()})`, 'ranked_records') .setParameters(queryBuilderForSubQuery.expressionMap.parameters) - .where('rn <= :recordsPerGroupLimit', { - recordsPerGroupLimit: RECORDS_PER_GROUP_LIMIT, - }) + .select(groupByAliases) .addSelect( `JSON_AGG( - CASE WHEN rn <= ${RECORDS_PER_GROUP_LIMIT} THEN + CASE WHEN record_row_number > ${pageStart} AND record_row_number <= ${pageEnd} THEN JSON_BUILD_OBJECT( ${[ ...Object.keys(columnsToSelect).map( @@ -219,7 +234,7 @@ export class GroupByWithRecordsService { ].join(',\n ')} ) END - ) FILTER (WHERE rn <= ${RECORDS_PER_GROUP_LIMIT})`, + ) FILTER (WHERE record_row_number > ${pageStart} AND record_row_number <= ${pageEnd})`, 'records', ) .groupBy(groupByAliases); @@ -232,6 +247,53 @@ export class GroupByWithRecordsService { return mainQuery as WorkspaceSelectQueryBuilder; } + private applyPartitionByToBuilder({ + groupByDefinitions, + flatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + orderByForRecords, + queryBuilder, + }: { + queryBuilder: WorkspaceSelectQueryBuilder; + groupByDefinitions: GroupByDefinition[]; + orderByForRecords: ObjectRecordOrderBy; + flatObjectMetadata: FlatObjectMetadata; + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; + }) { + const groupByExpressions = groupByDefinitions + .map((def) => def.expression) + .join(', '); + + const hasOrderByForRecords = !isEmpty(orderByForRecords); + + if (hasOrderByForRecords) { + const graphqlQueryParser = new GraphqlQueryParser( + flatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + ); + + const orderByRawSQL = graphqlQueryParser.getOrderByRawSQL( + orderByForRecords, + flatObjectMetadata.nameSingular, + ); + + if (isNonEmptyString(orderByRawSQL)) { + return queryBuilder.addSelect( + `ROW_NUMBER() OVER (PARTITION BY ${groupByExpressions} ${orderByRawSQL})`, + 'record_row_number', + ); + } + } + + return queryBuilder.addSelect( + `ROW_NUMBER() OVER (PARTITION BY ${groupByExpressions})`, + 'record_row_number', + ); + } + private buildGroupConditions( groupsResult: Array>, groupByDefinitions: GroupByDefinition[], diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts index f7c88999bd..870fb9dfff 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts @@ -64,6 +64,7 @@ export interface GroupByResolverArgs { viewId?: string; orderBy?: OrderByWithGroupBy; orderByForRecords?: ObjectRecordOrderBy; + offsetForRecords?: number; limit?: number; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts index a8b5018c05..606e72c18c 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts @@ -190,6 +190,10 @@ export const getResolverArgs = ( type: GraphQLInt, isNullable: true, }, + offsetForRecords: { + type: GraphQLInt, + isNullable: true, + }, }; default: throw new Error(`Unknown resolver type: ${type}`);