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:
+48
-1
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+10
@@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+6
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+46
-15
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+79
@@ -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);
|
||||
});
|
||||
});
|
||||
+24
@@ -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,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user