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

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



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

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

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

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

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

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

The group-by query orders buckets by the related record's label
identifier at the database level (the engine now accepts ordering by a
target field when grouping by its id), so with more than 100 distinct
related records the surviving buckets match the label order.
This commit is contained in:
Raphaël Bosi
2026-07-24 16:12:22 +02:00
committed by GitHub
parent b036d67ec9
commit d1c6b8ee72
62 changed files with 4504 additions and 546 deletions
@@ -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',
},
@@ -242,6 +242,44 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
closeDropdown();
};
const handleSelectRelationRecord = () => {
if (!isDefined(selectedRelationField)) {
return;
}
updateCurrentWidgetConfig({
configToUpdate: buildChartGroupByFieldConfigUpdate({
configuration,
fieldMetadataIdKey,
subFieldNameKey,
fieldId: selectedRelationField.id,
subFieldName: null,
objectMetadataItem: sourceObjectMetadataItem,
objectMetadataItems,
}),
});
closeDropdown();
};
const handleSelectMorphTargetRecord = ({
perTargetFieldId,
}: {
perTargetFieldId: string;
}) => {
updateCurrentWidgetConfig({
configToUpdate: buildChartGroupByFieldConfigUpdate({
configuration,
fieldMetadataIdKey,
subFieldNameKey,
fieldId: perTargetFieldId,
subFieldName: null,
objectMetadataItem: sourceObjectMetadataItem,
objectMetadataItems,
}),
});
closeDropdown();
};
if (isDefined(selectedMorphField)) {
return (
<ChartGroupByFieldSelectionMorphRelationFieldView
@@ -250,6 +288,7 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
currentSubFieldName={currentSubFieldName}
onBack={handleBackFromMorph}
onSelectTargetSubField={handleSelectMorphTargetSubField}
onSelectTargetRecord={handleSelectMorphTargetRecord}
/>
);
}
@@ -258,9 +297,17 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
return (
<ChartGroupByFieldSelectionRelationFieldView
relationField={selectedRelationField}
currentSubFieldName={currentSubFieldName}
currentSubFieldName={
selectedRelationField.id === currentGroupByFieldMetadataId
? currentSubFieldName
: undefined
}
isCurrentGroupByField={
selectedRelationField.id === currentGroupByFieldMetadataId
}
onBack={handleBackFromRelation}
onSelectSubField={handleSelectRelationSubField}
onSelectRecord={handleSelectRelationRecord}
/>
);
}
@@ -34,6 +34,7 @@ type ChartGroupByFieldSelectionMorphRelationFieldViewProps = {
perTargetFieldId: string;
subFieldName: string;
}) => void;
onSelectTargetRecord: (params: { perTargetFieldId: string }) => void;
};
export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
@@ -42,6 +43,7 @@ export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
currentSubFieldName,
onBack,
onSelectTargetSubField,
onSelectTargetRecord,
}: ChartGroupByFieldSelectionMorphRelationFieldViewProps) => {
const { getIcon } = useIcons();
@@ -92,6 +94,9 @@ export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
? currentSubFieldName
: undefined
}
isCurrentGroupByField={
selectedTarget.perTargetFieldId === currentFieldMetadataId
}
onBack={() => setSelectedTarget(null)}
onSelectSubField={(subFieldName) =>
onSelectTargetSubField({
@@ -99,6 +104,11 @@ export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
subFieldName,
})
}
onSelectRecord={() =>
onSelectTargetRecord({
perTargetFieldId: selectedTarget.perTargetFieldId,
})
}
/>
);
}
@@ -4,15 +4,19 @@ import { ChartGroupByFieldSelectionTargetObjectFieldsView } from '@/side-panel/p
type ChartGroupByFieldSelectionRelationFieldViewProps = {
relationField: FieldMetadataItem;
currentSubFieldName: string | undefined;
isCurrentGroupByField: boolean;
onBack: () => void;
onSelectSubField: (subFieldName: string) => void;
onSelectRecord: () => void;
};
export const ChartGroupByFieldSelectionRelationFieldView = ({
relationField,
currentSubFieldName,
isCurrentGroupByField,
onBack,
onSelectSubField,
onSelectRecord,
}: ChartGroupByFieldSelectionRelationFieldViewProps) => {
return (
<ChartGroupByFieldSelectionTargetObjectFieldsView
@@ -21,8 +25,10 @@ export const ChartGroupByFieldSelectionRelationFieldView = ({
}
headerLabel={relationField.label}
currentSubFieldName={currentSubFieldName}
isCurrentGroupByField={isCurrentGroupByField}
onBack={onBack}
onSelectSubField={onSelectSubField}
onSelectRecord={onSelectRecord}
/>
);
};
@@ -1,9 +1,8 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { ChartGroupByFieldSelectionCompositeFieldView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionCompositeFieldView';
import { isFieldSupportedAsChartGroupBySubField } from '@/side-panel/pages/page-layout/utils/isFieldSupportedAsChartGroupBySubField';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@@ -21,21 +20,28 @@ import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, useIcons } from 'twenty-ui/icon';
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const RECORD_ITEM_ID = 'record';
type ChartGroupByFieldSelectionTargetObjectFieldsViewProps = {
targetObjectNameSingular?: string;
headerLabel: string;
currentSubFieldName: string | undefined;
isCurrentGroupByField: boolean;
onBack: () => void;
onSelectSubField: (subFieldName: string) => void;
onSelectRecord: () => void;
};
export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
targetObjectNameSingular,
headerLabel,
currentSubFieldName,
isCurrentGroupByField,
onBack,
onSelectSubField,
onSelectRecord,
}: ChartGroupByFieldSelectionTargetObjectFieldsViewProps) => {
const { getIcon } = useIcons();
@@ -70,7 +76,7 @@ export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
return filterBySearchQuery({
items: targetObjectMetadataItem.fields.filter(
(field) => !isHiddenSystemField(field) && !isFieldRelation(field),
isFieldSupportedAsChartGroupBySubField,
),
searchQuery,
getSearchableValues: (field) => [field.label, field.name],
@@ -99,6 +105,12 @@ export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
const [currentNestedFieldName, currentNestedSubFieldName] =
currentSubFieldName?.split('.') ?? [];
const recordOptionLabel = t`Record`;
const isRecordOptionVisible = normalizeSearchText(recordOptionLabel).includes(
normalizeSearchText(searchQuery),
);
if (isDefined(selectedCompositeField)) {
return (
<ChartGroupByFieldSelectionCompositeFieldView
@@ -131,15 +143,34 @@ export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
{availableFields.length === 0 ? (
<MenuItem text={t`No fields available`} />
) : (
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={availableFields.map((field) => field.id)}
>
{availableFields.map((fieldMetadataItem) => (
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={[
...(isRecordOptionVisible ? [RECORD_ITEM_ID] : []),
...availableFields.map((field) => field.id),
]}
>
{isRecordOptionVisible && (
<SelectableListItem
itemId={RECORD_ITEM_ID}
onEnter={onSelectRecord}
>
<MenuItemSelect
text={recordOptionLabel}
selected={
isCurrentGroupByField && !isDefined(currentSubFieldName)
}
focused={selectedItemId === RECORD_ITEM_ID}
LeftIcon={getIcon(targetObjectMetadataItem?.icon)}
onClick={onSelectRecord}
/>
</SelectableListItem>
)}
{availableFields.length === 0 && !isRecordOptionVisible ? (
<MenuItem text={t`No fields available`} />
) : (
availableFields.map((fieldMetadataItem) => (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
@@ -161,9 +192,9 @@ export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
}}
/>
</SelectableListItem>
))}
</SelectableList>
)}
))
)}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
@@ -0,0 +1,79 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isFieldSupportedAsChartGroupBySubField } from '@/side-panel/pages/page-layout/utils/isFieldSupportedAsChartGroupBySubField';
import { FieldMetadataType } from 'twenty-shared/types';
const createFieldMetadataItem = (
overrides: Partial<FieldMetadataItem>,
): FieldMetadataItem =>
({
id: 'field-id',
name: 'testField',
label: 'Test Field',
type: FieldMetadataType.TEXT,
isSystem: false,
...overrides,
}) as FieldMetadataItem;
describe('isFieldSupportedAsChartGroupBySubField', () => {
it('should accept a non-system scalar field', () => {
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({ name: 'name', type: FieldMetadataType.TEXT }),
),
).toBe(true);
});
it('should reject a system field like userId', () => {
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({
name: 'userId',
type: FieldMetadataType.UUID,
isSystem: true,
}),
),
).toBe(false);
});
it('should accept system createdAt and updatedAt date fields', () => {
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
isSystem: true,
}),
),
).toBe(true);
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({
name: 'updatedAt',
type: FieldMetadataType.DATE_TIME,
isSystem: true,
}),
),
).toBe(true);
});
it('should reject relation and morph relation fields', () => {
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({ type: FieldMetadataType.RELATION }),
),
).toBe(false);
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({ type: FieldMetadataType.MORPH_RELATION }),
),
).toBe(false);
});
it('should reject field types unsupported in group by', () => {
expect(
isFieldSupportedAsChartGroupBySubField(
createFieldMetadataItem({ type: FieldMetadataType.RAW_JSON }),
),
).toBe(false);
});
});
@@ -0,0 +1,24 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { isFieldMetadataSupportedInGroupBy } from 'twenty-shared/utils';
export const isFieldSupportedAsChartGroupBySubField = (
field: FieldMetadataItem,
): boolean => {
if (
isHiddenSystemField(field) ||
isFieldRelation(field) ||
isFieldMorphRelation(field)
) {
return false;
}
return isFieldMetadataSupportedInGroupBy({
type: field.type,
name: field.name,
isSystem: field.isSystem ?? false,
relationType: field.settings?.relationType ?? null,
});
};
@@ -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<FlatFieldMetadata>;
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<FlatObjectMetadata>;
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',
);
});
});
@@ -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
) {
@@ -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),
};
};
@@ -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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
objectIdByNameSingular: Record<string, string>;
}) => {
const columnsByJoinedAlias = new Map<string, Set<string>>();
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,
@@ -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,
@@ -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,
@@ -0,0 +1 @@
export const CHART_RELATION_LABEL_BATCH_SIZE = 500;
@@ -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');
});
});
});
@@ -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>,
): FlatFieldMetadata =>
({
id: 'test-field-id',
name: 'testField',
type: FieldMetadataType.TEXT,
universalIdentifier: 'test-field-universal-id',
...overrides,
}) as FlatFieldMetadata;
const createMockObjectMetadata = (
overrides: Partial<FlatObjectMetadata>,
): 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<TEntity> => ({
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<FlatFieldMetadata>);
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<FlatFieldMetadata>);
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(),
);
});
});
@@ -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>(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']));
});
});
@@ -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([
{
@@ -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 },
@@ -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<string, string> = {};
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<string>();
@@ -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<string, string | number>[],
keys: string[],
): Record<string, string | number>[] {
const runningTotals: Record<string, number> = {};
for (const key of keys) {
runningTotals[key] = 0;
}
const result: Record<string, string | number>[] = [];
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);
}
}
@@ -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);
}
}
}
@@ -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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
};
@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<string, string>();
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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): Promise<Map<string, Map<string, string>>> {
const rawLabelsByTargetObjectId = new Map<string, Map<string, string>>();
const axesByTargetObjectId = new Map<
string,
{
targetFlatObjectMetadata: FlatObjectMetadata;
labelIdentifierColumnNames: string[];
recordIds: Set<string>;
}
>();
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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): Promise<Record<string, unknown>[]> {
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<Record<string, unknown>[]>(
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 [];
}
}
}
@@ -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<string, string> = {};
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<T extends { y: number | null }>(
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;
}
}
@@ -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<string, string> = {};
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 }],
}),
};
}
}
@@ -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;
};
@@ -0,0 +1,4 @@
export type RelationLabelResolution = {
labelByRecordId: Map<string, string>;
unresolvedRecordIds: Set<string>;
};
@@ -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[];
};
@@ -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<string>,
): RelationLabelResolution => ({
labelByRecordId: new Map<string, string>(),
unresolvedRecordIds,
});
describe('buildFormattedToRawLookupDto', () => {
const lookup = new Map<string, RawDimensionValue>([
['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<string, RawDimensionValue>([
['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<string, RawDimensionValue>([
['__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<string, RawDimensionValue>([
['Alice', 'secondary-id'],
]),
relationLabelResolution: undefined,
},
{
formattedToRawLookup: lookup,
relationLabelResolution: buildResolution(new Set(['agent-id-2'])),
},
],
}),
).toEqual({
Alice: 'agent-id-1',
});
});
});
@@ -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<FlatFieldMetadata>,
): FlatEntityMaps<FlatFieldMetadata> =>
({
byUniversalIdentifier: {
'label-field-universal-id': {
id: labelFieldMetadataId,
universalIdentifier: 'label-field-universal-id',
...labelField,
},
},
universalIdentifierById: {
[labelFieldMetadataId]: 'label-field-universal-id',
},
universalIdentifiersByApplicationId: {},
}) as unknown as FlatEntityMaps<FlatFieldMetadata>;
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');
});
});
@@ -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);
});
});
@@ -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([]);
});
});
@@ -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<string, string>(),
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,
},
]);
});
});
@@ -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,
@@ -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<FlatFieldMetadata>,
): FlatEntityMaps<FlatFieldMetadata> =>
({
byUniversalIdentifier: {
'label-field-universal-id': {
id: labelFieldMetadataId,
universalIdentifier: 'label-field-universal-id',
...labelField,
},
},
universalIdentifierById: {
[labelFieldMetadataId]: 'label-field-universal-id',
},
universalIdentifiersByApplicationId: {},
}) as unknown as FlatEntityMaps<FlatFieldMetadata>;
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();
});
});
@@ -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<FlatObjectMetadata> = {
byUniversalIdentifier: {},
universalIdentifierById: {},
universalIdentifiersByApplicationId: {},
};
const emptyFlatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata> = {
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,
},
]);
});
});
});
@@ -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<FlatObjectMetadata> = {
byUniversalIdentifier: {},
universalIdentifierById: {},
universalIdentifiersByApplicationId: {},
};
const emptyFlatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata> = {
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,
},
},
]);
});
});
});
@@ -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>,
): 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<TEntity> => ({
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,
},
},
},
});
]);
});
});
});
@@ -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>,
): 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<FlatFieldMetadata>);
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');
});
});
@@ -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;
};
@@ -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;
};
@@ -0,0 +1,35 @@
import { isNumber } from '@sniptt/guards';
export const applyCumulativeToTwoDimensionalBarData = ({
data,
keys,
}: {
data: Record<string, string | number>[];
keys: string[];
}): Record<string, string | number>[] => {
const runningTotalByKey: Record<string, number> = {};
for (const key of keys) {
runningTotalByKey[key] = 0;
}
const result: Record<string, string | number>[] = [];
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;
};
@@ -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<string, RawDimensionValue>;
relationLabelResolution: RelationLabelResolution | undefined;
};
export const buildFormattedToRawLookupDto = ({
axisLookups,
}: {
axisLookups: AxisLookup[];
}): Record<string, RawDimensionValue> => {
const mergedLookup = new Map<string, RawDimensionValue>();
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);
};
@@ -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<string, unknown>,
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<string, unknown>[];
targetFlatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): Map<string, string> => {
const rawLabelByRecordId = new Map<string, string>();
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;
};
@@ -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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): 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,
};
};
@@ -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<string, string>;
allRecordIds: string[];
}): RelationLabelResolution => {
const sortedRecordIds = [...new Set(allRecordIds)].sort();
const takenLabels = new Set<string>([t`Not Set`, t`Unknown`]);
const labelByRecordId = new Map<string, string>();
const unresolvedRecordIds = new Set<string>();
const recordIdsByRawLabel = new Map<string, string[]>();
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 };
};
@@ -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;
};
@@ -0,0 +1,23 @@
export const claimUniqueSuffixedLabel = ({
baseLabel,
startOrdinal,
takenLabels,
formatOrdinal,
}: {
baseLabel: string;
startOrdinal: number;
takenLabels: Set<string>;
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 };
};
@@ -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,
);
};
@@ -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;
});
};
@@ -22,6 +22,7 @@ type FormatDimensionValueParams = {
subFieldName?: string;
userTimezone: string;
firstDayOfTheWeek: FirstDayOfTheWeek;
relationLabelByRecordId?: ReadonlyMap<string, string>;
};
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);
@@ -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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): 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,
];
};
@@ -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<FlatFieldMetadata>;
}): 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];
};
@@ -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>;
}): 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;
};
@@ -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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): 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,
},
];
};
@@ -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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): 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:
@@ -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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): 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,
},
},
},
};
];
};
@@ -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<string, RawDimensionValue>();
const processedDataPoints: ProcessedOneDimensionalDataPoint[] = [];
@@ -56,6 +59,7 @@ export const processOneDimensionalResults = ({
subFieldName: subFieldName ?? undefined,
userTimezone,
firstDayOfTheWeek,
relationLabelByRecordId: relationLabelResolution?.labelByRecordId,
});
if (isDefined(rawValue)) {
@@ -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<string, RawDimensionValue>();
const secondaryFormattedToRawLookup = new Map<string, RawDimensionValue>();
@@ -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)) {
@@ -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: "<opportunity-object-uuid>", configuration: { configurationType: "BAR_CHART", aggregateFieldMetadataId: "<amount-field-uuid>", aggregateOperation: "COUNT", primaryAxisGroupByFieldMetadataId: "<stage-field-uuid>", layout: "VERTICAL" } }
- Example (relation field): { type: "GRAPH", objectMetadataId: "<opportunity-object-uuid>", configuration: { configurationType: "BAR_CHART", aggregateFieldMetadataId: "<amount-field-uuid>", aggregateOperation: "SUM", primaryAxisGroupByFieldMetadataId: "<company-field-uuid>", primaryAxisGroupBySubFieldName: "name", layout: "VERTICAL" } }
- Example (relation field): { type: "GRAPH", objectMetadataId: "<opportunity-object-uuid>", configuration: { configurationType: "BAR_CHART", aggregateFieldMetadataId: "<amount-field-uuid>", aggregateOperation: "SUM", primaryAxisGroupByFieldMetadataId: "<company-field-uuid>", layout: "VERTICAL" } }
3. GRAPH with configurationType "LINE_CHART":
- Additional required: configuration.primaryAxisGroupByFieldMetadataId
@@ -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(),
@@ -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();
});
});
@@ -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',
);
});
});
});