Feat: group records by many to one relation (#22123)

## 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



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22123?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Abdul Rahman
2026-06-25 17:25:50 +05:30
committed by GitHub
parent 108cc5b6a5
commit db5338cf32
44 changed files with 981 additions and 238 deletions
@@ -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 (
<SingleRecordPickerComponentInstanceContext.Provider
value={{ instanceId: ADD_RECORD_GROUP_PICKER_INSTANCE_ID }}
>
<DropdownContent>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={handleBack}
Icon={IconChevronLeft}
/>
}
>
{t`New group`}
</DropdownMenuHeader>
<SingleRecordPickerMenuItemsWithSearch
focusId={ADD_RECORD_GROUP_PICKER_INSTANCE_ID}
onCancel={handleBack}
onMorphItemSelected={handleRecordSelected}
objectNameSingulars={[targetObjectNameSingular]}
excludedRecordIds={excludedRecordIds}
/>
</DropdownContent>
</SingleRecordPickerComponentInstanceContext.Provider>
);
};
@@ -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 <ObjectOptionsDropdownRecordGroupSortContent />;
case 'hiddenRecordGroups':
return <ObjectOptionsDropdownHiddenRecordGroupsContent />;
case 'addRecordGroup':
return <ObjectOptionsDropdownAddRecordGroupContent />;
case 'calendarView':
return <ObjectOptionsDropdownCalendarViewContent />;
case 'calendarFields':
@@ -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 (
<DropdownContent>
@@ -90,43 +103,53 @@ export const ObjectOptionsDropdownRecordGroupSortContent = () => {
focused={selectedItemId === RecordGroupSort.Manual}
/>
</SelectableListItem>
<SelectableListItem
itemId={RecordGroupSort.Alphabetical}
onEnter={() =>
handleRecordGroupSortChange(RecordGroupSort.Alphabetical)
}
>
<MenuItemSelect
onClick={() =>
handleRecordGroupSortChange(RecordGroupSort.Alphabetical)
}
LeftIcon={IconSortAZ}
text={RecordGroupSort.Alphabetical}
selected={
recordIndexRecordGroupSort === RecordGroupSort.Alphabetical
}
focused={selectedItemId === RecordGroupSort.Alphabetical}
/>
</SelectableListItem>
<SelectableListItem
itemId={RecordGroupSort.ReverseAlphabetical}
onEnter={() =>
handleRecordGroupSortChange(RecordGroupSort.ReverseAlphabetical)
}
>
<MenuItemSelect
onClick={() =>
handleRecordGroupSortChange(RecordGroupSort.ReverseAlphabetical)
}
LeftIcon={IconSortZA}
text={RecordGroupSort.ReverseAlphabetical}
selected={
recordIndexRecordGroupSort ===
RecordGroupSort.ReverseAlphabetical
}
focused={selectedItemId === RecordGroupSort.ReverseAlphabetical}
/>
</SelectableListItem>
{!isRelationGroupBy && (
<>
<SelectableListItem
itemId={RecordGroupSort.Alphabetical}
onEnter={() =>
handleRecordGroupSortChange(RecordGroupSort.Alphabetical)
}
>
<MenuItemSelect
onClick={() =>
handleRecordGroupSortChange(RecordGroupSort.Alphabetical)
}
LeftIcon={IconSortAZ}
text={RecordGroupSort.Alphabetical}
selected={
recordIndexRecordGroupSort === RecordGroupSort.Alphabetical
}
focused={selectedItemId === RecordGroupSort.Alphabetical}
/>
</SelectableListItem>
<SelectableListItem
itemId={RecordGroupSort.ReverseAlphabetical}
onEnter={() =>
handleRecordGroupSortChange(
RecordGroupSort.ReverseAlphabetical,
)
}
>
<MenuItemSelect
onClick={() =>
handleRecordGroupSortChange(
RecordGroupSort.ReverseAlphabetical,
)
}
LeftIcon={IconSortZA}
text={RecordGroupSort.ReverseAlphabetical}
selected={
recordIndexRecordGroupSort ===
RecordGroupSort.ReverseAlphabetical
}
focused={
selectedItemId === RecordGroupSort.ReverseAlphabetical
}
/>
</SelectableListItem>
</>
)}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
@@ -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' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
<MenuItem
onClick={() => onContentChange('addRecordGroup')}
LeftIcon={IconPlus}
text={t`New group`}
/>
</DropdownMenuItemsContainer>
</>
)}
{hiddenRecordGroupIds.length > 0 && (
<>
<DropdownMenuSeparator />
@@ -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),
);
@@ -7,6 +7,7 @@ export type ObjectOptionsContentId =
| 'hiddenRecordGroups'
| 'recordGroupFields'
| 'recordGroupSort'
| 'addRecordGroup'
| 'calendarFields'
| 'calendarView'
| 'visibility';