Improve board experience 🖼️ (#16063)

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.
This commit is contained in:
Lucas Bordeau
2025-12-02 11:09:29 +01:00
committed by GitHub
parent 68c429a54a
commit 2691222d5f
86 changed files with 1782 additions and 612 deletions
@@ -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();
};
@@ -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();
};
@@ -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);
@@ -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(
@@ -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);
@@ -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({
@@ -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,
@@ -5,9 +5,9 @@ export type RecordGqlConnection = {
__typename?: string;
edges: RecordGqlEdge[];
pageInfo: {
__typename?: string;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
__typename?: Nullable<string>;
hasNextPage?: Nullable<boolean>;
hasPreviousPage?: Nullable<boolean>;
startCursor?: Nullable<string>;
endCursor?: Nullable<string>;
totalCount?: number;
@@ -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],
});
});
@@ -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],
});
};
@@ -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
@@ -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(
@@ -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
@@ -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 {
@@ -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(
@@ -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`
@@ -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
}
}
}
`;
};
@@ -0,0 +1,9 @@
import { capitalize } from 'twenty-shared/utils';
export const getGroupByAggregateQueryName = ({
objectMetadataNamePlural,
}: {
objectMetadataNamePlural: string;
}) => {
return `${capitalize(objectMetadataNamePlural)}GroupByAggregates`;
};
@@ -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<HTMLDivElement>(null);
return (
<RecordBoardComponentInstanceContext.Provider
value={{ instanceId: recordBoardId }}
>
<>
<ScrollWrapper
componentInstanceId={`scroll-wrapper-record-board-${recordBoardId}`}
>
<RecordBoardStickyHeaderEffect />
<RecordBoardScrollToFocusedCardEffect />
<RecordBoardDeactivateBoardCardEffect />
<RecordBoardEffects />
<StyledContainerContainer>
<RecordBoardHeader />
<StyledBoardContentContainer>
<StyledContainer ref={boardRef}>
<RecordBoardDragDropContext>
<StyledColumnContainer>
<RecordBoardColumns />
</StyledColumnContainer>
<RecordBoardColumns />
</RecordBoardDragDropContext>
<RecordBoardDragSelect boardRef={boardRef} />
<RecordBoardFetchMoreInViewTriggerComponent />
</StyledContainer>
</StyledBoardContentContainer>
</StyledContainerContainer>
</ScrollWrapper>
<RecordBoardClickOutsideEffect />
</RecordBoardComponentInstanceContext.Provider>
</>
);
};
@@ -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();
};
@@ -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();
}
@@ -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 (
<RecordBoardColumn
key={recordGroupId}
recordBoardColumnId={recordGroupId}
recordBoardColumnIndex={index}
/>
);
});
return (
<StyledColumnContainer>
{visibleRecordGroupIds.map((recordGroupId, index) => {
return (
<RecordBoardColumn
key={recordGroupId}
recordBoardColumnId={recordGroupId}
recordBoardColumnIndex={index}
/>
);
})}
</StyledColumnContainer>
);
};
@@ -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,
}}
>
<RecordBoard />
{isRecordIndexRemoveSortingModalOpened && (
<RecordIndexRemoveSortingModal />
)}
<RecordBoardHotkeyEffect />
<RecordBoardBodyEscapeHotkeyEffect />
<RecordBoardComponentInstanceContext.Provider
value={{ instanceId: recordBoardId }}
>
<RecordBoard />
{isRecordIndexRemoveSortingModalOpened && (
<RecordIndexRemoveSortingModal />
)}
<RecordBoardHotkeyEffect />
<RecordBoardBodyEscapeHotkeyEffect />
</RecordBoardComponentInstanceContext.Provider>
</RecordBoardContext.Provider>
);
};
@@ -38,7 +38,6 @@ export const RecordBoardDragDropContext = ({
const { startRecordDrag } = useStartRecordDrag();
const { endRecordDrag } = useEndRecordDrag();
// const multiDragState = useRecordDragState('board', recordBoardId);
const { processBoardCardDrop } = useProcessBoardCardDrop();
@@ -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 (
<>
<RecordBoardStickyHeaderEffect />
<RecordBoardScrollToFocusedCardEffect />
<RecordBoardDeactivateBoardCardEffect />
<RecordBoardQueryEffect />
<RecordBoardSelectRecordsEffect />
<RecordBoardClickOutsideEffect />
</>
);
};
@@ -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 (
<StyledFetchMoreTriggerDiv
width={componentWidth}
ref={ref}
></StyledFetchMoreTriggerDiv>
);
};
@@ -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;
};
@@ -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(
@@ -0,0 +1 @@
export const RECORD_BOARD_COLUMN_PADDING_AND_BORDER_WIDTH = 17;
@@ -0,0 +1 @@
export const RECORD_BOARD_COLUMN_WIDTH = 200;
@@ -0,0 +1 @@
export const RECORD_BOARD_FETCH_MORE_THROTTLING_WAIT_TIME_IN_MILLISECONDS_TO_AVOID_REACT_FREEZE = 50;
@@ -0,0 +1 @@
export const RECORD_BOARD_QUERY_PAGE_SIZE = 10;
@@ -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();
}
};
@@ -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,
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
],
);
@@ -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,
);
@@ -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,
};
};
@@ -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,
};
};
@@ -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 = () => {
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
borderRadius={2}
>
<RecordCardHeaderContainer isCompact={isCompactModeActive}>
<StyledSkeletonTitle>
<Skeleton
width={titleSkeletonWidth}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
<StyledStaticCellSkeleton width={titleSkeletonWidth} height={12} />
</StyledSkeletonTitle>
</RecordCardHeaderContainer>
<StyledSeparator />
{!isCompactModeActive &&
skeletonItems.map(({ id }) => (
<RecordCardBodyContainer key={id}>
<StyledSkeletonIconAndText>
<Skeleton
width={16}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
<Skeleton
width={151}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
</StyledSkeletonIconAndText>
</RecordCardBodyContainer>
))}
<StyledBodyContainer>
{!isCompactModeActive &&
skeletonItems.map(({ id }) => (
<RecordCardBodyContainer key={id}>
<StyledSkeletonIconAndText>
<StyledStaticCellSkeleton
width={16}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
<StyledStaticCellSkeleton
width={151}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
</StyledSkeletonIconAndText>
</RecordCardBodyContainer>
))}
</StyledBodyContainer>
</SkeletonTheme>
);
};
@@ -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) => (
<StyledSkeletonCardContainer
key={`${columnDefinition.id}-${index}`}
>
<RecordBoardColumnCardContainerSkeletonLoader />
</StyledSkeletonCardContainer>
),
)
: recordIds.map((recordId, index) => (
<RecordBoardCardDraggableContainer
key={recordId}
recordId={recordId}
rowIndex={index}
/>
))}
<RecordBoardColumnFetchMoreLoader />
{recordIndexRecordGroupsAreInInitialLoading ? (
<RecordBoardColumnLoadingSkeletonCards />
) : (
recordIds.map((recordId, index) => (
<RecordBoardCardDraggableContainer
key={recordId}
recordId={recordId}
rowIndex={index}
/>
))
)}
{recordBoardShouldFetchMoreInColumn ? (
<RecordBoardColumnLoadingSkeletonCards />
) : null}
<Draggable
draggableId={`new-${columnDefinition.id}-bottom`}
index={recordIds.length}
@@ -1,44 +0,0 @@
import styled from '@emotion/styled';
import { useContext, useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { useRecoilValue } from 'recoil';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { isRecordBoardFetchingRecordsByColumnFamilyState } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsByColumnFamilyState';
import { recordBoardShouldFetchMoreInColumnComponentFamilyState } from '@/object-record/record-board/states/recordBoardShouldFetchMoreInColumnComponentFamilyState';
import { useSetRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentFamilyState';
const StyledText = styled.div`
align-items: center;
box-shadow: none;
color: ${({ theme }) => 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 (
<div ref={ref}>
{isFetchingRecord && <StyledText>Loading more...</StyledText>}
</div>
);
};
@@ -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 (
<StyledColumn>
<StyledHeader
@@ -166,13 +223,7 @@ export const RecordBoardColumnHeader = () => {
<LightIconButton
accent="tertiary"
Icon={IconPlus}
onClick={() => {
createNewIndexRecord({
position: 'first',
[selectFieldMetadataItem.name]:
columnDefinition.value,
});
}}
onClick={handleCreateNewRecordClick}
/>
)}
</StyledHeaderActions>
@@ -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) => (
<StyledSkeletonCardContainer key={`${index}`}>
<RecordBoardColumnCardContainerSkeletonLoader />
</StyledSkeletonCardContainer>
),
);
};
@@ -1,13 +0,0 @@
export const getNumberOfCardsPerColumnForSkeletonLoading = (
columnIndex: number,
): number => {
const skeletonCounts: Record<number, number> = {
0: 2,
1: 1,
2: 3,
3: 0,
4: 1,
};
return skeletonCounts[columnIndex] || 0;
};
@@ -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<string>({
key: 'lastRecordBoardQueryIdentifierComponentState',
componentInstanceContext: RecordBoardComponentInstanceContext,
defaultValue: '',
});
@@ -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<number>({
key: 'recordBoardCurrentGroupByQueryOffsetComponentState',
defaultValue: 0,
componentInstanceContext: RecordBoardComponentInstanceContext,
});
@@ -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<boolean>({
key: 'recordBoardIsFetchingMoreComponentState',
defaultValue: false,
componentInstanceContext: RecordBoardComponentInstanceContext,
});
@@ -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<boolean>({
key: 'recordBoardShouldFetchMoreComponentState',
defaultValue: false,
componentInstanceContext: RecordBoardComponentInstanceContext,
});
@@ -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,
],
);
@@ -91,6 +91,7 @@ export const useProcessCalendarCardDrop = () => {
arrayOfRecordsWithPosition: recordsWithPosition,
idOfItemToMove: recordId,
idOfTargetItem: targetRecord.id,
isDroppedAfterList,
});
}
@@ -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) {
@@ -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,
};
};
@@ -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([]);
@@ -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');
});
@@ -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) {
@@ -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 {
@@ -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 {
@@ -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, {
@@ -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(
@@ -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,
@@ -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,
});
@@ -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 <></>;
};
@@ -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) => (
<RecordIndexBoardColumnLoaderEffect
objectNameSingular={objectNameSingular}
kanbanFieldMetadataItem={recordIndexGroupFieldMetadataItem}
recordBoardId={recordBoardId}
columnId={recordGroupId}
key={recordGroupId}
/>
))}
{recordIndexGroupFieldMetadataItem.isNullable === true && (
<RecordIndexBoardColumnLoaderEffect
objectNameSingular={objectNameSingular}
kanbanFieldMetadataItem={recordIndexGroupFieldMetadataItem}
recordBoardId={recordBoardId}
columnId="no-value"
/>
)}
</>
);
};
@@ -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 && (
<>
<RecordIndexTableContainer recordTableId={recordIndexId} />
<RecordIndexTableContainerEffect />
</>
)}
{recordIndexViewType === ViewType.Kanban && (
<StyledContainerWithPadding>
<RecordIndexBoardContainer
<RecordBoardContainer
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
objectNameSingular={objectNameSingular}
/>
<RecordIndexBoardDataLoader
objectNameSingular={objectNameSingular}
recordBoardId={recordIndexId}
/>
<RecordIndexBoardDataLoaderEffect recordBoardId={recordIndexId} />
</StyledContainerWithPadding>
)}
{recordIndexViewType === ViewType.Calendar && (
@@ -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;
@@ -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';
@@ -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,
);
@@ -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,
};
};
@@ -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';
@@ -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,
};
};
@@ -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<FieldMetadataItem>;
}) => {
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<GroupsRecordsGroupByLazyResult>(
recordIndexGroupsRecordGroupsGroupByQuery,
{
variables: {
filter: { ...combinedFilters },
groupBy: {
...groupByGqlInput,
},
orderByForRecords: orderBy,
},
client: apolloCoreClient,
},
);
return {
executeRecordIndexGroupsRecordsLazyGroupBy,
};
};
@@ -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<FieldMetadataItem | null>({
createComponentState<FieldMetadataItem | undefined>({
key: 'recordIndexGroupFieldMetadataItemComponentState',
defaultValue: null,
componentInstanceContext: ContextStoreComponentInstanceContext,
defaultValue: undefined,
componentInstanceContext: ViewComponentInstanceContext,
});
@@ -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<boolean>({
key: 'recordIndexRecordGroupsAreInInitialLoadingComponentState',
defaultValue: false,
componentInstanceContext: ContextStoreComponentInstanceContext,
});
@@ -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 = ({
>
<RecordUpdateContext.Provider value={updateRecordMutation}>
<RecordTable />
<RecordIndexTableContainerEffect />
</RecordUpdateContext.Provider>
</ScrollWrapper>
</EntityDeleteContext.Provider>
@@ -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!]
@@ -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);
});
});
});
@@ -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 =
@@ -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,
@@ -158,6 +158,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
queryRunnerContext,
orderByForRecords: args.orderByForRecords ?? [],
groupLimit: args.limit,
offsetForRecords: args.offsetForRecords,
});
}
@@ -71,6 +71,7 @@ export interface GroupByQueryArgs {
includeRecords?: boolean;
selectedFields: CommonSelectedFields;
limit?: number;
offsetForRecords?: number;
}
export interface DestroyOneQueryArgs {
id: string;
@@ -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<any>,
@@ -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<ObjectLiteral>;
queryBuilderWithFiltersAndWithoutGroupBy: WorkspaceSelectQueryBuilder<ObjectLiteral>;
@@ -52,6 +54,7 @@ export class GroupByWithRecordsService {
queryRunnerContext: CommonExtendedQueryRunnerContext;
orderByForRecords: ObjectRecordOrderBy;
groupLimit?: number;
offsetForRecords?: number;
}): Promise<CommonGroupByOutputItem[]> {
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<ObjectLiteral>;
columnsToSelect: Record<string, boolean>;
@@ -151,11 +160,8 @@ export class GroupByWithRecordsService {
flatObjectMetadata: FlatObjectMetadata;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
offsetForRecords?: number;
}): WorkspaceSelectQueryBuilder<ObjectLiteral> {
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<ObjectLiteral>;
}
private applyPartitionByToBuilder({
groupByDefinitions,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
orderByForRecords,
queryBuilder,
}: {
queryBuilder: WorkspaceSelectQueryBuilder<ObjectLiteral>;
groupByDefinitions: GroupByDefinition[];
orderByForRecords: ObjectRecordOrderBy;
flatObjectMetadata: FlatObjectMetadata;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}) {
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<Record<string, unknown>>,
groupByDefinitions: GroupByDefinition[],
@@ -64,6 +64,7 @@ export interface GroupByResolverArgs<Filter = ObjectRecordFilter> {
viewId?: string;
orderBy?: OrderByWithGroupBy;
orderByForRecords?: ObjectRecordOrderBy;
offsetForRecords?: number;
limit?: number;
}
@@ -190,6 +190,10 @@ export const getResolverArgs = (
type: GraphQLInt,
isNullable: true,
},
offsetForRecords: {
type: GraphQLInt,
isNullable: true,
},
};
default:
throw new Error(`Unknown resolver type: ${type}`);