diff --git a/packages/twenty-apps/internal/real-estate/src/page-layouts/agency-dashboard.page-layout.ts b/packages/twenty-apps/internal/real-estate/src/page-layouts/agency-dashboard.page-layout.ts
index 8680a55d48..aff313068f 100644
--- a/packages/twenty-apps/internal/real-estate/src/page-layouts/agency-dashboard.page-layout.ts
+++ b/packages/twenty-apps/internal/real-estate/src/page-layouts/agency-dashboard.page-layout.ts
@@ -169,7 +169,6 @@ export default definePageLayout({
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
LISTING_AGENT_ON_PROPERTY_ID,
- primaryAxisGroupBySubFieldName: 'name.firstName',
...BAR,
primaryAxisOrderBy: 'VALUE_DESC',
},
@@ -187,7 +186,6 @@ export default definePageLayout({
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
AGENT_ON_SHOWING_ID,
- primaryAxisGroupBySubFieldName: 'name.firstName',
...BAR,
primaryAxisOrderBy: 'VALUE_DESC',
},
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionDropdownContentBase.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionDropdownContentBase.tsx
index 401fa6a760..546b6a423b 100644
--- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionDropdownContentBase.tsx
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionDropdownContentBase.tsx
@@ -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 (
);
}
@@ -258,9 +297,17 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
return (
);
}
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionMorphRelationFieldView.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionMorphRelationFieldView.tsx
index 91bc77700e..fa8b921804 100644
--- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionMorphRelationFieldView.tsx
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionMorphRelationFieldView.tsx
@@ -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,
+ })
+ }
/>
);
}
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionRelationFieldView.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionRelationFieldView.tsx
index 3e38974550..a7fc92643d 100644
--- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionRelationFieldView.tsx
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionRelationFieldView.tsx
@@ -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 (
);
};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionTargetObjectFieldsView.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionTargetObjectFieldsView.tsx
index bcc0652c26..8b53a3e28c 100644
--- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionTargetObjectFieldsView.tsx
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionTargetObjectFieldsView.tsx
@@ -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 (
- {availableFields.length === 0 ? (
-
- ) : (
- field.id)}
- >
- {availableFields.map((fieldMetadataItem) => (
+ field.id),
+ ]}
+ >
+ {isRecordOptionVisible && (
+
+
+
+ )}
+ {availableFields.length === 0 && !isRecordOptionVisible ? (
+
+ ) : (
+ availableFields.map((fieldMetadataItem) => (
- ))}
-
- )}
+ ))
+ )}
+
>
);
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/__tests__/isFieldSupportedAsChartGroupBySubField.test.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/__tests__/isFieldSupportedAsChartGroupBySubField.test.ts
new file mode 100644
index 0000000000..3b68ca71a0
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/__tests__/isFieldSupportedAsChartGroupBySubField.test.ts
@@ -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 =>
+ ({
+ 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);
+ });
+});
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/isFieldSupportedAsChartGroupBySubField.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/isFieldSupportedAsChartGroupBySubField.ts
new file mode 100644
index 0000000000..b85773b6e1
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/isFieldSupportedAsChartGroupBySubField.ts
@@ -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,
+ });
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/graphql-query-order-group-by.parser.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/graphql-query-order-group-by.parser.spec.ts
index 17c3a36015..c5994b4246 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/graphql-query-order-group-by.parser.spec.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/graphql-query-order-group-by.parser.spec.ts
@@ -2,11 +2,13 @@ import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
OrderByDirection,
+ RelationType,
} from 'twenty-shared/types';
import {
type GroupByDateField,
type GroupByRegularField,
+ type GroupByRelationField,
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util';
import { GraphqlQueryOrderGroupByParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser';
@@ -141,3 +143,274 @@ describe('GraphqlQueryOrderGroupByParser - object alias in order-by clauses', ()
expect(orderByKey).not.toContain(physicalTableName);
});
});
+
+describe('GraphqlQueryOrderGroupByParser - relation order-by under target primary key group-by', () => {
+ const SOURCE_OBJECT_ID = 'person-object-id';
+ const SOURCE_OBJECT_UNIVERSAL_ID = 'person-object-universal-id';
+ const COMPANY_RELATION_FIELD_ID = 'person-company-field-id';
+ const COMPANY_RELATION_FIELD_UNIVERSAL_ID =
+ 'person-company-field-universal-id';
+ const MEMBER_RELATION_FIELD_ID = 'person-member-field-id';
+ const MEMBER_RELATION_FIELD_UNIVERSAL_ID = 'person-member-field-universal-id';
+ const COMPANY_OBJECT_ID = 'company-object-id';
+ const COMPANY_OBJECT_UNIVERSAL_ID = 'company-object-universal-id';
+ const COMPANY_ID_FIELD_ID = 'company-id-field-id';
+ const COMPANY_ID_FIELD_UNIVERSAL_ID = 'company-id-field-universal-id';
+ const COMPANY_NAME_FIELD_ID = 'company-name-field-id';
+ const COMPANY_NAME_FIELD_UNIVERSAL_ID = 'company-name-field-universal-id';
+ const COMPANY_OWNER_FIELD_ID = 'company-owner-field-id';
+ const COMPANY_OWNER_FIELD_UNIVERSAL_ID = 'company-owner-field-universal-id';
+ const MEMBER_OBJECT_ID = 'member-object-id';
+ const MEMBER_OBJECT_UNIVERSAL_ID = 'member-object-universal-id';
+ const MEMBER_ID_FIELD_ID = 'member-id-field-id';
+ const MEMBER_ID_FIELD_UNIVERSAL_ID = 'member-id-field-universal-id';
+ const MEMBER_NAME_FIELD_ID = 'member-name-field-id';
+ const MEMBER_NAME_FIELD_UNIVERSAL_ID = 'member-name-field-universal-id';
+
+ const companyRelationField = getFlatFieldMetadataMock({
+ universalIdentifier: COMPANY_RELATION_FIELD_UNIVERSAL_ID,
+ objectMetadataId: SOURCE_OBJECT_ID,
+ type: FieldMetadataType.RELATION,
+ id: COMPANY_RELATION_FIELD_ID,
+ name: 'company',
+ relationTargetObjectMetadataId: COMPANY_OBJECT_ID,
+ settings: {
+ relationType: RelationType.MANY_TO_ONE,
+ joinColumnName: 'companyId',
+ },
+ });
+
+ const memberRelationField = getFlatFieldMetadataMock({
+ universalIdentifier: MEMBER_RELATION_FIELD_UNIVERSAL_ID,
+ objectMetadataId: SOURCE_OBJECT_ID,
+ type: FieldMetadataType.RELATION,
+ id: MEMBER_RELATION_FIELD_ID,
+ name: 'member',
+ relationTargetObjectMetadataId: MEMBER_OBJECT_ID,
+ settings: {
+ relationType: RelationType.MANY_TO_ONE,
+ joinColumnName: 'memberId',
+ },
+ });
+
+ const companyIdField = getFlatFieldMetadataMock({
+ universalIdentifier: COMPANY_ID_FIELD_UNIVERSAL_ID,
+ objectMetadataId: COMPANY_OBJECT_ID,
+ type: FieldMetadataType.UUID,
+ id: COMPANY_ID_FIELD_ID,
+ name: 'id',
+ });
+
+ const companyNameField = getFlatFieldMetadataMock({
+ universalIdentifier: COMPANY_NAME_FIELD_UNIVERSAL_ID,
+ objectMetadataId: COMPANY_OBJECT_ID,
+ type: FieldMetadataType.TEXT,
+ id: COMPANY_NAME_FIELD_ID,
+ name: 'name',
+ });
+
+ const companyOwnerField = getFlatFieldMetadataMock({
+ universalIdentifier: COMPANY_OWNER_FIELD_UNIVERSAL_ID,
+ objectMetadataId: COMPANY_OBJECT_ID,
+ type: FieldMetadataType.RELATION,
+ id: COMPANY_OWNER_FIELD_ID,
+ name: 'owner',
+ relationTargetObjectMetadataId: MEMBER_OBJECT_ID,
+ settings: {
+ relationType: RelationType.MANY_TO_ONE,
+ joinColumnName: 'ownerId',
+ },
+ });
+
+ const memberIdField = getFlatFieldMetadataMock({
+ universalIdentifier: MEMBER_ID_FIELD_UNIVERSAL_ID,
+ objectMetadataId: MEMBER_OBJECT_ID,
+ type: FieldMetadataType.UUID,
+ id: MEMBER_ID_FIELD_ID,
+ name: 'id',
+ });
+
+ const memberNameField = getFlatFieldMetadataMock({
+ universalIdentifier: MEMBER_NAME_FIELD_UNIVERSAL_ID,
+ objectMetadataId: MEMBER_OBJECT_ID,
+ type: FieldMetadataType.FULL_NAME,
+ id: MEMBER_NAME_FIELD_ID,
+ name: 'name',
+ });
+
+ const sourceObjectMetadata = getFlatObjectMetadataMock({
+ universalIdentifier: SOURCE_OBJECT_UNIVERSAL_ID,
+ id: SOURCE_OBJECT_ID,
+ nameSingular: 'person',
+ namePlural: 'people',
+ fieldIds: [COMPANY_RELATION_FIELD_ID, MEMBER_RELATION_FIELD_ID],
+ });
+
+ const companyObjectMetadata = getFlatObjectMetadataMock({
+ universalIdentifier: COMPANY_OBJECT_UNIVERSAL_ID,
+ id: COMPANY_OBJECT_ID,
+ nameSingular: 'company',
+ namePlural: 'companies',
+ fieldIds: [
+ COMPANY_ID_FIELD_ID,
+ COMPANY_NAME_FIELD_ID,
+ COMPANY_OWNER_FIELD_ID,
+ ],
+ });
+
+ const memberObjectMetadata = getFlatObjectMetadataMock({
+ universalIdentifier: MEMBER_OBJECT_UNIVERSAL_ID,
+ id: MEMBER_OBJECT_ID,
+ nameSingular: 'member',
+ namePlural: 'members',
+ fieldIds: [MEMBER_ID_FIELD_ID, MEMBER_NAME_FIELD_ID],
+ });
+
+ const flatFieldMetadataMaps = {
+ byUniversalIdentifier: {
+ [COMPANY_RELATION_FIELD_UNIVERSAL_ID]: companyRelationField,
+ [MEMBER_RELATION_FIELD_UNIVERSAL_ID]: memberRelationField,
+ [COMPANY_ID_FIELD_UNIVERSAL_ID]: companyIdField,
+ [COMPANY_NAME_FIELD_UNIVERSAL_ID]: companyNameField,
+ [COMPANY_OWNER_FIELD_UNIVERSAL_ID]: companyOwnerField,
+ [MEMBER_ID_FIELD_UNIVERSAL_ID]: memberIdField,
+ [MEMBER_NAME_FIELD_UNIVERSAL_ID]: memberNameField,
+ },
+ universalIdentifierById: {
+ [COMPANY_RELATION_FIELD_ID]: COMPANY_RELATION_FIELD_UNIVERSAL_ID,
+ [MEMBER_RELATION_FIELD_ID]: MEMBER_RELATION_FIELD_UNIVERSAL_ID,
+ [COMPANY_ID_FIELD_ID]: COMPANY_ID_FIELD_UNIVERSAL_ID,
+ [COMPANY_NAME_FIELD_ID]: COMPANY_NAME_FIELD_UNIVERSAL_ID,
+ [COMPANY_OWNER_FIELD_ID]: COMPANY_OWNER_FIELD_UNIVERSAL_ID,
+ [MEMBER_ID_FIELD_ID]: MEMBER_ID_FIELD_UNIVERSAL_ID,
+ [MEMBER_NAME_FIELD_ID]: MEMBER_NAME_FIELD_UNIVERSAL_ID,
+ },
+ universalIdentifiersByApplicationId: {},
+ } as unknown as FlatEntityMaps;
+
+ const flatObjectMetadataMaps = {
+ byUniversalIdentifier: {
+ [SOURCE_OBJECT_UNIVERSAL_ID]: sourceObjectMetadata,
+ [COMPANY_OBJECT_UNIVERSAL_ID]: companyObjectMetadata,
+ [MEMBER_OBJECT_UNIVERSAL_ID]: memberObjectMetadata,
+ },
+ universalIdentifierById: {
+ [SOURCE_OBJECT_ID]: SOURCE_OBJECT_UNIVERSAL_ID,
+ [COMPANY_OBJECT_ID]: COMPANY_OBJECT_UNIVERSAL_ID,
+ [MEMBER_OBJECT_ID]: MEMBER_OBJECT_UNIVERSAL_ID,
+ },
+ universalIdentifiersByApplicationId: {},
+ } as unknown as FlatEntityMaps;
+
+ const groupByCompanyId: GroupByRelationField = {
+ fieldMetadata: companyRelationField,
+ nestedFieldMetadata: companyIdField,
+ };
+
+ const groupByMemberId: GroupByRelationField = {
+ fieldMetadata: memberRelationField,
+ nestedFieldMetadata: memberIdField,
+ };
+
+ const buildParser = () =>
+ new GraphqlQueryOrderGroupByParser(
+ sourceObjectMetadata,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ );
+
+ it('orders by a target scalar field when grouping by the target id', () => {
+ const parser = buildParser();
+
+ const result = parser.parse({
+ orderBy: [{ company: { name: OrderByDirection.AscNullsLast } }],
+ groupByFields: [groupByCompanyId],
+ });
+
+ expect(result).toEqual([
+ { '"company"."name"': { order: 'ASC', nulls: 'NULLS LAST' } },
+ ]);
+ });
+
+ it('orders by FULL_NAME subfields when grouping by the target id', () => {
+ const parser = buildParser();
+
+ const result = parser.parse({
+ orderBy: [
+ { member: { name: { firstName: OrderByDirection.AscNullsLast } } },
+ { member: { name: { lastName: OrderByDirection.AscNullsLast } } },
+ ],
+ groupByFields: [groupByMemberId],
+ });
+
+ expect(result).toEqual([
+ { '"member"."nameFirstName"': { order: 'ASC', nulls: 'NULLS LAST' } },
+ { '"member"."nameLastName"': { order: 'ASC', nulls: 'NULLS LAST' } },
+ ]);
+ });
+
+ it('still orders by the exact nested field present in groupBy', () => {
+ const parser = buildParser();
+
+ const groupByCompanyName: GroupByRelationField = {
+ fieldMetadata: companyRelationField,
+ nestedFieldMetadata: companyNameField,
+ };
+
+ const result = parser.parse({
+ orderBy: [{ company: { name: OrderByDirection.DescNullsLast } }],
+ groupByFields: [groupByCompanyName],
+ });
+
+ expect(result).toEqual([
+ { '"company"."name"': { order: 'DESC', nulls: 'NULLS LAST' } },
+ ]);
+ });
+
+ it('throws when ordering by a relation absent from groupBy', () => {
+ const parser = buildParser();
+
+ expect(() =>
+ parser.parse({
+ orderBy: [
+ { member: { name: { firstName: OrderByDirection.AscNullsLast } } },
+ ],
+ groupByFields: [groupByCompanyId],
+ }),
+ ).toThrow(
+ 'Cannot order by a relation field that is not in groupBy criteria: member.name',
+ );
+ });
+
+ it('throws when ordering by an unknown composite subfield under target id group-by', () => {
+ const parser = buildParser();
+
+ expect(() =>
+ parser.parse({
+ orderBy: [
+ {
+ member: {
+ name: { unknownSubField: OrderByDirection.AscNullsLast },
+ },
+ },
+ ],
+ groupByFields: [groupByMemberId],
+ }),
+ ).toThrow(
+ 'Composite subfield "unknownSubField" is not orderable for "name"',
+ );
+ });
+
+ it('throws when ordering by a nested relation field under target id group-by', () => {
+ const parser = buildParser();
+
+ expect(() =>
+ parser.parse({
+ orderBy: [{ company: { owner: OrderByDirection.AscNullsLast } }],
+ groupByFields: [groupByCompanyId],
+ }),
+ ).toThrow(
+ 'Cannot order by a relation field that is not in groupBy criteria: company.owner',
+ );
+ });
+});
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts
index 9fcbfb3d69..192c1931e8 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts
@@ -29,6 +29,7 @@ import {
getAvailableAggregationsFromObjectFields,
} from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
+import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
@@ -524,6 +525,7 @@ export class GraphqlQueryOrderGroupByParser {
associatedGroupByField,
nestedFieldMetadata,
nestedFieldOrderByValue,
+ isMatchedOnTargetPrimaryKeyGroupBy,
} = prepareForOrderByRelationFieldParsing({
orderByArg,
fieldMetadata,
@@ -565,7 +567,17 @@ export class GraphqlQueryOrderGroupByParser {
return null;
}
- if (
+ if (isMatchedOnTargetPrimaryKeyGroupBy === true) {
+ const groupableSubFields = getGroupableSubFieldsForCompositeType(
+ nestedFieldMetadata.type,
+ );
+
+ if (!groupableSubFields?.includes(nestedSubFieldName)) {
+ throw new UserInputError(
+ `Composite subfield "${nestedSubFieldName}" is not orderable for "${nestedFieldMetadata.name}"`,
+ );
+ }
+ } else if (
!isDefined(associatedGroupByField.nestedSubFieldName) ||
associatedGroupByField.nestedSubFieldName !== nestedSubFieldName
) {
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util.ts
index 3eba4d7084..c14cf9d442 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util.ts
@@ -9,6 +9,7 @@ import {
import { isGroupByRelationField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-relation-field.util';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
+import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -106,7 +107,7 @@ export const prepareForOrderByRelationFieldParsing = ({
}
}
- const associatedGroupByField = groupByFields.find((groupByField) => {
+ const exactGroupByFieldMatch = groupByFields.find((groupByField) => {
if (!isGroupByRelationField(groupByField)) {
return false;
}
@@ -126,6 +127,23 @@ export const prepareForOrderByRelationFieldParsing = ({
return true;
}) as GroupByRelationField | undefined;
+ const canOrderByFunctionallyDependentNestedField =
+ !isMorphOrRelationFlatFieldMetadata(nestedFieldMetadata);
+
+ const groupByFieldOnTargetPrimaryKey =
+ !isDefined(exactGroupByFieldMatch) &&
+ canOrderByFunctionallyDependentNestedField
+ ? (groupByFields.find(
+ (groupByField) =>
+ isGroupByRelationField(groupByField) &&
+ groupByField.fieldMetadata.id === fieldMetadata.id &&
+ groupByField.nestedFieldMetadata.name === 'id',
+ ) as GroupByRelationField | undefined)
+ : undefined;
+
+ const associatedGroupByField =
+ exactGroupByFieldMatch ?? groupByFieldOnTargetPrimaryKey;
+
if (!isDefined(associatedGroupByField)) {
throw new UserInputError(
`Cannot order by a relation field that is not in groupBy criteria: ${relationFieldName}.${nestedFieldName}`,
@@ -136,5 +154,6 @@ export const prepareForOrderByRelationFieldParsing = ({
associatedGroupByField,
nestedFieldMetadata,
nestedFieldOrderByValue,
+ isMatchedOnTargetPrimaryKeyGroupBy: !isDefined(exactGroupByFieldMatch),
};
};
diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts
index c03d19d0e7..d9765633ed 100644
--- a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts
+++ b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts
@@ -299,6 +299,14 @@ export const validateQueryIsPermittedOrThrow = ({
});
expressionMapSelectsOnMainEntity = selectsWithoutJoinedAliases;
+
+ validateJoinedOrderByColumnsArePermittedOrThrow({
+ expressionMap,
+ objectsPermissions,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ objectIdByNameSingular,
+ });
}
const allFieldsSelected = expressionMapSelectsOnMainEntity.some(
@@ -411,6 +419,63 @@ const validatePermissionsForJoinsAndReturnSelectsWithoutJoins = ({
return { selectsWithoutJoinedAliases };
};
+const validateJoinedOrderByColumnsArePermittedOrThrow = ({
+ expressionMap,
+ objectsPermissions,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ objectIdByNameSingular,
+}: {
+ expressionMap: QueryExpressionMap;
+ objectsPermissions: ObjectsPermissions;
+ flatObjectMetadataMaps: FlatEntityMaps;
+ flatFieldMetadataMaps: FlatEntityMaps;
+ objectIdByNameSingular: Record;
+}) => {
+ const columnsByJoinedAlias = new Map>();
+ const columnReferenceRegex = /"(\w+)"\."(\w+)"/g;
+
+ for (const orderByExpression of Object.keys(expressionMap.orderBys)) {
+ for (const [, alias, column] of orderByExpression.matchAll(
+ columnReferenceRegex,
+ )) {
+ const columnsForAlias = columnsByJoinedAlias.get(alias) ?? new Set();
+
+ columnsForAlias.add(column);
+ columnsByJoinedAlias.set(alias, columnsForAlias);
+ }
+ }
+
+ for (const joinAttribute of expressionMap.joinAttributes) {
+ const joinedAlias = joinAttribute.alias.name;
+ const referencedColumns = columnsByJoinedAlias.get(joinedAlias);
+
+ if (!isDefined(referencedColumns)) {
+ continue;
+ }
+
+ const entity = expressionMap.aliases.find(
+ (alias) => alias.type === 'join' && alias.name === joinedAlias,
+ )?.metadata;
+
+ if (!isDefined(entity)) {
+ continue;
+ }
+
+ validateOperationIsPermittedOrThrow({
+ entityName: entity.name,
+ operationType: 'select',
+ objectsPermissions,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ objectIdByNameSingular,
+ selectedColumns: [...referencedColumns],
+ allFieldsSelected: false,
+ updatedColumns: [],
+ });
+ }
+};
+
const buildFieldPermissionDeniedMessage = ({
action,
column,
diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/compute-my-first-dashboard-widgets.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/compute-my-first-dashboard-widgets.util.ts
index e421da9d30..6829a7787d 100644
--- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/compute-my-first-dashboard-widgets.util.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/compute-my-first-dashboard-widgets.util.ts
@@ -490,11 +490,9 @@ const createOpportunitiesByOwner = ({
aggregateFieldMetadataId: opportunityFields.id.id,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataId: opportunityFields.owner.id,
- primaryAxisGroupBySubFieldName: 'name.firstName',
primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.DAY,
primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
secondaryAxisGroupByFieldMetadataId: opportunityFields.owner.id,
- secondaryAxisGroupBySubFieldName: 'name.firstName',
secondaryAxisGroupByDateGranularity:
ObjectRecordGroupByDateGranularity.DAY,
secondaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
@@ -514,12 +512,10 @@ const createOpportunitiesByOwner = ({
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
STANDARD_OBJECTS.opportunity.fields.owner.universalIdentifier,
- primaryAxisGroupBySubFieldName: 'name.firstName',
primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.DAY,
primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
secondaryAxisGroupByFieldMetadataUniversalIdentifier:
STANDARD_OBJECTS.opportunity.fields.owner.universalIdentifier,
- secondaryAxisGroupBySubFieldName: 'name.firstName',
secondaryAxisGroupByDateGranularity:
ObjectRecordGroupByDateGranularity.DAY,
secondaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/chart-data.module.ts b/packages/twenty-server/src/modules/dashboard/chart-data/chart-data.module.ts
index da8dee1ee9..48b6eb9ab9 100644
--- a/packages/twenty-server/src/modules/dashboard/chart-data/chart-data.module.ts
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/chart-data.module.ts
@@ -9,6 +9,7 @@ import { LineChartDataResolver } from 'src/modules/dashboard/chart-data/resolver
import { PieChartDataResolver } from 'src/modules/dashboard/chart-data/resolvers/pie-chart-data.resolver';
import { BarChartDataService } from 'src/modules/dashboard/chart-data/services/bar-chart-data.service';
import { ChartDataQueryService } from 'src/modules/dashboard/chart-data/services/chart-data-query.service';
+import { ChartRelationLabelService } from 'src/modules/dashboard/chart-data/services/chart-relation-label.service';
import { LineChartDataService } from 'src/modules/dashboard/chart-data/services/line-chart-data.service';
import { PieChartDataService } from 'src/modules/dashboard/chart-data/services/pie-chart-data.service';
@@ -21,6 +22,7 @@ import { PieChartDataService } from 'src/modules/dashboard/chart-data/services/p
],
providers: [
ChartDataQueryService,
+ ChartRelationLabelService,
PieChartDataService,
PieChartDataResolver,
LineChartDataService,
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/constants/chart-relation-label-batch-size.constant.ts b/packages/twenty-server/src/modules/dashboard/chart-data/constants/chart-relation-label-batch-size.constant.ts
new file mode 100644
index 0000000000..470114c034
--- /dev/null
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/constants/chart-relation-label-batch-size.constant.ts
@@ -0,0 +1 @@
+export const CHART_RELATION_LABEL_BATCH_SIZE = 500;
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/bar-chart-data.service.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/bar-chart-data.service.spec.ts
index 040ef76d03..5f81d596bb 100644
--- a/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/bar-chart-data.service.spec.ts
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/bar-chart-data.service.spec.ts
@@ -10,10 +10,12 @@ import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout
import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from 'src/modules/dashboard/chart-data/constants/bar-chart-maximum-number-of-bars.constant';
import { BarChartDataService } from 'src/modules/dashboard/chart-data/services/bar-chart-data.service';
import { ChartDataQueryService } from 'src/modules/dashboard/chart-data/services/chart-data-query.service';
+import { ChartRelationLabelService } from 'src/modules/dashboard/chart-data/services/chart-relation-label.service';
describe('BarChartDataService', () => {
let service: BarChartDataService;
let mockExecuteGroupByQuery: jest.Mock;
+ let mockResolveRelationLabels: jest.Mock;
let mockGetOrRecomputeManyOrAllFlatEntityMaps: jest.Mock;
const workspaceId = 'test-workspace-id';
@@ -48,6 +50,14 @@ describe('BarChartDataService', () => {
],
};
+ const mockRelationField = {
+ id: 'relation-field-id',
+ name: 'agent',
+ label: 'Agent',
+ type: FieldMetadataType.RELATION,
+ relationTargetObjectMetadataId: 'agent-object-id',
+ };
+
const mockObjectMetadata = {
id: objectMetadataId,
nameSingular: 'company',
@@ -56,6 +66,7 @@ describe('BarChartDataService', () => {
beforeEach(async () => {
mockExecuteGroupByQuery = jest.fn();
+ mockResolveRelationLabels = jest.fn().mockResolvedValue({});
mockGetOrRecomputeManyOrAllFlatEntityMaps = jest.fn().mockResolvedValue({
flatObjectMetadataMaps: {
byUniversalIdentifier: {
@@ -83,11 +94,16 @@ describe('BarChartDataService', () => {
...mockSelectField,
universalIdentifier: 'select-field-universal-id',
},
+ 'relation-field-universal-id': {
+ ...mockRelationField,
+ universalIdentifier: 'relation-field-universal-id',
+ },
},
universalIdentifierById: {
[mockGroupByField.id]: 'group-by-field-universal-id',
[mockAggregateField.id]: 'aggregate-field-universal-id',
[mockSelectField.id]: 'select-field-universal-id',
+ [mockRelationField.id]: 'relation-field-universal-id',
},
universalIdentifiersByApplicationId: {},
},
@@ -109,6 +125,12 @@ describe('BarChartDataService', () => {
executeGroupByQuery: mockExecuteGroupByQuery,
},
},
+ {
+ provide: ChartRelationLabelService,
+ useValue: {
+ resolveRelationLabels: mockResolveRelationLabels,
+ },
+ },
],
}).compile();
@@ -405,4 +427,94 @@ describe('BarChartDataService', () => {
).rejects.toThrow();
});
});
+
+ describe('getBarChartData - Relation group by', () => {
+ const relationConfiguration = {
+ configurationType: WidgetConfigurationType.BAR_CHART,
+ primaryAxisGroupByFieldMetadataId: mockRelationField.id,
+ aggregateFieldMetadataId: mockAggregateField.id,
+ aggregateOperation: AggregateOperations.COUNT,
+ layout: BarChartLayout.VERTICAL,
+ };
+
+ it('should use resolved labels on the axis and drop unresolved buckets', async () => {
+ mockExecuteGroupByQuery.mockResolvedValue([
+ { groupByDimensionValues: ['agent-id-1'], aggregateValue: 8 },
+ { groupByDimensionValues: ['agent-id-2'], aggregateValue: 5 },
+ ]);
+ mockResolveRelationLabels.mockResolvedValue({
+ primary: {
+ labelByRecordId: new Map([['agent-id-1', 'Alice']]),
+ unresolvedRecordIds: new Set(['agent-id-2']),
+ },
+ });
+
+ const result = await service.getBarChartData({
+ workspaceId,
+ objectMetadataId,
+ configuration: relationConfiguration as any,
+ authContext: mockAuthContext,
+ });
+
+ const axisValues = result.data.map((item) => item[result.indexBy]);
+
+ expect(axisValues).toContain('Alice');
+ expect(axisValues).not.toContain('Unknown');
+ expect(axisValues).not.toContain('agent-id-2');
+ expect(result.data).toHaveLength(1);
+ expect(result.formattedToRawLookup).toEqual({ Alice: 'agent-id-1' });
+ });
+
+ it('should compute hasTooManyGroups from the set reduced by dropped buckets', async () => {
+ const rawResults = Array.from(
+ { length: BAR_CHART_MAXIMUM_NUMBER_OF_BARS + 1 },
+ (_, index) => ({
+ groupByDimensionValues: [`agent-id-${index}`],
+ aggregateValue: index + 1,
+ }),
+ );
+ const unresolvedRecordIds = new Set([
+ `agent-id-${BAR_CHART_MAXIMUM_NUMBER_OF_BARS}`,
+ `agent-id-${BAR_CHART_MAXIMUM_NUMBER_OF_BARS - 1}`,
+ ]);
+ const labelByRecordId = new Map(
+ rawResults
+ .map((result) => String(result.groupByDimensionValues[0]))
+ .filter((recordId) => !unresolvedRecordIds.has(recordId))
+ .map((recordId) => [recordId, `Agent ${recordId}`]),
+ );
+
+ mockExecuteGroupByQuery.mockResolvedValue(rawResults);
+ mockResolveRelationLabels.mockResolvedValue({
+ primary: { labelByRecordId, unresolvedRecordIds },
+ });
+
+ const result = await service.getBarChartData({
+ workspaceId,
+ objectMetadataId,
+ configuration: relationConfiguration as any,
+ authContext: mockAuthContext,
+ });
+
+ expect(result.hasTooManyGroups).toBe(false);
+ expect(result.data).toHaveLength(BAR_CHART_MAXIMUM_NUMBER_OF_BARS - 1);
+ });
+
+ it('should keep raw values when no resolution is returned', async () => {
+ mockExecuteGroupByQuery.mockResolvedValue([
+ { groupByDimensionValues: ['agent-id-1'], aggregateValue: 8 },
+ ]);
+
+ const result = await service.getBarChartData({
+ workspaceId,
+ objectMetadataId,
+ configuration: relationConfiguration as any,
+ authContext: mockAuthContext,
+ });
+
+ const axisValues = result.data.map((item) => item[result.indexBy]);
+
+ expect(axisValues).toContain('agent-id-1');
+ });
+ });
});
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/chart-data-query.service.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/chart-data-query.service.spec.ts
new file mode 100644
index 0000000000..7993c2e57b
--- /dev/null
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/chart-data-query.service.spec.ts
@@ -0,0 +1,198 @@
+import {
+ AggregateOperations,
+ FieldMetadataType,
+ OrderByDirection,
+ RelationType,
+} from 'twenty-shared/types';
+
+import { type CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service';
+import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
+import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
+import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
+import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
+import { ChartDataQueryService } from 'src/modules/dashboard/chart-data/services/chart-data-query.service';
+
+const createMockFieldMetadata = (
+ overrides: Partial,
+): FlatFieldMetadata =>
+ ({
+ id: 'test-field-id',
+ name: 'testField',
+ type: FieldMetadataType.TEXT,
+ universalIdentifier: 'test-field-universal-id',
+ ...overrides,
+ }) as FlatFieldMetadata;
+
+const createMockObjectMetadata = (
+ overrides: Partial,
+): FlatObjectMetadata =>
+ ({
+ id: 'test-object-id',
+ nameSingular: 'testObject',
+ namePlural: 'testObjects',
+ fieldIds: [],
+ universalIdentifier: 'test-object-universal-id',
+ ...overrides,
+ }) as FlatObjectMetadata;
+
+const buildFlatEntityMaps = <
+ TEntity extends FlatFieldMetadata | FlatObjectMetadata,
+>(
+ entities: TEntity[],
+): FlatEntityMaps => ({
+ byUniversalIdentifier: Object.fromEntries(
+ entities.map((entity) => [entity.universalIdentifier as string, entity]),
+ ),
+ universalIdentifierById: Object.fromEntries(
+ entities.map((entity) => [entity.id, entity.universalIdentifier as string]),
+ ),
+ universalIdentifiersByApplicationId: {},
+});
+
+describe('ChartDataQueryService', () => {
+ const aggregateField = createMockFieldMetadata({
+ id: 'amount-field-id',
+ name: 'amount',
+ type: FieldMetadataType.NUMBER,
+ universalIdentifier: 'amount-field-universal-id',
+ });
+
+ const companyNameField = createMockFieldMetadata({
+ id: 'company-name-field-id',
+ name: 'name',
+ type: FieldMetadataType.TEXT,
+ universalIdentifier: 'company-name-universal-id',
+ });
+
+ const companyObject = createMockObjectMetadata({
+ id: 'company-object-id',
+ nameSingular: 'company',
+ namePlural: 'companies',
+ labelIdentifierFieldMetadataId: companyNameField.id,
+ universalIdentifier: 'company-object-universal-id',
+ });
+
+ const companyRelationField = createMockFieldMetadata({
+ id: 'company-relation-field-id',
+ name: 'company',
+ type: FieldMetadataType.RELATION,
+ relationTargetObjectMetadataId: companyObject.id,
+ universalIdentifier: 'company-relation-universal-id',
+ settings: {
+ relationType: RelationType.MANY_TO_ONE,
+ joinColumnName: 'companyId',
+ },
+ } as Partial);
+
+ const assigneeNameField = createMockFieldMetadata({
+ id: 'assignee-name-field-id',
+ name: 'name',
+ type: FieldMetadataType.FULL_NAME,
+ universalIdentifier: 'assignee-name-universal-id',
+ });
+
+ const assigneeObject = createMockObjectMetadata({
+ id: 'assignee-object-id',
+ nameSingular: 'workspaceMember',
+ namePlural: 'workspaceMembers',
+ labelIdentifierFieldMetadataId: assigneeNameField.id,
+ universalIdentifier: 'assignee-object-universal-id',
+ });
+
+ const assigneeRelationField = createMockFieldMetadata({
+ id: 'assignee-relation-field-id',
+ name: 'assignee',
+ type: FieldMetadataType.RELATION,
+ relationTargetObjectMetadataId: assigneeObject.id,
+ universalIdentifier: 'assignee-relation-universal-id',
+ settings: {
+ relationType: RelationType.MANY_TO_ONE,
+ joinColumnName: 'assigneeId',
+ },
+ } as Partial);
+
+ const sourceObject = createMockObjectMetadata({
+ id: 'source-object-id',
+ nameSingular: 'opportunity',
+ namePlural: 'opportunities',
+ fieldIds: [
+ aggregateField.id,
+ companyRelationField.id,
+ assigneeRelationField.id,
+ ],
+ universalIdentifier: 'source-object-universal-id',
+ });
+
+ const flatObjectMetadataMaps = buildFlatEntityMaps([
+ sourceObject,
+ companyObject,
+ assigneeObject,
+ ]);
+
+ const flatFieldMetadataMaps = buildFlatEntityMaps([
+ aggregateField,
+ companyRelationField,
+ assigneeRelationField,
+ companyNameField,
+ assigneeNameField,
+ ]);
+
+ const executeMock = jest.fn();
+
+ const service = new ChartDataQueryService({
+ execute: executeMock,
+ } as unknown as CommonGroupByQueryRunnerService);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ executeMock.mockResolvedValue({ results: [] });
+ });
+
+ const executeGroupByQueryForRelation = async (
+ groupByFieldMetadataId: string,
+ ) =>
+ service.executeGroupByQuery({
+ flatObjectMetadata: sourceObject,
+ flatFieldMetadataMaps,
+ flatObjectMetadataMaps,
+ objectIdByNameSingular: {},
+ authContext: {} as WorkspaceAuthContext,
+ groupByFieldMetadataId,
+ aggregateFieldMetadataId: aggregateField.id,
+ aggregateOperation: AggregateOperations.SUM,
+ userTimezone: 'UTC',
+ firstDayOfTheWeek: 1,
+ limit: 101,
+ primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
+ });
+
+ it('should order by the target label then id when grouping by a bare relation', async () => {
+ await executeGroupByQueryForRelation(companyRelationField.id);
+
+ expect(executeMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ orderBy: [
+ { company: { name: OrderByDirection.AscNullsLast } },
+ { company: { id: OrderByDirection.AscNullsLast } },
+ ],
+ }),
+ expect.anything(),
+ );
+ });
+
+ it('should order by FULL_NAME subfields then id when grouping by a bare relation', async () => {
+ await executeGroupByQueryForRelation(assigneeRelationField.id);
+
+ expect(executeMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ orderBy: [
+ { assignee: { name: { firstName: OrderByDirection.AscNullsLast } } },
+ { assignee: { name: { lastName: OrderByDirection.AscNullsLast } } },
+ { assignee: { id: OrderByDirection.AscNullsLast } },
+ ],
+ }),
+ expect.anything(),
+ );
+ });
+});
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/chart-relation-label.service.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/chart-relation-label.service.spec.ts
new file mode 100644
index 0000000000..de69fff34e
--- /dev/null
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/chart-relation-label.service.spec.ts
@@ -0,0 +1,358 @@
+import { Test, type TestingModule } from '@nestjs/testing';
+
+import { FieldMetadataType } from 'twenty-shared/types';
+
+import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
+import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
+import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
+import { ChartRelationLabelService } from 'src/modules/dashboard/chart-data/services/chart-relation-label.service';
+
+jest.mock(
+ 'src/engine/twenty-orm/storage/orm-workspace-context.storage',
+ () => ({
+ getWorkspaceContext: jest.fn(() => ({
+ authContext: {},
+ userWorkspaceRoleMap: {},
+ apiKeyRoleMap: {},
+ })),
+ }),
+);
+
+jest.mock(
+ 'src/engine/twenty-orm/utils/resolve-role-permission-config.util',
+ () => ({
+ resolveRolePermissionConfig: jest.fn(() => ({ shouldBypass: false })),
+ }),
+);
+
+jest.mock('src/engine/twenty-orm/utils/format-result.util', () => ({
+ formatResult: jest.fn((rawRow) => rawRow),
+}));
+
+jest.mock(
+ 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util',
+ () => ({
+ isMorphOrRelationFlatFieldMetadata: jest.fn(
+ (fieldMetadata: FlatFieldMetadata) =>
+ fieldMetadata.type === FieldMetadataType.RELATION ||
+ fieldMetadata.type === FieldMetadataType.MORPH_RELATION,
+ ),
+ }),
+);
+
+const workspaceId = 'test-workspace-id';
+const mockAuthContext = {
+ type: 'system',
+ workspace: { id: workspaceId },
+} as unknown as WorkspaceAuthContext;
+
+const targetObjectMetadataId = 'agent-object-id';
+const labelFieldMetadataId = 'agent-name-field-id';
+const idFieldMetadataId = 'agent-id-field-id';
+
+const mockIdField = {
+ id: idFieldMetadataId,
+ name: 'id',
+ type: FieldMetadataType.UUID,
+ universalIdentifier: 'agent-id-field-universal-id',
+};
+
+const mockLabelField = {
+ id: labelFieldMetadataId,
+ name: 'name',
+ type: FieldMetadataType.TEXT,
+ universalIdentifier: 'agent-name-field-universal-id',
+};
+
+const mockTargetObjectMetadata = {
+ id: targetObjectMetadataId,
+ nameSingular: 'agent',
+ labelIdentifierFieldMetadataId: labelFieldMetadataId,
+ universalIdentifier: 'agent-object-universal-id',
+ fieldIds: [idFieldMetadataId, labelFieldMetadataId],
+};
+
+const flatObjectMetadataMaps = {
+ byUniversalIdentifier: {
+ 'agent-object-universal-id': mockTargetObjectMetadata,
+ },
+ universalIdentifierById: {
+ [targetObjectMetadataId]: 'agent-object-universal-id',
+ },
+ universalIdentifiersByApplicationId: {},
+} as never;
+
+const flatFieldMetadataMaps = {
+ byUniversalIdentifier: {
+ 'agent-id-field-universal-id': mockIdField,
+ 'agent-name-field-universal-id': mockLabelField,
+ },
+ universalIdentifierById: {
+ [idFieldMetadataId]: 'agent-id-field-universal-id',
+ [labelFieldMetadataId]: 'agent-name-field-universal-id',
+ },
+ universalIdentifiersByApplicationId: {},
+} as never;
+
+const relationGroupByField = {
+ id: 'relation-field-id',
+ name: 'agent',
+ type: FieldMetadataType.RELATION,
+ relationTargetObjectMetadataId: targetObjectMetadataId,
+} as unknown as FlatFieldMetadata;
+
+describe('ChartRelationLabelService', () => {
+ let service: ChartRelationLabelService;
+ let mockGetRawMany: jest.Mock;
+ let mockGetRepository: jest.Mock;
+
+ beforeEach(async () => {
+ mockGetRawMany = jest.fn().mockResolvedValue([]);
+
+ const mockQueryBuilder = {
+ select: jest.fn().mockReturnThis(),
+ addSelect: jest.fn().mockReturnThis(),
+ where: jest.fn().mockReturnThis(),
+ getRawMany: mockGetRawMany,
+ };
+
+ mockGetRepository = jest.fn().mockResolvedValue({
+ createQueryBuilder: jest.fn(() => mockQueryBuilder),
+ });
+
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ ChartRelationLabelService,
+ {
+ provide: GlobalWorkspaceOrmManager,
+ useValue: {
+ executeInWorkspaceContext: jest.fn((callback) => callback()),
+ getRepository: mockGetRepository,
+ },
+ },
+ ],
+ }).compile();
+
+ service = module.get(ChartRelationLabelService);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should resolve labels for a bare relation axis', async () => {
+ mockGetRawMany.mockResolvedValue([
+ { id: 'agent-id-1', name: 'Alice' },
+ { id: 'agent-id-2', name: 'Bob' },
+ ]);
+
+ const { primary } = await service.resolveRelationLabels({
+ rawResults: [
+ { groupByDimensionValues: ['agent-id-1'], aggregateValue: 3 },
+ { groupByDimensionValues: ['agent-id-2'], aggregateValue: 1 },
+ ],
+ primaryAxis: { groupByField: relationGroupByField, subFieldName: null },
+ workspaceId,
+ authContext: mockAuthContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ });
+
+ expect(primary?.labelByRecordId.get('agent-id-1')).toBe('Alice');
+ expect(primary?.labelByRecordId.get('agent-id-2')).toBe('Bob');
+ expect(primary?.unresolvedRecordIds.size).toBe(0);
+ });
+
+ it('should preserve the record id through the real formatResult so labels resolve', async () => {
+ const actualFormatResult = jest.requireActual(
+ 'src/engine/twenty-orm/utils/format-result.util',
+ ).formatResult;
+
+ (formatResult as jest.Mock).mockImplementationOnce((...args) =>
+ actualFormatResult(...args),
+ );
+
+ mockGetRawMany.mockResolvedValue([{ id: 'agent-id-1', name: 'Alice' }]);
+
+ const { primary } = await service.resolveRelationLabels({
+ rawResults: [
+ { groupByDimensionValues: ['agent-id-1'], aggregateValue: 3 },
+ ],
+ primaryAxis: { groupByField: relationGroupByField, subFieldName: null },
+ workspaceId,
+ authContext: mockAuthContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ });
+
+ expect(primary?.labelByRecordId.get('agent-id-1')).toBe('Alice');
+ expect(primary?.unresolvedRecordIds.size).toBe(0);
+ });
+
+ it('should suffix duplicate labels', async () => {
+ mockGetRawMany.mockResolvedValue([
+ { id: 'agent-id-1', name: 'John Smith' },
+ { id: 'agent-id-2', name: 'John Smith' },
+ ]);
+
+ const { primary } = await service.resolveRelationLabels({
+ rawResults: [
+ { groupByDimensionValues: ['agent-id-1'], aggregateValue: 3 },
+ { groupByDimensionValues: ['agent-id-2'], aggregateValue: 1 },
+ ],
+ primaryAxis: { groupByField: relationGroupByField, subFieldName: null },
+ workspaceId,
+ authContext: mockAuthContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ });
+
+ expect(primary?.labelByRecordId.get('agent-id-1')).toBe('John Smith (1)');
+ expect(primary?.labelByRecordId.get('agent-id-2')).toBe('John Smith (2)');
+ });
+
+ it('should mark records missing from the query result as unresolved', async () => {
+ mockGetRawMany.mockResolvedValue([{ id: 'agent-id-1', name: 'Alice' }]);
+
+ const { primary } = await service.resolveRelationLabels({
+ rawResults: [
+ { groupByDimensionValues: ['agent-id-1'], aggregateValue: 3 },
+ { groupByDimensionValues: ['agent-id-2'], aggregateValue: 1 },
+ ],
+ primaryAxis: { groupByField: relationGroupByField, subFieldName: null },
+ workspaceId,
+ authContext: mockAuthContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ });
+
+ expect(primary?.labelByRecordId.has('agent-id-2')).toBe(false);
+ expect(primary?.unresolvedRecordIds).toEqual(new Set(['agent-id-2']));
+ });
+
+ it('should degrade to unresolved labels when the query throws', async () => {
+ mockGetRepository.mockRejectedValue(new Error('permission denied'));
+
+ const { primary } = await service.resolveRelationLabels({
+ rawResults: [
+ { groupByDimensionValues: ['agent-id-1'], aggregateValue: 3 },
+ ],
+ primaryAxis: { groupByField: relationGroupByField, subFieldName: null },
+ workspaceId,
+ authContext: mockAuthContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ });
+
+ expect(primary?.labelByRecordId.has('agent-id-1')).toBe(false);
+ expect(primary?.unresolvedRecordIds).toEqual(new Set(['agent-id-1']));
+ });
+
+ it('should not resolve an axis with a subFieldName', async () => {
+ const resolutions = await service.resolveRelationLabels({
+ rawResults: [{ groupByDimensionValues: ['Acme'], aggregateValue: 3 }],
+ primaryAxis: { groupByField: relationGroupByField, subFieldName: 'name' },
+ workspaceId,
+ authContext: mockAuthContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ });
+
+ expect(resolutions).toEqual({});
+ expect(mockGetRepository).not.toHaveBeenCalled();
+ });
+
+ it('should not resolve a non-relation axis', async () => {
+ const resolutions = await service.resolveRelationLabels({
+ rawResults: [{ groupByDimensionValues: ['Active'], aggregateValue: 3 }],
+ primaryAxis: {
+ groupByField: {
+ id: 'text-field-id',
+ name: 'status',
+ type: FieldMetadataType.TEXT,
+ } as unknown as FlatFieldMetadata,
+ subFieldName: null,
+ },
+ workspaceId,
+ authContext: mockAuthContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ });
+
+ expect(resolutions).toEqual({});
+ expect(mockGetRepository).not.toHaveBeenCalled();
+ });
+
+ it('should issue a single query when both axes target the same object', async () => {
+ mockGetRawMany.mockResolvedValue([
+ { id: 'agent-id-1', name: 'Alice' },
+ { id: 'agent-id-2', name: 'Bob' },
+ ]);
+
+ const { primary, secondary } = await service.resolveRelationLabels({
+ rawResults: [
+ {
+ groupByDimensionValues: ['agent-id-1', 'agent-id-2'],
+ aggregateValue: 3,
+ },
+ ],
+ primaryAxis: { groupByField: relationGroupByField, subFieldName: null },
+ secondaryAxis: { groupByField: relationGroupByField, subFieldName: null },
+ workspaceId,
+ authContext: mockAuthContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ });
+
+ expect(mockGetRepository).toHaveBeenCalledTimes(1);
+ expect(primary?.labelByRecordId.get('agent-id-1')).toBe('Alice');
+ expect(secondary?.labelByRecordId.get('agent-id-2')).toBe('Bob');
+ });
+
+ it('should resolve labels when the group by field is a morph relation', async () => {
+ mockGetRawMany.mockResolvedValue([{ id: 'agent-id-1', name: 'Alice' }]);
+
+ const morphRelationGroupByField = {
+ id: 'morph-relation-field-id',
+ name: 'morphAgent',
+ type: FieldMetadataType.MORPH_RELATION,
+ relationTargetObjectMetadataId: targetObjectMetadataId,
+ } as unknown as FlatFieldMetadata;
+
+ const { primary } = await service.resolveRelationLabels({
+ rawResults: [
+ { groupByDimensionValues: ['agent-id-1'], aggregateValue: 3 },
+ ],
+ primaryAxis: {
+ groupByField: morphRelationGroupByField,
+ subFieldName: null,
+ },
+ workspaceId,
+ authContext: mockAuthContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ });
+
+ expect(primary?.labelByRecordId.get('agent-id-1')).toBe('Alice');
+ expect(primary?.unresolvedRecordIds.size).toBe(0);
+ });
+
+ it('should treat a label falling back to the record id as unresolved', async () => {
+ mockGetRawMany.mockResolvedValue([{ id: 'agent-id-1', name: null }]);
+
+ const { primary } = await service.resolveRelationLabels({
+ rawResults: [
+ { groupByDimensionValues: ['agent-id-1'], aggregateValue: 3 },
+ ],
+ primaryAxis: { groupByField: relationGroupByField, subFieldName: null },
+ workspaceId,
+ authContext: mockAuthContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ });
+
+ expect(primary?.labelByRecordId.has('agent-id-1')).toBe(false);
+ expect(primary?.unresolvedRecordIds).toEqual(new Set(['agent-id-1']));
+ });
+});
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/line-chart-data.service.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/line-chart-data.service.spec.ts
index 5bd8afe133..a6893e38f8 100644
--- a/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/line-chart-data.service.spec.ts
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/line-chart-data.service.spec.ts
@@ -7,11 +7,13 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS } from 'src/modules/dashboard/chart-data/constants/line-chart-maximum-number-of-data-points.constant';
import { ChartDataQueryService } from 'src/modules/dashboard/chart-data/services/chart-data-query.service';
+import { ChartRelationLabelService } from 'src/modules/dashboard/chart-data/services/chart-relation-label.service';
import { LineChartDataService } from 'src/modules/dashboard/chart-data/services/line-chart-data.service';
describe('LineChartDataService', () => {
let service: LineChartDataService;
let mockExecuteGroupByQuery: jest.Mock;
+ let mockResolveRelationLabels: jest.Mock;
const workspaceId = 'test-workspace-id';
const mockAuthContext = {
@@ -41,6 +43,14 @@ describe('LineChartDataService', () => {
type: FieldMetadataType.TEXT,
};
+ const mockRelationFieldY = {
+ id: 'secondary-relation-field-id',
+ name: 'agent',
+ label: 'Agent',
+ type: FieldMetadataType.RELATION,
+ relationTargetObjectMetadataId: 'agent-object-id',
+ };
+
const mockAggregateField = {
id: 'aggregate-field-id',
name: 'amount',
@@ -56,6 +66,7 @@ describe('LineChartDataService', () => {
beforeEach(async () => {
mockExecuteGroupByQuery = jest.fn();
+ mockResolveRelationLabels = jest.fn().mockResolvedValue({});
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -90,6 +101,11 @@ describe('LineChartDataService', () => {
...mockGroupByFieldY,
universalIdentifier: 'group-by-field-y-universal-id',
},
+ 'secondary-relation-field-universal-id': {
+ ...mockRelationFieldY,
+ universalIdentifier:
+ 'secondary-relation-field-universal-id',
+ },
'aggregate-field-universal-id': {
...mockAggregateField,
universalIdentifier: 'aggregate-field-universal-id',
@@ -100,6 +116,8 @@ describe('LineChartDataService', () => {
[mockGroupByFieldXText.id]:
'group-by-field-x-text-universal-id',
[mockGroupByFieldY.id]: 'group-by-field-y-universal-id',
+ [mockRelationFieldY.id]:
+ 'secondary-relation-field-universal-id',
[mockAggregateField.id]: 'aggregate-field-universal-id',
},
universalIdentifiersByApplicationId: {},
@@ -113,6 +131,12 @@ describe('LineChartDataService', () => {
executeGroupByQuery: mockExecuteGroupByQuery,
},
},
+ {
+ provide: ChartRelationLabelService,
+ useValue: {
+ resolveRelationLabels: mockResolveRelationLabels,
+ },
+ },
],
}).compile();
@@ -276,6 +300,79 @@ describe('LineChartDataService', () => {
expect(result.hasTooManyGroups).toBe(false);
});
+ it('should label secondary relation series with resolved record names and drop unresolved series', async () => {
+ mockExecuteGroupByQuery.mockResolvedValue([
+ {
+ groupByDimensionValues: ['2024-01-01', 'agent-id-1'],
+ aggregateValue: 8,
+ },
+ {
+ groupByDimensionValues: ['2024-01-01', 'agent-id-2'],
+ aggregateValue: 5,
+ },
+ ]);
+ mockResolveRelationLabels.mockResolvedValue({
+ secondary: {
+ labelByRecordId: new Map([['agent-id-1', 'Alice']]),
+ unresolvedRecordIds: new Set(['agent-id-2']),
+ },
+ });
+
+ const result = await service.getLineChartData({
+ workspaceId,
+ objectMetadataId,
+ configuration: {
+ ...twoDimConfiguration,
+ secondaryAxisGroupByFieldMetadataId: mockRelationFieldY.id,
+ } as any,
+ authContext: mockAuthContext,
+ });
+
+ const seriesLabels = result.series.map((serie) => serie.label);
+
+ expect(seriesLabels).toContain('Alice');
+ expect(seriesLabels).not.toContain('Unknown');
+ expect(Object.values(result.formattedToRawLookup)).toContain(
+ 'agent-id-1',
+ );
+ expect(Object.values(result.formattedToRawLookup)).not.toContain(
+ 'agent-id-2',
+ );
+ });
+
+ it('should key the secondary lookup by prefixed series id only', async () => {
+ mockExecuteGroupByQuery.mockResolvedValue([
+ {
+ groupByDimensionValues: ['2024-01-01', 'agent-id-1'],
+ aggregateValue: 8,
+ },
+ ]);
+ mockResolveRelationLabels.mockResolvedValue({
+ secondary: {
+ labelByRecordId: new Map([['agent-id-1', 'Alice']]),
+ unresolvedRecordIds: new Set(),
+ },
+ });
+
+ const result = await service.getLineChartData({
+ workspaceId,
+ objectMetadataId,
+ configuration: {
+ ...twoDimConfiguration,
+ secondaryAxisGroupByFieldMetadataId: mockRelationFieldY.id,
+ } as any,
+ authContext: mockAuthContext,
+ });
+
+ const aliceSeries = result.series.find(
+ (serie) => serie.label === 'Alice',
+ );
+
+ expect(aliceSeries).toBeDefined();
+ expect(result.formattedToRawLookup[aliceSeries!.key]).toBe('agent-id-1');
+ expect(result.formattedToRawLookup['Alice']).toBeUndefined();
+ });
+
it('should normalize sparse data (fill missing x values with 0)', async () => {
mockExecuteGroupByQuery.mockResolvedValue([
{
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/pie-chart-data.service.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/pie-chart-data.service.spec.ts
index 4ae4d66847..581e55c132 100644
--- a/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/pie-chart-data.service.spec.ts
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/__tests__/pie-chart-data.service.spec.ts
@@ -4,14 +4,17 @@ import { AggregateOperations, FieldMetadataType } from 'twenty-shared/types';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
+import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from 'src/modules/dashboard/chart-data/constants/pie-chart-maximum-number-of-slices.constant';
import { ChartDataQueryService } from 'src/modules/dashboard/chart-data/services/chart-data-query.service';
+import { ChartRelationLabelService } from 'src/modules/dashboard/chart-data/services/chart-relation-label.service';
import { PieChartDataService } from 'src/modules/dashboard/chart-data/services/pie-chart-data.service';
describe('PieChartDataService', () => {
let service: PieChartDataService;
let mockExecuteGroupByQuery: jest.Mock;
+ let mockResolveRelationLabels: jest.Mock;
let mockGetOrRecomputeManyOrAllFlatEntityMaps: jest.Mock;
const workspaceId = 'test-workspace-id';
@@ -46,6 +49,13 @@ describe('PieChartDataService', () => {
type: FieldMetadataType.UUID,
};
+ const mockRelationField = {
+ id: 'relation-field-id',
+ name: 'assignedAgent',
+ label: 'Assigned Agent',
+ type: FieldMetadataType.RELATION,
+ };
+
const mockObjectMetadata = {
id: objectMetadataId,
nameSingular: 'company',
@@ -54,6 +64,7 @@ describe('PieChartDataService', () => {
beforeEach(async () => {
mockExecuteGroupByQuery = jest.fn();
+ mockResolveRelationLabels = jest.fn().mockResolvedValue({});
mockGetOrRecomputeManyOrAllFlatEntityMaps = jest.fn().mockResolvedValue({
flatObjectMetadataMaps: {
byUniversalIdentifier: {
@@ -81,11 +92,16 @@ describe('PieChartDataService', () => {
...mockAggregateField,
universalIdentifier: 'aggregate-field-universal-id',
},
+ 'relation-field-universal-id': {
+ ...mockRelationField,
+ universalIdentifier: 'relation-field-universal-id',
+ },
},
universalIdentifierById: {
[mockGroupByField.id]: 'group-by-field-universal-id',
[mockSelectField.id]: 'select-field-universal-id',
[mockAggregateField.id]: 'aggregate-field-universal-id',
+ [mockRelationField.id]: 'relation-field-universal-id',
},
universalIdentifiersByApplicationId: {},
},
@@ -107,6 +123,12 @@ describe('PieChartDataService', () => {
executeGroupByQuery: mockExecuteGroupByQuery,
},
},
+ {
+ provide: ChartRelationLabelService,
+ useValue: {
+ resolveRelationLabels: mockResolveRelationLabels,
+ },
+ },
],
}).compile();
@@ -189,6 +211,64 @@ describe('PieChartDataService', () => {
expect(result.data[0].key).toBe('Active');
});
+ it('should resolve relation labels from the buckets surviving hideEmptyCategory', async () => {
+ mockExecuteGroupByQuery.mockResolvedValue([
+ { groupByDimensionValues: [null], aggregateValue: 2 },
+ { groupByDimensionValues: ['agent-id-1'], aggregateValue: 0 },
+ { groupByDimensionValues: ['agent-id-2'], aggregateValue: 5 },
+ ]);
+
+ await service.getPieChartData({
+ workspaceId,
+ objectMetadataId,
+ configuration: {
+ ...baseConfiguration,
+ hideEmptyCategory: true,
+ } as any,
+ authContext: mockAuthContext,
+ });
+
+ expect(mockResolveRelationLabels).toHaveBeenCalledWith(
+ expect.objectContaining({
+ rawResults: [
+ { groupByDimensionValues: ['agent-id-2'], aggregateValue: 5 },
+ ],
+ }),
+ );
+ });
+
+ it('should drop unresolved relation buckets while keeping resolved and Not Set slices', async () => {
+ mockExecuteGroupByQuery.mockResolvedValue([
+ { groupByDimensionValues: [null], aggregateValue: 2 },
+ { groupByDimensionValues: ['agent-id-1'], aggregateValue: 8 },
+ { groupByDimensionValues: ['agent-id-2'], aggregateValue: 5 },
+ ]);
+ mockResolveRelationLabels.mockResolvedValue({
+ primary: {
+ labelByRecordId: new Map([['agent-id-1', 'Alice']]),
+ unresolvedRecordIds: new Set(['agent-id-2']),
+ },
+ });
+
+ const result = await service.getPieChartData({
+ workspaceId,
+ objectMetadataId,
+ configuration: {
+ ...baseConfiguration,
+ groupByFieldMetadataId: mockRelationField.id,
+ } as any,
+ authContext: mockAuthContext,
+ });
+
+ const keys = result.data.map((item) => item.key);
+
+ expect(keys).toContain('Alice');
+ expect(keys).toContain('Not Set');
+ expect(keys).not.toContain('Unknown');
+ expect(keys).not.toContain('agent-id-2');
+ expect(result.data).toHaveLength(2);
+ });
+
it('should flag too many groups and limit slices', async () => {
const manyResults = Array.from(
{ length: PIE_CHART_MAXIMUM_NUMBER_OF_SLICES + 5 },
@@ -211,6 +291,39 @@ describe('PieChartDataService', () => {
expect(result.data).toHaveLength(PIE_CHART_MAXIMUM_NUMBER_OF_SLICES);
});
+ it('should keep the highest-value slices when ordering by value with too many groups', async () => {
+ const extraSlices = 5;
+ const manyResults = Array.from(
+ { length: PIE_CHART_MAXIMUM_NUMBER_OF_SLICES + extraSlices },
+ (_, index) => ({
+ groupByDimensionValues: [`Group ${index}`],
+ aggregateValue: index + 1,
+ }),
+ );
+
+ mockExecuteGroupByQuery.mockResolvedValue(manyResults);
+
+ const result = await service.getPieChartData({
+ workspaceId,
+ objectMetadataId,
+ configuration: {
+ ...baseConfiguration,
+ orderBy: GraphOrderBy.VALUE_DESC,
+ } as any,
+ authContext: mockAuthContext,
+ });
+
+ expect(result.data).toHaveLength(PIE_CHART_MAXIMUM_NUMBER_OF_SLICES);
+ expect(result.data[0].value).toBe(
+ PIE_CHART_MAXIMUM_NUMBER_OF_SLICES + extraSlices,
+ );
+ const smallestSurvivingValue = Math.min(
+ ...result.data.map((slice) => slice.value),
+ );
+
+ expect(smallestSurvivingValue).toBe(extraSlices + 1);
+ });
+
it('should respect displayLegend configuration', async () => {
mockExecuteGroupByQuery.mockResolvedValue([
{ groupByDimensionValues: ['Active'], aggregateValue: 10 },
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts
index ca98ffa91d..82cfcb4320 100644
--- a/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts
@@ -4,6 +4,7 @@ import { isNumber } from '@sniptt/guards';
import { CalendarStartDay } from 'twenty-shared/constants';
import { FirstDayOfTheWeek } from 'twenty-shared/types';
import {
+ capitalize,
convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek,
isDefined,
} from 'twenty-shared/utils';
@@ -12,6 +13,7 @@ import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/wo
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { BarChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto';
import { BarChartGroupMode } from 'src/engine/metadata-modules/page-layout-widget/enums/bar-chart-group-mode.enum';
import { BarChartLayout } from 'src/engine/metadata-modules/page-layout-widget/enums/bar-chart-layout.enum';
@@ -26,10 +28,17 @@ import {
generateChartDataExceptionMessage,
} from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception';
import { ChartDataQueryService } from 'src/modules/dashboard/chart-data/services/chart-data-query.service';
+import { ChartRelationLabelService } from 'src/modules/dashboard/chart-data/services/chart-relation-label.service';
import { FieldMetadataOption } from 'src/modules/dashboard/chart-data/types/field-metadata-option.type';
import { GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
import { RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type';
+import { RelationLabelResolution } from 'src/modules/dashboard/chart-data/types/relation-label-resolution.type';
+import { applyCumulativeToOneDimensionalBarData } from 'src/modules/dashboard/chart-data/utils/apply-cumulative-to-one-dimensional-bar-data.util';
+import { applyCumulativeToTwoDimensionalBarData } from 'src/modules/dashboard/chart-data/utils/apply-cumulative-to-two-dimensional-bar-data.util';
import { applyGapFilling } from 'src/modules/dashboard/chart-data/utils/apply-gap-filling.util';
+import { buildFormattedToRawLookupDto } from 'src/modules/dashboard/chart-data/utils/build-formatted-to-raw-lookup-dto.util';
+import { filterOutEmptyChartBuckets } from 'src/modules/dashboard/chart-data/utils/filter-out-empty-chart-buckets.util';
+import { filterOutUnresolvedRelationBuckets } from 'src/modules/dashboard/chart-data/utils/filter-out-unresolved-relation-buckets.util';
import { getAggregateOperationLabel } from 'src/modules/dashboard/chart-data/utils/get-aggregate-operation-label.util';
import { getFieldMetadata } from 'src/modules/dashboard/chart-data/utils/get-field-metadata.util';
import { getSelectOptions } from 'src/modules/dashboard/chart-data/utils/get-select-options.util';
@@ -51,6 +60,7 @@ export class BarChartDataService {
constructor(
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly chartDataQueryService: ChartDataQueryService,
+ private readonly chartRelationLabelService: ChartRelationLabelService,
) {}
async getBarChartData({
@@ -132,15 +142,8 @@ export class BarChartDataService {
(configuration.firstDayOfTheWeek as CalendarStartDay | undefined) ??
CalendarStartDay.MONDAY;
- const objectIdByNameSingular: Record = {};
-
- for (const objMetadata of Object.values(
- flatObjectMetadataMaps.byUniversalIdentifier,
- )) {
- if (isDefined(objMetadata)) {
- objectIdByNameSingular[objMetadata.nameSingular] = objMetadata.id;
- }
- }
+ const { idByNameSingular: objectIdByNameSingular } =
+ buildObjectIdByNameMaps(flatObjectMetadataMaps);
const rawResults = await this.chartDataQueryService.executeGroupByQuery({
flatObjectMetadata,
@@ -168,25 +171,59 @@ export class BarChartDataService {
splitMultiValueFields: configuration.splitMultiValueFields,
});
+ const filteredResults = filterOutEmptyChartBuckets({
+ rawResults,
+ shouldOmitEmptyBuckets: configuration.omitNullValues ?? false,
+ });
+
+ const relationLabelResolutions =
+ await this.chartRelationLabelService.resolveRelationLabels({
+ rawResults: filteredResults,
+ primaryAxis: {
+ groupByField: primaryAxisGroupByField,
+ subFieldName: configuration.primaryAxisGroupBySubFieldName,
+ },
+ secondaryAxis:
+ isTwoDimensional && isDefined(secondaryAxisGroupByField)
+ ? {
+ groupByField: secondaryAxisGroupByField,
+ subFieldName: configuration.secondaryAxisGroupBySubFieldName,
+ }
+ : undefined,
+ workspaceId,
+ authContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ });
+
+ const resolvedResults = filterOutUnresolvedRelationBuckets({
+ rawResults: filteredResults,
+ primaryRelationLabelResolution: relationLabelResolutions.primary,
+ secondaryRelationLabelResolution: relationLabelResolutions.secondary,
+ });
+
if (isTwoDimensional && isDefined(secondaryAxisGroupByField)) {
return this.transformToTwoDimensionalBarChartData({
- rawResults,
+ filteredRawResults: resolvedResults,
primaryAxisGroupByField,
secondaryAxisGroupByField,
aggregateField,
configuration,
userTimezone,
firstDayOfTheWeek,
+ primaryRelationLabelResolution: relationLabelResolutions.primary,
+ secondaryRelationLabelResolution: relationLabelResolutions.secondary,
});
}
return this.transformToOneDimensionalBarChartData({
- rawResults,
+ filteredRawResults: resolvedResults,
primaryAxisGroupByField,
aggregateField,
configuration,
userTimezone,
firstDayOfTheWeek,
+ relationLabelResolution: relationLabelResolutions.primary,
});
} catch (error) {
throw wrapChartDataQueryError(error, 'Bar chart data retrieval failed');
@@ -194,37 +231,31 @@ export class BarChartDataService {
}
private transformToOneDimensionalBarChartData({
- rawResults,
+ filteredRawResults,
primaryAxisGroupByField,
aggregateField,
configuration,
userTimezone,
firstDayOfTheWeek,
+ relationLabelResolution,
}: {
- rawResults: GroupByRawResult[];
+ filteredRawResults: GroupByRawResult[];
primaryAxisGroupByField: FlatFieldMetadata;
aggregateField: FlatFieldMetadata;
configuration: BarChartConfigurationDTO;
userTimezone: string;
firstDayOfTheWeek: CalendarStartDay;
+ relationLabelResolution: RelationLabelResolution | undefined;
}): BarChartDataDTO {
const layout = configuration.layout ?? BarChartLayout.VERTICAL;
const isHorizontal = layout === BarChartLayout.HORIZONTAL;
- const filteredResults = configuration.omitNullValues
- ? rawResults.filter(
- (result) =>
- isDefined(result.groupByDimensionValues?.[0]) &&
- result.aggregateValue !== 0,
- )
- : rawResults;
-
const isDescOrder =
configuration.primaryAxisOrderBy === GraphOrderBy.FIELD_DESC;
const { data: gapFilledResults, wasTruncated: dateRangeWasTruncated } =
applyGapFilling({
- data: filteredResults,
+ data: filteredRawResults,
primaryAxisGroupByField,
dateGranularity: configuration.primaryAxisDateGranularity,
omitNullValues: configuration.omitNullValues ?? false,
@@ -242,7 +273,7 @@ export class BarChartDataService {
);
const indexByKey = configuration.primaryAxisGroupBySubFieldName
- ? `${primaryAxisGroupByField.name}${this.capitalizeFirst(configuration.primaryAxisGroupBySubFieldName)}`
+ ? `${primaryAxisGroupByField.name}${capitalize(configuration.primaryAxisGroupBySubFieldName)}`
: primaryAxisGroupByField.name;
const aggregateValueKey =
@@ -258,6 +289,7 @@ export class BarChartDataService {
subFieldName: configuration.primaryAxisGroupBySubFieldName,
userTimezone,
firstDayOfTheWeek: convertedFirstDayOfTheWeek,
+ relationLabelResolution,
});
const sortedData = sortChartDataIfNeeded({
@@ -279,7 +311,7 @@ export class BarChartDataService {
);
const transformedData = configuration.isCumulative
- ? this.applyCumulativeTransformInternal(limitedSortedData)
+ ? applyCumulativeToOneDimensionalBarData(limitedSortedData)
: limitedSortedData;
const data = transformedData.map((item) => ({
@@ -312,40 +344,38 @@ export class BarChartDataService {
layout,
groupMode: configuration.groupMode ?? BarChartGroupMode.GROUPED,
hasTooManyGroups:
- filteredResults.length > BAR_CHART_MAXIMUM_NUMBER_OF_BARS ||
+ filteredRawResults.length > BAR_CHART_MAXIMUM_NUMBER_OF_BARS ||
dateRangeWasTruncated,
- formattedToRawLookup: Object.fromEntries(formattedToRawLookup),
+ formattedToRawLookup: buildFormattedToRawLookupDto({
+ axisLookups: [{ formattedToRawLookup, relationLabelResolution }],
+ }),
};
}
private transformToTwoDimensionalBarChartData({
- rawResults,
+ filteredRawResults,
primaryAxisGroupByField,
secondaryAxisGroupByField,
aggregateField,
configuration,
userTimezone,
firstDayOfTheWeek,
+ primaryRelationLabelResolution,
+ secondaryRelationLabelResolution,
}: {
- rawResults: GroupByRawResult[];
+ filteredRawResults: GroupByRawResult[];
primaryAxisGroupByField: FlatFieldMetadata;
secondaryAxisGroupByField: FlatFieldMetadata;
aggregateField: FlatFieldMetadata;
configuration: BarChartConfigurationDTO;
userTimezone: string;
firstDayOfTheWeek: CalendarStartDay;
+ primaryRelationLabelResolution: RelationLabelResolution | undefined;
+ secondaryRelationLabelResolution: RelationLabelResolution | undefined;
}): BarChartDataDTO {
const layout = configuration.layout ?? BarChartLayout.VERTICAL;
const isHorizontal = layout === BarChartLayout.HORIZONTAL;
- const filteredResults = configuration.omitNullValues
- ? rawResults.filter(
- (result) =>
- isDefined(result.groupByDimensionValues?.[0]) &&
- result.aggregateValue !== 0,
- )
- : rawResults;
-
const effectiveGroupMode =
configuration.groupMode ?? BarChartGroupMode.STACKED;
const isStacked = effectiveGroupMode === BarChartGroupMode.STACKED;
@@ -355,7 +385,7 @@ export class BarChartDataService {
const { data: gapFilledResults, wasTruncated: dateRangeWasTruncated } =
applyGapFilling({
- data: filteredResults,
+ data: filteredRawResults,
primaryAxisGroupByField,
dateGranularity: configuration.primaryAxisDateGranularity,
omitNullValues: configuration.omitNullValues ?? false,
@@ -368,7 +398,7 @@ export class BarChartDataService {
const secondarySelectOptions = getSelectOptions(secondaryAxisGroupByField);
const indexByKey = configuration.primaryAxisGroupBySubFieldName
- ? `${primaryAxisGroupByField.name}${this.capitalizeFirst(configuration.primaryAxisGroupBySubFieldName)}`
+ ? `${primaryAxisGroupByField.name}${capitalize(configuration.primaryAxisGroupBySubFieldName)}`
: primaryAxisGroupByField.name;
const convertedFirstDayOfTheWeek =
@@ -392,6 +422,8 @@ export class BarChartDataService {
secondarySubFieldName: configuration.secondaryAxisGroupBySubFieldName,
userTimezone,
firstDayOfTheWeek: convertedFirstDayOfTheWeek,
+ primaryRelationLabelResolution,
+ secondaryRelationLabelResolution,
});
const allSecondaryValues = new Set();
@@ -479,7 +511,10 @@ export class BarChartDataService {
}
const finalData = configuration.isCumulative
- ? this.applyCumulativeTwoDimensional(finalLimitedData, limitedKeys)
+ ? applyCumulativeToTwoDimensionalBarData({
+ data: finalLimitedData,
+ keys: limitedKeys,
+ })
: finalLimitedData;
const series = limitedKeys.map((key) => ({
@@ -505,11 +540,6 @@ export class BarChartDataService {
hasTooManyGroups = hasTooManyGroups || dateRangeWasTruncated;
- const mergedLookup = new Map([
- ...formattedToRawLookup,
- ...secondaryFormattedToRawLookup,
- ]);
-
return {
data: finalData,
indexBy: indexByKey,
@@ -522,7 +552,18 @@ export class BarChartDataService {
layout,
groupMode: configuration.groupMode ?? BarChartGroupMode.GROUPED,
hasTooManyGroups,
- formattedToRawLookup: Object.fromEntries(mergedLookup),
+ formattedToRawLookup: buildFormattedToRawLookupDto({
+ axisLookups: [
+ {
+ formattedToRawLookup: secondaryFormattedToRawLookup,
+ relationLabelResolution: secondaryRelationLabelResolution,
+ },
+ {
+ formattedToRawLookup,
+ relationLabelResolution: primaryRelationLabelResolution,
+ },
+ ],
+ }),
};
}
@@ -572,68 +613,4 @@ export class BarChartDataService {
dateGranularity: configuration.secondaryAxisGroupByDateGranularity,
});
}
-
- private applyCumulativeTwoDimensional(
- data: Record[],
- keys: string[],
- ): Record[] {
- const runningTotals: Record = {};
-
- for (const key of keys) {
- runningTotals[key] = 0;
- }
-
- const result: Record[] = [];
-
- for (const datum of data) {
- const newDatum = { ...datum };
-
- for (const key of keys) {
- const value = datum[key];
-
- if (isNumber(value)) {
- runningTotals[key] += value;
- }
-
- newDatum[key] = runningTotals[key];
- }
-
- result.push(newDatum);
- }
-
- return result;
- }
-
- private applyCumulativeTransformInternal(
- data: Array<{
- formattedValue: string;
- aggregateValue: number;
- rawValue: RawDimensionValue;
- }>,
- ): Array<{
- formattedValue: string;
- aggregateValue: number;
- rawValue: RawDimensionValue;
- }> {
- const result: Array<{
- formattedValue: string;
- aggregateValue: number;
- rawValue: RawDimensionValue;
- }> = [];
- let runningTotal = 0;
-
- for (const point of data) {
- runningTotal += point.aggregateValue;
-
- const cumulativeValue = runningTotal;
-
- result.push({ ...point, aggregateValue: cumulativeValue });
- }
-
- return result;
- }
-
- private capitalizeFirst(str: string): string {
- return str.charAt(0).toUpperCase() + str.slice(1);
- }
}
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts
index 795455769f..2dee8d61e9 100644
--- a/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts
@@ -160,10 +160,12 @@ export class ChartDataQueryService {
dateGranularity: shouldApplyPrimaryDateGranularity
? (dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
});
if (isDefined(primaryOrderBy)) {
- orderBy.push(primaryOrderBy);
+ orderBy.push(...primaryOrderBy);
}
if (isDefined(secondaryGroupByFieldMetadataId)) {
@@ -214,7 +216,7 @@ export class ChartDataQueryService {
);
if (isDefined(secondaryAxisOrderBy)) {
- const secondaryOrderByItem = getGroupByOrderBy({
+ const secondaryOrderBy = getGroupByOrderBy({
graphOrderBy: secondaryAxisOrderBy,
groupByFieldMetadata: secondaryGroupByField,
groupBySubFieldName: secondaryGroupBySubFieldName,
@@ -223,10 +225,12 @@ export class ChartDataQueryService {
dateGranularity: shouldApplySecondaryDateGranularity
? (secondaryDateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
});
- if (isDefined(secondaryOrderByItem)) {
- orderBy.push(secondaryOrderByItem);
+ if (isDefined(secondaryOrderBy)) {
+ orderBy.push(...secondaryOrderBy);
}
}
}
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-relation-label.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-relation-label.service.ts
new file mode 100644
index 0000000000..dc3f0b0b09
--- /dev/null
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-relation-label.service.ts
@@ -0,0 +1,272 @@
+import { Injectable, Logger } from '@nestjs/common';
+
+import chunk from 'lodash.chunk';
+import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
+
+import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
+import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
+import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
+import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
+import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
+import { getWorkspaceContext } from 'src/engine/twenty-orm/storage/orm-workspace-context.storage';
+import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
+import { resolveRolePermissionConfig } from 'src/engine/twenty-orm/utils/resolve-role-permission-config.util';
+import { CHART_RELATION_LABEL_BATCH_SIZE } from 'src/modules/dashboard/chart-data/constants/chart-relation-label-batch-size.constant';
+import { type ChartRelationLabelAxisInput } from 'src/modules/dashboard/chart-data/types/chart-relation-label-axis-input.type';
+import { type GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
+import { type RelationLabelResolution } from 'src/modules/dashboard/chart-data/types/relation-label-resolution.type';
+import { type ResolvableChartRelationAxis } from 'src/modules/dashboard/chart-data/types/resolvable-chart-relation-axis.type';
+import { buildRawLabelByRecordId } from 'src/modules/dashboard/chart-data/utils/build-raw-label-by-record-id.util';
+import { buildResolvableChartRelationAxis } from 'src/modules/dashboard/chart-data/utils/build-resolvable-chart-relation-axis.util';
+import { buildUniqueRelationLabels } from 'src/modules/dashboard/chart-data/utils/build-unique-relation-labels.util';
+
+type ResolveRelationLabelsParams = {
+ rawResults: GroupByRawResult[];
+ primaryAxis: ChartRelationLabelAxisInput;
+ secondaryAxis?: ChartRelationLabelAxisInput;
+ workspaceId: string;
+ authContext: WorkspaceAuthContext;
+ flatObjectMetadataMaps: FlatEntityMaps;
+ flatFieldMetadataMaps: FlatEntityMaps;
+};
+
+@Injectable()
+export class ChartRelationLabelService {
+ private readonly logger = new Logger(ChartRelationLabelService.name);
+
+ constructor(
+ private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
+ ) {}
+
+ async resolveRelationLabels({
+ rawResults,
+ primaryAxis,
+ secondaryAxis,
+ workspaceId,
+ authContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ }: ResolveRelationLabelsParams): Promise<{
+ primary?: RelationLabelResolution;
+ secondary?: RelationLabelResolution;
+ }> {
+ const axisInputs = [
+ { dimensionIndex: 0, axis: primaryAxis },
+ ...(isDefined(secondaryAxis)
+ ? [{ dimensionIndex: 1, axis: secondaryAxis }]
+ : []),
+ ];
+
+ const resolvableAxes = axisInputs
+ .map(({ dimensionIndex, axis }) =>
+ buildResolvableChartRelationAxis({
+ dimensionIndex,
+ axis,
+ rawResults,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ }),
+ )
+ .filter(isDefined);
+
+ if (!isNonEmptyArray(resolvableAxes)) {
+ return {};
+ }
+
+ const rawLabelsByTargetObjectId = await this.fetchRawLabelsPerTargetObject({
+ resolvableAxes,
+ workspaceId,
+ authContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ });
+
+ const resolutionByDimensionIndex = new Map<
+ number,
+ RelationLabelResolution
+ >();
+
+ for (const resolvableAxis of resolvableAxes) {
+ const rawLabelByRecordId =
+ rawLabelsByTargetObjectId.get(
+ resolvableAxis.targetFlatObjectMetadata.id,
+ ) ?? new Map();
+
+ resolutionByDimensionIndex.set(
+ resolvableAxis.dimensionIndex,
+ buildUniqueRelationLabels({
+ rawLabelByRecordId,
+ allRecordIds: resolvableAxis.recordIds,
+ }),
+ );
+ }
+
+ return {
+ primary: resolutionByDimensionIndex.get(0),
+ secondary: resolutionByDimensionIndex.get(1),
+ };
+ }
+
+ private async fetchRawLabelsPerTargetObject({
+ resolvableAxes,
+ workspaceId,
+ authContext,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ }: {
+ resolvableAxes: ResolvableChartRelationAxis[];
+ workspaceId: string;
+ authContext: WorkspaceAuthContext;
+ flatObjectMetadataMaps: FlatEntityMaps;
+ flatFieldMetadataMaps: FlatEntityMaps;
+ }): Promise