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:
+90
@@ -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>
|
||||
);
|
||||
};
|
||||
+3
@@ -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':
|
||||
|
||||
+65
-42
@@ -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>
|
||||
|
||||
+20
-1
@@ -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 />
|
||||
|
||||
+2
-2
@@ -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),
|
||||
);
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ export type ObjectOptionsContentId =
|
||||
| 'hiddenRecordGroups'
|
||||
| 'recordGroupFields'
|
||||
| 'recordGroupSort'
|
||||
| 'addRecordGroup'
|
||||
| 'calendarFields'
|
||||
| 'calendarView'
|
||||
| 'visibility';
|
||||
|
||||
Reference in New Issue
Block a user