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,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<FieldMetadataItem, 'name' | 'type' | 'relation'>,
): string => {
if (isManyToOneRelationField(fieldMetadataItem)) {
return computeRelationGqlFieldJoinColumnName({
name: fieldMetadataItem.name,
});
}
return fieldMetadataItem.name;
};
@@ -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';
@@ -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 (
<StyledColumn>
<AddRecordGroupButton
fieldMetadataItem={selectFieldMetadataItem}
dropdownId={RECORD_BOARD_ADD_GROUP_DROPDOWN_ID}
dropdownOffset={{ x: 0, y: 10 }}
/>
</StyledColumn>
);
};
@@ -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 (
<StyledColumnContainer>
{visibleRecordGroupIds.map((recordGroupId, index) => {
@@ -31,6 +44,7 @@ export const RecordBoardColumns = () => {
/>
);
})}
{shouldShowAddGroupColumnDivider && <StyledAddGroupColumnDivider />}
</StyledColumnContainer>
);
};
@@ -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(
@@ -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 = () => {
/>
</RecordGroupContext.Provider>
))}
<RecordBoardAddGroupColumn />
<RecordIndexGroupAggregatesDataLoader />
</StyledHeaderContainer>
);
@@ -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(
@@ -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,
);
@@ -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);
@@ -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={
<StyledTagContainer>
<Tag
variant={
columnDefinition.type ===
RecordGroupDefinitionType.Value
? 'solid'
: 'outline'
}
color={
columnDefinition.type ===
RecordGroupDefinitionType.Value
? columnDefinition.color
: 'transparent'
}
text={columnDefinition.title}
weight={
columnDefinition.type ===
RecordGroupDefinitionType.Value
? 'regular'
: 'medium'
}
<RecordGroupChip
recordGroupDefinition={columnDefinition}
fieldMetadataItem={selectFieldMetadataItem}
/>
</StyledTagContainer>
}
@@ -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,
});
}}
>
@@ -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,
},
});
},
@@ -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 }),
},
});
@@ -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 }
: {}),
@@ -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 (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
dropdownOffset={dropdownOffset}
clickableComponent={
<LightButton Icon={IconPlus} title={t`New group`} accent="tertiary" />
}
dropdownComponents={
<SingleRecordPicker
focusId={dropdownId}
componentInstanceId={dropdownId}
onCancel={() => closeDropdown(dropdownId)}
onMorphItemSelected={handleRecordSelected}
objectNameSingulars={[targetObjectNameSingular]}
excludedRecordIds={excludedRecordIds}
/>
}
/>
);
};
@@ -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 (
<RecordGroupRelationChip
fieldMetadataItem={fieldMetadataItem}
recordId={recordGroupDefinition.value}
/>
);
}
return (
<Tag
variant={isValueGroup ? 'solid' : 'outline'}
color={isValueGroup ? recordGroupDefinition.color : 'transparent'}
text={recordGroupDefinition.title}
weight={isValueGroup ? valueTagWeight : 'medium'}
/>
);
};
@@ -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 = ({
<MenuItemDraggable
key={recordGroupDefinition.id}
text={
<Tag
variant={
recordGroupDefinition.type !== RecordGroupDefinitionType.NoValue
? 'solid'
: 'outline'
}
color={
recordGroupDefinition.type !== RecordGroupDefinitionType.NoValue
? recordGroupDefinition.color
: 'transparent'
}
text={recordGroupDefinition.title}
weight={
recordGroupDefinition.type !== RecordGroupDefinitionType.NoValue
? 'regular'
: 'medium'
}
<RecordGroupChip
recordGroupDefinition={recordGroupDefinition}
fieldMetadataItem={recordIndexGroupFieldMetadataItem}
/>
}
accent={isNoValue || showDragGrip ? 'placeholder' : 'default'}
@@ -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<ObjectRecord>({
objectNameSingular: targetObjectNameSingular ?? '',
objectRecordId: recordId,
withSoftDeleted: true,
skip: !isDefined(targetObjectNameSingular),
});
if (
!isDefined(targetObjectNameSingular) ||
(!isDefined(record) && !loading)
) {
return <Tag variant="outline" color="transparent" text={t`Deleted`} />;
}
if (!isDefined(record)) {
return <Tag variant="outline" color="transparent" text="" />;
}
return (
<RecordChip
objectNameSingular={targetObjectNameSingular}
record={record}
forceDisableClick
/>
);
};
@@ -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 };
};
@@ -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,
},
};
@@ -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);
@@ -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<FieldMetadataItem, 'type' | 'relation'>,
): boolean =>
fieldMetadataItem.type === FieldMetadataType.SELECT ||
isManyToOneRelationField(fieldMetadataItem);
@@ -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<typeof value> => !isNull(value),
@@ -88,7 +88,7 @@ export const RecordIndexGroupAggregateQueryEffect = ({
setRecordIndexAggregateDisplayValueForRecordGroupValue(
recordIndexGroupAggregateOperation,
recordIndexGroupAggregateFieldMetadataItem,
foundAggregateValueForGroup.recordGroupValue,
foundAggregateValueForGroup.recordGroupValue ?? '',
foundAggregateValueForGroup.recordAggregateValue,
);
} else {
@@ -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,
@@ -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,
},
@@ -1,6 +1,6 @@
import { type Nullable } from 'twenty-shared/types';
export type RecordAggregateValueByRecordGroupValue = {
recordGroupValue: string;
recordGroupValue: Nullable<string>;
recordAggregateValue: Nullable<string | number>;
};
@@ -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,
)
],
),
);
@@ -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 = () => {
</RecordGroupContext.Provider>
</RecordTableRecordGroupBodyContextProvider>
))}
<RecordTableRecordGroupAddNewGroup />
<RecordIndexGroupAggregatesDataLoader />
</RecordTableBodyRecordGroupDragDropContextProvider>
</>
@@ -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 (
<StyledContainer>
<AddRecordGroupButton
fieldMetadataItem={recordIndexGroupFieldMetadataItem}
dropdownId={RECORD_TABLE_ADD_GROUP_DROPDOWN_ID}
/>
</StyledContainer>
);
};
@@ -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}
>
<StyledTagContainer>
<Tag
variant={
recordGroupDefinition.type !== RecordGroupDefinitionType.NoValue
? 'solid'
: 'outline'
}
color={
recordGroupDefinition.type !== RecordGroupDefinitionType.NoValue
? recordGroupDefinition.color
: 'transparent'
}
text={recordGroupDefinition.title}
weight="medium"
<RecordGroupChip
recordGroupDefinition={recordGroupDefinition}
fieldMetadataItem={recordIndexGroupFieldMetadataItem}
valueTagWeight="medium"
/>
</StyledTagContainer>
<RecordBoardColumnHeaderAggregateDropdown
@@ -1,13 +1,14 @@
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { useCurrentRecordGroupId } from '@/object-record/record-group/hooks/useCurrentRecordGroupId';
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 { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { RecordTableActionRow } from '@/object-record/record-table/record-table-row/components/RecordTableActionRow';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
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 { IconPlus } from 'twenty-ui/icon';
@@ -57,7 +58,8 @@ export const RecordTableRecordGroupSectionAddNew = () => {
createNewIndexRecord({
position: 'last',
[fieldMetadataItem.name]: recordGroupDefinition?.value,
[getFieldMetadataItemGqlFieldName(fieldMetadataItem)]:
recordGroupDefinition?.value,
});
}}
/>
@@ -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,
);
@@ -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<typeof updateManyViewGroupsMutation>
> | 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<Awaited<
ReturnType<typeof createManyViewGroupsMutation>
> | 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,
};
};
@@ -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,
};
};
@@ -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) || []
);
};
@@ -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);
};
@@ -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();
@@ -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`,
});
}
}