Show relation record labels instead of UUIDs in dashboard charts (#23163)

https://github.com/user-attachments/assets/d012a013-2c90-49a1-a27e-b8e4b684a84f



Charts grouped by a relation without a sub-field rendered raw FK UUIDs
on axis ticks, legends and tooltips. The server now batch-resolves the
grouped record ids to their label identifier through a permission-scoped
query and formats every bucket with the record's display name.
Unresolvable records (deleted or not readable) render as Unknown and
their ids are stripped from the response payload. Same-named records get
an ordinal suffix so their buckets don't merge. Covers bar, line and
pie, plain and morph relations.

```mermaid
flowchart TD
    A["Dashboard widget load"] --> B["Chart data service<br/>(bar / line / pie)"]
    B --> C["executeGroupByQuery:<br/>group by relation FK id,<br/>ORDER BY target label identifier,<br/>scoped to source object permissions"]
    C --> D["filterOutEmptyChartBuckets"]
    D --> E{"Bare relation axis?<br/>(no sub-field)"}

    subgraph RL["ChartRelationLabelService.resolveRelationLabels"]
        direction TB
        G1["Collect distinct record ids<br/>per target object"] --> G2["Batch SELECT label identifier columns,<br/>scoped to TARGET object permissions"]
        G2 --> G3["buildRawLabelByRecordId:<br/>display name per record"]
        G3 --> G4["buildUniqueRelationLabels:<br/>suffix duplicates, Unknown for unresolved"]
    end

    E -- No --> H["formatDimensionValue per bucket"]
    E -- Yes --> G1
    G4 --> H
    H --> I["Strip unresolved ids from<br/>formattedToRawLookup"]
    I --> J["Chart DTO to frontend"]
```

The chart settings sub-field dropdown gains a Record option to group by
the related record itself, and now only offers sub-fields the backend
accepts (system fields like a workspace member's updatedBy were
selectable but rejected at query time). Chart-data errors are now logged
server-side.

Also fixes two latent bugs on this path: sorting a bare-relation chart
by field threw `Cannot orderBy unknown field: agentId`, and the pie
chart truncated slices before sorting. The AI dashboard tool guidance
and the seeded dashboards no longer force the sub-field workaround.

The group-by query orders buckets by the related record's label
identifier at the database level (the engine now accepts ordering by a
target field when grouping by its id), so with more than 100 distinct
related records the surviving buckets match the label order.
This commit is contained in:
Raphaël Bosi
2026-07-24 16:12:22 +02:00
committed by GitHub
parent b036d67ec9
commit d1c6b8ee72
62 changed files with 4504 additions and 546 deletions
@@ -242,6 +242,44 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
closeDropdown();
};
const handleSelectRelationRecord = () => {
if (!isDefined(selectedRelationField)) {
return;
}
updateCurrentWidgetConfig({
configToUpdate: buildChartGroupByFieldConfigUpdate({
configuration,
fieldMetadataIdKey,
subFieldNameKey,
fieldId: selectedRelationField.id,
subFieldName: null,
objectMetadataItem: sourceObjectMetadataItem,
objectMetadataItems,
}),
});
closeDropdown();
};
const handleSelectMorphTargetRecord = ({
perTargetFieldId,
}: {
perTargetFieldId: string;
}) => {
updateCurrentWidgetConfig({
configToUpdate: buildChartGroupByFieldConfigUpdate({
configuration,
fieldMetadataIdKey,
subFieldNameKey,
fieldId: perTargetFieldId,
subFieldName: null,
objectMetadataItem: sourceObjectMetadataItem,
objectMetadataItems,
}),
});
closeDropdown();
};
if (isDefined(selectedMorphField)) {
return (
<ChartGroupByFieldSelectionMorphRelationFieldView
@@ -250,6 +288,7 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
currentSubFieldName={currentSubFieldName}
onBack={handleBackFromMorph}
onSelectTargetSubField={handleSelectMorphTargetSubField}
onSelectTargetRecord={handleSelectMorphTargetRecord}
/>
);
}
@@ -258,9 +297,17 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
return (
<ChartGroupByFieldSelectionRelationFieldView
relationField={selectedRelationField}
currentSubFieldName={currentSubFieldName}
currentSubFieldName={
selectedRelationField.id === currentGroupByFieldMetadataId
? currentSubFieldName
: undefined
}
isCurrentGroupByField={
selectedRelationField.id === currentGroupByFieldMetadataId
}
onBack={handleBackFromRelation}
onSelectSubField={handleSelectRelationSubField}
onSelectRecord={handleSelectRelationRecord}
/>
);
}
@@ -34,6 +34,7 @@ type ChartGroupByFieldSelectionMorphRelationFieldViewProps = {
perTargetFieldId: string;
subFieldName: string;
}) => void;
onSelectTargetRecord: (params: { perTargetFieldId: string }) => void;
};
export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
@@ -42,6 +43,7 @@ export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
currentSubFieldName,
onBack,
onSelectTargetSubField,
onSelectTargetRecord,
}: ChartGroupByFieldSelectionMorphRelationFieldViewProps) => {
const { getIcon } = useIcons();
@@ -92,6 +94,9 @@ export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
? currentSubFieldName
: undefined
}
isCurrentGroupByField={
selectedTarget.perTargetFieldId === currentFieldMetadataId
}
onBack={() => setSelectedTarget(null)}
onSelectSubField={(subFieldName) =>
onSelectTargetSubField({
@@ -99,6 +104,11 @@ export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
subFieldName,
})
}
onSelectRecord={() =>
onSelectTargetRecord({
perTargetFieldId: selectedTarget.perTargetFieldId,
})
}
/>
);
}
@@ -4,15 +4,19 @@ import { ChartGroupByFieldSelectionTargetObjectFieldsView } from '@/side-panel/p
type ChartGroupByFieldSelectionRelationFieldViewProps = {
relationField: FieldMetadataItem;
currentSubFieldName: string | undefined;
isCurrentGroupByField: boolean;
onBack: () => void;
onSelectSubField: (subFieldName: string) => void;
onSelectRecord: () => void;
};
export const ChartGroupByFieldSelectionRelationFieldView = ({
relationField,
currentSubFieldName,
isCurrentGroupByField,
onBack,
onSelectSubField,
onSelectRecord,
}: ChartGroupByFieldSelectionRelationFieldViewProps) => {
return (
<ChartGroupByFieldSelectionTargetObjectFieldsView
@@ -21,8 +25,10 @@ export const ChartGroupByFieldSelectionRelationFieldView = ({
}
headerLabel={relationField.label}
currentSubFieldName={currentSubFieldName}
isCurrentGroupByField={isCurrentGroupByField}
onBack={onBack}
onSelectSubField={onSelectSubField}
onSelectRecord={onSelectRecord}
/>
);
};
@@ -1,9 +1,8 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { ChartGroupByFieldSelectionCompositeFieldView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionCompositeFieldView';
import { isFieldSupportedAsChartGroupBySubField } from '@/side-panel/pages/page-layout/utils/isFieldSupportedAsChartGroupBySubField';
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';
@@ -21,21 +20,28 @@ import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, useIcons } from 'twenty-ui/icon';
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const RECORD_ITEM_ID = 'record';
type ChartGroupByFieldSelectionTargetObjectFieldsViewProps = {
targetObjectNameSingular?: string;
headerLabel: string;
currentSubFieldName: string | undefined;
isCurrentGroupByField: boolean;
onBack: () => void;
onSelectSubField: (subFieldName: string) => void;
onSelectRecord: () => void;
};
export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
targetObjectNameSingular,
headerLabel,
currentSubFieldName,
isCurrentGroupByField,
onBack,
onSelectSubField,
onSelectRecord,
}: ChartGroupByFieldSelectionTargetObjectFieldsViewProps) => {
const { getIcon } = useIcons();
@@ -70,7 +76,7 @@ export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
return filterBySearchQuery({
items: targetObjectMetadataItem.fields.filter(
(field) => !isHiddenSystemField(field) && !isFieldRelation(field),
isFieldSupportedAsChartGroupBySubField,
),
searchQuery,
getSearchableValues: (field) => [field.label, field.name],
@@ -99,6 +105,12 @@ export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
const [currentNestedFieldName, currentNestedSubFieldName] =
currentSubFieldName?.split('.') ?? [];
const recordOptionLabel = t`Record`;
const isRecordOptionVisible = normalizeSearchText(recordOptionLabel).includes(
normalizeSearchText(searchQuery),
);
if (isDefined(selectedCompositeField)) {
return (
<ChartGroupByFieldSelectionCompositeFieldView
@@ -131,15 +143,34 @@ export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
/>
<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) => (
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={[
...(isRecordOptionVisible ? [RECORD_ITEM_ID] : []),
...availableFields.map((field) => field.id),
]}
>
{isRecordOptionVisible && (
<SelectableListItem
itemId={RECORD_ITEM_ID}
onEnter={onSelectRecord}
>
<MenuItemSelect
text={recordOptionLabel}
selected={
isCurrentGroupByField && !isDefined(currentSubFieldName)
}
focused={selectedItemId === RECORD_ITEM_ID}
LeftIcon={getIcon(targetObjectMetadataItem?.icon)}
onClick={onSelectRecord}
/>
</SelectableListItem>
)}
{availableFields.length === 0 && !isRecordOptionVisible ? (
<MenuItem text={t`No fields available`} />
) : (
availableFields.map((fieldMetadataItem) => (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
@@ -161,9 +192,9 @@ export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
}}
/>
</SelectableListItem>
))}
</SelectableList>
)}
))
)}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
@@ -0,0 +1,79 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isFieldSupportedAsChartGroupBySubField } from '@/side-panel/pages/page-layout/utils/isFieldSupportedAsChartGroupBySubField';
import { FieldMetadataType } from 'twenty-shared/types';
const createFieldMetadataItem = (
overrides: Partial<FieldMetadataItem>,
): FieldMetadataItem =>
({
id: 'field-id',
name: 'testField',
label: 'Test Field',
type: FieldMetadataType.TEXT,
isSystem: false,
...overrides,
}) as FieldMetadataItem;
describe('isFieldSupportedAsChartGroupBySubField', () => {
it('should accept a non-system scalar field', () => {
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({ name: 'name', type: FieldMetadataType.TEXT }),
),
).toBe(true);
});
it('should reject a system field like userId', () => {
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({
name: 'userId',
type: FieldMetadataType.UUID,
isSystem: true,
}),
),
).toBe(false);
});
it('should accept system createdAt and updatedAt date fields', () => {
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
isSystem: true,
}),
),
).toBe(true);
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({
name: 'updatedAt',
type: FieldMetadataType.DATE_TIME,
isSystem: true,
}),
),
).toBe(true);
});
it('should reject relation and morph relation fields', () => {
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({ type: FieldMetadataType.RELATION }),
),
).toBe(false);
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({ type: FieldMetadataType.MORPH_RELATION }),
),
).toBe(false);
});
it('should reject field types unsupported in group by', () => {
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({ type: FieldMetadataType.RAW_JSON }),
),
).toBe(false);
});
});
@@ -0,0 +1,24 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { isFieldMetadataSupportedInGroupBy } from 'twenty-shared/utils';
export const isFieldSupportedAsChartGroupBySubField = (
field: FieldMetadataItem,
): boolean => {
if (
isHiddenSystemField(field) ||
isFieldRelation(field) ||
isFieldMorphRelation(field)
) {
return false;
}
return isFieldMetadataSupportedInGroupBy({
type: field.type,
name: field.name,
isSystem: field.isSystem ?? false,
relationType: field.settings?.relationType ?? null,
});
};