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>> { + const rawLabelsByTargetObjectId = new Map>(); + + const axesByTargetObjectId = new Map< + string, + { + targetFlatObjectMetadata: FlatObjectMetadata; + labelIdentifierColumnNames: string[]; + recordIds: Set; + } + >(); + + for (const resolvableAxis of resolvableAxes) { + const targetObjectId = resolvableAxis.targetFlatObjectMetadata.id; + const existingAxesGroup = axesByTargetObjectId.get(targetObjectId); + + if (!isDefined(existingAxesGroup)) { + axesByTargetObjectId.set(targetObjectId, { + targetFlatObjectMetadata: resolvableAxis.targetFlatObjectMetadata, + labelIdentifierColumnNames: resolvableAxis.labelIdentifierColumnNames, + recordIds: new Set(resolvableAxis.recordIds), + }); + continue; + } + + for (const recordId of resolvableAxis.recordIds) { + existingAxesGroup.recordIds.add(recordId); + } + } + + await Promise.all( + [...axesByTargetObjectId.values()].map( + async ({ + targetFlatObjectMetadata, + labelIdentifierColumnNames, + recordIds, + }) => { + const records = await this.fetchLabelIdentifierRecords({ + targetFlatObjectMetadata, + labelIdentifierColumnNames, + recordIds: [...recordIds], + workspaceId, + authContext, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); + + rawLabelsByTargetObjectId.set( + targetFlatObjectMetadata.id, + buildRawLabelByRecordId({ + records, + targetFlatObjectMetadata, + flatFieldMetadataMaps, + }), + ); + }, + ), + ); + + return rawLabelsByTargetObjectId; + } + + private async fetchLabelIdentifierRecords({ + targetFlatObjectMetadata, + labelIdentifierColumnNames, + recordIds, + workspaceId, + authContext, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }: { + targetFlatObjectMetadata: FlatObjectMetadata; + labelIdentifierColumnNames: string[]; + recordIds: string[]; + workspaceId: string; + authContext: WorkspaceAuthContext; + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; + }): Promise[]> { + try { + return await this.globalWorkspaceOrmManager.executeInWorkspaceContext( + async () => { + const workspaceContext = getWorkspaceContext(); + const rolePermissionConfig = resolveRolePermissionConfig({ + authContext: workspaceContext.authContext, + userWorkspaceRoleMap: workspaceContext.userWorkspaceRoleMap, + apiKeyRoleMap: workspaceContext.apiKeyRoleMap, + }); + + if (!isDefined(rolePermissionConfig)) { + return []; + } + + const repository = await this.globalWorkspaceOrmManager.getRepository( + workspaceId, + targetFlatObjectMetadata.nameSingular, + rolePermissionConfig, + ); + + const alias = targetFlatObjectMetadata.nameSingular; + + const rawRowChunks = await Promise.all( + chunk(recordIds, CHART_RELATION_LABEL_BATCH_SIZE).map( + (recordIdChunk) => { + const queryBuilder = repository.createQueryBuilder(alias); + + queryBuilder.select([]); + + for (const columnName of labelIdentifierColumnNames) { + queryBuilder.addSelect( + `"${alias}"."${columnName}"`, + columnName, + ); + } + + return queryBuilder + .where(`${alias}.id IN (:...recordIdChunk)`, { + recordIdChunk, + }) + .getRawMany(); + }, + ), + ); + + const fieldMapsForObject = buildFieldMapsFromFlatObjectMetadata( + flatFieldMetadataMaps, + targetFlatObjectMetadata, + ); + + return formatResult[]>( + rawRowChunks.flat(), + targetFlatObjectMetadata, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + fieldMapsForObject, + ); + }, + authContext, + ); + } catch (error) { + this.logger.warn( + `Failed to resolve relation labels for object ${targetFlatObjectMetadata.nameSingular}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + + return []; + } + } +} diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts index 6e6cc80de1..deac82af5a 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts @@ -11,6 +11,7 @@ import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/wo import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; 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 { LineChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto'; import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum'; import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from 'src/modules/dashboard/chart-data/constants/extra-item-to-detect-too-many-groups.constant'; @@ -24,10 +25,16 @@ 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 { applyCumulativeToLineDataPoints } from 'src/modules/dashboard/chart-data/utils/apply-cumulative-to-line-data-points.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'; @@ -50,6 +57,7 @@ export class LineChartDataService { constructor( private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly chartDataQueryService: ChartDataQueryService, + private readonly chartRelationLabelService: ChartRelationLabelService, ) {} async getLineChartData({ @@ -133,15 +141,8 @@ export class LineChartDataService { (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, @@ -174,9 +175,40 @@ export class LineChartDataService { configuration, ); + 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.transformToTwoDimensionalLineChartData({ - rawResults, + filteredRawResults: resolvedResults, primaryAxisGroupByField, secondaryAxisGroupByField, aggregateField, @@ -184,17 +216,20 @@ export class LineChartDataService { userTimezone, firstDayOfTheWeek, seriesIdPrefix, + primaryRelationLabelResolution: relationLabelResolutions.primary, + secondaryRelationLabelResolution: relationLabelResolutions.secondary, }); } return this.transformToOneDimensionalLineChartData({ - rawResults, + filteredRawResults: resolvedResults, primaryAxisGroupByField, aggregateField, configuration, userTimezone, firstDayOfTheWeek, seriesIdPrefix, + relationLabelResolution: relationLabelResolutions.primary, }); } catch (error) { throw wrapChartDataQueryError(error, 'Line chart data retrieval failed'); @@ -202,36 +237,30 @@ export class LineChartDataService { } private transformToOneDimensionalLineChartData({ - rawResults, + filteredRawResults, primaryAxisGroupByField, aggregateField, configuration, userTimezone, firstDayOfTheWeek, seriesIdPrefix, + relationLabelResolution, }: { - rawResults: GroupByRawResult[]; + filteredRawResults: GroupByRawResult[]; primaryAxisGroupByField: FlatFieldMetadata; aggregateField: FlatFieldMetadata; configuration: LineChartConfigurationDTO; userTimezone: string; firstDayOfTheWeek: CalendarStartDay; seriesIdPrefix: string; + relationLabelResolution: RelationLabelResolution | undefined; }): LineChartDataDTO { - 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, @@ -258,6 +287,7 @@ export class LineChartDataService { subFieldName: configuration.primaryAxisGroupBySubFieldName, userTimezone, firstDayOfTheWeek: convertedFirstDayOfTheWeek, + relationLabelResolution, }); const processedDataPoints = rawProcessedDataPoints.map((point) => ({ @@ -285,7 +315,7 @@ export class LineChartDataService { ); const transformedData = configuration.isCumulative - ? this.applyCumulativeTransform(limitedSortedData) + ? applyCumulativeToLineDataPoints(limitedSortedData) : limitedSortedData; const dataPoints = transformedData.map(({ x, y }) => ({ @@ -311,14 +341,16 @@ export class LineChartDataService { showLegend: configuration.displayLegend ?? true, showDataLabels: configuration.displayDataLabel ?? false, hasTooManyGroups: - filteredResults.length > LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS || + filteredRawResults.length > LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS || dateRangeWasTruncated, - formattedToRawLookup: Object.fromEntries(formattedToRawLookup), + formattedToRawLookup: buildFormattedToRawLookupDto({ + axisLookups: [{ formattedToRawLookup, relationLabelResolution }], + }), }; } private transformToTwoDimensionalLineChartData({ - rawResults, + filteredRawResults, primaryAxisGroupByField, secondaryAxisGroupByField, aggregateField, @@ -326,8 +358,10 @@ export class LineChartDataService { userTimezone, firstDayOfTheWeek, seriesIdPrefix, + primaryRelationLabelResolution, + secondaryRelationLabelResolution, }: { - rawResults: GroupByRawResult[]; + filteredRawResults: GroupByRawResult[]; primaryAxisGroupByField: FlatFieldMetadata; secondaryAxisGroupByField: FlatFieldMetadata; aggregateField: FlatFieldMetadata; @@ -335,15 +369,9 @@ export class LineChartDataService { userTimezone: string; firstDayOfTheWeek: CalendarStartDay; seriesIdPrefix: string; + primaryRelationLabelResolution: RelationLabelResolution | undefined; + secondaryRelationLabelResolution: RelationLabelResolution | undefined; }): LineChartDataDTO { - const filteredResults = configuration.omitNullValues - ? rawResults.filter( - (result) => - isDefined(result.groupByDimensionValues?.[0]) && - result.aggregateValue !== 0, - ) - : rawResults; - const isStacked = configuration.isStacked ?? false; const isDescOrder = @@ -351,7 +379,7 @@ export class LineChartDataService { const { data: gapFilledResults, wasTruncated: dateRangeWasTruncated } = applyGapFilling({ - data: filteredResults, + data: filteredRawResults, primaryAxisGroupByField, dateGranularity: configuration.primaryAxisDateGranularity, omitNullValues: configuration.omitNullValues ?? false, @@ -384,6 +412,8 @@ export class LineChartDataService { secondarySubFieldName: configuration.secondaryAxisGroupBySubFieldName, userTimezone, firstDayOfTheWeek: convertedFirstDayOfTheWeek, + primaryRelationLabelResolution, + secondaryRelationLabelResolution, }); const allXValues: string[] = []; @@ -476,7 +506,7 @@ export class LineChartDataService { })); if (configuration.isCumulative) { - dataPoints = this.applyCumulativeTransform(dataPoints); + dataPoints = applyCumulativeToLineDataPoints(dataPoints); } return { @@ -495,16 +525,19 @@ export class LineChartDataService { const hasTooManyGroups = hasTooManySeries || hasTooManyDataPoints || dateRangeWasTruncated; - const mergedLookup = new Map([ - ...formattedToRawLookup, - ...secondaryFormattedToRawLookup, - ]); + const secondaryLookupWithPrefixedSeriesIds = new Map< + string, + RawDimensionValue + >(); for (const seriesId of limitedSeriesIds) { const rawValue = secondaryFormattedToRawLookup.get(seriesId); if (isDefined(rawValue)) { - mergedLookup.set(`${seriesIdPrefix}${seriesId}`, rawValue); + secondaryLookupWithPrefixedSeriesIds.set( + `${seriesIdPrefix}${seriesId}`, + rawValue, + ); } } @@ -515,7 +548,18 @@ export class LineChartDataService { showLegend: configuration.displayLegend ?? true, showDataLabels: configuration.displayDataLabel ?? false, hasTooManyGroups, - formattedToRawLookup: Object.fromEntries(mergedLookup), + formattedToRawLookup: buildFormattedToRawLookupDto({ + axisLookups: [ + { + formattedToRawLookup: secondaryLookupWithPrefixedSeriesIds, + relationLabelResolution: secondaryRelationLabelResolution, + }, + { + formattedToRawLookup, + relationLabelResolution: primaryRelationLabelResolution, + }, + ], + }), }; } @@ -567,23 +611,4 @@ export class LineChartDataService { dateGranularity: configuration.secondaryAxisGroupByDateGranularity, }); } - - private applyCumulativeTransform( - data: T[], - ): T[] { - const result: T[] = []; - let runningTotal = 0; - - for (const point of data) { - if (isDefined(point.y)) { - runningTotal += point.y; - } - - const cumulativeValue = runningTotal; - - result.push({ ...point, y: cumulativeValue }); - } - - return result; - } } diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts index 9ea4984ee4..ddf3bfc28f 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts @@ -11,6 +11,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 { PieChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto'; import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from 'src/modules/dashboard/chart-data/constants/extra-item-to-detect-too-many-groups.constant'; @@ -22,6 +23,11 @@ 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 { RelationLabelResolution } from 'src/modules/dashboard/chart-data/types/relation-label-resolution.type'; +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 { 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'; import { processOneDimensionalResults } from 'src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util'; @@ -40,6 +46,7 @@ export class PieChartDataService { constructor( private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly chartDataQueryService: ChartDataQueryService, + private readonly chartRelationLabelService: ChartRelationLabelService, ) {} async getPieChartData({ @@ -103,15 +110,8 @@ export class PieChartDataService { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES + EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS; - 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, @@ -134,14 +134,39 @@ export class PieChartDataService { splitMultiValueFields: configuration.splitMultiValueFields, }); - return this.transformToPieChartData({ + const filteredResults = filterOutEmptyChartBuckets({ rawResults, + shouldOmitEmptyBuckets: configuration.hideEmptyCategory ?? false, + }); + + const relationLabelResolutions = + await this.chartRelationLabelService.resolveRelationLabels({ + rawResults: filteredResults, + primaryAxis: { + groupByField, + subFieldName: configuration.groupBySubFieldName, + }, + workspaceId, + authContext, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); + + const resolvedResults = filterOutUnresolvedRelationBuckets({ + rawResults: filteredResults, + primaryRelationLabelResolution: relationLabelResolutions.primary, + secondaryRelationLabelResolution: undefined, + }); + + return this.transformToPieChartData({ + filteredRawResults: resolvedResults, groupByField, configuration, userTimezone: configuration.timezone ?? 'UTC', firstDayOfTheWeek: (configuration.firstDayOfTheWeek as CalendarStartDay | undefined) ?? CalendarStartDay.MONDAY, + relationLabelResolution: relationLabelResolutions.primary, }); } catch (error) { throw wrapChartDataQueryError(error, 'Pie chart data retrieval failed'); @@ -149,13 +174,14 @@ export class PieChartDataService { } private transformToPieChartData({ - rawResults, + filteredRawResults, groupByField, configuration, userTimezone, firstDayOfTheWeek, + relationLabelResolution, }: { - rawResults: Array<{ + filteredRawResults: Array<{ groupByDimensionValues: unknown[]; aggregateValue: number; }>; @@ -163,15 +189,8 @@ export class PieChartDataService { configuration: PieChartConfigurationDTO; userTimezone: string; firstDayOfTheWeek: CalendarStartDay; + relationLabelResolution: RelationLabelResolution | undefined; }): PieChartDataDTO { - const filteredResults = configuration.hideEmptyCategory - ? rawResults.filter( - (result) => - isDefined(result.groupByDimensionValues?.[0]) && - result.aggregateValue !== 0, - ) - : rawResults; - const selectOptions = getSelectOptions(groupByField); const convertedFirstDayOfTheWeek = @@ -180,21 +199,17 @@ export class PieChartDataService { FirstDayOfTheWeek.SUNDAY, ); - const limitedResults = filteredResults.slice( - 0, - PIE_CHART_MAXIMUM_NUMBER_OF_SLICES, - ); - const { processedDataPoints: rawProcessedDataPoints, formattedToRawLookup, } = processOneDimensionalResults({ - rawResults: limitedResults, + rawResults: filteredRawResults, primaryAxisGroupByField: groupByField, dateGranularity: configuration.dateGranularity, subFieldName: configuration.groupBySubFieldName, userTimezone, firstDayOfTheWeek: convertedFirstDayOfTheWeek, + relationLabelResolution, }); const processedDataPoints = rawProcessedDataPoints.map((point) => { @@ -221,7 +236,14 @@ export class PieChartDataService { dateGranularity: configuration.dateGranularity, }); - const data = sortedData.map(({ rawValue: _rawValue, ...item }) => item); + const limitedSortedData = sortedData.slice( + 0, + PIE_CHART_MAXIMUM_NUMBER_OF_SLICES, + ); + + const data = limitedSortedData.map( + ({ rawValue: _rawValue, ...item }) => item, + ); return { data, @@ -229,8 +251,10 @@ export class PieChartDataService { showDataLabels: configuration.displayDataLabel ?? false, showCenterMetric: configuration.showCenterMetric ?? true, hasTooManyGroups: - filteredResults.length > PIE_CHART_MAXIMUM_NUMBER_OF_SLICES, - formattedToRawLookup: Object.fromEntries(formattedToRawLookup), + filteredRawResults.length > PIE_CHART_MAXIMUM_NUMBER_OF_SLICES, + formattedToRawLookup: buildFormattedToRawLookupDto({ + axisLookups: [{ formattedToRawLookup, relationLabelResolution }], + }), }; } } diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/types/chart-relation-label-axis-input.type.ts b/packages/twenty-server/src/modules/dashboard/chart-data/types/chart-relation-label-axis-input.type.ts new file mode 100644 index 0000000000..3701a125ed --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/types/chart-relation-label-axis-input.type.ts @@ -0,0 +1,6 @@ +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; + +export type ChartRelationLabelAxisInput = { + groupByField: FlatFieldMetadata; + subFieldName?: string | null; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/types/relation-label-resolution.type.ts b/packages/twenty-server/src/modules/dashboard/chart-data/types/relation-label-resolution.type.ts new file mode 100644 index 0000000000..a52d1b9975 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/types/relation-label-resolution.type.ts @@ -0,0 +1,4 @@ +export type RelationLabelResolution = { + labelByRecordId: Map; + unresolvedRecordIds: Set; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/types/resolvable-chart-relation-axis.type.ts b/packages/twenty-server/src/modules/dashboard/chart-data/types/resolvable-chart-relation-axis.type.ts new file mode 100644 index 0000000000..d4a3f328d9 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/types/resolvable-chart-relation-axis.type.ts @@ -0,0 +1,8 @@ +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; + +export type ResolvableChartRelationAxis = { + dimensionIndex: number; + targetFlatObjectMetadata: FlatObjectMetadata; + labelIdentifierColumnNames: string[]; + recordIds: string[]; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/build-formatted-to-raw-lookup-dto.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/build-formatted-to-raw-lookup-dto.util.spec.ts new file mode 100644 index 0000000000..d59fc021d7 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/build-formatted-to-raw-lookup-dto.util.spec.ts @@ -0,0 +1,103 @@ +import { type RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type'; +import { type RelationLabelResolution } from 'src/modules/dashboard/chart-data/types/relation-label-resolution.type'; +import { buildFormattedToRawLookupDto } from 'src/modules/dashboard/chart-data/utils/build-formatted-to-raw-lookup-dto.util'; + +const buildResolution = ( + unresolvedRecordIds: Set, +): RelationLabelResolution => ({ + labelByRecordId: new Map(), + unresolvedRecordIds, +}); + +describe('buildFormattedToRawLookupDto', () => { + const lookup = new Map([ + ['Alice', 'agent-id-1'], + ['Unknown', 'agent-id-2'], + ]); + + it('should keep every entry without an unresolved set', () => { + expect( + buildFormattedToRawLookupDto({ + axisLookups: [ + { formattedToRawLookup: lookup, relationLabelResolution: undefined }, + ], + }), + ).toEqual({ + Alice: 'agent-id-1', + Unknown: 'agent-id-2', + }); + }); + + it('should strip entries whose raw value is unresolved on their own axis', () => { + expect( + buildFormattedToRawLookupDto({ + axisLookups: [ + { + formattedToRawLookup: lookup, + relationLabelResolution: buildResolution(new Set(['agent-id-2'])), + }, + ], + }), + ).toEqual({ + Alice: 'agent-id-1', + }); + }); + + it('should not strip an entry matching another axis unresolved record id', () => { + expect( + buildFormattedToRawLookupDto({ + axisLookups: [ + { + formattedToRawLookup: new Map([ + ['2024-01-01', 'agent-id-1'], + ]), + relationLabelResolution: undefined, + }, + { + formattedToRawLookup: lookup, + relationLabelResolution: buildResolution(new Set(['agent-id-2'])), + }, + ], + }), + ).toEqual({ + '2024-01-01': 'agent-id-1', + Alice: 'agent-id-1', + }); + }); + + it('should keep an entry whose formatted value is __proto__', () => { + const result = buildFormattedToRawLookupDto({ + axisLookups: [ + { + formattedToRawLookup: new Map([ + ['__proto__', 'agent-id-1'], + ]), + relationLabelResolution: undefined, + }, + ], + }); + + expect(Object.entries(result)).toEqual([['__proto__', 'agent-id-1']]); + }); + + it('should let later axis entries win formatted key collisions', () => { + expect( + buildFormattedToRawLookupDto({ + axisLookups: [ + { + formattedToRawLookup: new Map([ + ['Alice', 'secondary-id'], + ]), + relationLabelResolution: undefined, + }, + { + formattedToRawLookup: lookup, + relationLabelResolution: buildResolution(new Set(['agent-id-2'])), + }, + ], + }), + ).toEqual({ + Alice: 'agent-id-1', + }); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/build-raw-label-by-record-id.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/build-raw-label-by-record-id.util.spec.ts new file mode 100644 index 0000000000..64c45ce9c4 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/build-raw-label-by-record-id.util.spec.ts @@ -0,0 +1,91 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +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 { buildRawLabelByRecordId } from 'src/modules/dashboard/chart-data/utils/build-raw-label-by-record-id.util'; + +const labelFieldMetadataId = 'label-field-id'; + +const buildFlatFieldMetadataMaps = ( + labelField: Partial, +): FlatEntityMaps => + ({ + byUniversalIdentifier: { + 'label-field-universal-id': { + id: labelFieldMetadataId, + universalIdentifier: 'label-field-universal-id', + ...labelField, + }, + }, + universalIdentifierById: { + [labelFieldMetadataId]: 'label-field-universal-id', + }, + universalIdentifiersByApplicationId: {}, + }) as unknown as FlatEntityMaps; + +const targetFlatObjectMetadata = { + id: 'target-object-id', + nameSingular: 'agent', + labelIdentifierFieldMetadataId: labelFieldMetadataId, +} as unknown as FlatObjectMetadata; + +describe('buildRawLabelByRecordId', () => { + it('should build labels from a TEXT label identifier', () => { + const rawLabelByRecordId = buildRawLabelByRecordId({ + records: [ + { id: 'agent-id-1', name: 'Alice' }, + { id: 'agent-id-2', name: 'Bob' }, + ], + targetFlatObjectMetadata, + flatFieldMetadataMaps: buildFlatFieldMetadataMaps({ + name: 'name', + type: FieldMetadataType.TEXT, + }), + }); + + expect(rawLabelByRecordId.get('agent-id-1')).toBe('Alice'); + expect(rawLabelByRecordId.get('agent-id-2')).toBe('Bob'); + }); + + it('should join FULL_NAME subfields into a single label', () => { + const rawLabelByRecordId = buildRawLabelByRecordId({ + records: [ + { id: 'agent-id-1', name: { firstName: 'Alice', lastName: 'Ng' } }, + ], + targetFlatObjectMetadata, + flatFieldMetadataMaps: buildFlatFieldMetadataMaps({ + name: 'name', + type: FieldMetadataType.FULL_NAME, + }), + }); + + expect(rawLabelByRecordId.get('agent-id-1')).toBe('Alice Ng'); + }); + + it('should skip records whose label identifier value is empty', () => { + const rawLabelByRecordId = buildRawLabelByRecordId({ + records: [{ id: 'agent-id-1', name: null }], + targetFlatObjectMetadata, + flatFieldMetadataMaps: buildFlatFieldMetadataMaps({ + name: 'name', + type: FieldMetadataType.TEXT, + }), + }); + + expect(rawLabelByRecordId.has('agent-id-1')).toBe(false); + }); + + it('should keep a UUID label whose value equals the record id', () => { + const rawLabelByRecordId = buildRawLabelByRecordId({ + records: [{ id: 'agent-id-1', externalId: 'agent-id-1' }], + targetFlatObjectMetadata, + flatFieldMetadataMaps: buildFlatFieldMetadataMaps({ + name: 'externalId', + type: FieldMetadataType.UUID, + }), + }); + + expect(rawLabelByRecordId.get('agent-id-1')).toBe('agent-id-1'); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/build-unique-relation-labels.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/build-unique-relation-labels.util.spec.ts new file mode 100644 index 0000000000..c4c7e7a17f --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/build-unique-relation-labels.util.spec.ts @@ -0,0 +1,112 @@ +import { buildUniqueRelationLabels } from 'src/modules/dashboard/chart-data/utils/build-unique-relation-labels.util'; + +describe('buildUniqueRelationLabels', () => { + it('should keep unique labels untouched', () => { + const { labelByRecordId, unresolvedRecordIds } = buildUniqueRelationLabels({ + rawLabelByRecordId: new Map([ + ['id-1', 'Alice'], + ['id-2', 'Bob'], + ]), + allRecordIds: ['id-1', 'id-2'], + }); + + expect(labelByRecordId.get('id-1')).toBe('Alice'); + expect(labelByRecordId.get('id-2')).toBe('Bob'); + expect(unresolvedRecordIds.size).toBe(0); + }); + + it('should suffix every member of a colliding label group', () => { + const { labelByRecordId } = buildUniqueRelationLabels({ + rawLabelByRecordId: new Map([ + ['id-1', 'John Smith'], + ['id-2', 'John Smith'], + ['id-3', 'Maria'], + ]), + allRecordIds: ['id-1', 'id-2', 'id-3'], + }); + + expect(labelByRecordId.get('id-1')).toBe('John Smith (1)'); + expect(labelByRecordId.get('id-2')).toBe('John Smith (2)'); + expect(labelByRecordId.get('id-3')).toBe('Maria'); + }); + + it('should assign ordinals by ascending record id regardless of input order', () => { + const resolutionFromShuffledInput = buildUniqueRelationLabels({ + rawLabelByRecordId: new Map([ + ['id-b', 'John Smith'], + ['id-a', 'John Smith'], + ]), + allRecordIds: ['id-b', 'id-a'], + }); + + expect(resolutionFromShuffledInput.labelByRecordId.get('id-a')).toBe( + 'John Smith (1)', + ); + expect(resolutionFromShuffledInput.labelByRecordId.get('id-b')).toBe( + 'John Smith (2)', + ); + }); + + it('should classify missing labels as unresolved without assigning a label', () => { + const { labelByRecordId, unresolvedRecordIds } = buildUniqueRelationLabels({ + rawLabelByRecordId: new Map([['id-1', 'Alice']]), + allRecordIds: ['id-1', 'id-2'], + }); + + expect(labelByRecordId.has('id-2')).toBe(false); + expect(unresolvedRecordIds).toEqual(new Set(['id-2'])); + }); + + it('should classify every record with no label as unresolved', () => { + const allRecordIds = Array.from( + { length: 11 }, + (_, index) => `id-${String(index).padStart(2, '0')}`, + ); + + const { labelByRecordId, unresolvedRecordIds } = buildUniqueRelationLabels({ + rawLabelByRecordId: new Map(), + allRecordIds, + }); + + expect(unresolvedRecordIds.size).toBe(11); + expect(labelByRecordId.size).toBe(0); + }); + + it('should classify whitespace-only labels as unresolved', () => { + const { labelByRecordId, unresolvedRecordIds } = buildUniqueRelationLabels({ + rawLabelByRecordId: new Map([['id-1', ' ']]), + allRecordIds: ['id-1'], + }); + + expect(labelByRecordId.has('id-1')).toBe(false); + expect(unresolvedRecordIds).toEqual(new Set(['id-1'])); + }); + + it('should suffix records genuinely named like the sentinels', () => { + const { labelByRecordId } = buildUniqueRelationLabels({ + rawLabelByRecordId: new Map([ + ['id-1', 'Unknown'], + ['id-2', 'Not Set'], + ]), + allRecordIds: ['id-1', 'id-2'], + }); + + expect(labelByRecordId.get('id-1')).toBe('Unknown (1)'); + expect(labelByRecordId.get('id-2')).toBe('Not Set (1)'); + }); + + it('should not reuse a label already taken by a suffixed record', () => { + const { labelByRecordId } = buildUniqueRelationLabels({ + rawLabelByRecordId: new Map([ + ['id-1', 'John Smith'], + ['id-2', 'John Smith'], + ['id-3', 'John Smith (1)'], + ]), + allRecordIds: ['id-1', 'id-2', 'id-3'], + }); + + const assignedLabels = [...labelByRecordId.values()]; + + expect(new Set(assignedLabels).size).toBe(assignedLabels.length); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/filter-out-empty-chart-buckets.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/filter-out-empty-chart-buckets.util.spec.ts new file mode 100644 index 0000000000..649420501b --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/filter-out-empty-chart-buckets.util.spec.ts @@ -0,0 +1,69 @@ +import { type GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type'; +import { filterOutEmptyChartBuckets } from 'src/modules/dashboard/chart-data/utils/filter-out-empty-chart-buckets.util'; + +describe('filterOutEmptyChartBuckets', () => { + it('should return the input unchanged when shouldOmitEmptyBuckets is false', () => { + const rawResults: GroupByRawResult[] = [ + { groupByDimensionValues: [null], aggregateValue: 0 }, + ]; + + const result = filterOutEmptyChartBuckets({ + rawResults, + shouldOmitEmptyBuckets: false, + }); + + expect(result).toBe(rawResults); + }); + + it('should drop buckets whose primary dimension is null', () => { + const result = filterOutEmptyChartBuckets({ + rawResults: [ + { groupByDimensionValues: ['Active'], aggregateValue: 5 }, + { groupByDimensionValues: [null], aggregateValue: 3 }, + ], + shouldOmitEmptyBuckets: true, + }); + + expect(result).toEqual([ + { groupByDimensionValues: ['Active'], aggregateValue: 5 }, + ]); + }); + + it('should drop two-dimensional buckets whose secondary dimension is null', () => { + const result = filterOutEmptyChartBuckets({ + rawResults: [ + { groupByDimensionValues: ['2024-01-01', 'Acme'], aggregateValue: 5 }, + { groupByDimensionValues: ['2024-01-01', null], aggregateValue: 3 }, + ], + shouldOmitEmptyBuckets: true, + }); + + expect(result).toEqual([ + { groupByDimensionValues: ['2024-01-01', 'Acme'], aggregateValue: 5 }, + ]); + }); + + it('should drop buckets with zero or non-finite aggregate values', () => { + const result = filterOutEmptyChartBuckets({ + rawResults: [ + { groupByDimensionValues: ['Active'], aggregateValue: 5 }, + { groupByDimensionValues: ['Zero'], aggregateValue: 0 }, + { groupByDimensionValues: ['NaN'], aggregateValue: Number.NaN }, + ], + shouldOmitEmptyBuckets: true, + }); + + expect(result).toEqual([ + { groupByDimensionValues: ['Active'], aggregateValue: 5 }, + ]); + }); + + it('should drop buckets with no dimension values', () => { + const result = filterOutEmptyChartBuckets({ + rawResults: [{ groupByDimensionValues: [], aggregateValue: 5 }], + shouldOmitEmptyBuckets: true, + }); + + expect(result).toEqual([]); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/filter-out-unresolved-relation-buckets.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/filter-out-unresolved-relation-buckets.util.spec.ts new file mode 100644 index 0000000000..c149a43465 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/filter-out-unresolved-relation-buckets.util.spec.ts @@ -0,0 +1,113 @@ +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 { filterOutUnresolvedRelationBuckets } from 'src/modules/dashboard/chart-data/utils/filter-out-unresolved-relation-buckets.util'; + +const buildResolution = ( + unresolvedRecordIds: string[], +): RelationLabelResolution => ({ + labelByRecordId: new Map(), + unresolvedRecordIds: new Set(unresolvedRecordIds), +}); + +describe('filterOutUnresolvedRelationBuckets', () => { + it('should drop rows whose primary dimension is unresolved', () => { + const rawResults: GroupByRawResult[] = [ + { groupByDimensionValues: ['agent-id-1'], aggregateValue: 8 }, + { groupByDimensionValues: ['agent-id-2'], aggregateValue: 5 }, + ]; + + const result = filterOutUnresolvedRelationBuckets({ + rawResults, + primaryRelationLabelResolution: buildResolution(['agent-id-2']), + secondaryRelationLabelResolution: undefined, + }); + + expect(result).toEqual([ + { groupByDimensionValues: ['agent-id-1'], aggregateValue: 8 }, + ]); + }); + + it('should drop rows whose secondary dimension is unresolved', () => { + const rawResults: GroupByRawResult[] = [ + { + groupByDimensionValues: ['2024-01-01', 'agent-id-1'], + aggregateValue: 8, + }, + { + groupByDimensionValues: ['2024-01-01', 'agent-id-2'], + aggregateValue: 5, + }, + ]; + + const result = filterOutUnresolvedRelationBuckets({ + rawResults, + primaryRelationLabelResolution: undefined, + secondaryRelationLabelResolution: buildResolution(['agent-id-2']), + }); + + expect(result).toEqual([ + { + groupByDimensionValues: ['2024-01-01', 'agent-id-1'], + aggregateValue: 8, + }, + ]); + }); + + it('should keep null "Not Set" rows', () => { + const rawResults: GroupByRawResult[] = [ + { groupByDimensionValues: [null], aggregateValue: 3 }, + { groupByDimensionValues: ['agent-id-2'], aggregateValue: 5 }, + ]; + + const result = filterOutUnresolvedRelationBuckets({ + rawResults, + primaryRelationLabelResolution: buildResolution(['agent-id-2']), + secondaryRelationLabelResolution: undefined, + }); + + expect(result).toEqual([ + { groupByDimensionValues: [null], aggregateValue: 3 }, + ]); + }); + + it('should return the input unchanged when no resolution is provided', () => { + const rawResults: GroupByRawResult[] = [ + { groupByDimensionValues: ['agent-id-1'], aggregateValue: 8 }, + { groupByDimensionValues: ['agent-id-2'], aggregateValue: 5 }, + ]; + + const result = filterOutUnresolvedRelationBuckets({ + rawResults, + primaryRelationLabelResolution: undefined, + secondaryRelationLabelResolution: undefined, + }); + + expect(result).toBe(rawResults); + }); + + it('should only apply the secondary resolution when the primary axis is not a relation', () => { + const rawResults: GroupByRawResult[] = [ + { + groupByDimensionValues: ['agent-id-2', 'agent-id-1'], + aggregateValue: 8, + }, + { + groupByDimensionValues: ['agent-id-1', 'agent-id-2'], + aggregateValue: 5, + }, + ]; + + const result = filterOutUnresolvedRelationBuckets({ + rawResults, + primaryRelationLabelResolution: undefined, + secondaryRelationLabelResolution: buildResolution(['agent-id-2']), + }); + + expect(result).toEqual([ + { + groupByDimensionValues: ['agent-id-2', 'agent-id-1'], + aggregateValue: 8, + }, + ]); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/format-dimension-value.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/format-dimension-value.util.spec.ts index d282e62248..bc97e5728f 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/format-dimension-value.util.spec.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/format-dimension-value.util.spec.ts @@ -282,6 +282,104 @@ describe('formatDimensionValue', () => { }); }); + describe('RELATION field with label resolution', () => { + const relationFieldMetadata = createMockFieldMetadata({ + type: FieldMetadataType.RELATION, + }); + const relationLabelByRecordId = new Map([['record-id-1', 'Alice Smith']]); + + it('should return the resolved label when the record id is in the map', () => { + const result = formatDimensionValue({ + value: 'record-id-1', + fieldMetadata: relationFieldMetadata, + userTimezone, + firstDayOfTheWeek, + relationLabelByRecordId, + }); + + expect(result).toBe('Alice Smith'); + }); + + it('should return the resolved label even when a date granularity is set', () => { + const result = formatDimensionValue({ + value: 'record-id-1', + fieldMetadata: relationFieldMetadata, + dateGranularity: ObjectRecordGroupByDateGranularity.DAY, + userTimezone, + firstDayOfTheWeek, + relationLabelByRecordId, + }); + + expect(result).toBe('Alice Smith'); + }); + + it('should return "Unknown" when the record id is not in the map', () => { + const result = formatDimensionValue({ + value: 'record-id-2', + fieldMetadata: relationFieldMetadata, + userTimezone, + firstDayOfTheWeek, + relationLabelByRecordId, + }); + + expect(result).toBe('Unknown'); + }); + + it('should keep returning the raw value when a subFieldName is set', () => { + const result = formatDimensionValue({ + value: 'Acme', + fieldMetadata: relationFieldMetadata, + subFieldName: 'name', + userTimezone, + firstDayOfTheWeek, + relationLabelByRecordId, + }); + + expect(result).toBe('Acme'); + }); + + it('should return "Not Set" for null value even with a map', () => { + const result = formatDimensionValue({ + value: null, + fieldMetadata: relationFieldMetadata, + userTimezone, + firstDayOfTheWeek, + relationLabelByRecordId, + }); + + expect(result).toBe('Not Set'); + }); + }); + + describe('MORPH_RELATION field', () => { + const morphRelationFieldMetadata = createMockFieldMetadata({ + type: FieldMetadataType.MORPH_RELATION, + }); + + it('should return the resolved label when the record id is in the map', () => { + const result = formatDimensionValue({ + value: 'record-id-1', + fieldMetadata: morphRelationFieldMetadata, + userTimezone, + firstDayOfTheWeek, + relationLabelByRecordId: new Map([['record-id-1', 'Acme Inc']]), + }); + + expect(result).toBe('Acme Inc'); + }); + + it('should return the raw value without a map', () => { + const result = formatDimensionValue({ + value: 'record-id-1', + fieldMetadata: morphRelationFieldMetadata, + userTimezone, + firstDayOfTheWeek, + }); + + expect(result).toBe('record-id-1'); + }); + }); + describe('NUMBER field', () => { const numberFieldMetadata = createMockFieldMetadata({ type: FieldMetadataType.NUMBER, diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-chart-label-identifier-column-names.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-chart-label-identifier-column-names.util.spec.ts new file mode 100644 index 0000000000..4c1c762974 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-chart-label-identifier-column-names.util.spec.ts @@ -0,0 +1,96 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +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 { getChartLabelIdentifierColumnNames } from 'src/modules/dashboard/chart-data/utils/get-chart-label-identifier-column-names.util'; + +const labelFieldMetadataId = 'label-field-id'; + +const buildFlatFieldMetadataMaps = ( + labelField: Partial, +): FlatEntityMaps => + ({ + byUniversalIdentifier: { + 'label-field-universal-id': { + id: labelFieldMetadataId, + universalIdentifier: 'label-field-universal-id', + ...labelField, + }, + }, + universalIdentifierById: { + [labelFieldMetadataId]: 'label-field-universal-id', + }, + universalIdentifiersByApplicationId: {}, + }) as unknown as FlatEntityMaps; + +const buildFlatObjectMetadata = ( + labelIdentifierFieldMetadataId: string | null, +): FlatObjectMetadata => + ({ + id: 'target-object-id', + nameSingular: 'agent', + labelIdentifierFieldMetadataId, + }) as unknown as FlatObjectMetadata; + +describe('getChartLabelIdentifierColumnNames', () => { + it('should return id and the column name for a TEXT label identifier', () => { + expect( + getChartLabelIdentifierColumnNames({ + flatObjectMetadata: buildFlatObjectMetadata(labelFieldMetadataId), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps({ + name: 'name', + type: FieldMetadataType.TEXT, + }), + }), + ).toEqual(['id', 'name']); + }); + + it('should return id and the column name for a UUID label identifier', () => { + expect( + getChartLabelIdentifierColumnNames({ + flatObjectMetadata: buildFlatObjectMetadata(labelFieldMetadataId), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps({ + name: 'externalId', + type: FieldMetadataType.UUID, + }), + }), + ).toEqual(['id', 'externalId']); + }); + + it('should return id and the composite column names for a FULL_NAME label identifier', () => { + expect( + getChartLabelIdentifierColumnNames({ + flatObjectMetadata: buildFlatObjectMetadata(labelFieldMetadataId), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps({ + name: 'name', + type: FieldMetadataType.FULL_NAME, + }), + }), + ).toEqual(['id', 'nameFirstName', 'nameLastName']); + }); + + it('should return null when the object has no label identifier field', () => { + expect( + getChartLabelIdentifierColumnNames({ + flatObjectMetadata: buildFlatObjectMetadata(null), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps({ + name: 'name', + type: FieldMetadataType.TEXT, + }), + }), + ).toBeNull(); + }); + + it('should return null when the label identifier is the id field itself', () => { + expect( + getChartLabelIdentifierColumnNames({ + flatObjectMetadata: buildFlatObjectMetadata(labelFieldMetadataId), + flatFieldMetadataMaps: buildFlatFieldMetadataMaps({ + name: 'id', + type: FieldMetadataType.UUID, + }), + }), + ).toBeNull(); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-field-order-by.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-field-order-by.util.spec.ts index 2c90b73df8..62a0cd9bac 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-field-order-by.util.spec.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-field-order-by.util.spec.ts @@ -4,7 +4,9 @@ import { OrderByDirection, } from 'twenty-shared/types'; +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 { getFieldOrderBy } from 'src/modules/dashboard/chart-data/utils/get-field-order-by.util'; const createMockFieldMetadata = ( @@ -18,6 +20,18 @@ const createMockFieldMetadata = ( ...overrides, }) as FlatFieldMetadata; +const emptyFlatObjectMetadataMaps: FlatEntityMaps = { + byUniversalIdentifier: {}, + universalIdentifierById: {}, + universalIdentifiersByApplicationId: {}, +}; + +const emptyFlatFieldMetadataMaps: FlatEntityMaps = { + byUniversalIdentifier: {}, + universalIdentifierById: {}, + universalIdentifiersByApplicationId: {}, +}; + describe('getFieldOrderBy', () => { describe('composite fields', () => { it('should return nested object for FULL_NAME field', () => { @@ -26,18 +40,22 @@ describe('getFieldOrderBy', () => { type: FieldMetadataType.FULL_NAME, }); - const result = getFieldOrderBy( - fieldMetadata, - 'firstName', - undefined, - OrderByDirection.AscNullsLast, - ); - - expect(result).toEqual({ - name: { - firstName: OrderByDirection.AscNullsLast, - }, + const result = getFieldOrderBy({ + groupByFieldMetadata: fieldMetadata, + groupBySubFieldName: 'firstName', + dateGranularity: undefined, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps: emptyFlatObjectMetadataMaps, + flatFieldMetadataMaps: emptyFlatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + name: { + firstName: OrderByDirection.AscNullsLast, + }, + }, + ]); }); it('should return nested object for ADDRESS field', () => { @@ -46,18 +64,22 @@ describe('getFieldOrderBy', () => { type: FieldMetadataType.ADDRESS, }); - const result = getFieldOrderBy( - fieldMetadata, - 'addressCity', - undefined, - OrderByDirection.DescNullsLast, - ); - - expect(result).toEqual({ - address: { - addressCity: OrderByDirection.DescNullsLast, - }, + const result = getFieldOrderBy({ + groupByFieldMetadata: fieldMetadata, + groupBySubFieldName: 'addressCity', + dateGranularity: undefined, + direction: OrderByDirection.DescNullsLast, + flatObjectMetadataMaps: emptyFlatObjectMetadataMaps, + flatFieldMetadataMaps: emptyFlatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + address: { + addressCity: OrderByDirection.DescNullsLast, + }, + }, + ]); }); it('should throw error for composite field without subFieldName', () => { @@ -67,12 +89,14 @@ describe('getFieldOrderBy', () => { }); expect(() => - getFieldOrderBy( - fieldMetadata, - null, - undefined, - OrderByDirection.AscNullsLast, - ), + getFieldOrderBy({ + groupByFieldMetadata: fieldMetadata, + groupBySubFieldName: null, + dateGranularity: undefined, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps: emptyFlatObjectMetadataMaps, + flatFieldMetadataMaps: emptyFlatFieldMetadataMaps, + }), ).toThrow( 'Group by subFieldName is required for composite fields (field: name)', ); @@ -86,19 +110,23 @@ describe('getFieldOrderBy', () => { type: FieldMetadataType.DATE, }); - const result = getFieldOrderBy( - fieldMetadata, - null, - undefined, - OrderByDirection.AscNullsLast, - ); - - expect(result).toEqual({ - createdAt: { - orderBy: OrderByDirection.AscNullsLast, - granularity: ObjectRecordGroupByDateGranularity.DAY, - }, + const result = getFieldOrderBy({ + groupByFieldMetadata: fieldMetadata, + groupBySubFieldName: null, + dateGranularity: undefined, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps: emptyFlatObjectMetadataMaps, + flatFieldMetadataMaps: emptyFlatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + createdAt: { + orderBy: OrderByDirection.AscNullsLast, + granularity: ObjectRecordGroupByDateGranularity.DAY, + }, + }, + ]); }); it('should return date order by with custom granularity', () => { @@ -107,19 +135,23 @@ describe('getFieldOrderBy', () => { type: FieldMetadataType.DATE, }); - const result = getFieldOrderBy( - fieldMetadata, - null, - ObjectRecordGroupByDateGranularity.MONTH, - OrderByDirection.AscNullsLast, - ); - - expect(result).toEqual({ - createdAt: { - orderBy: OrderByDirection.AscNullsLast, - granularity: ObjectRecordGroupByDateGranularity.MONTH, - }, + const result = getFieldOrderBy({ + groupByFieldMetadata: fieldMetadata, + groupBySubFieldName: null, + dateGranularity: ObjectRecordGroupByDateGranularity.MONTH, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps: emptyFlatObjectMetadataMaps, + flatFieldMetadataMaps: emptyFlatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + createdAt: { + orderBy: OrderByDirection.AscNullsLast, + granularity: ObjectRecordGroupByDateGranularity.MONTH, + }, + }, + ]); }); it('should handle DATE_TIME field', () => { @@ -128,40 +160,113 @@ describe('getFieldOrderBy', () => { type: FieldMetadataType.DATE_TIME, }); - const result = getFieldOrderBy( - fieldMetadata, - null, - ObjectRecordGroupByDateGranularity.YEAR, - OrderByDirection.DescNullsLast, - ); - - expect(result).toEqual({ - updatedAt: { - orderBy: OrderByDirection.DescNullsLast, - granularity: ObjectRecordGroupByDateGranularity.YEAR, - }, + const result = getFieldOrderBy({ + groupByFieldMetadata: fieldMetadata, + groupBySubFieldName: null, + dateGranularity: ObjectRecordGroupByDateGranularity.YEAR, + direction: OrderByDirection.DescNullsLast, + flatObjectMetadataMaps: emptyFlatObjectMetadataMaps, + flatFieldMetadataMaps: emptyFlatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + updatedAt: { + orderBy: OrderByDirection.DescNullsLast, + granularity: ObjectRecordGroupByDateGranularity.YEAR, + }, + }, + ]); }); }); describe('relation fields', () => { - it('should return Id suffix for relation field without subFieldName', () => { + it('should fall back to id when the target object metadata is missing', () => { const fieldMetadata = createMockFieldMetadata({ name: 'company', type: FieldMetadataType.RELATION, relationTargetObjectMetadataId: 'target-id', }); - const result = getFieldOrderBy( - fieldMetadata, - null, - undefined, - OrderByDirection.AscNullsLast, - ); - - expect(result).toEqual({ - companyId: OrderByDirection.AscNullsLast, + const result = getFieldOrderBy({ + groupByFieldMetadata: fieldMetadata, + groupBySubFieldName: null, + dateGranularity: undefined, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps: emptyFlatObjectMetadataMaps, + flatFieldMetadataMaps: emptyFlatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + company: { + id: OrderByDirection.AscNullsLast, + }, + }, + ]); + }); + + it('should order by the target label identifier for relation field without subFieldName', () => { + const targetNameField = createMockFieldMetadata({ + id: 'target-name-field-id', + name: 'name', + type: FieldMetadataType.TEXT, + universalIdentifier: 'target-name-universal-id', + }); + + const targetObjectMetadata = { + id: 'target-id', + nameSingular: 'company', + namePlural: 'companies', + labelIdentifierFieldMetadataId: targetNameField.id, + universalIdentifier: 'target-object-universal-id', + } as FlatObjectMetadata; + + const fieldMetadata = createMockFieldMetadata({ + name: 'company', + type: FieldMetadataType.RELATION, + relationTargetObjectMetadataId: targetObjectMetadata.id, + }); + + const result = getFieldOrderBy({ + groupByFieldMetadata: fieldMetadata, + groupBySubFieldName: null, + dateGranularity: undefined, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps: { + byUniversalIdentifier: { + [targetObjectMetadata.universalIdentifier as string]: + targetObjectMetadata, + }, + universalIdentifierById: { + [targetObjectMetadata.id]: + targetObjectMetadata.universalIdentifier as string, + }, + universalIdentifiersByApplicationId: {}, + }, + flatFieldMetadataMaps: { + byUniversalIdentifier: { + [targetNameField.universalIdentifier as string]: targetNameField, + }, + universalIdentifierById: { + [targetNameField.id]: targetNameField.universalIdentifier as string, + }, + universalIdentifiersByApplicationId: {}, + }, + }); + + expect(result).toEqual([ + { + company: { + name: OrderByDirection.AscNullsLast, + }, + }, + { + company: { + id: OrderByDirection.AscNullsLast, + }, + }, + ]); }); it('should return nested object for relation field with subFieldName', () => { @@ -171,18 +276,22 @@ describe('getFieldOrderBy', () => { relationTargetObjectMetadataId: 'target-id', }); - const result = getFieldOrderBy( - fieldMetadata, - 'name', - undefined, - OrderByDirection.AscNullsLast, - ); - - expect(result).toEqual({ - company: { - name: OrderByDirection.AscNullsLast, - }, + const result = getFieldOrderBy({ + groupByFieldMetadata: fieldMetadata, + groupBySubFieldName: 'name', + dateGranularity: undefined, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps: emptyFlatObjectMetadataMaps, + flatFieldMetadataMaps: emptyFlatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + company: { + name: OrderByDirection.AscNullsLast, + }, + }, + ]); }); }); @@ -193,16 +302,20 @@ describe('getFieldOrderBy', () => { type: FieldMetadataType.TEXT, }); - const result = getFieldOrderBy( - fieldMetadata, - null, - undefined, - OrderByDirection.AscNullsLast, - ); - - expect(result).toEqual({ - status: OrderByDirection.AscNullsLast, + const result = getFieldOrderBy({ + groupByFieldMetadata: fieldMetadata, + groupBySubFieldName: null, + dateGranularity: undefined, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps: emptyFlatObjectMetadataMaps, + flatFieldMetadataMaps: emptyFlatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + status: OrderByDirection.AscNullsLast, + }, + ]); }); it('should return simple order by for SELECT field', () => { @@ -211,16 +324,20 @@ describe('getFieldOrderBy', () => { type: FieldMetadataType.SELECT, }); - const result = getFieldOrderBy( - fieldMetadata, - null, - undefined, - OrderByDirection.DescNullsLast, - ); - - expect(result).toEqual({ - priority: OrderByDirection.DescNullsLast, + const result = getFieldOrderBy({ + groupByFieldMetadata: fieldMetadata, + groupBySubFieldName: null, + dateGranularity: undefined, + direction: OrderByDirection.DescNullsLast, + flatObjectMetadataMaps: emptyFlatObjectMetadataMaps, + flatFieldMetadataMaps: emptyFlatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + priority: OrderByDirection.DescNullsLast, + }, + ]); }); it('should return simple order by for NUMBER field', () => { @@ -229,16 +346,20 @@ describe('getFieldOrderBy', () => { type: FieldMetadataType.NUMBER, }); - const result = getFieldOrderBy( - fieldMetadata, - null, - undefined, - OrderByDirection.AscNullsLast, - ); - - expect(result).toEqual({ - quantity: OrderByDirection.AscNullsLast, + const result = getFieldOrderBy({ + groupByFieldMetadata: fieldMetadata, + groupBySubFieldName: null, + dateGranularity: undefined, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps: emptyFlatObjectMetadataMaps, + flatFieldMetadataMaps: emptyFlatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + quantity: OrderByDirection.AscNullsLast, + }, + ]); }); it('should return simple order by for BOOLEAN field', () => { @@ -247,16 +368,20 @@ describe('getFieldOrderBy', () => { type: FieldMetadataType.BOOLEAN, }); - const result = getFieldOrderBy( - fieldMetadata, - null, - undefined, - OrderByDirection.AscNullsLast, - ); - - expect(result).toEqual({ - isActive: OrderByDirection.AscNullsLast, + const result = getFieldOrderBy({ + groupByFieldMetadata: fieldMetadata, + groupBySubFieldName: null, + dateGranularity: undefined, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps: emptyFlatObjectMetadataMaps, + flatFieldMetadataMaps: emptyFlatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + isActive: OrderByDirection.AscNullsLast, + }, + ]); }); }); }); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-group-by-order-by.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-group-by-order-by.util.spec.ts index 90e829febf..391c5882ab 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-group-by-order-by.util.spec.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-group-by-order-by.util.spec.ts @@ -5,7 +5,9 @@ import { OrderByDirection, } from 'twenty-shared/types'; +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 { getGroupByOrderBy } from 'src/modules/dashboard/chart-data/utils/get-group-by-order-by.util'; @@ -20,6 +22,23 @@ const createMockFieldMetadata = ( ...overrides, }) as FlatFieldMetadata; +const emptyFlatObjectMetadataMaps: FlatEntityMaps = { + byUniversalIdentifier: {}, + universalIdentifierById: {}, + universalIdentifiersByApplicationId: {}, +}; + +const emptyFlatFieldMetadataMaps: FlatEntityMaps = { + byUniversalIdentifier: {}, + universalIdentifierById: {}, + universalIdentifiersByApplicationId: {}, +}; + +const emptyMetadataMaps = { + flatObjectMetadataMaps: emptyFlatObjectMetadataMaps, + flatFieldMetadataMaps: emptyFlatFieldMetadataMaps, +}; + describe('getGroupByOrderBy', () => { const groupByFieldMetadata = createMockFieldMetadata({ name: 'status', @@ -36,11 +55,14 @@ describe('getGroupByOrderBy', () => { const result = getGroupByOrderBy({ graphOrderBy: GraphOrderBy.FIELD_ASC, groupByFieldMetadata, + ...emptyMetadataMaps, }); - expect(result).toEqual({ - status: OrderByDirection.AscNullsLast, - }); + expect(result).toEqual([ + { + status: OrderByDirection.AscNullsLast, + }, + ]); }); it('should handle date field with granularity', () => { @@ -53,14 +75,17 @@ describe('getGroupByOrderBy', () => { graphOrderBy: GraphOrderBy.FIELD_ASC, groupByFieldMetadata: dateFieldMetadata, dateGranularity: ObjectRecordGroupByDateGranularity.MONTH, + ...emptyMetadataMaps, }); - expect(result).toEqual({ - createdAt: { - orderBy: OrderByDirection.AscNullsLast, - granularity: ObjectRecordGroupByDateGranularity.MONTH, + expect(result).toEqual([ + { + createdAt: { + orderBy: OrderByDirection.AscNullsLast, + granularity: ObjectRecordGroupByDateGranularity.MONTH, + }, }, - }); + ]); }); }); @@ -69,11 +94,14 @@ describe('getGroupByOrderBy', () => { const result = getGroupByOrderBy({ graphOrderBy: GraphOrderBy.FIELD_DESC, groupByFieldMetadata, + ...emptyMetadataMaps, }); - expect(result).toEqual({ - status: OrderByDirection.DescNullsLast, - }); + expect(result).toEqual([ + { + status: OrderByDirection.DescNullsLast, + }, + ]); }); }); @@ -84,13 +112,16 @@ describe('getGroupByOrderBy', () => { groupByFieldMetadata, aggregateOperation: AggregateOperations.SUM, aggregateFieldMetadata, + ...emptyMetadataMaps, }); - expect(result).toEqual({ - aggregate: { - sumAmount: OrderByDirection.AscNullsLast, + expect(result).toEqual([ + { + aggregate: { + sumAmount: OrderByDirection.AscNullsLast, + }, }, - }); + ]); }); it('should throw error when aggregate operation is missing', () => { @@ -99,6 +130,7 @@ describe('getGroupByOrderBy', () => { graphOrderBy: GraphOrderBy.VALUE_ASC, groupByFieldMetadata, aggregateFieldMetadata, + ...emptyMetadataMaps, }), ).toThrow( 'Aggregate operation or field metadata not found (field: status)', @@ -111,6 +143,7 @@ describe('getGroupByOrderBy', () => { graphOrderBy: GraphOrderBy.VALUE_ASC, groupByFieldMetadata, aggregateOperation: AggregateOperations.SUM, + ...emptyMetadataMaps, }), ).toThrow( 'Aggregate operation or field metadata not found (field: status)', @@ -125,13 +158,16 @@ describe('getGroupByOrderBy', () => { groupByFieldMetadata, aggregateOperation: AggregateOperations.COUNT, aggregateFieldMetadata, + ...emptyMetadataMaps, }); - expect(result).toEqual({ - aggregate: { - totalCount: OrderByDirection.DescNullsLast, + expect(result).toEqual([ + { + aggregate: { + totalCount: OrderByDirection.DescNullsLast, + }, }, - }); + ]); }); it('should handle different aggregate operations', () => { @@ -140,13 +176,16 @@ describe('getGroupByOrderBy', () => { groupByFieldMetadata, aggregateOperation: AggregateOperations.AVG, aggregateFieldMetadata, + ...emptyMetadataMaps, }); - expect(result).toEqual({ - aggregate: { - avgAmount: OrderByDirection.DescNullsLast, + expect(result).toEqual([ + { + aggregate: { + avgAmount: OrderByDirection.DescNullsLast, + }, }, - }); + ]); }); }); @@ -155,6 +194,7 @@ describe('getGroupByOrderBy', () => { const result = getGroupByOrderBy({ graphOrderBy: GraphOrderBy.FIELD_POSITION_ASC, groupByFieldMetadata, + ...emptyMetadataMaps, }); expect(result).toBeUndefined(); @@ -166,6 +206,7 @@ describe('getGroupByOrderBy', () => { const result = getGroupByOrderBy({ graphOrderBy: GraphOrderBy.FIELD_POSITION_DESC, groupByFieldMetadata, + ...emptyMetadataMaps, }); expect(result).toBeUndefined(); @@ -177,6 +218,7 @@ describe('getGroupByOrderBy', () => { const result = getGroupByOrderBy({ graphOrderBy: GraphOrderBy.MANUAL, groupByFieldMetadata, + ...emptyMetadataMaps, }); expect(result).toBeUndefined(); @@ -194,13 +236,16 @@ describe('getGroupByOrderBy', () => { graphOrderBy: GraphOrderBy.FIELD_ASC, groupByFieldMetadata: compositeFieldMetadata, groupBySubFieldName: 'firstName', + ...emptyMetadataMaps, }); - expect(result).toEqual({ - name: { - firstName: OrderByDirection.AscNullsLast, + expect(result).toEqual([ + { + name: { + firstName: OrderByDirection.AscNullsLast, + }, }, - }); + ]); }); it('should handle relation field with subFieldName', () => { @@ -214,13 +259,79 @@ describe('getGroupByOrderBy', () => { graphOrderBy: GraphOrderBy.FIELD_DESC, groupByFieldMetadata: relationFieldMetadata, groupBySubFieldName: 'name', + ...emptyMetadataMaps, }); - expect(result).toEqual({ - company: { - name: OrderByDirection.DescNullsLast, + expect(result).toEqual([ + { + company: { + name: OrderByDirection.DescNullsLast, + }, + }, + ]); + }); + }); + + describe('with bare relation field', () => { + it('should order by the target label identifier then id', () => { + const targetNameField = createMockFieldMetadata({ + id: 'target-name-field-id', + name: 'name', + type: FieldMetadataType.TEXT, + universalIdentifier: 'target-name-universal-id', + }); + + const targetObjectMetadata = { + id: 'target-object-id', + nameSingular: 'company', + namePlural: 'companies', + labelIdentifierFieldMetadataId: targetNameField.id, + universalIdentifier: 'target-object-universal-id', + } as FlatObjectMetadata; + + const relationFieldMetadata = createMockFieldMetadata({ + name: 'company', + type: FieldMetadataType.RELATION, + relationTargetObjectMetadataId: targetObjectMetadata.id, + }); + + const result = getGroupByOrderBy({ + graphOrderBy: GraphOrderBy.FIELD_ASC, + groupByFieldMetadata: relationFieldMetadata, + flatObjectMetadataMaps: { + byUniversalIdentifier: { + [targetObjectMetadata.universalIdentifier as string]: + targetObjectMetadata, + }, + universalIdentifierById: { + [targetObjectMetadata.id]: + targetObjectMetadata.universalIdentifier as string, + }, + universalIdentifiersByApplicationId: {}, + }, + flatFieldMetadataMaps: { + byUniversalIdentifier: { + [targetNameField.universalIdentifier as string]: targetNameField, + }, + universalIdentifierById: { + [targetNameField.id]: targetNameField.universalIdentifier as string, + }, + universalIdentifiersByApplicationId: {}, }, }); + + expect(result).toEqual([ + { + company: { + name: OrderByDirection.AscNullsLast, + }, + }, + { + company: { + id: OrderByDirection.AscNullsLast, + }, + }, + ]); }); }); }); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-relation-field-order-by.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-relation-field-order-by.util.spec.ts index e00d7d9e8c..67542d2f90 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-relation-field-order-by.util.spec.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/get-relation-field-order-by.util.spec.ts @@ -4,7 +4,9 @@ import { OrderByDirection, } from 'twenty-shared/types'; +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 { getRelationFieldOrderBy } from 'src/modules/dashboard/chart-data/utils/get-relation-field-order-by.util'; const createMockFieldMetadata = ( @@ -18,142 +20,421 @@ const createMockFieldMetadata = ( ...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('getRelationFieldOrderBy', () => { + 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 workspaceMemberNameField = createMockFieldMetadata({ + id: 'workspace-member-name-field-id', + name: 'name', + type: FieldMetadataType.FULL_NAME, + universalIdentifier: 'workspace-member-name-universal-id', + }); + + const workspaceMemberObject = createMockObjectMetadata({ + id: 'workspace-member-object-id', + nameSingular: 'workspaceMember', + namePlural: 'workspaceMembers', + labelIdentifierFieldMetadataId: workspaceMemberNameField.id, + universalIdentifier: 'workspace-member-object-universal-id', + }); + + const flatObjectMetadataMaps = buildFlatEntityMaps([ + companyObject, + workspaceMemberObject, + ]); + + const flatFieldMetadataMaps = buildFlatEntityMaps([ + companyNameField, + workspaceMemberNameField, + ]); + const relationFieldMetadata = createMockFieldMetadata({ name: 'company', type: FieldMetadataType.RELATION, - relationTargetObjectMetadataId: 'target-id', + relationTargetObjectMetadataId: companyObject.id, }); describe('without subFieldName', () => { - it('should return Id suffix for relation field', () => { - const result = getRelationFieldOrderBy( - relationFieldMetadata, - null, - OrderByDirection.AscNullsLast, - ); - - expect(result).toEqual({ - companyId: OrderByDirection.AscNullsLast, + it('should order by the target label identifier then id for a TEXT label', () => { + const result = getRelationFieldOrderBy({ + groupByFieldMetadata: relationFieldMetadata, + groupBySubFieldName: null, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps, + flatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + company: { + name: OrderByDirection.AscNullsLast, + }, + }, + { + company: { + id: OrderByDirection.AscNullsLast, + }, + }, + ]); }); - it('should return Id suffix for undefined subFieldName', () => { - const result = getRelationFieldOrderBy( - relationFieldMetadata, - undefined, - OrderByDirection.DescNullsLast, - ); - - expect(result).toEqual({ - companyId: OrderByDirection.DescNullsLast, + it('should order by firstName then lastName then id for a FULL_NAME label', () => { + const workspaceMemberRelationField = createMockFieldMetadata({ + name: 'assignee', + type: FieldMetadataType.RELATION, + relationTargetObjectMetadataId: workspaceMemberObject.id, }); + + const result = getRelationFieldOrderBy({ + groupByFieldMetadata: workspaceMemberRelationField, + groupBySubFieldName: null, + direction: OrderByDirection.DescNullsLast, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); + + expect(result).toEqual([ + { + assignee: { + name: { + firstName: OrderByDirection.DescNullsLast, + }, + }, + }, + { + assignee: { + name: { + lastName: OrderByDirection.DescNullsLast, + }, + }, + }, + { + assignee: { + id: OrderByDirection.DescNullsLast, + }, + }, + ]); + }); + + it('should order by the target label identifier for a MORPH_RELATION field', () => { + const morphRelationField = createMockFieldMetadata({ + name: 'owner', + type: FieldMetadataType.MORPH_RELATION, + relationTargetObjectMetadataId: companyObject.id, + }); + + const result = getRelationFieldOrderBy({ + groupByFieldMetadata: morphRelationField, + groupBySubFieldName: undefined, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); + + expect(result).toEqual([ + { + owner: { + name: OrderByDirection.AscNullsLast, + }, + }, + { + owner: { + id: OrderByDirection.AscNullsLast, + }, + }, + ]); + }); + + it('should fall back to id when the target has no label identifier', () => { + const objectWithoutLabelIdentifier = createMockObjectMetadata({ + id: 'no-label-object-id', + nameSingular: 'noLabel', + namePlural: 'noLabels', + labelIdentifierFieldMetadataId: null, + universalIdentifier: 'no-label-object-universal-id', + }); + + const result = getRelationFieldOrderBy({ + groupByFieldMetadata: createMockFieldMetadata({ + name: 'noLabel', + relationTargetObjectMetadataId: objectWithoutLabelIdentifier.id, + }), + groupBySubFieldName: null, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps: buildFlatEntityMaps([ + objectWithoutLabelIdentifier, + ]), + flatFieldMetadataMaps, + }); + + expect(result).toEqual([ + { + noLabel: { + id: OrderByDirection.AscNullsLast, + }, + }, + ]); + }); + + it('should fall back to id when the label identifier is the id field', () => { + const idField = createMockFieldMetadata({ + id: 'id-field-id', + name: 'id', + type: FieldMetadataType.UUID, + universalIdentifier: 'id-field-universal-id', + }); + + const objectWithIdLabel = createMockObjectMetadata({ + id: 'id-label-object-id', + nameSingular: 'idLabel', + namePlural: 'idLabels', + labelIdentifierFieldMetadataId: idField.id, + universalIdentifier: 'id-label-object-universal-id', + }); + + const result = getRelationFieldOrderBy({ + groupByFieldMetadata: createMockFieldMetadata({ + name: 'idLabel', + relationTargetObjectMetadataId: objectWithIdLabel.id, + }), + groupBySubFieldName: null, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps: buildFlatEntityMaps([objectWithIdLabel]), + flatFieldMetadataMaps: buildFlatEntityMaps([idField]), + }); + + expect(result).toEqual([ + { + idLabel: { + id: OrderByDirection.AscNullsLast, + }, + }, + ]); + }); + + it('should fall back to id when the label identifier type is unsupported', () => { + const numberField = createMockFieldMetadata({ + id: 'number-field-id', + name: 'amount', + type: FieldMetadataType.NUMBER, + universalIdentifier: 'number-field-universal-id', + }); + + const objectWithNumberLabel = createMockObjectMetadata({ + id: 'number-label-object-id', + nameSingular: 'numberLabel', + namePlural: 'numberLabels', + labelIdentifierFieldMetadataId: numberField.id, + universalIdentifier: 'number-label-object-universal-id', + }); + + const result = getRelationFieldOrderBy({ + groupByFieldMetadata: createMockFieldMetadata({ + name: 'numberLabel', + relationTargetObjectMetadataId: objectWithNumberLabel.id, + }), + groupBySubFieldName: null, + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps: buildFlatEntityMaps([objectWithNumberLabel]), + flatFieldMetadataMaps: buildFlatEntityMaps([numberField]), + }); + + expect(result).toEqual([ + { + numberLabel: { + id: OrderByDirection.AscNullsLast, + }, + }, + ]); + }); + + it('should fall back to id when the target object metadata is missing', () => { + const result = getRelationFieldOrderBy({ + groupByFieldMetadata: createMockFieldMetadata({ + name: 'company', + relationTargetObjectMetadataId: 'unknown-object-id', + }), + groupBySubFieldName: null, + direction: OrderByDirection.DescNullsLast, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); + + expect(result).toEqual([ + { + company: { + id: OrderByDirection.DescNullsLast, + }, + }, + ]); }); }); describe('with simple subFieldName', () => { it('should return nested object for simple subfield', () => { - const result = getRelationFieldOrderBy( - relationFieldMetadata, - 'name', - OrderByDirection.AscNullsLast, - ); - - expect(result).toEqual({ - company: { - name: OrderByDirection.AscNullsLast, - }, + const result = getRelationFieldOrderBy({ + groupByFieldMetadata: relationFieldMetadata, + groupBySubFieldName: 'name', + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps, + flatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + company: { + name: OrderByDirection.AscNullsLast, + }, + }, + ]); }); it('should handle descending direction', () => { - const result = getRelationFieldOrderBy( - relationFieldMetadata, - 'name', - OrderByDirection.DescNullsLast, - ); - - expect(result).toEqual({ - company: { - name: OrderByDirection.DescNullsLast, - }, + const result = getRelationFieldOrderBy({ + groupByFieldMetadata: relationFieldMetadata, + groupBySubFieldName: 'name', + direction: OrderByDirection.DescNullsLast, + flatObjectMetadataMaps, + flatFieldMetadataMaps, }); + + expect(result).toEqual([ + { + company: { + name: OrderByDirection.DescNullsLast, + }, + }, + ]); }); }); describe('with composite subFieldName', () => { it('should return deeply nested object for composite subfield', () => { - const result = getRelationFieldOrderBy( - relationFieldMetadata, - 'address.addressCity', - OrderByDirection.AscNullsLast, - ); + const result = getRelationFieldOrderBy({ + groupByFieldMetadata: relationFieldMetadata, + groupBySubFieldName: 'address.addressCity', + direction: OrderByDirection.AscNullsLast, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); - expect(result).toEqual({ - company: { - address: { - addressCity: OrderByDirection.AscNullsLast, + expect(result).toEqual([ + { + company: { + address: { + addressCity: OrderByDirection.AscNullsLast, + }, }, }, - }); + ]); }); }); describe('with date granularity', () => { it('should return date order by with granularity', () => { - const result = getRelationFieldOrderBy( - relationFieldMetadata, - 'createdAt', - OrderByDirection.AscNullsLast, - ObjectRecordGroupByDateGranularity.MONTH, - true, - ); + const result = getRelationFieldOrderBy({ + groupByFieldMetadata: relationFieldMetadata, + groupBySubFieldName: 'createdAt', + direction: OrderByDirection.AscNullsLast, + dateGranularity: ObjectRecordGroupByDateGranularity.MONTH, + isNestedDateField: true, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); - expect(result).toEqual({ - company: { - createdAt: { - orderBy: OrderByDirection.AscNullsLast, - granularity: ObjectRecordGroupByDateGranularity.MONTH, + expect(result).toEqual([ + { + company: { + createdAt: { + orderBy: OrderByDirection.AscNullsLast, + granularity: ObjectRecordGroupByDateGranularity.MONTH, + }, }, }, - }); + ]); }); it('should use default granularity when isNestedDateField is true but granularity not provided', () => { - const result = getRelationFieldOrderBy( - relationFieldMetadata, - 'createdAt', - OrderByDirection.AscNullsLast, - undefined, - true, - ); + const result = getRelationFieldOrderBy({ + groupByFieldMetadata: relationFieldMetadata, + groupBySubFieldName: 'createdAt', + direction: OrderByDirection.AscNullsLast, + isNestedDateField: true, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); - expect(result).toEqual({ - company: { - createdAt: { - orderBy: OrderByDirection.AscNullsLast, - granularity: ObjectRecordGroupByDateGranularity.DAY, + expect(result).toEqual([ + { + company: { + createdAt: { + orderBy: OrderByDirection.AscNullsLast, + granularity: ObjectRecordGroupByDateGranularity.DAY, + }, }, }, - }); + ]); }); it('should return date order by when dateGranularity is provided', () => { - const result = getRelationFieldOrderBy( - relationFieldMetadata, - 'createdAt', - OrderByDirection.DescNullsLast, - ObjectRecordGroupByDateGranularity.YEAR, - ); + const result = getRelationFieldOrderBy({ + groupByFieldMetadata: relationFieldMetadata, + groupBySubFieldName: 'createdAt', + direction: OrderByDirection.DescNullsLast, + dateGranularity: ObjectRecordGroupByDateGranularity.YEAR, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); - expect(result).toEqual({ - company: { - createdAt: { - orderBy: OrderByDirection.DescNullsLast, - granularity: ObjectRecordGroupByDateGranularity.YEAR, + expect(result).toEqual([ + { + company: { + createdAt: { + orderBy: OrderByDirection.DescNullsLast, + granularity: ObjectRecordGroupByDateGranularity.YEAR, + }, }, }, - }); + ]); }); }); }); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/process-two-dimensional-results.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/process-two-dimensional-results.util.spec.ts new file mode 100644 index 0000000000..4020722974 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/process-two-dimensional-results.util.spec.ts @@ -0,0 +1,111 @@ +import { FieldMetadataType, FirstDayOfTheWeek } from 'twenty-shared/types'; + +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { processTwoDimensionalResults } from 'src/modules/dashboard/chart-data/utils/process-two-dimensional-results.util'; + +const createMockFieldMetadata = ( + overrides: Partial, +): FlatFieldMetadata => + ({ + id: 'test-id', + name: 'testField', + type: FieldMetadataType.TEXT, + universalIdentifier: 'test-universal-id', + ...overrides, + }) as FlatFieldMetadata; + +const userTimezone = 'UTC'; +const firstDayOfTheWeek = FirstDayOfTheWeek.MONDAY; + +describe('processTwoDimensionalResults', () => { + const relationFieldMetadata = createMockFieldMetadata({ + name: 'agent', + type: FieldMetadataType.RELATION, + }); + const selectFieldMetadata = createMockFieldMetadata({ + name: 'status', + type: FieldMetadataType.SELECT, + options: [{ value: 'open', label: 'Open', color: 'green', position: 0 }], + } as Partial); + + it('should keep two records resolving to distinct labels as distinct data points', () => { + const { processedDataPoints, formattedToRawLookup } = + processTwoDimensionalResults({ + rawResults: [ + { + groupByDimensionValues: ['agent-id-1', 'open'], + aggregateValue: 12, + }, + { + groupByDimensionValues: ['agent-id-2', 'open'], + aggregateValue: 7, + }, + ], + primaryAxisGroupByField: relationFieldMetadata, + secondaryAxisGroupByField: selectFieldMetadata, + userTimezone, + firstDayOfTheWeek, + primaryRelationLabelResolution: { + labelByRecordId: new Map([ + ['agent-id-1', 'John Smith (1)'], + ['agent-id-2', 'John Smith (2)'], + ]), + unresolvedRecordIds: new Set(), + }, + secondaryRelationLabelResolution: undefined, + }); + + expect(processedDataPoints).toHaveLength(2); + expect(processedDataPoints[0].xFormatted).toBe('John Smith (1)'); + expect(processedDataPoints[1].xFormatted).toBe('John Smith (2)'); + expect(processedDataPoints[0].aggregateValue).toBe(12); + expect(processedDataPoints[1].aggregateValue).toBe(7); + expect(formattedToRawLookup.size).toBe(2); + expect(formattedToRawLookup.get('John Smith (1)')).toBe('agent-id-1'); + expect(formattedToRawLookup.get('John Smith (2)')).toBe('agent-id-2'); + }); + + it('should resolve secondary axis relation labels independently', () => { + const { processedDataPoints, secondaryFormattedToRawLookup } = + processTwoDimensionalResults({ + rawResults: [ + { + groupByDimensionValues: ['open', 'agent-id-1'], + aggregateValue: 3, + }, + ], + primaryAxisGroupByField: selectFieldMetadata, + secondaryAxisGroupByField: relationFieldMetadata, + userTimezone, + firstDayOfTheWeek, + primaryRelationLabelResolution: undefined, + secondaryRelationLabelResolution: { + labelByRecordId: new Map([['agent-id-1', 'Alice']]), + unresolvedRecordIds: new Set(), + }, + }); + + expect(processedDataPoints[0].xFormatted).toBe('Open'); + expect(processedDataPoints[0].yFormatted).toBe('Alice'); + expect(secondaryFormattedToRawLookup.get('Alice')).toBe('agent-id-1'); + }); + + it('should keep raw values untouched without a resolution', () => { + const { processedDataPoints } = processTwoDimensionalResults({ + rawResults: [ + { + groupByDimensionValues: ['agent-id-1', 'open'], + aggregateValue: 5, + }, + ], + primaryAxisGroupByField: relationFieldMetadata, + secondaryAxisGroupByField: selectFieldMetadata, + userTimezone, + firstDayOfTheWeek, + primaryRelationLabelResolution: undefined, + secondaryRelationLabelResolution: undefined, + }); + + expect(processedDataPoints[0].xFormatted).toBe('agent-id-1'); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-cumulative-to-line-data-points.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-cumulative-to-line-data-points.util.ts new file mode 100644 index 0000000000..63a38e6520 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-cumulative-to-line-data-points.util.ts @@ -0,0 +1,20 @@ +import { isDefined } from 'twenty-shared/utils'; + +export const applyCumulativeToLineDataPoints = < + TDataPoint extends { y: number | null }, +>( + dataPoints: TDataPoint[], +): TDataPoint[] => { + const result: TDataPoint[] = []; + let runningTotal = 0; + + for (const dataPoint of dataPoints) { + if (isDefined(dataPoint.y)) { + runningTotal += dataPoint.y; + } + + result.push({ ...dataPoint, y: runningTotal }); + } + + return result; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-cumulative-to-one-dimensional-bar-data.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-cumulative-to-one-dimensional-bar-data.util.ts new file mode 100644 index 0000000000..5201c10e0e --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-cumulative-to-one-dimensional-bar-data.util.ts @@ -0,0 +1,16 @@ +import { type ProcessedOneDimensionalDataPoint } from 'src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util'; + +export const applyCumulativeToOneDimensionalBarData = ( + dataPoints: ProcessedOneDimensionalDataPoint[], +): ProcessedOneDimensionalDataPoint[] => { + const result: ProcessedOneDimensionalDataPoint[] = []; + let runningTotal = 0; + + for (const dataPoint of dataPoints) { + runningTotal += dataPoint.aggregateValue; + + result.push({ ...dataPoint, aggregateValue: runningTotal }); + } + + return result; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-cumulative-to-two-dimensional-bar-data.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-cumulative-to-two-dimensional-bar-data.util.ts new file mode 100644 index 0000000000..64e9bad5f2 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-cumulative-to-two-dimensional-bar-data.util.ts @@ -0,0 +1,35 @@ +import { isNumber } from '@sniptt/guards'; + +export const applyCumulativeToTwoDimensionalBarData = ({ + data, + keys, +}: { + data: Record[]; + keys: string[]; +}): Record[] => { + const runningTotalByKey: Record = {}; + + for (const key of keys) { + runningTotalByKey[key] = 0; + } + + const result: Record[] = []; + + for (const datum of data) { + const cumulativeDatum = { ...datum }; + + for (const key of keys) { + const value = datum[key]; + + if (isNumber(value) && Number.isFinite(value)) { + runningTotalByKey[key] += value; + } + + cumulativeDatum[key] = runningTotalByKey[key]; + } + + result.push(cumulativeDatum); + } + + return result; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-formatted-to-raw-lookup-dto.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-formatted-to-raw-lookup-dto.util.ts new file mode 100644 index 0000000000..a761fff0a0 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-formatted-to-raw-lookup-dto.util.ts @@ -0,0 +1,35 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type'; +import { type RelationLabelResolution } from 'src/modules/dashboard/chart-data/types/relation-label-resolution.type'; + +type AxisLookup = { + formattedToRawLookup: Map; + relationLabelResolution: RelationLabelResolution | undefined; +}; + +export const buildFormattedToRawLookupDto = ({ + axisLookups, +}: { + axisLookups: AxisLookup[]; +}): Record => { + const mergedLookup = new Map(); + + for (const { formattedToRawLookup, relationLabelResolution } of axisLookups) { + const unresolvedRecordIds = relationLabelResolution?.unresolvedRecordIds; + + for (const [formattedValue, rawValue] of formattedToRawLookup) { + const isUnresolvedRecordId = + isDefined(unresolvedRecordIds) && + unresolvedRecordIds.has(String(rawValue)); + + if (isUnresolvedRecordId) { + continue; + } + + mergedLookup.set(formattedValue, rawValue); + } + } + + return Object.fromEntries(mergedLookup); +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-raw-label-by-record-id.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-raw-label-by-record-id.util.ts new file mode 100644 index 0000000000..1c9dd097a8 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-raw-label-by-record-id.util.ts @@ -0,0 +1,66 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util'; +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 { getChartLabelIdentifierField } from 'src/modules/dashboard/chart-data/utils/get-chart-label-identifier-field.util'; + +const hasNonEmptyLabelIdentifierValue = ( + record: Record, + labelIdentifierField: FlatFieldMetadata, +): boolean => { + const fieldValue = record[labelIdentifierField.name]; + + if (labelIdentifierField.type === FieldMetadataType.FULL_NAME) { + const nameValue = fieldValue as + | { firstName?: string; lastName?: string } + | undefined; + + return isNonEmptyString( + `${nameValue?.firstName ?? ''} ${nameValue?.lastName ?? ''}`.trim(), + ); + } + + return isDefined(fieldValue) && isNonEmptyString(String(fieldValue).trim()); +}; + +export const buildRawLabelByRecordId = ({ + records, + targetFlatObjectMetadata, + flatFieldMetadataMaps, +}: { + records: Record[]; + targetFlatObjectMetadata: FlatObjectMetadata; + flatFieldMetadataMaps: FlatEntityMaps; +}): Map => { + const rawLabelByRecordId = new Map(); + + const labelIdentifierField = getChartLabelIdentifierField({ + flatObjectMetadata: targetFlatObjectMetadata, + flatFieldMetadataMaps, + }); + + if (!isDefined(labelIdentifierField)) { + return rawLabelByRecordId; + } + + for (const record of records) { + if (!hasNonEmptyLabelIdentifierValue(record, labelIdentifierField)) { + continue; + } + + rawLabelByRecordId.set( + String(record.id), + getRecordDisplayName( + record, + targetFlatObjectMetadata, + flatFieldMetadataMaps, + ), + ); + } + + return rawLabelByRecordId; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-resolvable-chart-relation-axis.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-resolvable-chart-relation-axis.util.ts new file mode 100644 index 0000000000..3908ca33b0 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-resolvable-chart-relation-axis.util.ts @@ -0,0 +1,77 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; + +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'; +import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +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 ResolvableChartRelationAxis } from 'src/modules/dashboard/chart-data/types/resolvable-chart-relation-axis.type'; +import { getChartLabelIdentifierColumnNames } from 'src/modules/dashboard/chart-data/utils/get-chart-label-identifier-column-names.util'; + +export const buildResolvableChartRelationAxis = ({ + dimensionIndex, + axis, + rawResults, + flatObjectMetadataMaps, + flatFieldMetadataMaps, +}: { + dimensionIndex: number; + axis: ChartRelationLabelAxisInput; + rawResults: GroupByRawResult[]; + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; +}): ResolvableChartRelationAxis | undefined => { + if ( + !isMorphOrRelationFlatFieldMetadata(axis.groupByField) || + isDefined(axis.subFieldName) + ) { + return undefined; + } + + const targetObjectMetadataId = + axis.groupByField.relationTargetObjectMetadataId; + + if (!isDefined(targetObjectMetadataId)) { + return undefined; + } + + const targetFlatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: targetObjectMetadataId, + flatEntityMaps: flatObjectMetadataMaps, + }); + + if (!isDefined(targetFlatObjectMetadata)) { + return undefined; + } + + const labelIdentifierColumnNames = getChartLabelIdentifierColumnNames({ + flatObjectMetadata: targetFlatObjectMetadata, + flatFieldMetadataMaps, + }); + + if (!isDefined(labelIdentifierColumnNames)) { + return undefined; + } + + const recordIds = [ + ...new Set( + rawResults + .map((result) => result.groupByDimensionValues?.[dimensionIndex]) + .filter(isNonEmptyString), + ), + ]; + + if (!isNonEmptyArray(recordIds)) { + return undefined; + } + + return { + dimensionIndex, + targetFlatObjectMetadata, + labelIdentifierColumnNames, + recordIds, + }; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-unique-relation-labels.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-unique-relation-labels.util.ts new file mode 100644 index 0000000000..24407dea97 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-unique-relation-labels.util.ts @@ -0,0 +1,60 @@ +import { t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +import { type RelationLabelResolution } from 'src/modules/dashboard/chart-data/types/relation-label-resolution.type'; +import { claimUniqueSuffixedLabel } from 'src/modules/dashboard/chart-data/utils/claim-unique-suffixed-label.util'; + +export const buildUniqueRelationLabels = ({ + rawLabelByRecordId, + allRecordIds, +}: { + rawLabelByRecordId: ReadonlyMap; + allRecordIds: string[]; +}): RelationLabelResolution => { + const sortedRecordIds = [...new Set(allRecordIds)].sort(); + const takenLabels = new Set([t`Not Set`, t`Unknown`]); + const labelByRecordId = new Map(); + const unresolvedRecordIds = new Set(); + const recordIdsByRawLabel = new Map(); + + for (const recordId of sortedRecordIds) { + const rawLabel = rawLabelByRecordId.get(recordId); + + if (!isDefined(rawLabel) || !isNonEmptyString(rawLabel.trim())) { + unresolvedRecordIds.add(recordId); + continue; + } + + const recordIdsWithSameLabel = recordIdsByRawLabel.get(rawLabel) ?? []; + + recordIdsWithSameLabel.push(recordId); + recordIdsByRawLabel.set(rawLabel, recordIdsWithSameLabel); + } + + for (const [rawLabel, recordIds] of recordIdsByRawLabel) { + const isCollidingLabel = recordIds.length > 1 || takenLabels.has(rawLabel); + + if (!isCollidingLabel) { + takenLabels.add(rawLabel); + labelByRecordId.set(recordIds[0], rawLabel); + continue; + } + + let ordinal = 1; + + for (const recordId of recordIds) { + const { label, nextOrdinal } = claimUniqueSuffixedLabel({ + baseLabel: rawLabel, + startOrdinal: ordinal, + takenLabels, + formatOrdinal: String, + }); + + labelByRecordId.set(recordId, label); + ordinal = nextOrdinal; + } + } + + return { labelByRecordId, unresolvedRecordIds }; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/chart-data-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/chart-data-graphql-api-exception-handler.util.ts index 75587427d8..cb6c198891 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/chart-data-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/chart-data-graphql-api-exception-handler.util.ts @@ -1,3 +1,5 @@ +import { Logger } from '@nestjs/common'; + import { assertUnreachable } from 'twenty-shared/utils'; import { @@ -11,19 +13,25 @@ import { ChartDataExceptionCode, } from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception'; +const logger = new Logger('ChartDataGraphqlApiExceptionHandler'); + export const chartDataGraphqlApiExceptionHandler = (error: Error) => { if (error instanceof ChartDataException) { switch (error.code) { case ChartDataExceptionCode.WIDGET_NOT_FOUND: case ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND: case ChartDataExceptionCode.FIELD_METADATA_NOT_FOUND: + logger.warn(error.message); throw new NotFoundError(error.message); case ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION: + logger.warn(error.message); throw new UserInputError(error.message); case ChartDataExceptionCode.PERMISSION_DENIED: + logger.warn(error.message); throw new ForbiddenError(error.message); case ChartDataExceptionCode.QUERY_EXECUTION_FAILED: case ChartDataExceptionCode.TRANSFORMATION_FAILED: + logger.error(error.message, error.stack); throw new InternalServerError(error.message); default: { return assertUnreachable(error.code); @@ -31,5 +39,6 @@ export const chartDataGraphqlApiExceptionHandler = (error: Error) => { } } + logger.error(error.message, error.stack); throw error; }; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/claim-unique-suffixed-label.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/claim-unique-suffixed-label.util.ts new file mode 100644 index 0000000000..f50c26ca5a --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/claim-unique-suffixed-label.util.ts @@ -0,0 +1,23 @@ +export const claimUniqueSuffixedLabel = ({ + baseLabel, + startOrdinal, + takenLabels, + formatOrdinal, +}: { + baseLabel: string; + startOrdinal: number; + takenLabels: Set; + formatOrdinal: (ordinal: number) => string; +}): { label: string; nextOrdinal: number } => { + let ordinal = startOrdinal; + let candidateLabel = `${baseLabel} (${formatOrdinal(ordinal)})`; + + while (takenLabels.has(candidateLabel)) { + ordinal += 1; + candidateLabel = `${baseLabel} (${formatOrdinal(ordinal)})`; + } + + takenLabels.add(candidateLabel); + + return { label: candidateLabel, nextOrdinal: ordinal + 1 }; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-out-empty-chart-buckets.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-out-empty-chart-buckets.util.ts new file mode 100644 index 0000000000..4f530c63e8 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-out-empty-chart-buckets.util.ts @@ -0,0 +1,23 @@ +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; + +import { type GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type'; + +export const filterOutEmptyChartBuckets = ({ + rawResults, + shouldOmitEmptyBuckets, +}: { + rawResults: GroupByRawResult[]; + shouldOmitEmptyBuckets: boolean; +}): GroupByRawResult[] => { + if (!shouldOmitEmptyBuckets) { + return rawResults; + } + + return rawResults.filter( + (result) => + isNonEmptyArray(result.groupByDimensionValues) && + result.groupByDimensionValues.every(isDefined) && + Number.isFinite(result.aggregateValue) && + result.aggregateValue !== 0, + ); +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-out-unresolved-relation-buckets.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-out-unresolved-relation-buckets.util.ts new file mode 100644 index 0000000000..b4f3bf41cb --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/filter-out-unresolved-relation-buckets.util.ts @@ -0,0 +1,40 @@ +import { isDefined } from 'twenty-shared/utils'; + +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'; + +export const filterOutUnresolvedRelationBuckets = ({ + rawResults, + primaryRelationLabelResolution, + secondaryRelationLabelResolution, +}: { + rawResults: GroupByRawResult[]; + primaryRelationLabelResolution: RelationLabelResolution | undefined; + secondaryRelationLabelResolution: RelationLabelResolution | undefined; +}): GroupByRawResult[] => { + const primaryUnresolvedRecordIds = + primaryRelationLabelResolution?.unresolvedRecordIds; + const secondaryUnresolvedRecordIds = + secondaryRelationLabelResolution?.unresolvedRecordIds; + + if ( + !isDefined(primaryUnresolvedRecordIds) && + !isDefined(secondaryUnresolvedRecordIds) + ) { + return rawResults; + } + + return rawResults.filter((result) => { + const dimensionValues = result.groupByDimensionValues; + + const isPrimaryUnresolved = + isDefined(primaryUnresolvedRecordIds) && + primaryUnresolvedRecordIds.has(String(dimensionValues?.[0])); + + const isSecondaryUnresolved = + isDefined(secondaryUnresolvedRecordIds) && + secondaryUnresolvedRecordIds.has(String(dimensionValues?.[1])); + + return !isPrimaryUnresolved && !isSecondaryUnresolved; + }); +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/format-dimension-value.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/format-dimension-value.util.ts index 2146c08f03..24ec8db3b4 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/format-dimension-value.util.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/format-dimension-value.util.ts @@ -22,6 +22,7 @@ type FormatDimensionValueParams = { subFieldName?: string; userTimezone: string; firstDayOfTheWeek: FirstDayOfTheWeek; + relationLabelByRecordId?: ReadonlyMap; }; const normalizeMultiSelectValue = (value: unknown): unknown[] => { @@ -53,6 +54,7 @@ export const formatDimensionValue = ({ subFieldName, userTimezone, firstDayOfTheWeek, + relationLabelByRecordId, }: FormatDimensionValueParams): string => { if (!isDefined(value)) { return t`Not Set`; @@ -111,7 +113,16 @@ export const formatDimensionValue = ({ ); } - case FieldMetadataType.RELATION: { + case FieldMetadataType.RELATION: + case FieldMetadataType.MORPH_RELATION: { + if (!isDefined(subFieldName)) { + if (!isDefined(relationLabelByRecordId)) { + return String(value); + } + + return relationLabelByRecordId.get(String(value)) ?? t`Unknown`; + } + if (isDefined(dateGranularity)) { const parsedDayString = String(value); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-bare-relation-order-by.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-bare-relation-order-by.util.ts new file mode 100644 index 0000000000..423eaa4eed --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-bare-relation-order-by.util.ts @@ -0,0 +1,85 @@ +import { + compositeTypeDefinitions, + FieldMetadataType, + type ObjectRecordOrderByForRelationField, + type OrderByDirection, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +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'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { getChartLabelIdentifierField } from 'src/modules/dashboard/chart-data/utils/get-chart-label-identifier-field.util'; + +export const getBareRelationOrderBy = ({ + groupByFieldMetadata, + direction, + flatObjectMetadataMaps, + flatFieldMetadataMaps, +}: { + groupByFieldMetadata: FlatFieldMetadata; + direction: OrderByDirection; + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; +}): ObjectRecordOrderByForRelationField[] => { + const orderById: ObjectRecordOrderByForRelationField = { + [groupByFieldMetadata.name]: { + id: direction, + }, + }; + + const { relationTargetObjectMetadataId } = groupByFieldMetadata; + + if (!isDefined(relationTargetObjectMetadataId)) { + return [orderById]; + } + + const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: relationTargetObjectMetadataId, + flatEntityMaps: flatObjectMetadataMaps, + }); + + if (!isDefined(targetObjectMetadata)) { + return [orderById]; + } + + const labelIdentifierField = getChartLabelIdentifierField({ + flatObjectMetadata: targetObjectMetadata, + flatFieldMetadataMaps, + }); + + if (!isDefined(labelIdentifierField)) { + return [orderById]; + } + + if (labelIdentifierField.type === FieldMetadataType.FULL_NAME) { + const compositeType = compositeTypeDefinitions.get( + FieldMetadataType.FULL_NAME, + ); + + if (!isDefined(compositeType)) { + return [orderById]; + } + + return [ + ...compositeType.properties.map((property) => ({ + [groupByFieldMetadata.name]: { + [labelIdentifierField.name]: { + [property.name]: direction, + }, + }, + })), + orderById, + ]; + } + + return [ + { + [groupByFieldMetadata.name]: { + [labelIdentifierField.name]: direction, + }, + }, + orderById, + ]; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-chart-label-identifier-column-names.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-chart-label-identifier-column-names.util.ts new file mode 100644 index 0000000000..c3287a045d --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-chart-label-identifier-column-names.util.ts @@ -0,0 +1,47 @@ +import { + compositeTypeDefinitions, + FieldMetadataType, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util'; +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 { getChartLabelIdentifierField } from 'src/modules/dashboard/chart-data/utils/get-chart-label-identifier-field.util'; + +export const getChartLabelIdentifierColumnNames = ({ + flatObjectMetadata, + flatFieldMetadataMaps, +}: { + flatObjectMetadata: FlatObjectMetadata; + flatFieldMetadataMaps: FlatEntityMaps; +}): string[] | null => { + const labelIdentifierField = getChartLabelIdentifierField({ + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + if (!isDefined(labelIdentifierField)) { + return null; + } + + if (labelIdentifierField.type === FieldMetadataType.FULL_NAME) { + const compositeType = compositeTypeDefinitions.get( + FieldMetadataType.FULL_NAME, + ); + + if (!isDefined(compositeType)) { + return null; + } + + return [ + 'id', + ...compositeType.properties.map((property) => + computeCompositeColumnName(labelIdentifierField.name, property), + ), + ]; + } + + return ['id', labelIdentifierField.name]; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-chart-label-identifier-field.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-chart-label-identifier-field.util.ts new file mode 100644 index 0000000000..7d7d71af44 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-chart-label-identifier-field.util.ts @@ -0,0 +1,40 @@ +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +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'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; + +export const getChartLabelIdentifierField = ({ + flatObjectMetadata, + flatFieldMetadataMaps, +}: { + flatObjectMetadata: FlatObjectMetadata; + flatFieldMetadataMaps: FlatEntityMaps; +}): FlatFieldMetadata | null => { + const { labelIdentifierFieldMetadataId } = flatObjectMetadata; + + if (!isDefined(labelIdentifierFieldMetadataId)) { + return null; + } + + const labelIdentifierField = findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatFieldMetadataMaps, + flatEntityId: labelIdentifierFieldMetadataId, + }); + + if (!isDefined(labelIdentifierField) || labelIdentifierField.name === 'id') { + return null; + } + + if ( + labelIdentifierField.type !== FieldMetadataType.FULL_NAME && + labelIdentifierField.type !== FieldMetadataType.TEXT && + labelIdentifierField.type !== FieldMetadataType.UUID + ) { + return null; + } + + return labelIdentifierField; +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-field-order-by.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-field-order-by.util.ts index be8fff88f3..1dff430f9c 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-field-order-by.util.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-field-order-by.util.ts @@ -9,21 +9,33 @@ import { import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; 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 { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { GRAPH_DEFAULT_DATE_GRANULARITY } from 'src/modules/dashboard/chart-data/constants/graph-default-date-granularity.constant'; import { getRelationFieldOrderBy } from 'src/modules/dashboard/chart-data/utils/get-relation-field-order-by.util'; -export const getFieldOrderBy = ( - groupByFieldMetadata: FlatFieldMetadata, - groupBySubFieldName: string | null | undefined, - dateGranularity: ObjectRecordGroupByDateGranularity | undefined, - direction: OrderByDirection, -): +export const getFieldOrderBy = ({ + groupByFieldMetadata, + groupBySubFieldName, + dateGranularity, + direction, + flatObjectMetadataMaps, + flatFieldMetadataMaps, +}: { + groupByFieldMetadata: FlatFieldMetadata; + groupBySubFieldName: string | null | undefined; + dateGranularity: ObjectRecordGroupByDateGranularity | undefined; + direction: OrderByDirection; + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; +}): Array< | ObjectRecordOrderByForScalarField | ObjectRecordOrderByWithGroupByDateField | ObjectRecordOrderByForCompositeField - | ObjectRecordOrderByForRelationField => { + | ObjectRecordOrderByForRelationField +> => { if (isCompositeFieldMetadataType(groupByFieldMetadata.type)) { if (!isDefined(groupBySubFieldName)) { throw new Error( @@ -31,32 +43,40 @@ export const getFieldOrderBy = ( ); } - return { - [groupByFieldMetadata.name]: { - [groupBySubFieldName]: direction, + return [ + { + [groupByFieldMetadata.name]: { + [groupBySubFieldName]: direction, + }, }, - }; + ]; } if (isFieldMetadataDateKind(groupByFieldMetadata.type)) { - return { - [groupByFieldMetadata.name]: { - orderBy: direction, - granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY, + return [ + { + [groupByFieldMetadata.name]: { + orderBy: direction, + granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY, + }, }, - }; + ]; } if (isMorphOrRelationFlatFieldMetadata(groupByFieldMetadata)) { - return getRelationFieldOrderBy( + return getRelationFieldOrderBy({ groupByFieldMetadata, groupBySubFieldName, direction, dateGranularity, - ); + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); } - return { - [groupByFieldMetadata.name]: direction, - }; + return [ + { + [groupByFieldMetadata.name]: direction, + }, + ]; }; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-group-by-order-by.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-group-by-order-by.util.ts index f8e7a8cbde..f98f14bb58 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-group-by-order-by.util.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-group-by-order-by.util.ts @@ -1,15 +1,13 @@ import { type AggregateOperations, - type AggregateOrderByWithGroupByField, type ObjectRecordGroupByDateGranularity, - type ObjectRecordOrderByForCompositeField, - type ObjectRecordOrderByForRelationField, - type ObjectRecordOrderByForScalarField, - type ObjectRecordOrderByWithGroupByDateField, + type OrderByWithGroupBy, } from 'twenty-shared/types'; import { assertUnreachable, isDefined } from 'twenty-shared/utils'; +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 { buildAggregateFieldKey } from 'src/modules/dashboard/chart-data/utils/build-aggregate-field-key.util'; import { getFieldOrderBy } from 'src/modules/dashboard/chart-data/utils/get-field-order-by.util'; @@ -22,6 +20,8 @@ export const getGroupByOrderBy = ({ aggregateOperation, aggregateFieldMetadata, dateGranularity, + flatObjectMetadataMaps, + flatFieldMetadataMaps, }: { graphOrderBy: GraphOrderBy; groupByFieldMetadata: FlatFieldMetadata; @@ -29,22 +29,20 @@ export const getGroupByOrderBy = ({ aggregateOperation?: AggregateOperations; aggregateFieldMetadata?: FlatFieldMetadata; dateGranularity?: ObjectRecordGroupByDateGranularity; -}): - | AggregateOrderByWithGroupByField - | ObjectRecordOrderByForScalarField - | ObjectRecordOrderByWithGroupByDateField - | ObjectRecordOrderByForCompositeField - | ObjectRecordOrderByForRelationField - | undefined => { + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; +}): OrderByWithGroupBy | undefined => { switch (graphOrderBy) { case GraphOrderBy.FIELD_ASC: case GraphOrderBy.FIELD_DESC: - return getFieldOrderBy( + return getFieldOrderBy({ groupByFieldMetadata, groupBySubFieldName, dateGranularity, - mapOrderByToDirection(graphOrderBy), - ); + direction: mapOrderByToDirection(graphOrderBy), + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); case GraphOrderBy.VALUE_ASC: case GraphOrderBy.VALUE_DESC: { if ( @@ -61,11 +59,13 @@ export const getGroupByOrderBy = ({ aggregateFieldMetadata, }); - return { - aggregate: { - [aggregateFieldKey]: mapOrderByToDirection(graphOrderBy), + return [ + { + aggregate: { + [aggregateFieldKey]: mapOrderByToDirection(graphOrderBy), + }, }, - }; + ]; } case GraphOrderBy.FIELD_POSITION_ASC: case GraphOrderBy.FIELD_POSITION_DESC: diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-relation-field-order-by.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-relation-field-order-by.util.ts index f3744ac753..1e2a874a63 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-relation-field-order-by.util.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/get-relation-field-order-by.util.ts @@ -6,48 +6,72 @@ import { } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +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 { GRAPH_DEFAULT_DATE_GRANULARITY } from 'src/modules/dashboard/chart-data/constants/graph-default-date-granularity.constant'; +import { getBareRelationOrderBy } from 'src/modules/dashboard/chart-data/utils/get-bare-relation-order-by.util'; -export const getRelationFieldOrderBy = ( - groupByFieldMetadata: FlatFieldMetadata, - groupBySubFieldName: string | null | undefined, - direction: OrderByDirection, - dateGranularity?: ObjectRecordGroupByDateGranularity, - isNestedDateField?: boolean, -): ObjectRecordOrderByForScalarField | ObjectRecordOrderByForRelationField => { +export const getRelationFieldOrderBy = ({ + groupByFieldMetadata, + groupBySubFieldName, + direction, + dateGranularity, + isNestedDateField, + flatObjectMetadataMaps, + flatFieldMetadataMaps, +}: { + groupByFieldMetadata: FlatFieldMetadata; + groupBySubFieldName: string | null | undefined; + direction: OrderByDirection; + dateGranularity?: ObjectRecordGroupByDateGranularity; + isNestedDateField?: boolean; + flatObjectMetadataMaps: FlatEntityMaps; + flatFieldMetadataMaps: FlatEntityMaps; +}): Array< + ObjectRecordOrderByForScalarField | ObjectRecordOrderByForRelationField +> => { if (!isDefined(groupBySubFieldName)) { - return { - [`${groupByFieldMetadata.name}Id`]: direction, - }; + return getBareRelationOrderBy({ + groupByFieldMetadata, + direction, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }); } const [nestedFieldName, nestedSubFieldName] = groupBySubFieldName.split('.'); if (isNestedDateField === true || isDefined(dateGranularity)) { - return { - [groupByFieldMetadata.name]: { - [nestedFieldName]: { - orderBy: direction, - granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY, + return [ + { + [groupByFieldMetadata.name]: { + [nestedFieldName]: { + orderBy: direction, + granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY, + }, }, }, - }; + ]; } if (!isDefined(nestedSubFieldName)) { - return { - [groupByFieldMetadata.name]: { - [nestedFieldName]: direction, + return [ + { + [groupByFieldMetadata.name]: { + [nestedFieldName]: direction, + }, }, - }; + ]; } - return { - [groupByFieldMetadata.name]: { - [nestedFieldName]: { - [nestedSubFieldName]: direction, + return [ + { + [groupByFieldMetadata.name]: { + [nestedFieldName]: { + [nestedSubFieldName]: direction, + }, }, }, - }; + ]; }; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util.ts index 9290d374c0..81011ac5f9 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util.ts @@ -7,6 +7,7 @@ import { isDefined } from 'twenty-shared/utils'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { type GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type'; import { type RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type'; +import { type RelationLabelResolution } from 'src/modules/dashboard/chart-data/types/relation-label-resolution.type'; import { formatDimensionValue } from 'src/modules/dashboard/chart-data/utils/format-dimension-value.util'; export type ProcessedOneDimensionalDataPoint = { @@ -27,6 +28,7 @@ type ProcessOneDimensionalResultsParams = { subFieldName?: string | null; userTimezone: string; firstDayOfTheWeek: FirstDayOfTheWeek; + relationLabelResolution: RelationLabelResolution | undefined; }; export const processOneDimensionalResults = ({ @@ -36,6 +38,7 @@ export const processOneDimensionalResults = ({ subFieldName, userTimezone, firstDayOfTheWeek, + relationLabelResolution, }: ProcessOneDimensionalResultsParams): ProcessOneDimensionalResultsOutput => { const formattedToRawLookup = new Map(); const processedDataPoints: ProcessedOneDimensionalDataPoint[] = []; @@ -56,6 +59,7 @@ export const processOneDimensionalResults = ({ subFieldName: subFieldName ?? undefined, userTimezone, firstDayOfTheWeek, + relationLabelByRecordId: relationLabelResolution?.labelByRecordId, }); if (isDefined(rawValue)) { diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-two-dimensional-results.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-two-dimensional-results.util.ts index 1c91dc0797..0e2ecb4f05 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-two-dimensional-results.util.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/process-two-dimensional-results.util.ts @@ -7,6 +7,7 @@ import { isDefined } from 'twenty-shared/utils'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { type GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type'; import { type RawDimensionValue } from 'src/modules/dashboard/chart-data/types/raw-dimension-value.type'; +import { type RelationLabelResolution } from 'src/modules/dashboard/chart-data/types/relation-label-resolution.type'; import { formatDimensionValue } from 'src/modules/dashboard/chart-data/utils/format-dimension-value.util'; export type ProcessedTwoDimensionalDataPoint = { @@ -33,6 +34,8 @@ type ProcessTwoDimensionalResultsParams = { secondarySubFieldName?: string | null; userTimezone: string; firstDayOfTheWeek: FirstDayOfTheWeek; + primaryRelationLabelResolution: RelationLabelResolution | undefined; + secondaryRelationLabelResolution: RelationLabelResolution | undefined; }; export const processTwoDimensionalResults = ({ @@ -45,6 +48,8 @@ export const processTwoDimensionalResults = ({ secondarySubFieldName, userTimezone, firstDayOfTheWeek, + primaryRelationLabelResolution, + secondaryRelationLabelResolution, }: ProcessTwoDimensionalResultsParams): ProcessTwoDimensionalResultsOutput => { const formattedToRawLookup = new Map(); const secondaryFormattedToRawLookup = new Map(); @@ -67,6 +72,7 @@ export const processTwoDimensionalResults = ({ subFieldName: primarySubFieldName ?? undefined, userTimezone, firstDayOfTheWeek, + relationLabelByRecordId: primaryRelationLabelResolution?.labelByRecordId, }); const yFormatted = formatDimensionValue({ @@ -76,6 +82,8 @@ export const processTwoDimensionalResults = ({ subFieldName: secondarySubFieldName ?? undefined, userTimezone, firstDayOfTheWeek, + relationLabelByRecordId: + secondaryRelationLabelResolution?.labelByRecordId, }); if (isDefined(rawXValue)) { diff --git a/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts index d884a1cd67..094db5ef5f 100644 --- a/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts +++ b/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts @@ -73,9 +73,9 @@ WIDGET TYPES: 2. GRAPH with configurationType "BAR_CHART": - Additional required: configuration.primaryAxisGroupByFieldMetadataId, configuration.layout ("VERTICAL" or "HORIZONTAL") - - IMPORTANT: When grouping by a RELATION field (e.g. owner, company), you MUST provide primaryAxisGroupBySubFieldName (e.g. "name", "email") — otherwise it groups by raw UUID which is useless. Composite fields (e.g. address) also require a subfield (e.g. "addressCity"). + - When grouping by a RELATION field (e.g. owner, company), omit primaryAxisGroupBySubFieldName to group by the related record itself, labelled with its display name. Provide a subfield (e.g. "name", "email") only to group by that specific attribute instead. Composite fields (e.g. address) always require a subfield (e.g. "addressCity"). - Example (simple field): { type: "GRAPH", objectMetadataId: "", configuration: { configurationType: "BAR_CHART", aggregateFieldMetadataId: "", aggregateOperation: "COUNT", primaryAxisGroupByFieldMetadataId: "", layout: "VERTICAL" } } - - Example (relation field): { type: "GRAPH", objectMetadataId: "", configuration: { configurationType: "BAR_CHART", aggregateFieldMetadataId: "", aggregateOperation: "SUM", primaryAxisGroupByFieldMetadataId: "", primaryAxisGroupBySubFieldName: "name", layout: "VERTICAL" } } + - Example (relation field): { type: "GRAPH", objectMetadataId: "", configuration: { configurationType: "BAR_CHART", aggregateFieldMetadataId: "", aggregateOperation: "SUM", primaryAxisGroupByFieldMetadataId: "", layout: "VERTICAL" } } 3. GRAPH with configurationType "LINE_CHART": - Additional required: configuration.primaryAxisGroupByFieldMetadataId diff --git a/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts b/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts index 3f07cc12f8..f3a56a6851 100644 --- a/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts +++ b/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts @@ -356,7 +356,7 @@ const barChartConfigSchemaCore = z.object({ .string() .optional() .describe( - 'REQUIRED for relation fields (e.g. "name", "address.addressCity") and composite fields (e.g. "addressCity"). Without this, relation fields group by raw UUID which is not useful.', + 'Optional for relation fields: omit it to group by the related record itself, labelled with its display name; provide it (e.g. "name", "address.addressCity") to group by that attribute instead. REQUIRED for composite fields (e.g. "addressCity").', ), secondaryAxisGroupByFieldMetadataId: z.uuid().optional(), secondaryAxisGroupByFieldName: z @@ -369,7 +369,7 @@ const barChartConfigSchemaCore = z.object({ .string() .optional() .describe( - 'REQUIRED for relation fields (e.g. "name", "stage") and composite fields (e.g. "addressCity"). Without this, relation fields group by raw UUID which is not useful.', + 'Optional for relation fields: omit it to group by the related record itself, labelled with its display name; provide it (e.g. "name", "stage") to group by that attribute instead. REQUIRED for composite fields (e.g. "addressCity").', ), primaryAxisOrderBy: z.enum(GRAPH_ORDER_BY_OPTIONS).optional(), primaryAxisManualSortOrder: z.array(z.string()).optional(), @@ -447,7 +447,7 @@ const lineChartConfigSchemaCore = z.object({ .string() .optional() .describe( - 'REQUIRED for relation fields (e.g. "name", "address.addressCity") and composite fields (e.g. "addressCity"). Without this, relation fields group by raw UUID which is not useful.', + 'Optional for relation fields: omit it to group by the related record itself, labelled with its display name; provide it (e.g. "name", "address.addressCity") to group by that attribute instead. REQUIRED for composite fields (e.g. "addressCity").', ), secondaryAxisGroupByFieldMetadataId: z.uuid().optional(), secondaryAxisGroupByFieldName: z @@ -460,7 +460,7 @@ const lineChartConfigSchemaCore = z.object({ .string() .optional() .describe( - 'REQUIRED for relation fields (e.g. "name", "stage") and composite fields (e.g. "addressCity"). Without this, relation fields group by raw UUID which is not useful.', + 'Optional for relation fields: omit it to group by the related record itself, labelled with its display name; provide it (e.g. "name", "stage") to group by that attribute instead. REQUIRED for composite fields (e.g. "addressCity").', ), primaryAxisOrderBy: z.enum(GRAPH_ORDER_BY_OPTIONS).optional(), primaryAxisManualSortOrder: z.array(z.string()).optional(), @@ -530,7 +530,7 @@ const pieChartConfigSchemaCore = z.object({ .string() .optional() .describe( - 'REQUIRED for relation fields (e.g. "name", "stage") and composite fields (e.g. "addressCity"). Without this, relation fields group by raw UUID which is not useful.', + 'Optional for relation fields: omit it to group by the related record itself, labelled with its display name; provide it (e.g. "name", "stage") to group by that attribute instead. REQUIRED for composite fields (e.g. "addressCity").', ), orderBy: z.enum(GRAPH_ORDER_BY_OPTIONS).optional(), manualSortOrder: z.array(z.string()).optional(), diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts index 121bd791c4..96d02583bf 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts @@ -10,6 +10,7 @@ import { deleteOneOperationFactory } from 'test/integration/graphql/utils/delete import { deleteRole } from 'test/integration/graphql/utils/delete-one-role.util'; import { findManyOperationFactory } from 'test/integration/graphql/utils/find-many-operation-factory.util'; import { findOneOperationFactory } from 'test/integration/graphql/utils/find-one-operation-factory.util'; +import { groupByOperationFactory } from 'test/integration/graphql/utils/group-by-operation-factory.util'; import { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util'; import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; import { updateManyOperationFactory } from 'test/integration/graphql/utils/update-many-operation-factory.util'; @@ -298,9 +299,7 @@ describe('Field permissions restrictions', () => { await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); expectNoGraphQLErrors(response); - expect( - response.body.data.companies.edges[0].node.position, - ).toBeDefined(); + expect(response.body.data.companies.edges[0].node.position).toBeDefined(); }); it('2. findOne', async () => { @@ -412,8 +411,8 @@ describe('Field permissions restrictions', () => { const graphqlOperation = findManyOperationFactory({ objectMetadataSingularName: 'company', objectMetadataPluralName: 'companies', - gqlFields: COMPANY_GQL_FIELDS_WITH_PEOPLE_JOB_TITLE, - }); + gqlFields: COMPANY_GQL_FIELDS_WITH_PEOPLE_JOB_TITLE, + }); const response = await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); @@ -439,7 +438,8 @@ describe('Field permissions restrictions', () => { const graphqlOperation = findManyOperationFactory({ objectMetadataSingularName: 'company', objectMetadataPluralName: 'companies', - gqlFields: COMPANY_GQL_FIELDS_WITHOUT_POSITION_AND_WITHOUT_PEOPLE_JOB_TITLE, + gqlFields: + COMPANY_GQL_FIELDS_WITHOUT_POSITION_AND_WITHOUT_PEOPLE_JOB_TITLE, }); const response = await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); @@ -481,6 +481,58 @@ describe('Field permissions restrictions', () => { expectPermissionDeniedError(response); }); + it('should reject ordering a group by on a relation record by a target field without read permission', async () => { + await upsertFieldPermissions({ + roleId: customRoleId, + fieldPermissions: [ + { + objectMetadataId: companyObjectId, + fieldMetadataId: restrictedCompanyFieldId, + canReadFieldValue: false, + canUpdateFieldValue: null, + }, + ], + }); + + const graphqlOperation = groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [{ companyId: true }], + orderBy: [{ company: { position: 'AscNullsLast' } }], + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + + it('should allow ordering a group by on a relation record by a readable target field', async () => { + await upsertFieldPermissions({ + roleId: customRoleId, + fieldPermissions: [ + { + objectMetadataId: companyObjectId, + fieldMetadataId: restrictedCompanyFieldId, + canReadFieldValue: false, + canUpdateFieldValue: null, + }, + ], + }); + + const graphqlOperation = groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [{ companyId: true }], + orderBy: [{ company: { name: 'AscNullsLast' } }], + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectNoGraphQLErrors(response); + }); + describe('Aggregate operations', () => { it('1. should allow aggregate over a restricted field', async () => { await restrictAccessToCompanyEmployee( @@ -524,7 +576,8 @@ describe('Field permissions restrictions', () => { expectNoGraphQLErrors(response); expect( - response.body.data.companies.edges[0].node.people.percentageEmptyJobTitle, + response.body.data.companies.edges[0].node.people + .percentageEmptyJobTitle, ).toBeDefined(); }); }); diff --git a/packages/twenty-server/test/integration/graphql/suites/order-by-with-group-by.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/order-by-with-group-by.integration-spec.ts index 19019d11a2..9005c3cf5c 100644 --- a/packages/twenty-server/test/integration/graphql/suites/order-by-with-group-by.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/order-by-with-group-by.integration-spec.ts @@ -698,4 +698,195 @@ describe('group-by resolvers - order by', () => { ); }); }); + + describe('relation field ordering under target id group by', () => { + const aardvarkCompanyId = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; + const mangoCompanyId = '99999999-9999-4999-8999-999999999999'; + const zebraCompanyId = '00000000-0000-4000-8000-000000000001'; + const alicePersonId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + const bobPersonId = '11111111-1111-4111-8111-111111111111'; + const carolPersonId = '22222222-2222-4222-8222-222222222222'; + const personWithoutCompanyId = '33333333-3333-4333-8333-333333333333'; + const aliceOpportunityId = randomUUID(); + const bobOpportunityId = randomUUID(); + + beforeAll(async () => { + const companies = [ + { id: aardvarkCompanyId, name: 'Aardvark' }, + { id: mangoCompanyId, name: 'Mango' }, + { id: zebraCompanyId, name: 'Zebra' }, + ]; + + for (const company of companies) { + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: company, + }), + ); + } + + const people = [ + { + id: alicePersonId, + name: { firstName: 'Alice', lastName: 'Brown' }, + companyId: aardvarkCompanyId, + }, + { + id: bobPersonId, + name: { firstName: 'Bob', lastName: 'Johnson' }, + companyId: mangoCompanyId, + }, + { + id: carolPersonId, + name: { firstName: 'Carol', lastName: 'Smith' }, + companyId: zebraCompanyId, + }, + { + id: personWithoutCompanyId, + name: { firstName: 'Dave', lastName: 'Miller' }, + }, + ]; + + for (const person of people) { + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id', + data: person, + }), + ); + } + + const opportunities = [ + { id: aliceOpportunityId, pointOfContactId: alicePersonId }, + { id: bobOpportunityId, pointOfContactId: bobPersonId }, + ]; + + for (const opportunity of opportunities) { + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'opportunity', + gqlFields: 'id', + data: opportunity, + }), + ); + } + }); + + afterAll(async () => { + for (const id of [aliceOpportunityId, bobOpportunityId]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'opportunity', + gqlFields: 'id', + recordId: id, + }), + ); + } + + for (const id of [ + alicePersonId, + bobPersonId, + carolPersonId, + personWithoutCompanyId, + ]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id', + recordId: id, + }), + ); + } + + for (const id of [aardvarkCompanyId, mangoCompanyId, zebraCompanyId]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: 'id', + recordId: id, + }), + ); + } + }); + + it('should order groups by the related record TEXT label, not its id', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [{ companyId: true }], + filter: { + id: { + in: [ + alicePersonId, + bobPersonId, + carolPersonId, + personWithoutCompanyId, + ], + }, + }, + orderBy: [ + { company: { name: 'AscNullsLast' } }, + { company: { id: 'AscNullsLast' } }, + ], + }), + ); + + expect(response.body.errors).toBeUndefined(); + + const groups = response.body.data.peopleGroupBy; + + expect( + groups.map((group: any) => group.groupByDimensionValues[0]), + ).toEqual([aardvarkCompanyId, mangoCompanyId, zebraCompanyId, null]); + }); + + it('should order groups by the related record FULL_NAME label subfields', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'opportunity', + objectMetadataPluralName: 'opportunities', + groupBy: [{ pointOfContactId: true }], + filter: { + id: { + in: [aliceOpportunityId, bobOpportunityId], + }, + }, + orderBy: [ + { pointOfContact: { name: { firstName: 'AscNullsLast' } } }, + { pointOfContact: { name: { lastName: 'AscNullsLast' } } }, + { pointOfContact: { id: 'AscNullsLast' } }, + ], + }), + ); + + expect(response.body.errors).toBeUndefined(); + + const groups = response.body.data.opportunitiesGroupBy; + + expect( + groups.map((group: any) => group.groupByDimensionValues[0]), + ).toEqual([alicePersonId, bobPersonId]); + }); + + it('should fail when ordering by a relation absent from groupBy', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [{ jobTitle: true }], + orderBy: [{ company: { name: 'AscNullsLast' } }], + }), + ); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.length).toBe(1); + expect(response.body.errors[0].message).toBe( + 'Cannot order by a relation field that is not in groupBy criteria: company.name', + ); + }); + }); });