[Dashboards] Relation fields groupby (#16093)

This commit is contained in:
nitin
2025-11-28 00:58:53 +05:30
committed by GitHub
parent f23aa632a7
commit fa87603fd8
22 changed files with 597 additions and 136 deletions
@@ -25,12 +25,9 @@ import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectab
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { isFieldMetadataDateKind } from 'twenty-shared/utils';
import {
FieldMetadataType,
GraphType,
type PageLayoutWidget,
} from '~/generated/graphql';
import { GraphType, type PageLayoutWidget } from '~/generated/graphql';
const StyledCommandMenuContainer = styled.div`
display: flex;
@@ -136,9 +133,7 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => {
(field) => field.id === primaryAxisFieldMetadataId,
);
const isPrimaryAxisDate =
primaryAxisField?.type === FieldMetadataType.DATE ||
primaryAxisField?.type === FieldMetadataType.DATE_TIME;
const isPrimaryAxisDate = isFieldMetadataDateKind(primaryAxisField?.type);
const primaryAxisDateGranularity =
configuration.__typename === 'BarChartConfiguration' ||
@@ -1,4 +1,5 @@
import { ChartGroupByFieldSelectionCompositeFieldView } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionCompositeFieldView';
import { ChartGroupByFieldSelectionRelationFieldView } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionRelationFieldView';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
@@ -23,6 +24,7 @@ import { useMemo, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { RelationType } from '~/generated/graphql';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
type ChartGroupByFieldSelectionDropdownContentBaseProps<
@@ -43,6 +45,9 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
const [selectedCompositeField, setSelectedCompositeField] =
useState<FieldMetadataItem | null>(null);
const [selectedRelationField, setSelectedRelationField] =
useState<FieldMetadataItem | null>(null);
const { objectMetadataItems } = useObjectMetadataItems();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
@@ -77,8 +82,15 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
items: sourceObjectMetadataItem?.fields || [],
searchQuery,
getSearchableValues: (item) => [item.label, item.name],
// TODO: remove the relation filter once group by is supported for relation fields
}).filter((field) => !isFieldRelation(field) && !field.isSystem),
}).filter((field) => {
if (field.isSystem === true) {
return false;
}
if (isFieldRelation(field)) {
return field.relation?.type === RelationType.MANY_TO_ONE;
}
return true;
}),
[sourceObjectMetadataItem?.fields, searchQuery],
);
@@ -94,20 +106,26 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
}
const handleSelectField = (fieldMetadataItem: FieldMetadataItem) => {
if (isFieldRelation(fieldMetadataItem)) {
setSelectedRelationField(fieldMetadataItem);
return;
}
if (isCompositeFieldType(fieldMetadataItem.type)) {
setSelectedCompositeField(fieldMetadataItem);
} else {
updateCurrentWidgetConfig({
configToUpdate: buildChartGroupByFieldConfigUpdate({
configuration,
fieldMetadataIdKey,
subFieldNameKey,
fieldId: fieldMetadataItem.id,
subFieldName: null,
}),
});
closeDropdown();
return;
}
updateCurrentWidgetConfig({
configToUpdate: buildChartGroupByFieldConfigUpdate({
configuration,
fieldMetadataIdKey,
subFieldNameKey,
fieldId: fieldMetadataItem.id,
subFieldName: null,
}),
});
closeDropdown();
};
const handleSelectNone = () => {
@@ -123,11 +141,15 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
closeDropdown();
};
const handleBack = () => {
const handleBackFromComposite = () => {
setSelectedCompositeField(null);
};
const handleSelectSubField = (subFieldName: string) => {
const handleBackFromRelation = () => {
setSelectedRelationField(null);
};
const handleSelectCompositeSubField = (subFieldName: string) => {
if (!isDefined(selectedCompositeField)) {
return;
}
@@ -144,13 +166,41 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
closeDropdown();
};
const handleSelectRelationSubField = (subFieldName: string) => {
if (!isDefined(selectedRelationField)) {
return;
}
updateCurrentWidgetConfig({
configToUpdate: buildChartGroupByFieldConfigUpdate({
configuration,
fieldMetadataIdKey,
subFieldNameKey,
fieldId: selectedRelationField.id,
subFieldName,
}),
});
closeDropdown();
};
if (isDefined(selectedRelationField)) {
return (
<ChartGroupByFieldSelectionRelationFieldView
relationField={selectedRelationField}
currentSubFieldName={currentSubFieldName}
onBack={handleBackFromRelation}
onSelectSubField={handleSelectRelationSubField}
/>
);
}
if (isDefined(selectedCompositeField)) {
return (
<ChartGroupByFieldSelectionCompositeFieldView
compositeField={selectedCompositeField}
currentSubFieldName={currentSubFieldName}
onBack={handleBack}
onSelectSubField={handleSelectSubField}
onBack={handleBackFromComposite}
onSelectSubField={handleSelectCompositeSubField}
/>
);
}
@@ -195,11 +245,15 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
text={fieldMetadataItem.label}
selected={
!isCompositeFieldType(fieldMetadataItem.type) &&
!isFieldRelation(fieldMetadataItem) &&
currentGroupByFieldMetadataId === fieldMetadataItem.id
}
focused={selectedItemId === fieldMetadataItem.id}
LeftIcon={getIcon(fieldMetadataItem.icon)}
hasSubMenu={isCompositeFieldType(fieldMetadataItem.type)}
hasSubMenu={
isCompositeFieldType(fieldMetadataItem.type) ||
isFieldRelation(fieldMetadataItem)
}
onClick={() => {
handleSelectField(fieldMetadataItem);
}}
@@ -0,0 +1,174 @@
import { ChartGroupByFieldSelectionCompositeFieldView } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionCompositeFieldView';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
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 { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { useMemo, useState } from 'react';
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
import { IconChevronLeft, useIcons } from 'twenty-ui/display';
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
type ChartGroupByFieldSelectionRelationFieldViewProps = {
relationField: FieldMetadataItem;
currentSubFieldName: string | undefined;
onBack: () => void;
onSelectSubField: (subFieldName: string) => void;
};
export const ChartGroupByFieldSelectionRelationFieldView = ({
relationField,
currentSubFieldName,
onBack,
onSelectSubField,
}: ChartGroupByFieldSelectionRelationFieldViewProps) => {
const { getIcon } = useIcons();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCompositeField, setSelectedCompositeField] =
useState<FieldMetadataItem | null>(null);
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
);
const { objectMetadataItems } = useObjectMetadataItems();
const targetObjectNameSingular =
relationField.relation?.targetObjectMetadata?.nameSingular;
const targetObjectMetadataItem = useMemo(
() =>
objectMetadataItems.find(
(item) => item.nameSingular === targetObjectNameSingular,
),
[objectMetadataItems, targetObjectNameSingular],
);
const availableFields = useMemo(() => {
if (!isDefined(targetObjectMetadataItem)) {
return [];
}
return filterBySearchQuery({
items: targetObjectMetadataItem.fields.filter(
(field) =>
!field.isSystem &&
!isFieldRelation(field) &&
// TODO: Backend doesn't fully support date fields for relation fields yet so we hide them for now. https://github.com/twentyhq/core-team-issues/issues/1935
!isFieldMetadataDateKind(field.type),
),
searchQuery,
getSearchableValues: (field) => [field.label, field.name],
});
}, [targetObjectMetadataItem, searchQuery]);
const handleSelectField = (fieldMetadataItem: FieldMetadataItem) => {
if (isCompositeFieldType(fieldMetadataItem.type)) {
setSelectedCompositeField(fieldMetadataItem);
} else {
onSelectSubField(fieldMetadataItem.name);
}
};
const handleSelectCompositeSubField = (compositeSubFieldName: string) => {
if (!isDefined(selectedCompositeField)) {
return;
}
onSelectSubField(`${selectedCompositeField.name}.${compositeSubFieldName}`);
};
const handleBackFromComposite = () => {
setSelectedCompositeField(null);
};
const [currentNestedFieldName, currentNestedSubFieldName] =
currentSubFieldName?.split('.') ?? [];
if (isDefined(selectedCompositeField)) {
return (
<ChartGroupByFieldSelectionCompositeFieldView
compositeField={selectedCompositeField}
currentSubFieldName={currentNestedSubFieldName}
onBack={handleBackFromComposite}
onSelectSubField={handleSelectCompositeSubField}
/>
);
}
return (
<>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{relationField.label}
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
type="text"
placeholder={t`Search fields`}
onChange={(event) => setSearchQuery(event.target.value)}
value={searchQuery}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
{availableFields.length === 0 ? (
<MenuItem text={t`No fields available`} />
) : (
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={availableFields.map((field) => field.id)}
>
{availableFields.map((fieldMetadataItem) => (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
onEnter={() => {
handleSelectField(fieldMetadataItem);
}}
>
<MenuItemSelect
text={fieldMetadataItem.label}
selected={
!isCompositeFieldType(fieldMetadataItem.type) &&
currentNestedFieldName === fieldMetadataItem.name
}
focused={selectedItemId === fieldMetadataItem.id}
LeftIcon={getIcon(fieldMetadataItem.icon)}
hasSubMenu={isCompositeFieldType(fieldMetadataItem.type)}
onClick={() => {
handleSelectField(fieldMetadataItem);
}}
/>
</SelectableListItem>
))}
</SelectableList>
)}
</DropdownMenuItemsContainer>
</>
);
};
@@ -84,6 +84,7 @@ export const useChartSettingsValues = ({
? getFieldLabelWithSubField({
field: groupByFieldX,
subFieldName: groupBySubFieldNameX,
objectMetadataItems,
})
: undefined;
@@ -181,6 +182,7 @@ export const useChartSettingsValues = ({
? getFieldLabelWithSubField({
field: pieChartGroupByField,
subFieldName: finalGroupBySubFieldNameY,
objectMetadataItems,
})
: undefined;
@@ -32,6 +32,7 @@ export const useGraphGroupBySortOptionLabels = ({
const fieldLabel = getFieldLabelWithSubField({
field,
subFieldName: groupBySubFieldName,
objectMetadataItems,
});
switch (graphOrderBy) {
@@ -39,6 +39,7 @@ export const useGraphXSortOptionLabels = ({
const fieldLabel = getFieldLabelWithSubField({
field: groupByField,
subFieldName: groupBySubFieldNameX,
objectMetadataItems,
});
const aggregateField = objectMetadataItem?.fields.find(
@@ -1,5 +1,9 @@
import { getRelationFieldLabel } from '@/command-menu/pages/page-layout/utils/getRelationFieldLabel';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { type CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
import { t } from '@lingui/core/macro';
import { type CompositeFieldSubFieldName } from 'twenty-shared/types';
@@ -8,24 +12,31 @@ import { isDefined } from 'twenty-shared/utils';
export const getFieldLabelWithSubField = ({
field,
subFieldName,
objectMetadataItems,
}: {
field: FieldMetadataItem | undefined;
subFieldName?: CompositeFieldSubFieldName;
subFieldName?: CompositeFieldSubFieldName | string;
objectMetadataItems?: ObjectMetadataItem[];
}): string => {
const subFieldNameLabel =
isDefined(subFieldName) && isDefined(field)
? getCompositeSubFieldLabel(
field.type as CompositeFieldType,
subFieldName,
)
: undefined;
if (!isDefined(field?.label)) {
return t`Field`;
}
const fieldLabel =
isDefined(subFieldNameLabel) && isDefined(field?.label)
? `${field.label} ${subFieldNameLabel}`
: isDefined(field?.label)
? field.label
: t`Field`;
if (!isDefined(subFieldName)) {
return field.label;
}
return fieldLabel;
if (isFieldRelation(field)) {
return getRelationFieldLabel(field, subFieldName, objectMetadataItems);
}
if (isCompositeFieldType(field.type)) {
const subFieldLabel = getCompositeSubFieldLabel(
field.type as CompositeFieldType,
subFieldName as CompositeFieldSubFieldName,
);
return subFieldLabel ? `${field.label} ${subFieldLabel}` : field.label;
}
return field.label;
};
@@ -0,0 +1,52 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { type CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
import { type CompositeFieldSubFieldName } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const getRelationFieldLabel = (
field: FieldMetadataItem,
subFieldName: string,
objectMetadataItems: ObjectMetadataItem[] | undefined,
): string => {
if (!isDefined(objectMetadataItems)) {
return field.label;
}
const targetObjectNameSingular =
field.relation?.targetObjectMetadata?.nameSingular;
const targetObjectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === targetObjectNameSingular,
);
if (!isDefined(targetObjectMetadataItem)) {
return field.label;
}
const [nestedFieldName, nestedSubFieldName] = subFieldName.split('.');
const nestedField = targetObjectMetadataItem.fields.find(
(f) => f.name === nestedFieldName,
);
if (!isDefined(nestedField)) {
return field.label;
}
if (
!isDefined(nestedSubFieldName) ||
!isCompositeFieldType(nestedField.type)
) {
return `${field.label} ${nestedField.label}`;
}
const compositeSubFieldLabel = getCompositeSubFieldLabel(
nestedField.type as CompositeFieldType,
nestedSubFieldName as CompositeFieldSubFieldName,
);
return compositeSubFieldLabel
? `${field.label} ${nestedField.label} ${compositeSubFieldLabel}`
: `${field.label} ${nestedField.label}`;
};
@@ -2,6 +2,7 @@ import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
@@ -21,6 +22,10 @@ const shouldHideDateGranularityBasedOnFieldType = (
return true;
}
if (isFieldRelation(field)) {
return true;
}
return !isFieldMetadataDateKind(field.type);
};