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:
-2
@@ -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',
|
||||
},
|
||||
|
||||
+48
-1
@@ -242,6 +242,44 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleSelectRelationRecord = () => {
|
||||
if (!isDefined(selectedRelationField)) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateCurrentWidgetConfig({
|
||||
configToUpdate: buildChartGroupByFieldConfigUpdate({
|
||||
configuration,
|
||||
fieldMetadataIdKey,
|
||||
subFieldNameKey,
|
||||
fieldId: selectedRelationField.id,
|
||||
subFieldName: null,
|
||||
objectMetadataItem: sourceObjectMetadataItem,
|
||||
objectMetadataItems,
|
||||
}),
|
||||
});
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleSelectMorphTargetRecord = ({
|
||||
perTargetFieldId,
|
||||
}: {
|
||||
perTargetFieldId: string;
|
||||
}) => {
|
||||
updateCurrentWidgetConfig({
|
||||
configToUpdate: buildChartGroupByFieldConfigUpdate({
|
||||
configuration,
|
||||
fieldMetadataIdKey,
|
||||
subFieldNameKey,
|
||||
fieldId: perTargetFieldId,
|
||||
subFieldName: null,
|
||||
objectMetadataItem: sourceObjectMetadataItem,
|
||||
objectMetadataItems,
|
||||
}),
|
||||
});
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
if (isDefined(selectedMorphField)) {
|
||||
return (
|
||||
<ChartGroupByFieldSelectionMorphRelationFieldView
|
||||
@@ -250,6 +288,7 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
|
||||
currentSubFieldName={currentSubFieldName}
|
||||
onBack={handleBackFromMorph}
|
||||
onSelectTargetSubField={handleSelectMorphTargetSubField}
|
||||
onSelectTargetRecord={handleSelectMorphTargetRecord}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -258,9 +297,17 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
|
||||
return (
|
||||
<ChartGroupByFieldSelectionRelationFieldView
|
||||
relationField={selectedRelationField}
|
||||
currentSubFieldName={currentSubFieldName}
|
||||
currentSubFieldName={
|
||||
selectedRelationField.id === currentGroupByFieldMetadataId
|
||||
? currentSubFieldName
|
||||
: undefined
|
||||
}
|
||||
isCurrentGroupByField={
|
||||
selectedRelationField.id === currentGroupByFieldMetadataId
|
||||
}
|
||||
onBack={handleBackFromRelation}
|
||||
onSelectSubField={handleSelectRelationSubField}
|
||||
onSelectRecord={handleSelectRelationRecord}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+10
@@ -34,6 +34,7 @@ type ChartGroupByFieldSelectionMorphRelationFieldViewProps = {
|
||||
perTargetFieldId: string;
|
||||
subFieldName: string;
|
||||
}) => void;
|
||||
onSelectTargetRecord: (params: { perTargetFieldId: string }) => void;
|
||||
};
|
||||
|
||||
export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
|
||||
@@ -42,6 +43,7 @@ export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
|
||||
currentSubFieldName,
|
||||
onBack,
|
||||
onSelectTargetSubField,
|
||||
onSelectTargetRecord,
|
||||
}: ChartGroupByFieldSelectionMorphRelationFieldViewProps) => {
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
@@ -92,6 +94,9 @@ export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
|
||||
? currentSubFieldName
|
||||
: undefined
|
||||
}
|
||||
isCurrentGroupByField={
|
||||
selectedTarget.perTargetFieldId === currentFieldMetadataId
|
||||
}
|
||||
onBack={() => setSelectedTarget(null)}
|
||||
onSelectSubField={(subFieldName) =>
|
||||
onSelectTargetSubField({
|
||||
@@ -99,6 +104,11 @@ export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
|
||||
subFieldName,
|
||||
})
|
||||
}
|
||||
onSelectRecord={() =>
|
||||
onSelectTargetRecord({
|
||||
perTargetFieldId: selectedTarget.perTargetFieldId,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+6
@@ -4,15 +4,19 @@ import { ChartGroupByFieldSelectionTargetObjectFieldsView } from '@/side-panel/p
|
||||
type ChartGroupByFieldSelectionRelationFieldViewProps = {
|
||||
relationField: FieldMetadataItem;
|
||||
currentSubFieldName: string | undefined;
|
||||
isCurrentGroupByField: boolean;
|
||||
onBack: () => void;
|
||||
onSelectSubField: (subFieldName: string) => void;
|
||||
onSelectRecord: () => void;
|
||||
};
|
||||
|
||||
export const ChartGroupByFieldSelectionRelationFieldView = ({
|
||||
relationField,
|
||||
currentSubFieldName,
|
||||
isCurrentGroupByField,
|
||||
onBack,
|
||||
onSelectSubField,
|
||||
onSelectRecord,
|
||||
}: ChartGroupByFieldSelectionRelationFieldViewProps) => {
|
||||
return (
|
||||
<ChartGroupByFieldSelectionTargetObjectFieldsView
|
||||
@@ -21,8 +25,10 @@ export const ChartGroupByFieldSelectionRelationFieldView = ({
|
||||
}
|
||||
headerLabel={relationField.label}
|
||||
currentSubFieldName={currentSubFieldName}
|
||||
isCurrentGroupByField={isCurrentGroupByField}
|
||||
onBack={onBack}
|
||||
onSelectSubField={onSelectSubField}
|
||||
onSelectRecord={onSelectRecord}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+46
-15
@@ -1,9 +1,8 @@
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
|
||||
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
||||
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
|
||||
import { ChartGroupByFieldSelectionCompositeFieldView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionCompositeFieldView';
|
||||
import { isFieldSupportedAsChartGroupBySubField } from '@/side-panel/pages/page-layout/utils/isFieldSupportedAsChartGroupBySubField';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
@@ -21,21 +20,28 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconChevronLeft, useIcons } from 'twenty-ui/icon';
|
||||
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
|
||||
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
|
||||
import { normalizeSearchText } from '~/utils/normalizeSearchText';
|
||||
|
||||
const RECORD_ITEM_ID = 'record';
|
||||
|
||||
type ChartGroupByFieldSelectionTargetObjectFieldsViewProps = {
|
||||
targetObjectNameSingular?: string;
|
||||
headerLabel: string;
|
||||
currentSubFieldName: string | undefined;
|
||||
isCurrentGroupByField: boolean;
|
||||
onBack: () => void;
|
||||
onSelectSubField: (subFieldName: string) => void;
|
||||
onSelectRecord: () => void;
|
||||
};
|
||||
|
||||
export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
|
||||
targetObjectNameSingular,
|
||||
headerLabel,
|
||||
currentSubFieldName,
|
||||
isCurrentGroupByField,
|
||||
onBack,
|
||||
onSelectSubField,
|
||||
onSelectRecord,
|
||||
}: ChartGroupByFieldSelectionTargetObjectFieldsViewProps) => {
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
@@ -70,7 +76,7 @@ export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
|
||||
|
||||
return filterBySearchQuery({
|
||||
items: targetObjectMetadataItem.fields.filter(
|
||||
(field) => !isHiddenSystemField(field) && !isFieldRelation(field),
|
||||
isFieldSupportedAsChartGroupBySubField,
|
||||
),
|
||||
searchQuery,
|
||||
getSearchableValues: (field) => [field.label, field.name],
|
||||
@@ -99,6 +105,12 @@ export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
|
||||
const [currentNestedFieldName, currentNestedSubFieldName] =
|
||||
currentSubFieldName?.split('.') ?? [];
|
||||
|
||||
const recordOptionLabel = t`Record`;
|
||||
|
||||
const isRecordOptionVisible = normalizeSearchText(recordOptionLabel).includes(
|
||||
normalizeSearchText(searchQuery),
|
||||
);
|
||||
|
||||
if (isDefined(selectedCompositeField)) {
|
||||
return (
|
||||
<ChartGroupByFieldSelectionCompositeFieldView
|
||||
@@ -131,15 +143,34 @@ export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer>
|
||||
{availableFields.length === 0 ? (
|
||||
<MenuItem text={t`No fields available`} />
|
||||
) : (
|
||||
<SelectableList
|
||||
selectableListInstanceId={dropdownId}
|
||||
focusId={dropdownId}
|
||||
selectableItemIdArray={availableFields.map((field) => field.id)}
|
||||
>
|
||||
{availableFields.map((fieldMetadataItem) => (
|
||||
<SelectableList
|
||||
selectableListInstanceId={dropdownId}
|
||||
focusId={dropdownId}
|
||||
selectableItemIdArray={[
|
||||
...(isRecordOptionVisible ? [RECORD_ITEM_ID] : []),
|
||||
...availableFields.map((field) => field.id),
|
||||
]}
|
||||
>
|
||||
{isRecordOptionVisible && (
|
||||
<SelectableListItem
|
||||
itemId={RECORD_ITEM_ID}
|
||||
onEnter={onSelectRecord}
|
||||
>
|
||||
<MenuItemSelect
|
||||
text={recordOptionLabel}
|
||||
selected={
|
||||
isCurrentGroupByField && !isDefined(currentSubFieldName)
|
||||
}
|
||||
focused={selectedItemId === RECORD_ITEM_ID}
|
||||
LeftIcon={getIcon(targetObjectMetadataItem?.icon)}
|
||||
onClick={onSelectRecord}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
)}
|
||||
{availableFields.length === 0 && !isRecordOptionVisible ? (
|
||||
<MenuItem text={t`No fields available`} />
|
||||
) : (
|
||||
availableFields.map((fieldMetadataItem) => (
|
||||
<SelectableListItem
|
||||
key={fieldMetadataItem.id}
|
||||
itemId={fieldMetadataItem.id}
|
||||
@@ -161,9 +192,9 @@ export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
|
||||
}}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
))}
|
||||
</SelectableList>
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</SelectableList>
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
);
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isFieldSupportedAsChartGroupBySubField } from '@/side-panel/pages/page-layout/utils/isFieldSupportedAsChartGroupBySubField';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
const createFieldMetadataItem = (
|
||||
overrides: Partial<FieldMetadataItem>,
|
||||
): FieldMetadataItem =>
|
||||
({
|
||||
id: 'field-id',
|
||||
name: 'testField',
|
||||
label: 'Test Field',
|
||||
type: FieldMetadataType.TEXT,
|
||||
isSystem: false,
|
||||
...overrides,
|
||||
}) as FieldMetadataItem;
|
||||
|
||||
describe('isFieldSupportedAsChartGroupBySubField', () => {
|
||||
it('should accept a non-system scalar field', () => {
|
||||
expect(
|
||||
isFieldSupportedAsChartGroupBySubField(
|
||||
createFieldMetadataItem({ name: 'name', type: FieldMetadataType.TEXT }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a system field like userId', () => {
|
||||
expect(
|
||||
isFieldSupportedAsChartGroupBySubField(
|
||||
createFieldMetadataItem({
|
||||
name: 'userId',
|
||||
type: FieldMetadataType.UUID,
|
||||
isSystem: true,
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept system createdAt and updatedAt date fields', () => {
|
||||
expect(
|
||||
isFieldSupportedAsChartGroupBySubField(
|
||||
createFieldMetadataItem({
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
isSystem: true,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isFieldSupportedAsChartGroupBySubField(
|
||||
createFieldMetadataItem({
|
||||
name: 'updatedAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
isSystem: true,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject relation and morph relation fields', () => {
|
||||
expect(
|
||||
isFieldSupportedAsChartGroupBySubField(
|
||||
createFieldMetadataItem({ type: FieldMetadataType.RELATION }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isFieldSupportedAsChartGroupBySubField(
|
||||
createFieldMetadataItem({ type: FieldMetadataType.MORPH_RELATION }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject field types unsupported in group by', () => {
|
||||
expect(
|
||||
isFieldSupportedAsChartGroupBySubField(
|
||||
createFieldMetadataItem({ type: FieldMetadataType.RAW_JSON }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
|
||||
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
|
||||
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
|
||||
import { isFieldMetadataSupportedInGroupBy } from 'twenty-shared/utils';
|
||||
|
||||
export const isFieldSupportedAsChartGroupBySubField = (
|
||||
field: FieldMetadataItem,
|
||||
): boolean => {
|
||||
if (
|
||||
isHiddenSystemField(field) ||
|
||||
isFieldRelation(field) ||
|
||||
isFieldMorphRelation(field)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isFieldMetadataSupportedInGroupBy({
|
||||
type: field.type,
|
||||
name: field.name,
|
||||
isSystem: field.isSystem ?? false,
|
||||
relationType: field.settings?.relationType ?? null,
|
||||
});
|
||||
};
|
||||
+273
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+13
-1
@@ -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
|
||||
) {
|
||||
|
||||
+20
-1
@@ -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,
|
||||
|
||||
-4
@@ -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,
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const CHART_RELATION_LABEL_BATCH_SIZE = 500;
|
||||
+112
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+198
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
+358
@@ -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']));
|
||||
});
|
||||
});
|
||||
+97
@@ -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([
|
||||
{
|
||||
|
||||
+113
@@ -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 },
|
||||
|
||||
+86
-109
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+272
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
-62
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+53
-29
@@ -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 }],
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type RelationLabelResolution = {
|
||||
labelByRecordId: Map<string, string>;
|
||||
unresolvedRecordIds: Set<string>;
|
||||
};
|
||||
+8
@@ -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[];
|
||||
};
|
||||
+103
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+91
@@ -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');
|
||||
});
|
||||
});
|
||||
+112
@@ -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);
|
||||
});
|
||||
});
|
||||
+69
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+113
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+98
@@ -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,
|
||||
|
||||
+96
@@ -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();
|
||||
});
|
||||
});
|
||||
+246
-121
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+141
-30
@@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+368
-87
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+111
@@ -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');
|
||||
});
|
||||
});
|
||||
+20
@@ -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;
|
||||
};
|
||||
+16
@@ -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;
|
||||
};
|
||||
+35
@@ -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;
|
||||
};
|
||||
+35
@@ -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);
|
||||
};
|
||||
+66
@@ -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;
|
||||
};
|
||||
+77
@@ -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,
|
||||
};
|
||||
};
|
||||
+60
@@ -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 };
|
||||
};
|
||||
+9
@@ -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;
|
||||
};
|
||||
|
||||
+23
@@ -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 };
|
||||
};
|
||||
+23
@@ -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,
|
||||
);
|
||||
};
|
||||
+40
@@ -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;
|
||||
});
|
||||
};
|
||||
+12
-1
@@ -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);
|
||||
|
||||
|
||||
+85
@@ -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,
|
||||
];
|
||||
};
|
||||
+47
@@ -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];
|
||||
};
|
||||
+40
@@ -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;
|
||||
};
|
||||
+41
-21
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
+19
-19
@@ -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:
|
||||
|
||||
+49
-25
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
];
|
||||
};
|
||||
|
||||
+4
@@ -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)) {
|
||||
|
||||
+8
@@ -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)) {
|
||||
|
||||
+2
-2
@@ -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(),
|
||||
|
||||
+60
-7
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+191
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user