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:
+15
@@ -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;
|
||||
};
|
||||
+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';
|
||||
|
||||
+32
@@ -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>
|
||||
);
|
||||
};
|
||||
+14
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-1
@@ -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(
|
||||
|
||||
+2
@@ -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>
|
||||
);
|
||||
|
||||
+5
-2
@@ -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(
|
||||
|
||||
+4
-1
@@ -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,
|
||||
);
|
||||
|
||||
+8
-4
@@ -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);
|
||||
|
||||
|
||||
+7
-23
@@ -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>
|
||||
}
|
||||
|
||||
+3
-1
@@ -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,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
+5
-1
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
+7
-2
@@ -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 }),
|
||||
},
|
||||
});
|
||||
|
||||
+7
-7
@@ -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 }
|
||||
: {}),
|
||||
|
||||
+75
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+47
@@ -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'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+10
-18
@@ -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'}
|
||||
|
||||
+46
@@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
+92
@@ -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 };
|
||||
};
|
||||
+6
-2
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
+10
@@ -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);
|
||||
+9
@@ -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);
|
||||
+2
-1
@@ -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),
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@ export const RecordIndexGroupAggregateQueryEffect = ({
|
||||
setRecordIndexAggregateDisplayValueForRecordGroupValue(
|
||||
recordIndexGroupAggregateOperation,
|
||||
recordIndexGroupAggregateFieldMetadataItem,
|
||||
foundAggregateValueForGroup.recordGroupValue,
|
||||
foundAggregateValueForGroup.recordGroupValue ?? '',
|
||||
foundAggregateValueForGroup.recordAggregateValue,
|
||||
);
|
||||
} else {
|
||||
|
||||
+15
-10
@@ -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,
|
||||
|
||||
+21
-2
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
|
||||
export type RecordAggregateValueByRecordGroupValue = {
|
||||
recordGroupValue: string;
|
||||
recordGroupValue: Nullable<string>;
|
||||
recordAggregateValue: Nullable<string | number>;
|
||||
};
|
||||
|
||||
+9
-4
@@ -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,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
+2
@@ -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>
|
||||
</>
|
||||
|
||||
+34
@@ -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>
|
||||
);
|
||||
};
|
||||
+10
-15
@@ -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
|
||||
|
||||
+4
-2
@@ -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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
+9
-3
@@ -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,
|
||||
);
|
||||
|
||||
+54
-6
@@ -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) || []
|
||||
);
|
||||
};
|
||||
+96
-44
@@ -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);
|
||||
};
|
||||
|
||||
+3
-2
@@ -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();
|
||||
|
||||
+47
-11
@@ -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`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user