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
---
.../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' && (
+ <>
+
+
+
+ >
+ )}
{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`,
});
}
}