Enable switching field to group records by on existing kanban view (#17015)

Fixes https://github.com/twentyhq/twenty/issues/16982 and
https://github.com/twentyhq/private-issues/issues/403

Re-introducing the feature to allow to switch field to group records by
on a kanban view


https://github.com/user-attachments/assets/a552d3f2-7900-4771-b512-98cb99cdd8de
This commit is contained in:
Marie
2026-01-08 14:08:06 +01:00
committed by GitHub
parent c207214f0b
commit f414f4799b
12 changed files with 403 additions and 44 deletions
@@ -20,7 +20,7 @@ import { type GraphQLView } from '@/views/types/GraphQLView';
import { ViewOpenRecordInType } from '@/views/types/ViewOpenRecordInType';
import { ViewType, viewTypeIconMapping } from '@/views/types/ViewType';
import { useGetAvailableFieldsForCalendar } from '@/views/view-picker/hooks/useGetAvailableFieldsForCalendar';
import { useGetAvailableFieldsForKanban } from '@/views/view-picker/hooks/useGetAvailableFieldsForKanban';
import { useGetAvailableFieldsToGroupRecordsBy } from '@/views/view-picker/hooks/useGetAvailableFieldsToGroupRecordsBy';
import { useLingui } from '@lingui/react/macro';
import { useCallback } from 'react';
import { useRecoilValue } from 'recoil';
@@ -75,8 +75,8 @@ export const ObjectOptionsDropdownLayoutContent = () => {
: undefined;
const { setAndPersistViewType } = useSetViewTypeFromLayoutOptionsMenu();
const { availableFieldsForKanban, navigateToSelectSettings } =
useGetAvailableFieldsForKanban();
const { availableFieldsForGrouping, navigateToSelectSettings } =
useGetAvailableFieldsToGroupRecordsBy();
const { availableFieldsForCalendar, navigateToDateFieldSettings } =
useGetAvailableFieldsForCalendar();
const { closeDropdown } = useCloseDropdown();
@@ -85,7 +85,7 @@ export const ObjectOptionsDropdownLayoutContent = () => {
if (isDefaultView) {
return;
}
if (availableFieldsForKanban.length === 0) {
if (availableFieldsForGrouping.length === 0) {
navigateToSelectSettings();
closeDropdown(dropdownId);
return;
@@ -200,7 +200,7 @@ export const ObjectOptionsDropdownLayoutContent = () => {
text={t`Not available for default view`}
/>
</>
) : availableFieldsForKanban.length === 0 ? (
) : availableFieldsForGrouping.length === 0 ? (
t`Create Select...`
) : undefined
}
@@ -20,6 +20,7 @@ import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { useGetAvailableFieldsToGroupRecordsBy } from '@/views/view-picker/hooks/useGetAvailableFieldsToGroupRecordsBy';
import { useLingui } from '@lingui/react/macro';
import {
IconChevronLeft,
@@ -75,6 +76,11 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => {
handleHideEmptyRecordGroupChange,
} = useRecordGroupVisibility();
const { availableFieldsForGrouping } =
useGetAvailableFieldsToGroupRecordsBy();
const hasOnlyOneGroupByOption = availableFieldsForGrouping.length <= 1;
useEffect(() => {
if (
currentContentId === 'hiddenRecordGroups' &&
@@ -116,10 +122,17 @@ export const ObjectOptionsDropdownRecordGroupsContent = () => {
>
{currentView?.key !== 'INDEX' && (
<>
<SelectableListItem itemId="GroupBy">
<SelectableListItem
itemId="GroupBy"
onEnter={() =>
!hasOnlyOneGroupByOption &&
onContentChange('recordGroupFields')
}
>
<MenuItem
focused={selectedItemId === 'GroupBy'}
disabled
disabled={hasOnlyOneGroupByOption}
onClick={() => onContentChange('recordGroupFields')}
LeftIcon={IconLayoutList}
text={t`Group by`}
contextualText={recordGroupFieldMetadata?.label}
@@ -24,9 +24,9 @@ describe('useSearchRecordGroupField', () => {
it('filters fields correctly based on input', () => {
const fields = [
{ type: FieldMetadataType.SELECT, label: 'First' },
{ type: FieldMetadataType.SELECT, label: 'Second' },
{ type: FieldMetadataType.TEXT, label: 'Third' },
{ type: FieldMetadataType.SELECT, label: 'First', isActive: true },
{ type: FieldMetadataType.SELECT, label: 'Second', isActive: true },
{ type: FieldMetadataType.TEXT, label: 'Third', isActive: true },
];
const mockContextValue = {
objectMetadataItem: {
@@ -43,15 +43,15 @@ describe('useSearchRecordGroupField', () => {
});
expect(result.current.filteredRecordGroupFieldMetadataItems).toEqual([
{ type: FieldMetadataType.SELECT, label: 'First' },
{ type: FieldMetadataType.SELECT, label: 'First', isActive: true },
]);
});
it('returns all select fields when search input is empty', () => {
const fields = [
{ type: FieldMetadataType.SELECT, label: 'First' },
{ type: FieldMetadataType.SELECT, label: 'Second' },
{ type: FieldMetadataType.TEXT, label: 'Third' },
{ type: FieldMetadataType.SELECT, label: 'First', isActive: true },
{ type: FieldMetadataType.SELECT, label: 'Second', isActive: true },
{ type: FieldMetadataType.TEXT, label: 'Third', isActive: true },
];
const mockContextValue = {
objectMetadataItem: {
@@ -64,8 +64,129 @@ describe('useSearchRecordGroupField', () => {
const { result } = renderWithContext(mockContextValue);
expect(result.current.filteredRecordGroupFieldMetadataItems).toEqual([
{ type: FieldMetadataType.SELECT, label: 'First' },
{ type: FieldMetadataType.SELECT, label: 'Second' },
{ type: FieldMetadataType.SELECT, label: 'First', isActive: true },
{ type: FieldMetadataType.SELECT, label: 'Second', isActive: true },
]);
});
it('filters out inactive SELECT fields', () => {
const fields = [
{ type: FieldMetadataType.SELECT, label: 'Active Field', isActive: true },
{
type: FieldMetadataType.SELECT,
label: 'Inactive Field',
isActive: false,
},
{ type: FieldMetadataType.TEXT, label: 'Text Field', isActive: true },
];
const mockContextValue = {
objectMetadataItem: {
fields,
readableFields: fields,
updatableFields: fields,
},
};
const { result } = renderWithContext(mockContextValue);
expect(result.current.filteredRecordGroupFieldMetadataItems).toHaveLength(
1,
);
expect(result.current.filteredRecordGroupFieldMetadataItems[0].label).toBe(
'Active Field',
);
});
it('performs case-insensitive search', () => {
const fields = [
{ type: FieldMetadataType.SELECT, label: 'Status', isActive: true },
{ type: FieldMetadataType.SELECT, label: 'Priority', isActive: true },
];
const mockContextValue = {
objectMetadataItem: {
fields,
readableFields: fields,
updatableFields: fields,
},
};
const { result } = renderWithContext(mockContextValue);
act(() => {
result.current.setRecordGroupFieldSearchInput('STATUS');
});
expect(result.current.filteredRecordGroupFieldMetadataItems).toEqual([
{ type: FieldMetadataType.SELECT, label: 'Status', isActive: true },
]);
});
it('returns empty array when no fields match search', () => {
const fields = [
{ type: FieldMetadataType.SELECT, label: 'Status', isActive: true },
{ type: FieldMetadataType.SELECT, label: 'Priority', isActive: true },
];
const mockContextValue = {
objectMetadataItem: {
fields,
readableFields: fields,
updatableFields: fields,
},
};
const { result } = renderWithContext(mockContextValue);
act(() => {
result.current.setRecordGroupFieldSearchInput('nonexistent');
});
expect(result.current.filteredRecordGroupFieldMetadataItems).toEqual([]);
});
it('returns partial matches in search', () => {
const fields = [
{ type: FieldMetadataType.SELECT, label: 'User Status', isActive: true },
{ type: FieldMetadataType.SELECT, label: 'Task Status', isActive: true },
{ type: FieldMetadataType.SELECT, label: 'Priority', isActive: true },
];
const mockContextValue = {
objectMetadataItem: {
fields,
readableFields: fields,
updatableFields: fields,
},
};
const { result } = renderWithContext(mockContextValue);
act(() => {
result.current.setRecordGroupFieldSearchInput('Status');
});
expect(result.current.filteredRecordGroupFieldMetadataItems).toHaveLength(
2,
);
expect(result.current.filteredRecordGroupFieldMetadataItems).toEqual([
{ type: FieldMetadataType.SELECT, label: 'User Status', isActive: true },
{ type: FieldMetadataType.SELECT, label: 'Task Status', isActive: true },
]);
});
it('returns empty array when no SELECT fields exist', () => {
const fields = [
{ type: FieldMetadataType.TEXT, label: 'Name', isActive: true },
{ type: FieldMetadataType.NUMBER, label: 'Count', isActive: true },
];
const mockContextValue = {
objectMetadataItem: {
fields,
readableFields: fields,
updatableFields: fields,
},
};
const { result } = renderWithContext(mockContextValue);
expect(result.current.filteredRecordGroupFieldMetadataItems).toEqual([]);
});
});
@@ -17,6 +17,7 @@ export const useSearchRecordGroupField = () => {
return objectMetadataItem.readableFields.filter(
(field) =>
field.type === FieldMetadataType.SELECT &&
field.isActive &&
field.label.toLocaleLowerCase().includes(searchInputLowerCase),
);
}, [objectMetadataItem.readableFields, recordGroupFieldSearchInput]);
@@ -11,7 +11,7 @@ import { ViewType, viewTypeIconMapping } from '@/views/types/ViewType';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { convertViewTypeToCore } from '@/views/utils/convertViewTypeToCore';
import { useGetAvailableFieldsForCalendar } from '@/views/view-picker/hooks/useGetAvailableFieldsForCalendar';
import { useGetAvailableFieldsForKanban } from '@/views/view-picker/hooks/useGetAvailableFieldsForKanban';
import { useGetAvailableFieldsToGroupRecordsBy } from '@/views/view-picker/hooks/useGetAvailableFieldsToGroupRecordsBy';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { ViewCalendarLayout } from '~/generated/graphql';
@@ -19,7 +19,8 @@ import { ViewCalendarLayout } from '~/generated/graphql';
export const useSetViewTypeFromLayoutOptionsMenu = () => {
const { updateCurrentView } = useUpdateCurrentView();
const setRecordIndexViewType = useSetRecoilState(recordIndexViewTypeState);
const { availableFieldsForKanban } = useGetAvailableFieldsForKanban();
const { availableFieldsForGrouping } =
useGetAvailableFieldsToGroupRecordsBy();
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { loadRecordIndexStates } = useLoadRecordIndexStates();
@@ -60,11 +61,11 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
switch (viewType) {
case ViewType.Kanban: {
if (availableFieldsForKanban.length === 0) {
if (availableFieldsForGrouping.length === 0) {
throw new Error('No fields for kanban - should not happen');
}
const mainGroupByFieldMetadataId = availableFieldsForKanban[0].id;
const mainGroupByFieldMetadataId = availableFieldsForGrouping[0].id;
updateCurrentViewParams.mainGroupByFieldMetadataId =
mainGroupByFieldMetadataId;
@@ -154,7 +155,7 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
}
},
[
availableFieldsForKanban,
availableFieldsForGrouping,
setRecordIndexViewType,
updateCurrentView,
availableFieldsForCalendar,