From db5338cf324ead0550089a80e51f30bceb724cb4 Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:25:50 +0530 Subject: [PATCH] Feat: group records by many to one relation (#22123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Group records by relation (Kanban + Table) Adds grouping by `MANY_TO_ONE` relation fields on both board and table views, reusing the existing `ViewGroup` storage (`fieldValue = related record id`). - **New group** record picker to create relation-backed groups (board column + table row) - Relation-aware group headers (name/avatar), filtering, and drag-drop (writes the FK join column) — all using one canonical `${name}Id` column - Sort menu hides alphabetical options when grouping by a relation (no comparable title) - A group whose backing record no longer exists renders a "Deleted" chip instead of a blank header - **Backend:** allow `MANY_TO_ONE` relations as the Kanban `mainGroupByField` in the flat-view validator https://github.com/user-attachments/assets/267077a6-2667-4506-b178-eee420a16f20 Review in cubic --- .../utils/getFieldMetadataItemGqlFieldName.ts | 15 ++ ...ctOptionsDropdownAddRecordGroupContent.tsx | 90 +++++++++++ .../ObjectOptionsDropdownContent.tsx | 3 + ...tOptionsDropdownRecordGroupSortContent.tsx | 107 +++++++------ ...jectOptionsDropdownRecordGroupsContent.tsx | 21 ++- .../hooks/useSearchRecordGroupField.ts | 4 +- .../types/ObjectOptionsContentId.ts | 1 + .../components/RecordBoardAddGroupColumn.tsx | 32 ++++ .../components/RecordBoardColumns.tsx | 14 ++ .../RecordBoardDataChangedEffect.tsx | 5 +- .../components/RecordBoardHeader.tsx | 2 + .../hooks/useRemoveRecordsFromBoard.ts | 7 +- .../hooks/useRepositionRecordsOnBoard.ts | 5 +- .../hooks/useSetRecordIdsForColumn.ts | 12 +- .../components/RecordBoardColumnHeader.tsx | 30 +--- .../RecordBoardColumnNewRecordButton.tsx | 4 +- .../useProcessTableWithGroupRecordDrop.ts | 6 +- .../hooks/useUpdateDroppedRecordOnBoard.ts | 9 +- .../hooks/useRelevantRecordsGqlFields.ts | 14 +- .../components/AddRecordGroupButton.tsx | 75 ++++++++++ .../components/RecordGroupChip.tsx | 47 ++++++ .../RecordGroupMenuItemDraggable.tsx | 28 ++-- .../components/RecordGroupRelationChip.tsx | 46 ++++++ .../record-group/hooks/useAddRecordGroup.ts | 92 ++++++++++++ .../hooks/useRecordGroupFilter.ts | 8 +- .../canAddRecordGroupForFieldMetadataItem.ts | 10 ++ .../canGroupRecordsByFieldMetadataItem.ts | 9 ++ .../utils/computeRecordGroupOptionsFilter.ts | 3 +- .../RecordIndexGroupAggregateQueryEffect.tsx | 2 +- .../hooks/useHandleRecordGroupField.ts | 25 ++-- .../useRecordIndexGroupsAggregatesGroupBy.ts | 23 ++- .../RecordAggregateValueByRecordGroupValue.ts | 2 +- .../hooks/useCreateNewIndexRecord.ts | 13 +- .../RecordTableRecordGroupsBody.tsx | 2 + .../RecordTableRecordGroupAddNewGroup.tsx | 34 +++++ .../RecordTableRecordGroupSection.tsx | 25 ++-- .../RecordTableRecordGroupSectionAddNew.tsx | 6 +- .../internal/useGetRecordIndexTotalCount.ts | 12 +- .../internal/usePerformViewGroupAPIPersist.ts | 60 +++++++- .../useGetRecordGroupVisibilityFilters.ts | 84 +++++++++++ .../views/hooks/useGetViewGroupsFilters.ts | 29 ---- .../mapViewGroupsToRecordGroupDefinitions.ts | 140 ++++++++++++------ .../useGetAvailableFieldsToGroupRecordsBy.ts | 5 +- .../services/flat-view-validator.service.ts | 58 ++++++-- 44 files changed, 981 insertions(+), 238 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/getFieldMetadataItemGqlFieldName.ts create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownAddRecordGroupContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardAddGroupColumn.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-group/components/AddRecordGroupButton.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupChip.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupRelationChip.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-group/hooks/useAddRecordGroup.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-group/utils/canAddRecordGroupForFieldMetadataItem.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-group/utils/canGroupRecordsByFieldMetadataItem.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupAddNewGroup.tsx create mode 100644 packages/twenty-front/src/modules/views/hooks/useGetRecordGroupVisibilityFilters.ts delete mode 100644 packages/twenty-front/src/modules/views/hooks/useGetViewGroupsFilters.ts diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getFieldMetadataItemGqlFieldName.ts b/packages/twenty-front/src/modules/object-metadata/utils/getFieldMetadataItemGqlFieldName.ts new file mode 100644 index 0000000000..b81ebfedf7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getFieldMetadataItemGqlFieldName.ts @@ -0,0 +1,15 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; +import { computeRelationGqlFieldJoinColumnName } from 'twenty-shared/utils'; + +export const getFieldMetadataItemGqlFieldName = ( + fieldMetadataItem: Pick, +): string => { + if (isManyToOneRelationField(fieldMetadataItem)) { + return computeRelationGqlFieldJoinColumnName({ + name: fieldMetadataItem.name, + }); + } + + return fieldMetadataItem.name; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownAddRecordGroupContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownAddRecordGroupContent.tsx new file mode 100644 index 0000000000..a0373a9014 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownAddRecordGroupContent.tsx @@ -0,0 +1,90 @@ +import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown'; +import { useAddRecordGroup } from '@/object-record/record-group/hooks/useAddRecordGroup'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; +import { SingleRecordPickerMenuItemsWithSearch } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPickerMenuItemsWithSearch'; +import { SingleRecordPickerComponentInstanceContext } from '@/object-record/record-picker/single-record-picker/states/contexts/SingleRecordPickerComponentInstanceContext'; +import { singleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchFilterComponentState'; +import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; +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'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; +import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; +import { useLingui } from '@lingui/react/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; +import { IconChevronLeft } from 'twenty-ui/icon'; + +export const ADD_RECORD_GROUP_PICKER_INSTANCE_ID = + 'object-options-add-record-group-picker'; + +export const ObjectOptionsDropdownAddRecordGroupContent = () => { + const { t } = useLingui(); + const { onContentChange } = useObjectOptionsDropdown(); + const { currentView } = useGetCurrentViewOnly(); + + const recordIndexGroupFieldMetadataItem = useAtomComponentStateValue( + recordIndexGroupFieldMetadataItemComponentState, + ); + + const { addRecordGroup } = useAddRecordGroup(); + + const setSingleRecordPickerSearchFilter = useSetAtomComponentState( + singleRecordPickerSearchFilterComponentState, + ADD_RECORD_GROUP_PICKER_INSTANCE_ID, + ); + + const targetObjectNameSingular = + recordIndexGroupFieldMetadataItem?.relation?.targetObjectMetadata + .nameSingular; + + const excludedRecordIds = (currentView?.viewGroups ?? []) + .map((viewGroup) => viewGroup.fieldValue) + .filter(isNonEmptyString); + + if (!isDefined(targetObjectNameSingular)) { + return null; + } + + const handleBack = () => { + setSingleRecordPickerSearchFilter(''); + onContentChange('recordGroups'); + }; + + const handleRecordSelected = async ( + selectedItem?: RecordPickerPickableMorphItem, + ) => { + if (isDefined(selectedItem?.recordId)) { + await addRecordGroup(selectedItem.recordId); + } + + handleBack(); + }; + + return ( + + + + } + > + {t`New group`} + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent.tsx index 8eff04948b..ae156da1fa 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownContent.tsx @@ -1,3 +1,4 @@ +import { ObjectOptionsDropdownAddRecordGroupContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownAddRecordGroupContent'; import { ObjectOptionsDropdownCalendarFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarFieldsContent'; import { ObjectOptionsDropdownCalendarViewContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent'; import { ObjectOptionsDropdownFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent'; @@ -32,6 +33,8 @@ export const ObjectOptionsDropdownContent = () => { return ; case 'hiddenRecordGroups': return ; + case 'addRecordGroup': + return ; case 'calendarView': return ; case 'calendarFields': diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent.tsx index eff163b7e0..82b0f437a4 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent.tsx @@ -1,9 +1,11 @@ import { useEffect } from 'react'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId'; import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown'; import { hiddenRecordGroupIdsComponentSelector } from '@/object-record/record-group/states/selectors/hiddenRecordGroupIdsComponentSelector'; import { RecordGroupSort } from '@/object-record/record-group/types/RecordGroupSort'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; @@ -16,6 +18,7 @@ import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/ import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; import { IconChevronLeft, IconHandMove, @@ -34,6 +37,14 @@ export const ObjectOptionsDropdownRecordGroupSortContent = () => { const [recordIndexRecordGroupSort, setRecordIndexRecordGroupSort] = useAtomComponentState(recordIndexRecordGroupSortComponentState); + const recordIndexGroupFieldMetadataItem = useAtomComponentStateValue( + recordIndexGroupFieldMetadataItemComponentState, + ); + + const isRelationGroupBy = + isDefined(recordIndexGroupFieldMetadataItem) && + isManyToOneRelationField(recordIndexGroupFieldMetadataItem); + const handleRecordGroupSortChange = (sort: RecordGroupSort) => { setRecordIndexRecordGroupSort(sort); }; @@ -52,11 +63,13 @@ export const ObjectOptionsDropdownRecordGroupSortContent = () => { } }, [hiddenRecordGroupIds, currentContentId, onContentChange]); - const selectableItemIdArray = [ - RecordGroupSort.Manual, - RecordGroupSort.Alphabetical, - RecordGroupSort.ReverseAlphabetical, - ]; + const selectableItemIdArray = isRelationGroupBy + ? [RecordGroupSort.Manual] + : [ + RecordGroupSort.Manual, + RecordGroupSort.Alphabetical, + RecordGroupSort.ReverseAlphabetical, + ]; return ( @@ -90,43 +103,53 @@ export const ObjectOptionsDropdownRecordGroupSortContent = () => { focused={selectedItemId === RecordGroupSort.Manual} /> - - handleRecordGroupSortChange(RecordGroupSort.Alphabetical) - } - > - - handleRecordGroupSortChange(RecordGroupSort.Alphabetical) - } - LeftIcon={IconSortAZ} - text={RecordGroupSort.Alphabetical} - selected={ - recordIndexRecordGroupSort === RecordGroupSort.Alphabetical - } - focused={selectedItemId === RecordGroupSort.Alphabetical} - /> - - - handleRecordGroupSortChange(RecordGroupSort.ReverseAlphabetical) - } - > - - handleRecordGroupSortChange(RecordGroupSort.ReverseAlphabetical) - } - LeftIcon={IconSortZA} - text={RecordGroupSort.ReverseAlphabetical} - selected={ - recordIndexRecordGroupSort === - RecordGroupSort.ReverseAlphabetical - } - focused={selectedItemId === RecordGroupSort.ReverseAlphabetical} - /> - + {!isRelationGroupBy && ( + <> + + handleRecordGroupSortChange(RecordGroupSort.Alphabetical) + } + > + + handleRecordGroupSortChange(RecordGroupSort.Alphabetical) + } + LeftIcon={IconSortAZ} + text={RecordGroupSort.Alphabetical} + selected={ + recordIndexRecordGroupSort === RecordGroupSort.Alphabetical + } + focused={selectedItemId === RecordGroupSort.Alphabetical} + /> + + + handleRecordGroupSortChange( + RecordGroupSort.ReverseAlphabetical, + ) + } + > + + handleRecordGroupSortChange( + RecordGroupSort.ReverseAlphabetical, + ) + } + LeftIcon={IconSortZA} + text={RecordGroupSort.ReverseAlphabetical} + selected={ + recordIndexRecordGroupSort === + RecordGroupSort.ReverseAlphabetical + } + focused={ + selectedItemId === RecordGroupSort.ReverseAlphabetical + } + /> + + + )} diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx index 1bb7b48ab7..d35f65aa4b 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId'; import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown'; import { RecordGroupsVisibilityDropdownSection } from '@/object-record/record-group/components/RecordGroupsVisibilityDropdownSection'; @@ -17,17 +18,19 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState'; -import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; import { useGetAvailableFieldsToGroupRecordsBy } from '@/views/view-picker/hooks/useGetAvailableFieldsToGroupRecordsBy'; import { useLingui } from '@lingui/react/macro'; +import { isDefined } from 'twenty-shared/utils'; import { IconChevronLeft, IconCircleOff, IconEyeOff, IconLayoutList, + IconPlus, IconSortDescending, } from 'twenty-ui/icon'; import { @@ -84,6 +87,10 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => { const hasOnlyOneGroupByOption = availableFieldsForGrouping.length <= 1; + const isRelationGroupBy = + isDefined(recordIndexGroupFieldMetadataItem) && + isManyToOneRelationField(recordIndexGroupFieldMetadataItem); + useEffect(() => { if ( currentContentId === 'hiddenRecordGroups' && @@ -187,6 +194,18 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => { /> )} + {isRelationGroupBy && currentView?.key !== 'INDEX' && ( + <> + + + onContentChange('addRecordGroup')} + LeftIcon={IconPlus} + text={t`New group`} + /> + + + )} {hiddenRecordGroupIds.length > 0 && ( <> diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSearchRecordGroupField.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSearchRecordGroupField.ts index c516cacdc7..c603be7940 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSearchRecordGroupField.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSearchRecordGroupField.ts @@ -1,8 +1,8 @@ import { objectOptionsDropdownSearchInputComponentState } from '@/object-record/object-options-dropdown/states/objectOptionsDropdownSearchInputComponentState'; +import { canGroupRecordsByFieldMetadataItem } from '@/object-record/record-group/utils/canGroupRecordsByFieldMetadataItem'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState'; import { useMemo } from 'react'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; export const useSearchRecordGroupField = () => { const { objectMetadataItem } = useRecordIndexContextOrThrow(); @@ -18,7 +18,7 @@ export const useSearchRecordGroupField = () => { return objectMetadataItem.readableFields.filter( (field) => - field.type === FieldMetadataType.SELECT && + canGroupRecordsByFieldMetadataItem(field) && field.isActive && field.label.toLocaleLowerCase().includes(searchInputLowerCase), ); diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/types/ObjectOptionsContentId.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/types/ObjectOptionsContentId.ts index ba310c6c1d..32df7efc30 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/types/ObjectOptionsContentId.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/types/ObjectOptionsContentId.ts @@ -7,6 +7,7 @@ export type ObjectOptionsContentId = | 'hiddenRecordGroups' | 'recordGroupFields' | 'recordGroupSort' + | 'addRecordGroup' | 'calendarFields' | 'calendarView' | 'visibility'; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardAddGroupColumn.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardAddGroupColumn.tsx new file mode 100644 index 0000000000..564904143f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardAddGroupColumn.tsx @@ -0,0 +1,32 @@ +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; +import { AddRecordGroupButton } from '@/object-record/record-group/components/AddRecordGroupButton'; +import { canAddRecordGroupForFieldMetadataItem } from '@/object-record/record-group/utils/canAddRecordGroupForFieldMetadataItem'; +import { styled } from '@linaria/react'; +import { useContext } from 'react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +const RECORD_BOARD_ADD_GROUP_DROPDOWN_ID = 'record-board-add-group-dropdown'; + +const StyledColumn = styled.div` + align-items: center; + display: flex; + padding-inline: ${themeCssVariables.spacing[2]}; +`; + +export const RecordBoardAddGroupColumn = () => { + const { selectFieldMetadataItem } = useContext(RecordBoardContext); + + if (!canAddRecordGroupForFieldMetadataItem(selectFieldMetadataItem)) { + return null; + } + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardColumns.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardColumns.tsx index 3e6d7fec7c..760f944ba7 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardColumns.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardColumns.tsx @@ -1,8 +1,11 @@ +import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { RecordBoardColumn } from '@/object-record/record-board/record-board-column/components/RecordBoardColumn'; import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; +import { canAddRecordGroupForFieldMetadataItem } from '@/object-record/record-group/utils/canAddRecordGroupForFieldMetadataItem'; import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; import { ViewType } from '@/views/types/ViewType'; import { styled } from '@linaria/react'; +import { useContext } from 'react'; import { themeCssVariables } from 'twenty-ui/theme-constants'; const StyledColumnContainer = styled.div` @@ -14,12 +17,22 @@ const StyledColumnContainer = styled.div` } `; +const StyledAddGroupColumnDivider = styled.div` + width: 0; +`; + export const RecordBoardColumns = () => { + const { selectFieldMetadataItem } = useContext(RecordBoardContext); + const visibleRecordGroupIds = useAtomComponentFamilySelectorValue( visibleRecordGroupIdsComponentFamilySelector, ViewType.KANBAN, ); + const shouldShowAddGroupColumnDivider = canAddRecordGroupForFieldMetadataItem( + selectFieldMetadataItem, + ); + return ( {visibleRecordGroupIds.map((recordGroupId, index) => { @@ -31,6 +44,7 @@ export const RecordBoardColumns = () => { /> ); })} + {shouldShowAddGroupColumnDivider && } ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx index a9165693dd..4c77422e0c 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardDataChangedEffect.tsx @@ -7,6 +7,7 @@ import { useRemoveRecordsFromBoard } from '@/object-record/record-board/hooks/us import { useRepositionRecordsOnBoard } from '@/object-record/record-board/hooks/useRepositionRecordsOnBoard'; import { useTriggerRecordBoardInitialQuery } from '@/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery'; import { recordGroupFromGroupValueComponentFamilySelector } from '@/object-record/record-group/states/selectors/recordGroupFromGroupValueComponentFamilySelector'; +import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; @@ -102,7 +103,9 @@ export const RecordBoardDataChangedEffect = () => { const recordGroupValue = objectRecordOperation.createdRecord[ - currentRecordIndexGroupFieldMetadataItem.name + getFieldMetadataItemGqlFieldName( + currentRecordIndexGroupFieldMetadataItem, + ) ]; const recordGroupDefinitionFromGroupValue = store.get( diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx index 279c8cd730..29c46235d9 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx @@ -1,3 +1,4 @@ +import { RecordBoardAddGroupColumn } from '@/object-record/record-board/components/RecordBoardAddGroupColumn'; import { RecordBoardColumnHeaderWrapper } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper'; import { RecordGroupContext } from '@/object-record/record-group/states/context/RecordGroupContext'; import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; @@ -45,6 +46,7 @@ export const RecordBoardHeader = () => { /> ))} + ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRemoveRecordsFromBoard.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRemoveRecordsFromBoard.ts index 311f690e80..ef12f15e37 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRemoveRecordsFromBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRemoveRecordsFromBoard.ts @@ -1,12 +1,13 @@ import { recordGroupFromGroupValueComponentFamilySelector } from '@/object-record/record-group/states/selectors/recordGroupFromGroupValueComponentFamilySelector'; +import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName'; import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; import { useStore } from 'jotai'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useAtomComponentFamilySelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorCallbackState'; import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; @@ -48,7 +49,9 @@ export const useRemoveRecordsFromBoard = () => { } const recordGroupValue = recordToRemove[ - currentRecordIndexGroupFieldMetadataItem.name + getFieldMetadataItemGqlFieldName( + currentRecordIndexGroupFieldMetadataItem, + ) ] as string | undefined; const recordGroupDefinitionFromGroupValue = store.get( diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRepositionRecordsOnBoard.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRepositionRecordsOnBoard.ts index 4b86886813..fe51bcc4bd 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRepositionRecordsOnBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRepositionRecordsOnBoard.ts @@ -2,6 +2,7 @@ import { useStore } from 'jotai'; import { extractRecordPositions } from '@/object-record/record-drag/utils/extractRecordPositions'; import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector'; +import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName'; 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'; @@ -42,7 +43,9 @@ export const useRepositionRecordsOnBoard = () => { return false; } - const groupFieldName = groupFieldMetadataItem.name; + const groupFieldName = getFieldMetadataItemGqlFieldName( + groupFieldMetadataItem, + ); const recordGroupDefinitions = store.get( recordGroupDefinitionsCallbackState, ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useSetRecordIdsForColumn.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useSetRecordIdsForColumn.ts index ada7809d5f..9826023836 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useSetRecordIdsForColumn.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useSetRecordIdsForColumn.ts @@ -1,13 +1,14 @@ -import { useCallback } from 'react'; import { useStore } from 'jotai'; +import { useCallback } from 'react'; import { emptyRecordGroupByIdComponentFamilyState } from '@/object-record/record-group/states/emptyRecordGroupByIdComponentFamilyState'; import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; +import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName'; 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 { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { isDefined } from 'twenty-shared/utils'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; @@ -48,10 +49,13 @@ export const useSetRecordIdsForColumn = (recordBoardId?: string) => { return; } + const recordGroupColumnName = getFieldMetadataItemGqlFieldName( + currentRecordGroupFieldMetadata, + ); + const recordGroupRowIds = records .filter( - (record) => - record[currentRecordGroupFieldMetadata.name] === recordGroup?.value, + (record) => record[recordGroupColumnName] === recordGroup?.value, ) .map((record) => record.id); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx index 709f6001ea..dc0832f35b 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx @@ -12,7 +12,8 @@ import { RECORD_BOARD_COLUMN_WIDTH_CSS_VARIABLE_NAME } from '@/object-record/rec import { RecordBoardColumnResizeHandler } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnResizeHandler'; import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView'; -import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { RecordGroupChip } from '@/object-record/record-group/components/RecordGroupChip'; +import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName'; import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/record-index/states/recordIndexAggregateDisplayLabelComponentState'; import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } from '@/object-record/record-index/states/recordIndexAggregateDisplayValueForGroupValueComponentFamilyState'; import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord'; @@ -22,7 +23,6 @@ import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown' import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; -import { Tag } from 'twenty-ui/data-display'; import { IconDotsVertical, IconPlus } from 'twenty-ui/icon'; import { LightIconButton } from 'twenty-ui/input'; @@ -130,7 +130,8 @@ export const RecordBoardColumnHeader = () => { const handleCreateNewRecordClick = async () => { await createNewIndexRecord({ position: 'first', - [selectFieldMetadataItem.name]: columnDefinition.value, + [getFieldMetadataItemGqlFieldName(selectFieldMetadataItem)]: + columnDefinition.value, }); }; @@ -152,26 +153,9 @@ export const RecordBoardColumnHeader = () => { }} clickableComponent={ - } diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewRecordButton.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewRecordButton.tsx index 45dac3eb0e..a213938002 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewRecordButton.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewRecordButton.tsx @@ -2,6 +2,7 @@ import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPe import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView'; +import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName'; import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord'; import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; @@ -65,7 +66,8 @@ export const RecordBoardColumnNewRecordButton = () => { onClick={async () => { await createNewIndexRecord({ position: 'last', - [selectFieldMetadataItem.name]: columnDefinition.value, + [getFieldMetadataItemGqlFieldName(selectFieldMetadataItem)]: + columnDefinition.value, }); }} > diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop.ts b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop.ts index a7ebfdc533..dba83d0e0c 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessTableWithGroupRecordDrop.ts @@ -8,6 +8,7 @@ import { isDraggingRecordComponentState } from '@/object-record/record-drag/stat import { originalDragSelectionComponentState } from '@/object-record/record-drag/states/originalDragSelectionComponentState'; import { processGroupDrop } from '@/object-record/record-drag/utils/processGroupDrop'; import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; +import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName'; import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId'; import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; @@ -80,6 +81,9 @@ export const useProcessTableWithGroupRecordDrop = () => { throw new Error('Field metadata is not defined'); } + const recordGroupColumnName = + getFieldMetadataItemGqlFieldName(fieldMetadata); + const existingOriginalDragSelection = store.get(originalDragSelection); const isCurrentlyDraggingRecord = store.get(isDraggingRecord); @@ -106,7 +110,7 @@ export const useProcessTableWithGroupRecordDrop = () => { idToUpdate: recordId, updateOneRecordInput: { position, - [fieldMetadata.name]: destinationRecordGroup.value, + [recordGroupColumnName]: destinationRecordGroup.value, }, }); }, diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useUpdateDroppedRecordOnBoard.ts b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useUpdateDroppedRecordOnBoard.ts index a2b1fda11f..04a94d7b16 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useUpdateDroppedRecordOnBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useUpdateDroppedRecordOnBoard.ts @@ -4,6 +4,7 @@ import { RecordBoardContext } from '@/object-record/record-board/contexts/Record 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 { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName'; 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'; @@ -85,6 +86,10 @@ export const useUpdateDroppedRecordOnBoard = () => { const targetRecordGroupId = targetRecordGroup.id; + const recordGroupColumnName = getFieldMetadataItemGqlFieldName( + selectFieldMetadataItem, + ); + const movingInsideSameRecordGroup = initialRecordGroupId === targetRecordGroupId; @@ -160,7 +165,7 @@ export const useUpdateDroppedRecordOnBoard = () => { __typename: (initialRecord as { __typename?: string })?.__typename ?? 'Record', - [selectFieldMetadataItem.name]: targetRecordGroupValue, + [recordGroupColumnName]: targetRecordGroupValue, ...(isDefined(newPosition) && { position: newPosition }), } as ObjectRecord, ], @@ -169,7 +174,7 @@ export const useUpdateDroppedRecordOnBoard = () => { updateOneRecord({ idToUpdate: recordId, updateOneRecordInput: { - [selectFieldMetadataItem.name]: targetRecordGroupValue, + [recordGroupColumnName]: targetRecordGroupValue, ...(isDefined(newPosition) && { position: newPosition }), }, }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useRelevantRecordsGqlFields.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useRelevantRecordsGqlFields.ts index 77efc371f8..9ef5975a6f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/useRelevantRecordsGqlFields.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useRelevantRecordsGqlFields.ts @@ -52,9 +52,16 @@ export const useRelevantRecordsGqlFields = ({ ) .filter(isDefined); + const additionalFieldMetadataItem = isDefined(additionalFieldMetadataId) + ? fieldMetadataItemByFieldMetadataItemId[additionalFieldMetadataId] + : undefined; + const fieldMetadataItemsToUse = [ ...visibleRecordFieldMetadataItems, ...(recordFilterFields ?? []), + ...(isDefined(additionalFieldMetadataItem) + ? [additionalFieldMetadataItem] + : []), ].filter(filterDuplicatesById); const allDepthOneGqlFields = generateDepthRecordGqlFieldsFromFields({ @@ -70,19 +77,12 @@ export const useRelevantRecordsGqlFields = ({ const hasPosition = hasObjectMetadataItemPositionField(objectMetadataItem); - const additionalFieldMetadataItem = isDefined(additionalFieldMetadataId) - ? fieldMetadataItemByFieldMetadataItemId[additionalFieldMetadataId] - : undefined; - const isObjectAnActivity = objectMetadataItem.nameSingular === CoreObjectNameSingular.Note || objectMetadataItem.nameSingular === CoreObjectNameSingular.Task; return { id: true, - ...(isDefined(additionalFieldMetadataItem) - ? { [additionalFieldMetadataItem.name]: true } - : {}), ...(isDefined(labelIdentifierFieldMetadataItem) ? { [labelIdentifierFieldMetadataItem.name]: true } : {}), diff --git a/packages/twenty-front/src/modules/object-record/record-group/components/AddRecordGroupButton.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/AddRecordGroupButton.tsx new file mode 100644 index 0000000000..f30f4cbc4b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/components/AddRecordGroupButton.tsx @@ -0,0 +1,75 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { useAddRecordGroup } from '@/object-record/record-group/hooks/useAddRecordGroup'; +import { canAddRecordGroupForFieldMetadataItem } from '@/object-record/record-group/utils/canAddRecordGroupForFieldMetadataItem'; +import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker'; +import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { type DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset'; +import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; +import { t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; +import { IconPlus } from 'twenty-ui/icon'; +import { LightButton } from 'twenty-ui/input'; + +type AddRecordGroupButtonProps = { + fieldMetadataItem: FieldMetadataItem; + dropdownId: string; + dropdownOffset?: DropdownOffset; +}; + +export const AddRecordGroupButton = ({ + fieldMetadataItem, + dropdownId, + dropdownOffset, +}: AddRecordGroupButtonProps) => { + const { currentView } = useGetCurrentViewOnly(); + const { addRecordGroup } = useAddRecordGroup(); + const { closeDropdown } = useCloseDropdown(); + + const targetObjectNameSingular = + fieldMetadataItem.relation?.targetObjectMetadata.nameSingular; + + if ( + !canAddRecordGroupForFieldMetadataItem(fieldMetadataItem) || + !isDefined(targetObjectNameSingular) + ) { + return null; + } + + const excludedRecordIds = (currentView?.viewGroups ?? []) + .map((viewGroup) => viewGroup.fieldValue) + .filter(isNonEmptyString); + + const handleRecordSelected = async ( + selectedItem?: RecordPickerPickableMorphItem, + ) => { + closeDropdown(dropdownId); + + if (isDefined(selectedItem?.recordId)) { + await addRecordGroup(selectedItem.recordId); + } + }; + + return ( + + } + dropdownComponents={ + closeDropdown(dropdownId)} + onMorphItemSelected={handleRecordSelected} + objectNameSingulars={[targetObjectNameSingular]} + excludedRecordIds={excludedRecordIds} + /> + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupChip.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupChip.tsx new file mode 100644 index 0000000000..b0ace4c1cf --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupChip.tsx @@ -0,0 +1,47 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; +import { RecordGroupRelationChip } from '@/object-record/record-group/components/RecordGroupRelationChip'; +import { + type RecordGroupDefinition, + RecordGroupDefinitionType, +} from '@/object-record/record-group/types/RecordGroupDefinition'; +import { isDefined } from 'twenty-shared/utils'; +import { Tag } from 'twenty-ui/data-display'; + +type RecordGroupChipProps = { + recordGroupDefinition: RecordGroupDefinition; + fieldMetadataItem?: FieldMetadataItem | null; + valueTagWeight?: 'regular' | 'medium'; +}; + +export const RecordGroupChip = ({ + recordGroupDefinition, + fieldMetadataItem, + valueTagWeight = 'regular', +}: RecordGroupChipProps) => { + const isValueGroup = + recordGroupDefinition.type === RecordGroupDefinitionType.Value; + + if ( + isValueGroup && + isDefined(fieldMetadataItem) && + isManyToOneRelationField(fieldMetadataItem) && + isDefined(recordGroupDefinition.value) + ) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupMenuItemDraggable.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupMenuItemDraggable.tsx index 9d749b1aad..41c613e7f5 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupMenuItemDraggable.tsx +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupMenuItemDraggable.tsx @@ -1,12 +1,14 @@ +import { RecordGroupChip } from '@/object-record/record-group/components/RecordGroupChip'; import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; import { type RecordGroupDefinition, RecordGroupDefinitionType, } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue'; import { t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; -import { Tag } from 'twenty-ui/data-display'; import { IconEye, IconEyeOff } from 'twenty-ui/icon'; import { MenuItemDraggable } from 'twenty-ui/navigation'; @@ -30,6 +32,10 @@ export const RecordGroupMenuItemDraggable = ({ recordGroupId, ); + const recordIndexGroupFieldMetadataItem = useAtomComponentStateValue( + recordIndexGroupFieldMetadataItemComponentState, + ); + if (!isDefined(recordGroupDefinition)) { return null; } @@ -68,23 +74,9 @@ export const RecordGroupMenuItemDraggable = ({ } accent={isNoValue || showDragGrip ? 'placeholder' : 'default'} diff --git a/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupRelationChip.tsx b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupRelationChip.tsx new file mode 100644 index 0000000000..22e5f1347f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/components/RecordGroupRelationChip.tsx @@ -0,0 +1,46 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { RecordChip } from '@/object-record/components/RecordChip'; +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { Tag } from 'twenty-ui/data-display'; + +type RecordGroupRelationChipProps = { + fieldMetadataItem: FieldMetadataItem; + recordId: string; +}; + +export const RecordGroupRelationChip = ({ + fieldMetadataItem, + recordId, +}: RecordGroupRelationChipProps) => { + const targetObjectNameSingular = + fieldMetadataItem.relation?.targetObjectMetadata.nameSingular; + + const { record, loading } = useFindOneRecord({ + objectNameSingular: targetObjectNameSingular ?? '', + objectRecordId: recordId, + withSoftDeleted: true, + skip: !isDefined(targetObjectNameSingular), + }); + + if ( + !isDefined(targetObjectNameSingular) || + (!isDefined(record) && !loading) + ) { + return ; + } + + if (!isDefined(record)) { + return ; + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useAddRecordGroup.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useAddRecordGroup.ts new file mode 100644 index 0000000000..693eb7ce81 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useAddRecordGroup.ts @@ -0,0 +1,92 @@ +import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; +import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; +import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRecordGroups'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { usePerformViewGroupAPIPersist } from '@/views/hooks/internal/usePerformViewGroupAPIPersist'; +import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges'; +import { useGetViewFromState } from '@/views/hooks/useGetViewFromState'; +import { type ViewGroup } from '@/views/types/ViewGroup'; +import { useStore } from 'jotai'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; + +export const useAddRecordGroup = () => { + const { canPersistChanges } = useCanPersistViewChanges(); + const { performViewGroupAPICreate } = usePerformViewGroupAPIPersist(); + const { getViewFromState } = useGetViewFromState(); + const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow(); + const { setRecordGroupsFromViewGroups } = useSetRecordGroups(); + + const currentViewIdCallbackState = useAtomComponentStateCallbackState( + contextStoreCurrentViewIdComponentState, + ); + + const store = useStore(); + + const addRecordGroup = async (recordId: string) => { + if (!canPersistChanges) { + return; + } + + const currentViewId = store.get(currentViewIdCallbackState); + + if (!currentViewId) { + return; + } + + const view = getViewFromState(currentViewId); + + if ( + isUndefinedOrNull(view) || + !isDefined(view.mainGroupByFieldMetadataId) + ) { + return; + } + + const recordAlreadyHasGroup = view.viewGroups.some( + (viewGroup) => viewGroup.fieldValue === recordId, + ); + + if (recordAlreadyHasGroup) { + return; + } + + const maxPosition = view.viewGroups.reduce( + (max, viewGroup) => Math.max(max, viewGroup.position), + -1, + ); + + const newViewGroup: ViewGroup = { + id: v4(), + fieldValue: recordId, + isVisible: true, + position: maxPosition + 1, + }; + + const result = await performViewGroupAPICreate({ + inputs: [ + { + id: newViewGroup.id, + viewId: currentViewId, + fieldValue: newViewGroup.fieldValue, + isVisible: newViewGroup.isVisible, + position: newViewGroup.position, + }, + ], + }); + + if (result.status !== 'successful') { + return; + } + + setRecordGroupsFromViewGroups({ + viewId: currentViewId, + mainGroupByFieldMetadataId: view.mainGroupByFieldMetadataId, + viewGroups: [...view.viewGroups, newViewGroup], + objectMetadataItem, + }); + }; + + return { addRecordGroup }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupFilter.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupFilter.ts index fb4358a4b8..96f7276a79 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupFilter.ts @@ -1,5 +1,6 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { useCurrentRecordGroupDefinition } from '@/object-record/record-group/hooks/useCurrentRecordGroupDefinition'; +import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName'; import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useMemo } from 'react'; @@ -24,12 +25,15 @@ export const useRecordGroupFilter = (fields: FieldMetadataItem[]) => { ); } + const recordGroupColumnName = + getFieldMetadataItemGqlFieldName(fieldMetadataItem); + if (!isDefined(currentRecordGroupDefinition.value)) { - return { [fieldMetadataItem.name]: { is: 'NULL' } }; + return { [recordGroupColumnName]: { is: 'NULL' } }; } return { - [fieldMetadataItem.name]: { + [recordGroupColumnName]: { eq: currentRecordGroupDefinition.value, }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-group/utils/canAddRecordGroupForFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-record/record-group/utils/canAddRecordGroupForFieldMetadataItem.ts new file mode 100644 index 0000000000..cd82fb80a2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/utils/canAddRecordGroupForFieldMetadataItem.ts @@ -0,0 +1,10 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; +import { isDefined } from 'twenty-shared/utils'; + +export const canAddRecordGroupForFieldMetadataItem = ( + fieldMetadataItem?: FieldMetadataItem, +): fieldMetadataItem is FieldMetadataItem => + isDefined(fieldMetadataItem) && + isManyToOneRelationField(fieldMetadataItem) && + isDefined(fieldMetadataItem.relation?.targetObjectMetadata?.nameSingular); diff --git a/packages/twenty-front/src/modules/object-record/record-group/utils/canGroupRecordsByFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-record/record-group/utils/canGroupRecordsByFieldMetadataItem.ts new file mode 100644 index 0000000000..088eda7030 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/utils/canGroupRecordsByFieldMetadataItem.ts @@ -0,0 +1,9 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const canGroupRecordsByFieldMetadataItem = ( + fieldMetadataItem: Pick, +): boolean => + fieldMetadataItem.type === FieldMetadataType.SELECT || + isManyToOneRelationField(fieldMetadataItem); diff --git a/packages/twenty-front/src/modules/object-record/record-group/utils/computeRecordGroupOptionsFilter.ts b/packages/twenty-front/src/modules/object-record/record-group/utils/computeRecordGroupOptionsFilter.ts index 0fda029544..566b1b949d 100644 --- a/packages/twenty-front/src/modules/object-record/record-group/utils/computeRecordGroupOptionsFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/utils/computeRecordGroupOptionsFilter.ts @@ -4,6 +4,7 @@ import { isDefined } from 'twenty-shared/utils'; import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { type RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName'; export const computeRecordGroupOptionsFilter = ({ recordGroupFieldMetadata, @@ -16,7 +17,7 @@ export const computeRecordGroupOptionsFilter = ({ return {}; } - const fieldName = recordGroupFieldMetadata.name; + const fieldName = getFieldMetadataItemGqlFieldName(recordGroupFieldMetadata); const hasNullValue = recordGroupValues.some(isNull); const nonNullValues = recordGroupValues.filter( (value): value is NonNullable => !isNull(value), diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexGroupAggregateQueryEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexGroupAggregateQueryEffect.tsx index 8cbe20e0b3..a4d2a1e333 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexGroupAggregateQueryEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexGroupAggregateQueryEffect.tsx @@ -88,7 +88,7 @@ export const RecordIndexGroupAggregateQueryEffect = ({ setRecordIndexAggregateDisplayValueForRecordGroupValue( recordIndexGroupAggregateOperation, recordIndexGroupAggregateFieldMetadataItem, - foundAggregateValueForGroup.recordGroupValue, + foundAggregateValueForGroup.recordGroupValue ?? '', foundAggregateValueForGroup.recordAggregateValue, ); } else { diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts index 0f5d143b0f..8370d47413 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts @@ -1,6 +1,7 @@ import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRecordGroups'; import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; @@ -44,9 +45,12 @@ export const useHandleRecordGroupField = () => { return; } + const isRelationGroupBy = isManyToOneRelationField(fieldMetadataItem); + if ( - isUndefinedOrNull(fieldMetadataItem.options) || - fieldMetadataItem.options.length === 0 + !isRelationGroupBy && + (isUndefinedOrNull(fieldMetadataItem.options) || + fieldMetadataItem.options.length === 0) ) { return; } @@ -73,7 +77,9 @@ export const useHandleRecordGroupField = () => { ), ); - const viewGroupsToCreate = fieldMetadataItem.options + const viewGroupsToCreate = ( + isRelationGroupBy ? [] : (fieldMetadataItem.options ?? []) + ) .filter( (option) => !existingGroupKeys.has(`${fieldMetadataItem.id}:${option.value}`), @@ -97,16 +103,15 @@ export const useHandleRecordGroupField = () => { id: v4(), fieldValue: '', isVisible: true, - position: fieldMetadataItem.options.length, + position: viewGroupsToCreate.length, } satisfies ViewGroup); } - const newViewGroupsList = [ - ...view.viewGroups.filter( - (_group) => view.mainGroupByFieldMetadataId === fieldMetadataItem.id, - ), - ...viewGroupsToCreate, - ]; + const isSameField = + view.mainGroupByFieldMetadataId === fieldMetadataItem.id; + const keptGroups = isSameField ? view.viewGroups : []; + + const newViewGroupsList = [...keptGroups, ...viewGroupsToCreate]; setRecordGroupsFromViewGroups({ viewId: view.id, diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupsAggregatesGroupBy.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupsAggregatesGroupBy.ts index 888cc241a4..931b6b874e 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupsAggregatesGroupBy.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexGroupsAggregatesGroupBy.ts @@ -1,7 +1,7 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector'; -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery'; import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; import { generateGroupByAggregateQuery } from '@/object-record/record-aggregate/utils/generateGroupByAggregateQuery'; @@ -9,15 +9,19 @@ import { currentRecordFilterGroupsComponentState } from '@/object-record/record- 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 { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector'; +import { computeRecordGroupOptionsFilter } from '@/object-record/record-group/utils/computeRecordGroupOptionsFilter'; import { useAggregateGqlFieldsFromRecordIndexGroupAggregates } from '@/object-record/record-index/hooks/useAggregateGqlFieldsFromRecordIndexGroupAggregates'; import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useQuery } from '@apollo/client/react'; import { useMemo } from 'react'; import { type Nullable } from 'twenty-shared/types'; import { + combineFilters, computeRecordGqlOperationFilter, isDefined, turnAnyFieldFilterIntoRecordGqlFilter, @@ -98,6 +102,17 @@ export const useRecordIndexGroupsAggregatesGroupBy = ({ field: groupByFieldMetadataItem, }); + const recordGroupDefinitions = useAtomComponentSelectorValue( + recordGroupDefinitionsComponentSelector, + ); + + const recordGroupOptionsFilter = computeRecordGroupOptionsFilter({ + recordGroupFieldMetadata: groupByFieldMetadataItem, + recordGroupValues: recordGroupDefinitions + .filter((recordGroupDefinition) => recordGroupDefinition.isVisible) + .map((recordGroupDefinition) => recordGroupDefinition.value), + }); + const { data, loading, error } = useQuery(groupByAggregateQuery, { skip: !isDefined(objectMetadataItem) || @@ -105,7 +120,11 @@ export const useRecordIndexGroupsAggregatesGroupBy = ({ skip || !isDefined(recordAggregateGqlField), variables: { - filter: { ...requestFilters, ...anyFieldFilter }, + filter: combineFilters([ + anyFieldFilter, + requestFilters, + recordGroupOptionsFilter, + ]), groupBy: { ...groupByGqlInput, }, diff --git a/packages/twenty-front/src/modules/object-record/record-index/types/RecordAggregateValueByRecordGroupValue.ts b/packages/twenty-front/src/modules/object-record/record-index/types/RecordAggregateValueByRecordGroupValue.ts index 733f786426..cf41ff9fc7 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/types/RecordAggregateValueByRecordGroupValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/types/RecordAggregateValueByRecordGroupValue.ts @@ -1,6 +1,6 @@ import { type Nullable } from 'twenty-shared/types'; export type RecordAggregateValueByRecordGroupValue = { - recordGroupValue: string; + recordGroupValue: Nullable; recordAggregateValue: Nullable; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts index 54c738dd9b..0abd5815a2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts @@ -1,10 +1,9 @@ -import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; -import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel'; import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; import { useBuildRecordInputFromRLSPredicates } from '@/object-record/hooks/useBuildRecordInputFromRLSPredicates'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector'; +import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName'; import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState'; import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState'; @@ -12,15 +11,17 @@ import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useU import { useBuildRecordInputFromFilters } from '@/object-record/record-table/hooks/useBuildRecordInputFromFilters'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { canOpenObjectInSidePanel } from '@/object-record/utils/canOpenObjectInSidePanel'; +import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel'; +import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; import { useAtomComponentFamilyStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateCallbackState'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; -import { ViewOpenRecordIn } from '~/generated-metadata/graphql'; import { useStore } from 'jotai'; import { useCallback } from 'react'; import { AppPath } from 'twenty-shared/types'; import { findByProperty, isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; +import { ViewOpenRecordIn } from '~/generated-metadata/graphql'; import { useNavigateApp } from '~/hooks/useNavigateApp'; type UseCreateNewIndexRecordProps = { @@ -128,7 +129,11 @@ export const useCreateNewIndexRecord = ({ const recordGroup = recordGroupDefinitions.find( findByProperty( 'value', - createdRecord[recordIndexGroupFieldMetadataItem.name], + createdRecord[ + getFieldMetadataItemGqlFieldName( + recordIndexGroupFieldMetadataItem, + ) + ], ), ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx index 34cf2fc2cd..b584e4a526 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-body/components/RecordTableRecordGroupsBody.tsx @@ -8,6 +8,7 @@ import { RecordTableBodyLoading } from '@/object-record/record-table/record-tabl import { RecordTableBodyRecordGroupDragDropContextProvider } from '@/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDragDropContextProvider'; import { RecordTableBodyRecordGroupDroppable } from '@/object-record/record-table/record-table-body/components/RecordTableBodyRecordGroupDroppable'; import { RecordTableCellPortals } from '@/object-record/record-table/record-table-cell/components/RecordTableCellPortals'; +import { RecordTableRecordGroupAddNewGroup } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupAddNewGroup'; import { RecordTableRecordGroupSection } from '@/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection'; import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; @@ -52,6 +53,7 @@ export const RecordTableRecordGroupsBody = () => { ))} + diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupAddNewGroup.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupAddNewGroup.tsx new file mode 100644 index 0000000000..2e8cc48192 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupAddNewGroup.tsx @@ -0,0 +1,34 @@ +import { AddRecordGroupButton } from '@/object-record/record-group/components/AddRecordGroupButton'; +import { canAddRecordGroupForFieldMetadataItem } from '@/object-record/record-group/utils/canAddRecordGroupForFieldMetadataItem'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { styled } from '@linaria/react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +const RECORD_TABLE_ADD_GROUP_DROPDOWN_ID = 'record-table-add-group-dropdown'; + +const StyledContainer = styled.div` + display: flex; + padding: ${themeCssVariables.spacing[2]}; +`; + +export const RecordTableRecordGroupAddNewGroup = () => { + const recordIndexGroupFieldMetadataItem = useAtomComponentStateValue( + recordIndexGroupFieldMetadataItemComponentState, + ); + + if ( + !canAddRecordGroupForFieldMetadataItem(recordIndexGroupFieldMetadataItem) + ) { + return null; + } + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx index 858ca5b667..23e77c56ca 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-section/components/RecordTableRecordGroupSection.tsx @@ -4,11 +4,12 @@ import { themeCssVariables } from 'twenty-ui/theme-constants'; import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown'; import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; +import { RecordGroupChip } from '@/object-record/record-group/components/RecordGroupChip'; import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId'; import { useShouldHideRecordGroup } from '@/object-record/record-group/hooks/useShouldHideRecordGroup'; import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState'; -import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState'; import { RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnDragAndDropWidth'; import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight'; import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; @@ -34,7 +35,6 @@ import { isDefined, sumByProperty, } from 'twenty-shared/utils'; -import { Tag } from 'twenty-ui/data-display'; import { IconChevronDown } from 'twenty-ui/icon'; import { AnimatedLightIconButton } from 'twenty-ui/input'; import { useIsMobile } from 'twenty-ui/utilities'; @@ -125,6 +125,10 @@ export const RecordTableRecordGroupSection = () => { currentRecordGroupId, ); + const recordIndexGroupFieldMetadataItem = useAtomComponentStateValue( + recordIndexGroupFieldMetadataItemComponentState, + ); + const recordIndexAggregateDisplayValueForGroupValue = useAtomComponentFamilyStateValue( recordIndexAggregateDisplayValueForGroupValueComponentFamilyState, @@ -206,19 +210,10 @@ export const RecordTableRecordGroupSection = () => { width={widthOfLabelIdentifierRecordField} > - { createNewIndexRecord({ position: 'last', - [fieldMetadataItem.name]: recordGroupDefinition?.value, + [getFieldMetadataItemGqlFieldName(fieldMetadataItem)]: + recordGroupDefinition?.value, }); }} /> diff --git a/packages/twenty-front/src/modules/views/hooks/internal/useGetRecordIndexTotalCount.ts b/packages/twenty-front/src/modules/views/hooks/internal/useGetRecordIndexTotalCount.ts index 22bb931c5f..8767949c59 100644 --- a/packages/twenty-front/src/modules/views/hooks/internal/useGetRecordIndexTotalCount.ts +++ b/packages/twenty-front/src/modules/views/hooks/internal/useGetRecordIndexTotalCount.ts @@ -8,9 +8,10 @@ import { currentRecordFiltersComponentState } from '@/object-record/record-filte import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { useGetViewGroupsFilters } from '@/views/hooks/useGetViewGroupsFilters'; +import { useGetRecordGroupVisibilityFilters } from '@/views/hooks/useGetRecordGroupVisibilityFilters'; import { computeRecordGqlOperationFilter, + isDefined, turnAnyFieldFilterIntoRecordGqlFilter, } from 'twenty-shared/utils'; @@ -31,15 +32,20 @@ export const useGetRecordIndexTotalCount = () => { flattenedFieldMetadataItemsSelector, ); - const recordGroupsVisibilityFilter = useGetViewGroupsFilters(); + const { recordFilters: recordGroupsVisibilityFilter, recordGroupGqlFilter } = + useGetRecordGroupVisibilityFilters(); - const filter = computeRecordGqlOperationFilter({ + const computedFilter = computeRecordGqlOperationFilter({ filterValueDependencies, recordFilters: [...currentRecordFilters, ...recordGroupsVisibilityFilter], recordFilterGroups: currentRecordFilterGroups, fieldMetadataItems: flattenedFieldMetadataItems, }); + const filter = isDefined(recordGroupGqlFilter) + ? { and: [computedFilter, recordGroupGqlFilter] } + : computedFilter; + const anyFieldFilterValue = useAtomComponentStateValue( anyFieldFilterValueComponentState, ); diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewGroupAPIPersist.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewGroupAPIPersist.ts index fdff739098..f9564d05d0 100644 --- a/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewGroupAPIPersist.ts +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewGroupAPIPersist.ts @@ -4,11 +4,14 @@ import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetad import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { CombinedGraphQLErrors } from '@apollo/client/errors'; -import { t } from '@lingui/core/macro'; -import { CrudOperationType } from 'twenty-shared/types'; import { useMutation } from '@apollo/client/react'; +import { t } from '@lingui/core/macro'; +import { isNonEmptyArray } from '@sniptt/guards'; +import { CrudOperationType } from 'twenty-shared/types'; import { + type CreateManyViewGroupsMutationVariables, type UpdateManyViewGroupsMutationVariables, + CreateManyViewGroupsDocument, UpdateManyViewGroupsDocument, } from '~/generated-metadata/graphql'; @@ -17,6 +20,10 @@ export const usePerformViewGroupAPIPersist = () => { UpdateManyViewGroupsDocument, ); + const [createManyViewGroupsMutation] = useMutation( + CreateManyViewGroupsDocument, + ); + const { handleMetadataError } = useMetadataErrorHandler(); const { enqueueErrorSnackBar } = useSnackBar(); @@ -28,10 +35,7 @@ export const usePerformViewGroupAPIPersist = () => { ReturnType > | null> > => { - if ( - !Array.isArray(updateViewGroupInputs.inputs) || - updateViewGroupInputs.inputs.length === 0 - ) { + if (!isNonEmptyArray(updateViewGroupInputs.inputs)) { return { status: 'successful', response: null, @@ -66,7 +70,51 @@ export const usePerformViewGroupAPIPersist = () => { [updateManyViewGroupsMutation, handleMetadataError, enqueueErrorSnackBar], ); + const performViewGroupAPICreate = useCallback( + async ( + createViewGroupInputs: CreateManyViewGroupsMutationVariables, + ): Promise< + MetadataRequestResult + > | null> + > => { + if (!isNonEmptyArray(createViewGroupInputs.inputs)) { + return { + status: 'successful', + response: null, + }; + } + + try { + const result = await createManyViewGroupsMutation({ + variables: createViewGroupInputs, + }); + + return { + status: 'successful', + response: result, + }; + } catch (error) { + if (CombinedGraphQLErrors.is(error)) { + handleMetadataError(error, { + primaryMetadataName: 'viewGroup', + operationType: CrudOperationType.CREATE, + }); + } else { + enqueueErrorSnackBar({ message: t`An error occurred.` }); + } + + return { + status: 'failed', + error, + }; + } + }, + [createManyViewGroupsMutation, handleMetadataError, enqueueErrorSnackBar], + ); + return { performViewGroupAPIUpdate, + performViewGroupAPICreate, }; }; diff --git a/packages/twenty-front/src/modules/views/hooks/useGetRecordGroupVisibilityFilters.ts b/packages/twenty-front/src/modules/views/hooks/useGetRecordGroupVisibilityFilters.ts new file mode 100644 index 0000000000..9cb8ab6430 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useGetRecordGroupVisibilityFilters.ts @@ -0,0 +1,84 @@ +import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; +import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; +import { computeRecordGroupOptionsFilter } from '@/object-record/record-group/utils/computeRecordGroupOptionsFilter'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; +import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; +import { + FieldMetadataType, + ViewFilterOperand, + type RecordGqlOperationFilter, +} from 'twenty-shared/types'; +import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils'; + +type RecordGroupVisibilityFilters = { + recordFilters: RecordFilter[]; + recordGroupGqlFilter: RecordGqlOperationFilter | null; +}; + +export const useGetRecordGroupVisibilityFilters = + (): RecordGroupVisibilityFilters => { + const { currentView } = useGetCurrentViewOnly(); + const flattenedFieldMetadataItems = useAtomStateValue( + flattenedFieldMetadataItemsSelector, + ); + + const mainGroupByFieldMetadataId = currentView?.mainGroupByFieldMetadataId; + + if (!isDefined(mainGroupByFieldMetadataId)) { + return { recordFilters: [], recordGroupGqlFilter: null }; + } + + const mainGroupByFieldMetadataItem = flattenedFieldMetadataItems.find( + (fieldMetadataItem) => + fieldMetadataItem.id === mainGroupByFieldMetadataId, + ); + + const viewGroups = currentView?.viewGroups ?? []; + + if ( + isDefined(mainGroupByFieldMetadataItem) && + isManyToOneRelationField(mainGroupByFieldMetadataItem) + ) { + const visibleGroups = viewGroups.filter((group) => group.isVisible); + + if (!isNonEmptyArray(visibleGroups)) { + return { + recordFilters: [], + recordGroupGqlFilter: { id: { is: 'NULL' } }, + }; + } + + const visibleRecordGroupValues = visibleGroups.map((group) => + isNonEmptyString(group.fieldValue) ? group.fieldValue : null, + ); + + return { + recordFilters: [], + recordGroupGqlFilter: computeRecordGroupOptionsFilter({ + recordGroupFieldMetadata: mainGroupByFieldMetadataItem, + recordGroupValues: visibleRecordGroupValues, + }), + }; + } + + const filterType = getFilterTypeFromFieldType( + mainGroupByFieldMetadataItem?.type ?? FieldMetadataType.SELECT, + ); + + return { + recordFilters: viewGroups + .filter((recordGroup) => !recordGroup.isVisible) + .map((recordGroup) => ({ + id: recordGroup.id, + fieldMetadataId: mainGroupByFieldMetadataId, + value: JSON.stringify([recordGroup.fieldValue]), + operand: ViewFilterOperand.IS_NOT, + displayValue: '', + type: filterType, + label: '', + })), + recordGroupGqlFilter: null, + }; + }; diff --git a/packages/twenty-front/src/modules/views/hooks/useGetViewGroupsFilters.ts b/packages/twenty-front/src/modules/views/hooks/useGetViewGroupsFilters.ts deleted file mode 100644 index 89bfbb1d69..0000000000 --- a/packages/twenty-front/src/modules/views/hooks/useGetViewGroupsFilters.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; -import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; -import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types'; -import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils'; - -export const useGetViewGroupsFilters = (): RecordFilter[] => { - const { currentView } = useGetCurrentViewOnly(); - - return ( - currentView?.viewGroups - .filter((recordGroup) => !recordGroup.isVisible) - .map((recordGroup) => { - if (!isDefined(currentView.mainGroupByFieldMetadataId)) { - throw new Error('mainGroupByFieldMetadataId is required'); - } - - return { - id: recordGroup.id, - fieldMetadataId: currentView.mainGroupByFieldMetadataId, - value: JSON.stringify([recordGroup.fieldValue]), - operand: ViewFilterOperand.IS_NOT, - displayValue: '', - type: getFilterTypeFromFieldType(FieldMetadataType.SELECT), - label: '', - }; - }) - .filter(isDefined) || [] - ); -}; diff --git a/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts b/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts index eb115fef62..30f5359566 100644 --- a/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts +++ b/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts @@ -1,12 +1,88 @@ +import { t } from '@lingui/core/macro'; + import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; import { type RecordGroupDefinition, RecordGroupDefinitionType, } from '@/object-record/record-group/types/RecordGroupDefinition'; import { type ViewGroup } from '@/views/types/ViewGroup'; import { isDefined } from 'twenty-shared/utils'; +import { type ThemeColor } from 'twenty-ui/theme'; import { FieldMetadataType } from '~/generated-metadata/graphql'; +const isNoValueGroup = (viewGroup: ViewGroup): boolean => + !isDefined(viewGroup.fieldValue) || viewGroup.fieldValue === ''; + +const buildRecordGroupDefinition = ({ + viewGroup, + isNoValue, + title, + value, + color, +}: { + viewGroup: ViewGroup; + isNoValue: boolean; + title: string; + value: string | null; + color: ThemeColor | 'transparent'; +}): RecordGroupDefinition => ({ + id: viewGroup.id, + type: isNoValue + ? RecordGroupDefinitionType.NoValue + : RecordGroupDefinitionType.Value, + title, + value, + color, + position: viewGroup.position, + isVisible: viewGroup.isVisible, +}); + +const mapRelationViewGroupsToRecordGroupDefinitions = ( + viewGroups: ViewGroup[], +): RecordGroupDefinition[] => + viewGroups.map((viewGroup) => { + const isNoValue = isNoValueGroup(viewGroup); + + return buildRecordGroupDefinition({ + viewGroup, + isNoValue, + title: isNoValue ? t`No Value` : '', + value: isNoValue ? null : viewGroup.fieldValue, + color: 'transparent', + }); + }); + +const mapSelectViewGroupsToRecordGroupDefinitions = ( + selectFieldMetadataItem: EnrichedObjectMetadataItem['fields'][number], + viewGroups: ViewGroup[], +): RecordGroupDefinition[] => + viewGroups + .map((viewGroup) => { + const selectedOption = selectFieldMetadataItem.options?.find( + (option) => option.value === viewGroup.fieldValue, + ); + + const hasNonEmptyValue = + isDefined(viewGroup.fieldValue) && viewGroup.fieldValue !== ''; + + if ( + !isDefined(selectedOption) && + (hasNonEmptyValue || selectFieldMetadataItem.isNullable === false) + ) { + return null; + } + + return buildRecordGroupDefinition({ + viewGroup, + isNoValue: !isDefined(selectedOption), + title: selectedOption?.label ?? t`No Value`, + value: selectedOption?.value ?? null, + color: selectedOption?.color ?? 'transparent', + }); + }) + .filter(isDefined); + export const mapViewGroupsToRecordGroupDefinitions = ({ mainGroupByFieldMetadataId, objectMetadataItem, @@ -16,59 +92,35 @@ export const mapViewGroupsToRecordGroupDefinitions = ({ objectMetadataItem: EnrichedObjectMetadataItem; viewGroups: ViewGroup[]; }): RecordGroupDefinition[] => { - if (viewGroups?.length === 0) { + if (viewGroups.length === 0) { return []; } - const selectFieldMetadataItem = objectMetadataItem.fields.find( - (field) => - field.id === mainGroupByFieldMetadataId && - field.type === FieldMetadataType.SELECT, + const groupByFieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.id === mainGroupByFieldMetadataId, ); - if (!selectFieldMetadataItem) { + if (!isDefined(groupByFieldMetadataItem)) { return []; } - if (!selectFieldMetadataItem.options) { - throw new Error( - `Select Field ${objectMetadataItem.nameSingular} has no options`, + let recordGroupDefinitions: RecordGroupDefinition[] = []; + + if (isManyToOneRelationField(groupByFieldMetadataItem)) { + recordGroupDefinitions = + mapRelationViewGroupsToRecordGroupDefinitions(viewGroups); + } else if (groupByFieldMetadataItem.type === FieldMetadataType.SELECT) { + if (!groupByFieldMetadataItem.options) { + throw new Error( + `Select Field ${objectMetadataItem.nameSingular} has no options`, + ); + } + + recordGroupDefinitions = mapSelectViewGroupsToRecordGroupDefinitions( + groupByFieldMetadataItem, + viewGroups, ); } - const recordGroupDefinitionsFromViewGroups = viewGroups - .map((viewGroup) => { - const selectedOption = selectFieldMetadataItem.options?.find( - (option) => option.value === viewGroup.fieldValue, - ); - - if ( - !selectedOption && - isDefined(viewGroup.fieldValue) && - viewGroup.fieldValue !== '' - ) { - return null; - } - - if (!selectedOption && selectFieldMetadataItem.isNullable === false) { - return null; - } - - return { - id: viewGroup.id, - type: !isDefined(selectedOption) - ? RecordGroupDefinitionType.NoValue - : RecordGroupDefinitionType.Value, - title: selectedOption?.label ?? 'No Value', - value: selectedOption?.value ?? null, - color: selectedOption?.color ?? 'transparent', - position: viewGroup.position, - isVisible: viewGroup.isVisible, - } as RecordGroupDefinition; - }) - .filter(isDefined); - - return recordGroupDefinitionsFromViewGroups.sort( - (a, b) => a.position - b.position, - ); + return recordGroupDefinitions.sort((a, b) => a.position - b.position); }; diff --git a/packages/twenty-front/src/modules/views/view-picker/hooks/useGetAvailableFieldsToGroupRecordsBy.ts b/packages/twenty-front/src/modules/views/view-picker/hooks/useGetAvailableFieldsToGroupRecordsBy.ts index f47d8422cd..3a9eabbfa2 100644 --- a/packages/twenty-front/src/modules/views/view-picker/hooks/useGetAvailableFieldsToGroupRecordsBy.ts +++ b/packages/twenty-front/src/modules/views/view-picker/hooks/useGetAvailableFieldsToGroupRecordsBy.ts @@ -2,10 +2,11 @@ import { useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector'; +import { canGroupRecordsByFieldMetadataItem } from '@/object-record/record-group/utils/canGroupRecordsByFieldMetadataItem'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; -import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { viewObjectMetadataIdComponentState } from '@/views/states/viewObjectMetadataIdComponentState'; import { SettingsPath } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; @@ -29,7 +30,7 @@ export const useGetAvailableFieldsToGroupRecordsBy = () => { const availableFieldsForGrouping = objectMetadataItem?.readableFields.filter( (field) => - field.type === FieldMetadataType.SELECT && field.isActive === true, + canGroupRecordsByFieldMetadataItem(field) && field.isActive === true, ) ?? []; const navigate = useNavigateSettings(); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-validator.service.ts index bbeed0d105..2fc4141f73 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-validator.service.ts @@ -1,11 +1,13 @@ import { msg, t } from '@lingui/core/macro'; import { type ALL_METADATA_NAME } from 'twenty-shared/metadata'; -import { FieldMetadataType, ViewType } from 'twenty-shared/types'; +import { FieldMetadataType, RelationType, ViewType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; -import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view.type'; +import { isMorphOrRelationUniversalFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util'; import { ViewExceptionCode } from 'src/engine/metadata-modules/view/exceptions/view.exception'; +import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; +import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view.type'; import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util'; import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type'; @@ -14,6 +16,28 @@ import { type UniversalFlatEntityValidationArgs } from 'src/engine/workspace-man export class FlatViewValidatorService { constructor() {} + private isAllowedKanbanMainGroupByField({ + mainGroupByFieldMetadata, + }: { + mainGroupByFieldMetadata: UniversalFlatFieldMetadata; + }): boolean { + if (mainGroupByFieldMetadata.type === FieldMetadataType.SELECT) { + return true; + } + + if ( + !isMorphOrRelationUniversalFlatFieldMetadata(mainGroupByFieldMetadata) + ) { + return false; + } + + return ( + mainGroupByFieldMetadata.type === FieldMetadataType.RELATION && + mainGroupByFieldMetadata.universalSettings?.relationType === + RelationType.MANY_TO_ONE + ); + } + public validateFlatViewUpdate({ universalIdentifier, flatEntityUpdate, @@ -104,11 +128,15 @@ export class FlatViewValidatorService { message: t`Kanban main group by field metadata not found`, userFriendlyMessage: msg`Kanban main group by field metadata not found`, }); - } else if (mainGroupByFieldMetadata.type !== FieldMetadataType.SELECT) { + } else if ( + !this.isAllowedKanbanMainGroupByField({ + mainGroupByFieldMetadata, + }) + ) { validationResult.errors.push({ code: ViewExceptionCode.INVALID_VIEW_DATA, - message: t`Kanban main group by field must be a SELECT field`, - userFriendlyMessage: msg`Kanban main group by field must be a select field`, + message: t`Kanban main group by field must be a SELECT or a many-to-one relation field`, + userFriendlyMessage: msg`Kanban main group by field must be a select or a many-to-one relation field`, }); } } @@ -133,11 +161,15 @@ export class FlatViewValidatorService { message: t`Kanban main group by field metadata not found`, userFriendlyMessage: msg`Kanban main group by field metadata not found`, }); - } else if (mainGroupByFieldMetadata.type !== FieldMetadataType.SELECT) { + } else if ( + !this.isAllowedKanbanMainGroupByField({ + mainGroupByFieldMetadata, + }) + ) { validationResult.errors.push({ code: ViewExceptionCode.INVALID_VIEW_DATA, - message: t`Kanban main group by field must be a SELECT field`, - userFriendlyMessage: msg`Kanban main group by field must be a select field`, + message: t`Kanban main group by field must be a SELECT or a many-to-one relation field`, + userFriendlyMessage: msg`Kanban main group by field must be a select or a many-to-one relation field`, }); } } @@ -300,11 +332,15 @@ export class FlatViewValidatorService { message: t`Kanban main group by field metadata not found`, userFriendlyMessage: msg`Kanban main group by field metadata not found`, }); - } else if (mainGroupByFieldMetadata.type !== FieldMetadataType.SELECT) { + } else if ( + !this.isAllowedKanbanMainGroupByField({ + mainGroupByFieldMetadata, + }) + ) { validationResult.errors.push({ code: ViewExceptionCode.INVALID_VIEW_DATA, - message: t`Kanban main group by field must be a SELECT field`, - userFriendlyMessage: msg`Kanban main group by field must be a select field`, + message: t`Kanban main group by field must be a SELECT or a many-to-one relation field`, + userFriendlyMessage: msg`Kanban main group by field must be a select or a many-to-one relation field`, }); } }