Connect the bar chart to the group by resolver (#14885)
Closes https://github.com/twentyhq/core-team-issues/issues/1535 Video QA: https://github.com/user-attachments/assets/153fa3f1-08d9-4952-9456-635c602e1f57 https://github.com/user-attachments/assets/d3111afb-4c84-4f57-8a56-5cf666748c86 There are still some formatting and design issues (the labels which are on top of each other and the values which should be formatted) that will be fixed in a future PR --------- Co-authored-by: ehconitin <nitinkoche03@gmail.com>
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-01-15T00:00:00.000Z 1`] = `"Q1 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-04-15T00:00:00.000Z 1`] = `"Q2 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-07-15T00:00:00.000Z 1`] = `"Q3 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-10-15T00:00:00.000Z 1`] = `"Q4 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for DAY granularity 1`] = `"Mar 20, 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for MONTH granularity 1`] = `"March 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for NONE granularity 1`] = `"3/20/2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for QUARTER granularity 1`] = `"Q1 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for YEAR granularity 1`] = `"2024"`;
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`generateGroupByQuery should generate valid GraphQL query for empty aggregate operations 1`] = `
|
||||
"
|
||||
query PeopleGroupBy(
|
||||
$groupBy: [PersonGroupByInput!]
|
||||
$filter: PersonFilterInput
|
||||
$orderBy: [PersonOrderByWithGroupByInput!]
|
||||
$viewId: UUID
|
||||
) {
|
||||
peopleGroupBy(
|
||||
groupBy: $groupBy
|
||||
filter: $filter
|
||||
orderBy: $orderBy
|
||||
viewId: $viewId
|
||||
) {
|
||||
groupByDimensionValues
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`generateGroupByQuery should generate valid GraphQL query for multiple aggregate operations 1`] = `
|
||||
"
|
||||
query OpportunitiesGroupBy(
|
||||
$groupBy: [OpportunityGroupByInput!]
|
||||
$filter: OpportunityFilterInput
|
||||
$orderBy: [OpportunityOrderByWithGroupByInput!]
|
||||
$viewId: UUID
|
||||
) {
|
||||
opportunitiesGroupBy(
|
||||
groupBy: $groupBy
|
||||
filter: $filter
|
||||
orderBy: $orderBy
|
||||
viewId: $viewId
|
||||
) {
|
||||
groupByDimensionValues
|
||||
totalCount
|
||||
sumAmountAmountMicros
|
||||
avgAmountAmountMicros
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`generateGroupByQuery should generate valid GraphQL query for single aggregate operation 1`] = `
|
||||
"
|
||||
query OpportunitiesGroupBy(
|
||||
$groupBy: [OpportunityGroupByInput!]
|
||||
$filter: OpportunityFilterInput
|
||||
$orderBy: [OpportunityOrderByWithGroupByInput!]
|
||||
$viewId: UUID
|
||||
) {
|
||||
opportunitiesGroupBy(
|
||||
groupBy: $groupBy
|
||||
filter: $filter
|
||||
orderBy: $orderBy
|
||||
viewId: $viewId
|
||||
) {
|
||||
groupByDimensionValues
|
||||
sumAmountAmountMicros
|
||||
}
|
||||
}
|
||||
"
|
||||
`;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`generateGroupByQueryVariablesFromBarChartConfiguration should generate variables with composite field 1`] = `
|
||||
{
|
||||
"groupBy": [
|
||||
{
|
||||
"name": {
|
||||
"firstName": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`generateGroupByQueryVariablesFromBarChartConfiguration should generate variables with single groupBy field 1`] = `
|
||||
{
|
||||
"groupBy": [
|
||||
{
|
||||
"stage": true,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`generateGroupByQueryVariablesFromBarChartConfiguration should generate variables with two groupBy fields 1`] = `
|
||||
{
|
||||
"groupBy": [
|
||||
{
|
||||
"stage": true,
|
||||
},
|
||||
{
|
||||
"ownerId": true,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject';
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
describe('buildGroupByFieldObject', () => {
|
||||
it('should return field with Id suffix for relation fields', () => {
|
||||
const field = {
|
||||
name: 'company',
|
||||
type: FieldMetadataType.RELATION,
|
||||
} as any;
|
||||
|
||||
const result = buildGroupByFieldObject({ field });
|
||||
|
||||
expect(result).toEqual({ companyId: true });
|
||||
});
|
||||
|
||||
it('should return nested object for composite fields with subfield', () => {
|
||||
const field = {
|
||||
name: 'name',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
} as any;
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
field,
|
||||
subFieldName: 'firstName',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: {
|
||||
firstName: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for composite field without subfield', () => {
|
||||
const field = {
|
||||
name: 'name',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
} as any;
|
||||
|
||||
expect(() => buildGroupByFieldObject({ field })).toThrow(
|
||||
'Composite field name requires a subfield to be specified',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return field with default granularity for DATE field', () => {
|
||||
const field = {
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE,
|
||||
} as any;
|
||||
|
||||
const result = buildGroupByFieldObject({ field });
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return field with default granularity for DATE_TIME field', () => {
|
||||
const field = {
|
||||
name: 'updatedAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
} as any;
|
||||
|
||||
const result = buildGroupByFieldObject({ field });
|
||||
|
||||
expect(result).toEqual({
|
||||
updatedAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return field with custom granularity for date field', () => {
|
||||
const field = {
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE,
|
||||
} as any;
|
||||
|
||||
const result = buildGroupByFieldObject({
|
||||
field,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return simple field object for regular fields', () => {
|
||||
const field = {
|
||||
name: 'status',
|
||||
type: FieldMetadataType.TEXT,
|
||||
} as any;
|
||||
|
||||
const result = buildGroupByFieldObject({ field });
|
||||
|
||||
expect(result).toEqual({ status: true });
|
||||
});
|
||||
});
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { computeAggregateNumericValueForGraph } from '../computeAggregateNumericValueForGraph';
|
||||
|
||||
describe('computeAggregateNumericValueForGraph', () => {
|
||||
it('should throw error for empty data', () => {
|
||||
expect(() =>
|
||||
computeAggregateNumericValueForGraph({
|
||||
data: {},
|
||||
objectMetadataItem: { fields: [] } as any,
|
||||
}),
|
||||
).toThrow('Empty aggregate records data');
|
||||
});
|
||||
|
||||
it('should return count value for COUNT operation', () => {
|
||||
const result = computeAggregateNumericValueForGraph({
|
||||
data: { status: { COUNT: 25 } },
|
||||
objectMetadataItem: {
|
||||
fields: [{ id: 'field-1', name: 'status' }],
|
||||
} as any,
|
||||
fieldMetadataId: 'field-1',
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
});
|
||||
|
||||
expect(result).toBe(25);
|
||||
});
|
||||
|
||||
it('should multiply by 100 for percentage operations', () => {
|
||||
const result = computeAggregateNumericValueForGraph({
|
||||
data: { status: { PERCENTAGE_EMPTY: 0.25 } },
|
||||
objectMetadataItem: {
|
||||
fields: [{ id: 'field-1', name: 'status' }],
|
||||
} as any,
|
||||
fieldMetadataId: 'field-1',
|
||||
aggregateOperation: AggregateOperations.PERCENTAGE_EMPTY,
|
||||
});
|
||||
|
||||
expect(result).toBe(25);
|
||||
});
|
||||
|
||||
it('should return numeric value for NUMBER field', () => {
|
||||
const result = computeAggregateNumericValueForGraph({
|
||||
data: { amount: { SUM: 1000 } },
|
||||
objectMetadataItem: {
|
||||
fields: [
|
||||
{ id: 'field-1', name: 'amount', type: FieldMetadataType.NUMBER },
|
||||
],
|
||||
} as any,
|
||||
fieldMetadataId: 'field-1',
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
});
|
||||
|
||||
expect(result).toBe(1000);
|
||||
});
|
||||
|
||||
it('should return timestamp for DATE field', () => {
|
||||
const dateString = '2024-01-15';
|
||||
const result = computeAggregateNumericValueForGraph({
|
||||
data: { createdAt: { MIN: dateString } },
|
||||
objectMetadataItem: {
|
||||
fields: [
|
||||
{ id: 'field-1', name: 'createdAt', type: FieldMetadataType.DATE },
|
||||
],
|
||||
} as any,
|
||||
fieldMetadataId: 'field-1',
|
||||
aggregateOperation: AggregateOperations.MIN,
|
||||
});
|
||||
|
||||
expect(result).toBe(new Date(dateString).getTime());
|
||||
});
|
||||
});
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { formatDateByGranularity } from '../formatDateByGranularity';
|
||||
|
||||
describe('formatDateByGranularity', () => {
|
||||
const testDate = new Date('2024-03-20T12:00:00Z');
|
||||
|
||||
beforeAll(() => {
|
||||
// Mock toLocaleDateString to avoid timezone/locale issues
|
||||
jest
|
||||
.spyOn(Date.prototype, 'toLocaleDateString')
|
||||
.mockImplementation((_locales, options) => {
|
||||
if (options?.weekday === 'long') return 'Wednesday';
|
||||
if (options?.month === 'long' && !isDefined(options?.year))
|
||||
return 'March';
|
||||
if (options?.month === 'long' && isDefined(options?.year))
|
||||
return 'March 2024';
|
||||
if (options?.month === 'short') return 'Mar 20, 2024';
|
||||
return '3/20/2024';
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const testCases: {
|
||||
granularity:
|
||||
| ObjectRecordGroupByDateGranularity.DAY
|
||||
| ObjectRecordGroupByDateGranularity.MONTH
|
||||
| ObjectRecordGroupByDateGranularity.QUARTER
|
||||
| ObjectRecordGroupByDateGranularity.YEAR
|
||||
| ObjectRecordGroupByDateGranularity.NONE;
|
||||
}[] = [
|
||||
{ granularity: ObjectRecordGroupByDateGranularity.DAY },
|
||||
{ granularity: ObjectRecordGroupByDateGranularity.MONTH },
|
||||
{ granularity: ObjectRecordGroupByDateGranularity.QUARTER },
|
||||
{ granularity: ObjectRecordGroupByDateGranularity.YEAR },
|
||||
{ granularity: ObjectRecordGroupByDateGranularity.NONE },
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'should format date for $granularity granularity',
|
||||
({ granularity }) => {
|
||||
expect(formatDateByGranularity(testDate, granularity)).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
|
||||
describe('quarter calculations', () => {
|
||||
const quarterTestCases: {
|
||||
date: Date;
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER;
|
||||
}[] = [
|
||||
{
|
||||
date: new Date('2024-01-15'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
date: new Date('2024-04-15'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
date: new Date('2024-07-15'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
date: new Date('2024-10-15'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(quarterTestCases)(
|
||||
'should calculate quarter for $granularity with date $date',
|
||||
({ date, granularity }) => {
|
||||
expect(formatDateByGranularity(date, granularity)).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { generateGroupByQuery } from '../generateGroupByQuery';
|
||||
|
||||
describe('generateGroupByQuery', () => {
|
||||
const testCases = [
|
||||
{
|
||||
description: 'single aggregate operation',
|
||||
objectMetadataItem: {
|
||||
nameSingular: 'opportunity',
|
||||
namePlural: 'opportunities',
|
||||
},
|
||||
aggregateOperations: ['sumAmountAmountMicros'],
|
||||
},
|
||||
{
|
||||
description: 'multiple aggregate operations',
|
||||
objectMetadataItem: {
|
||||
nameSingular: 'opportunity',
|
||||
namePlural: 'opportunities',
|
||||
},
|
||||
aggregateOperations: [
|
||||
'totalCount',
|
||||
'sumAmountAmountMicros',
|
||||
'avgAmountAmountMicros',
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'empty aggregate operations',
|
||||
objectMetadataItem: {
|
||||
nameSingular: 'person',
|
||||
namePlural: 'people',
|
||||
},
|
||||
aggregateOperations: [],
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'should generate valid GraphQL query for $description',
|
||||
({ objectMetadataItem, aggregateOperations }) => {
|
||||
const result = generateGroupByQuery({
|
||||
objectMetadataItem: objectMetadataItem as ObjectMetadataItem,
|
||||
aggregateOperations,
|
||||
});
|
||||
|
||||
expect(result.loc?.source.body).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
});
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import {
|
||||
type BarChartConfiguration,
|
||||
FieldMetadataType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { generateGroupByQueryVariablesFromBarChartConfiguration } from '../generateGroupByQueryVariablesFromBarChartConfiguration';
|
||||
|
||||
describe('generateGroupByQueryVariablesFromBarChartConfiguration', () => {
|
||||
const mockObjectMetadataItem: ObjectMetadataItem = {
|
||||
id: 'obj-1',
|
||||
nameSingular: 'opportunity',
|
||||
namePlural: 'opportunities',
|
||||
fields: [
|
||||
{
|
||||
id: 'field-1',
|
||||
name: 'stage',
|
||||
type: FieldMetadataType.TEXT,
|
||||
},
|
||||
{
|
||||
id: 'field-2',
|
||||
name: 'owner',
|
||||
type: FieldMetadataType.RELATION,
|
||||
},
|
||||
{
|
||||
id: 'field-3',
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
},
|
||||
{
|
||||
id: 'field-4',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.FULL_NAME,
|
||||
},
|
||||
],
|
||||
} as ObjectMetadataItem;
|
||||
|
||||
it('should generate variables with single groupBy field', () => {
|
||||
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
barChartConfiguration: {
|
||||
groupByFieldMetadataIdX: 'field-1',
|
||||
groupBySubFieldNameX: null,
|
||||
} as BarChartConfiguration,
|
||||
});
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should generate variables with two groupBy fields', () => {
|
||||
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
barChartConfiguration: {
|
||||
groupByFieldMetadataIdX: 'field-1',
|
||||
groupBySubFieldNameX: null,
|
||||
groupByFieldMetadataIdY: 'field-2',
|
||||
groupBySubFieldNameY: null,
|
||||
} as BarChartConfiguration,
|
||||
});
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should generate variables with composite field', () => {
|
||||
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
barChartConfiguration: {
|
||||
groupByFieldMetadataIdX: 'field-4',
|
||||
groupBySubFieldNameX: 'firstName',
|
||||
} as BarChartConfiguration,
|
||||
});
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should throw error when groupBy field X not found', () => {
|
||||
expect(() =>
|
||||
generateGroupByQueryVariablesFromBarChartConfiguration({
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
barChartConfiguration: {
|
||||
groupByFieldMetadataIdX: 'invalid-field',
|
||||
groupBySubFieldNameX: null,
|
||||
} as BarChartConfiguration,
|
||||
}),
|
||||
).toThrow('Field with id invalid-field not found in object metadata');
|
||||
});
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { getFieldKey } from '../getFieldKey';
|
||||
|
||||
describe('getFieldKey', () => {
|
||||
it('should return field name for non-composite field', () => {
|
||||
const field = {
|
||||
name: 'companyName',
|
||||
type: FieldMetadataType.TEXT,
|
||||
} as FieldMetadataItem;
|
||||
|
||||
expect(getFieldKey({ field })).toBe('companyName');
|
||||
});
|
||||
|
||||
it('should return composite key for composite field with subFieldName', () => {
|
||||
const field = {
|
||||
name: 'address',
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
} as FieldMetadataItem;
|
||||
|
||||
expect(getFieldKey({ field, subFieldName: 'city' })).toBe('address.city');
|
||||
});
|
||||
|
||||
it('should return field name when subFieldName is null', () => {
|
||||
const field = {
|
||||
name: 'address',
|
||||
type: FieldMetadataType.ADDRESS,
|
||||
} as FieldMetadataItem;
|
||||
|
||||
expect(getFieldKey({ field, subFieldName: null })).toBe('address');
|
||||
});
|
||||
});
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
||||
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
|
||||
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
|
||||
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
|
||||
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const buildGroupByFieldObject = ({
|
||||
field,
|
||||
subFieldName,
|
||||
dateGranularity,
|
||||
}: {
|
||||
field: FieldMetadataItem;
|
||||
subFieldName?: string | null;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
}): Record<string, boolean | Record<string, boolean | string>> => {
|
||||
const isRelation = isFieldRelation(field) || isFieldMorphRelation(field);
|
||||
const isComposite = isCompositeFieldType(field.type);
|
||||
const isDateField =
|
||||
field.type === FieldMetadataType.DATE ||
|
||||
field.type === FieldMetadataType.DATE_TIME;
|
||||
|
||||
if (isRelation) {
|
||||
return { [`${field.name}Id`]: true };
|
||||
}
|
||||
|
||||
if (isComposite) {
|
||||
if (!isDefined(subFieldName)) {
|
||||
throw new Error(
|
||||
`Composite field ${field.name} requires a subfield to be specified`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
[field.name]: {
|
||||
[subFieldName]: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isDateField) {
|
||||
return {
|
||||
[field.name]: {
|
||||
granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { [field.name]: true };
|
||||
};
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords';
|
||||
import { type AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
|
||||
import { COUNT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/countAggregateOperationOptions';
|
||||
import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-table/record-table-footer/constants/percentAggregateOperationOptions';
|
||||
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
|
||||
import { GRAPH_DEFAULT_AGGREGATE_VALUE } from '@/page-layout/widgets/graph/constants/GraphDefaultAggregateValue.constant';
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const computeAggregateNumericValueForGraph = ({
|
||||
data,
|
||||
objectMetadataItem,
|
||||
fieldMetadataId,
|
||||
aggregateOperation,
|
||||
}: {
|
||||
data: AggregateRecordsData;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
fieldMetadataId?: string | null;
|
||||
aggregateOperation?: ExtendedAggregateOperations | null;
|
||||
}): number => {
|
||||
if (isEmpty(data)) {
|
||||
throw new Error('Empty aggregate records data');
|
||||
}
|
||||
|
||||
const field = objectMetadataItem.fields?.find(
|
||||
(field) => field.id === fieldMetadataId,
|
||||
);
|
||||
|
||||
if (!isDefined(field)) {
|
||||
throw new Error(`Field with id ${fieldMetadataId} not found`);
|
||||
}
|
||||
|
||||
if (!isDefined(aggregateOperation)) {
|
||||
throw new Error(`Missing aggregate operation for field ${field.name}`);
|
||||
}
|
||||
|
||||
const aggregateValue = data[field.name]?.[aggregateOperation];
|
||||
|
||||
if (
|
||||
COUNT_AGGREGATE_OPERATION_OPTIONS.includes(
|
||||
aggregateOperation as AggregateOperations,
|
||||
)
|
||||
) {
|
||||
return isDefined(aggregateValue)
|
||||
? Number(aggregateValue)
|
||||
: GRAPH_DEFAULT_AGGREGATE_VALUE;
|
||||
}
|
||||
|
||||
if (!isDefined(aggregateValue)) {
|
||||
return GRAPH_DEFAULT_AGGREGATE_VALUE;
|
||||
}
|
||||
|
||||
if (
|
||||
PERCENT_AGGREGATE_OPERATION_OPTIONS.includes(
|
||||
aggregateOperation as AggregateOperations,
|
||||
)
|
||||
) {
|
||||
return Number(aggregateValue) * 100;
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case FieldMetadataType.CURRENCY:
|
||||
case FieldMetadataType.NUMBER: {
|
||||
return Number(aggregateValue);
|
||||
}
|
||||
|
||||
case FieldMetadataType.DATE_TIME:
|
||||
case FieldMetadataType.DATE: {
|
||||
return new Date(aggregateValue as string).getTime();
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported field type ${field.type}`);
|
||||
}
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
|
||||
import { computeAggregateNumericValueForGraph } from '@/page-layout/widgets/graph/utils/computeAggregateNumericValueForGraph';
|
||||
|
||||
type GroupByRawResult = {
|
||||
groupByDimensionValues: unknown[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type ComputeAggregateFromGroupByResultParams = {
|
||||
rawResult: GroupByRawResult;
|
||||
aggregateField: FieldMetadataItem;
|
||||
aggregateOperation: ExtendedAggregateOperations;
|
||||
aggregateOperationFromRawResult: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
};
|
||||
|
||||
export const computeAggregateValueFromGroupByResult = ({
|
||||
rawResult,
|
||||
aggregateField,
|
||||
aggregateOperation,
|
||||
aggregateOperationFromRawResult,
|
||||
objectMetadataItem,
|
||||
}: ComputeAggregateFromGroupByResultParams): number => {
|
||||
const transformedData = {
|
||||
[aggregateField.name]: {
|
||||
[aggregateOperation]: rawResult[aggregateOperationFromRawResult],
|
||||
},
|
||||
};
|
||||
|
||||
return computeAggregateNumericValueForGraph({
|
||||
data: transformedData,
|
||||
objectMetadataItem,
|
||||
fieldMetadataId: aggregateField.id,
|
||||
aggregateOperation: aggregateOperation,
|
||||
});
|
||||
};
|
||||
+8
@@ -80,4 +80,12 @@ export const createGraphColorRegistry = (
|
||||
},
|
||||
solid: theme.color.sky,
|
||||
},
|
||||
gray: {
|
||||
name: 'gray',
|
||||
gradient: {
|
||||
normal: [theme.adaptiveColors.gray1, theme.adaptiveColors.gray2],
|
||||
hover: [theme.adaptiveColors.gray3, theme.adaptiveColors.gray4],
|
||||
},
|
||||
solid: theme.color.gray,
|
||||
},
|
||||
});
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
|
||||
export const formatDateByGranularity = (
|
||||
date: Date,
|
||||
granularity:
|
||||
| ObjectRecordGroupByDateGranularity.DAY
|
||||
| ObjectRecordGroupByDateGranularity.MONTH
|
||||
| ObjectRecordGroupByDateGranularity.QUARTER
|
||||
| ObjectRecordGroupByDateGranularity.YEAR
|
||||
| ObjectRecordGroupByDateGranularity.NONE,
|
||||
): string => {
|
||||
switch (granularity) {
|
||||
case ObjectRecordGroupByDateGranularity.DAY:
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
case ObjectRecordGroupByDateGranularity.MONTH:
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
case ObjectRecordGroupByDateGranularity.QUARTER:
|
||||
return `Q${Math.floor(date.getMonth() / 3) + 1} ${date.getFullYear()}`;
|
||||
case ObjectRecordGroupByDateGranularity.YEAR:
|
||||
return date.getFullYear().toString();
|
||||
case ObjectRecordGroupByDateGranularity.NONE:
|
||||
default:
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
};
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
|
||||
import { formatDateByGranularity } from '@/page-layout/widgets/graph/utils/formatDateByGranularity';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type FormatDimensionValueParams = {
|
||||
value: unknown;
|
||||
fieldMetadata: FieldMetadataItem;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
};
|
||||
|
||||
const normalizeMultiSelectValue = (value: unknown): unknown[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return [value];
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
const isPostgresArrayFormat =
|
||||
trimmed.startsWith('{') && trimmed.endsWith('}');
|
||||
|
||||
if (!isPostgresArrayFormat) {
|
||||
return [value];
|
||||
}
|
||||
|
||||
const content = trimmed.slice(1, -1);
|
||||
return content ? content.split(',') : [];
|
||||
};
|
||||
|
||||
export const formatDimensionValue = ({
|
||||
value,
|
||||
fieldMetadata,
|
||||
dateGranularity = GRAPH_DEFAULT_DATE_GRANULARITY as ObjectRecordGroupByDateGranularity,
|
||||
}: FormatDimensionValueParams): string => {
|
||||
if (!isDefined(value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (fieldMetadata.type) {
|
||||
case FieldMetadataType.SELECT: {
|
||||
const selectedOption = fieldMetadata.options?.find(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
return selectedOption?.label ?? String(value);
|
||||
}
|
||||
|
||||
case FieldMetadataType.MULTI_SELECT: {
|
||||
const values = normalizeMultiSelectValue(value);
|
||||
|
||||
return values
|
||||
.map((value) => {
|
||||
const option = fieldMetadata.options?.find(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
return option?.label ?? String(value);
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
case FieldMetadataType.BOOLEAN: {
|
||||
return value === true ? t`Yes` : t`No`;
|
||||
}
|
||||
|
||||
case FieldMetadataType.DATE:
|
||||
case FieldMetadataType.DATE_TIME: {
|
||||
// TODO: granularity will be passed from the graph configuration when implemented
|
||||
if (
|
||||
dateGranularity ===
|
||||
ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK ||
|
||||
dateGranularity ===
|
||||
ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR ||
|
||||
dateGranularity ===
|
||||
ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR
|
||||
) {
|
||||
return String(value);
|
||||
}
|
||||
return formatDateByGranularity(new Date(String(value)), dateGranularity);
|
||||
}
|
||||
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
import { getGroupByQueryName } from '../../../utils/getGroupByQueryName';
|
||||
|
||||
export const generateGroupByQuery = ({
|
||||
objectMetadataItem,
|
||||
aggregateOperations,
|
||||
}: {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
aggregateOperations: string[];
|
||||
}) => {
|
||||
const capitalizedSingular = capitalize(objectMetadataItem.nameSingular);
|
||||
const queryName = `${capitalize(objectMetadataItem.namePlural)}GroupBy`;
|
||||
const queryFieldName = getGroupByQueryName(objectMetadataItem);
|
||||
|
||||
return gql`
|
||||
query ${queryName}(
|
||||
$groupBy: [${capitalizedSingular}GroupByInput!]
|
||||
$filter: ${capitalizedSingular}FilterInput
|
||||
$orderBy: [${capitalizedSingular}OrderByWithGroupByInput!]
|
||||
$viewId: UUID
|
||||
) {
|
||||
${queryFieldName}(
|
||||
groupBy: $groupBy
|
||||
filter: $filter
|
||||
orderBy: $orderBy
|
||||
viewId: $viewId
|
||||
) {
|
||||
groupByDimensionValues${aggregateOperations.length > 0 ? `\n ${aggregateOperations.join('\n ')}` : ''}
|
||||
}
|
||||
}
|
||||
`;
|
||||
};
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||
import { type BarChartConfiguration, GraphOrderBy } from '~/generated/graphql';
|
||||
import { buildGroupByFieldObject } from './buildGroupByFieldObject';
|
||||
|
||||
const _mapOrderByToDirection = (orderByEnum: GraphOrderBy) => {
|
||||
switch (orderByEnum) {
|
||||
case GraphOrderBy.FIELD_ASC:
|
||||
return 'AscNullsLast';
|
||||
case GraphOrderBy.FIELD_DESC:
|
||||
return 'DescNullsLast';
|
||||
case GraphOrderBy.VALUE_ASC:
|
||||
return 'AscNullsLast';
|
||||
case GraphOrderBy.VALUE_DESC:
|
||||
return 'DescNullsLast';
|
||||
default:
|
||||
assertUnreachable(orderByEnum);
|
||||
}
|
||||
};
|
||||
|
||||
export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
|
||||
objectMetadataItem,
|
||||
barChartConfiguration,
|
||||
}: {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
barChartConfiguration: BarChartConfiguration;
|
||||
}) => {
|
||||
const groupByFieldX = objectMetadataItem.fields.find(
|
||||
(field) => field.id === barChartConfiguration.groupByFieldMetadataIdX,
|
||||
);
|
||||
|
||||
const groupByFieldY = objectMetadataItem.fields.find(
|
||||
(field) => field.id === barChartConfiguration.groupByFieldMetadataIdY,
|
||||
);
|
||||
|
||||
if (!isDefined(groupByFieldX)) {
|
||||
throw new Error(
|
||||
`Field with id ${barChartConfiguration.groupByFieldMetadataIdX} not found in object metadata`,
|
||||
);
|
||||
}
|
||||
|
||||
const groupBy: Array<
|
||||
Record<string, boolean | Record<string, boolean | string>>
|
||||
> = [];
|
||||
|
||||
groupBy.push(
|
||||
buildGroupByFieldObject({
|
||||
field: groupByFieldX,
|
||||
subFieldName: barChartConfiguration.groupBySubFieldNameX,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isDefined(groupByFieldY)) {
|
||||
groupBy.push(
|
||||
buildGroupByFieldObject({
|
||||
field: groupByFieldY,
|
||||
subFieldName: barChartConfiguration.groupBySubFieldNameY,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const orderBy: Array<Record<string, string>> = [];
|
||||
|
||||
// TODO: Add orderBy back in when the backend is ready
|
||||
// if (isDefined(barChartConfiguration.orderByX)) {
|
||||
// orderBy.push({
|
||||
// [groupByFieldX.name]: mapOrderByToDirection(
|
||||
// barChartConfiguration.orderByX,
|
||||
// ),
|
||||
// });
|
||||
// }
|
||||
|
||||
// if (isDefined(groupByFieldY) && isDefined(barChartConfiguration.orderByY)) {
|
||||
// orderBy.push({
|
||||
// [groupByFieldY.name]: mapOrderByToDirection(
|
||||
// barChartConfiguration.orderByY,
|
||||
// ),
|
||||
// });
|
||||
// }
|
||||
|
||||
return {
|
||||
groupBy,
|
||||
// TODO: Add filters
|
||||
...(orderBy.length > 0 && { orderBy }),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type Maybe } from '~/generated/graphql';
|
||||
|
||||
type GetFieldKeyParams = {
|
||||
field: FieldMetadataItem;
|
||||
subFieldName?: Maybe<string>;
|
||||
};
|
||||
|
||||
export const getFieldKey = ({
|
||||
field,
|
||||
subFieldName,
|
||||
}: GetFieldKeyParams): string => {
|
||||
const isComposite = isCompositeFieldType(field.type);
|
||||
|
||||
return isComposite && isDefined(subFieldName)
|
||||
? `${field.name}.${subFieldName}`
|
||||
: field.name;
|
||||
};
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
|
||||
import { getGroupByQueryName } from '@/page-layout/utils/getGroupByQueryName';
|
||||
import { GRAPH_MAXIMUM_NUMBER_OF_GROUPS } from '@/page-layout/widgets/graph/constants/GraphMaximumNumberOfGroups.constant';
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
|
||||
import { transformOneDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToBarChartData';
|
||||
import { transformTwoDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToBarChartData';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
AxisNameDisplay,
|
||||
type BarChartConfiguration,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
type TransformGroupByDataToBarChartDataParams = {
|
||||
groupByData: Record<string, GroupByRawResult[]> | null | undefined;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
configuration: BarChartConfiguration;
|
||||
aggregateOperation: string;
|
||||
};
|
||||
|
||||
type TransformGroupByDataToBarChartDataResult = {
|
||||
data: BarChartDataItem[];
|
||||
indexBy: string;
|
||||
keys: string[];
|
||||
series: BarChartSeries[];
|
||||
xAxisLabel?: string;
|
||||
yAxisLabel?: string;
|
||||
showDataLabels: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_BAR_CHART_RESULT: TransformGroupByDataToBarChartDataResult = {
|
||||
data: [],
|
||||
indexBy: '',
|
||||
keys: [],
|
||||
series: [],
|
||||
xAxisLabel: undefined,
|
||||
yAxisLabel: undefined,
|
||||
showDataLabels: false,
|
||||
};
|
||||
|
||||
export const transformGroupByDataToBarChartData = ({
|
||||
groupByData,
|
||||
objectMetadataItem,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
}: TransformGroupByDataToBarChartDataParams): TransformGroupByDataToBarChartDataResult => {
|
||||
if (!isDefined(groupByData)) {
|
||||
return EMPTY_BAR_CHART_RESULT;
|
||||
}
|
||||
|
||||
const groupByFieldX = objectMetadataItem.fields.find(
|
||||
(field: FieldMetadataItem) =>
|
||||
field.id === configuration.groupByFieldMetadataIdX,
|
||||
);
|
||||
|
||||
const groupByFieldY = objectMetadataItem.fields.find(
|
||||
(field: FieldMetadataItem) =>
|
||||
field.id === configuration.groupByFieldMetadataIdY,
|
||||
);
|
||||
|
||||
const aggregateField = objectMetadataItem.fields.find(
|
||||
(field: FieldMetadataItem) =>
|
||||
field.id === configuration.aggregateFieldMetadataId,
|
||||
);
|
||||
|
||||
if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) {
|
||||
return EMPTY_BAR_CHART_RESULT;
|
||||
}
|
||||
|
||||
const indexByKey = getFieldKey({
|
||||
field: groupByFieldX,
|
||||
subFieldName: configuration.groupBySubFieldNameX,
|
||||
});
|
||||
|
||||
const queryName = getGroupByQueryName(objectMetadataItem);
|
||||
const queryResults = groupByData[queryName];
|
||||
|
||||
if (!isDefined(queryResults) || !Array.isArray(queryResults)) {
|
||||
return {
|
||||
...EMPTY_BAR_CHART_RESULT,
|
||||
indexBy: indexByKey,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
|
||||
const rawResults = queryResults.slice(0, GRAPH_MAXIMUM_NUMBER_OF_GROUPS);
|
||||
|
||||
const showXAxis =
|
||||
configuration.axisNameDisplay === AxisNameDisplay.X ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
|
||||
|
||||
const showYAxis =
|
||||
configuration.axisNameDisplay === AxisNameDisplay.Y ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
|
||||
|
||||
const xAxisLabel = showXAxis ? groupByFieldX.label : undefined;
|
||||
|
||||
const yAxisLabel = showYAxis
|
||||
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
|
||||
: undefined;
|
||||
|
||||
const showDataLabels = configuration.displayDataLabel;
|
||||
|
||||
const baseResult = isDefined(groupByFieldY)
|
||||
? transformTwoDimensionalGroupByToBarChartData({
|
||||
rawResults,
|
||||
groupByFieldX,
|
||||
groupByFieldY,
|
||||
aggregateField,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
objectMetadataItem,
|
||||
})
|
||||
: transformOneDimensionalGroupByToBarChartData({
|
||||
rawResults,
|
||||
groupByFieldX,
|
||||
aggregateField,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
return {
|
||||
...baseResult,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
showDataLabels,
|
||||
};
|
||||
};
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
|
||||
import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant';
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
|
||||
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
|
||||
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
|
||||
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type BarChartConfiguration } from '~/generated/graphql';
|
||||
|
||||
type TransformOneDimensionalGroupByToBarChartDataParams = {
|
||||
rawResults: GroupByRawResult[];
|
||||
groupByFieldX: FieldMetadataItem;
|
||||
aggregateField: FieldMetadataItem;
|
||||
configuration: BarChartConfiguration;
|
||||
aggregateOperation: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
};
|
||||
|
||||
type TransformOneDimensionalGroupByToBarChartDataResult = {
|
||||
data: BarChartDataItem[];
|
||||
indexBy: string;
|
||||
keys: string[];
|
||||
series: BarChartSeries[];
|
||||
};
|
||||
|
||||
export const transformOneDimensionalGroupByToBarChartData = ({
|
||||
rawResults,
|
||||
groupByFieldX,
|
||||
aggregateField,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
objectMetadataItem,
|
||||
}: TransformOneDimensionalGroupByToBarChartDataParams): TransformOneDimensionalGroupByToBarChartDataResult => {
|
||||
const indexByKey = getFieldKey({
|
||||
field: groupByFieldX,
|
||||
subFieldName: configuration.groupBySubFieldNameX,
|
||||
});
|
||||
|
||||
const data: BarChartDataItem[] = rawResults.map((result) => {
|
||||
const dimensionValues = result.groupByDimensionValues;
|
||||
|
||||
const xValue = isDefined(dimensionValues?.[0])
|
||||
? formatDimensionValue({
|
||||
value: dimensionValues[0],
|
||||
fieldMetadata: groupByFieldX,
|
||||
})
|
||||
: '';
|
||||
|
||||
const aggregateValue = computeAggregateValueFromGroupByResult({
|
||||
rawResult: result,
|
||||
aggregateField,
|
||||
aggregateOperation:
|
||||
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
|
||||
aggregateOperationFromRawResult: aggregateOperation,
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
return {
|
||||
[indexByKey]: xValue,
|
||||
[aggregateField.name]: aggregateValue,
|
||||
};
|
||||
});
|
||||
|
||||
const series: BarChartSeries[] = [
|
||||
{
|
||||
key: aggregateField.name,
|
||||
label: aggregateField.label,
|
||||
color: (configuration.color ?? GRAPH_DEFAULT_COLOR) as GraphColor,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
data,
|
||||
indexBy: indexByKey,
|
||||
keys: [aggregateField.name],
|
||||
series,
|
||||
};
|
||||
};
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
|
||||
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
|
||||
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type BarChartConfiguration } from '~/generated/graphql';
|
||||
|
||||
type TransformTwoDimensionalGroupByToBarChartDataParams = {
|
||||
rawResults: GroupByRawResult[];
|
||||
groupByFieldX: FieldMetadataItem;
|
||||
groupByFieldY: FieldMetadataItem;
|
||||
aggregateField: FieldMetadataItem;
|
||||
configuration: BarChartConfiguration;
|
||||
aggregateOperation: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
};
|
||||
|
||||
type TransformTwoDimensionalGroupByToBarChartDataResult = {
|
||||
data: BarChartDataItem[];
|
||||
indexBy: string;
|
||||
keys: string[];
|
||||
series: BarChartSeries[];
|
||||
};
|
||||
|
||||
export const transformTwoDimensionalGroupByToBarChartData = ({
|
||||
rawResults,
|
||||
groupByFieldX,
|
||||
groupByFieldY,
|
||||
aggregateField,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
objectMetadataItem,
|
||||
}: TransformTwoDimensionalGroupByToBarChartDataParams): TransformTwoDimensionalGroupByToBarChartDataResult => {
|
||||
const indexByKey = getFieldKey({
|
||||
field: groupByFieldX,
|
||||
subFieldName: configuration.groupBySubFieldNameX,
|
||||
});
|
||||
|
||||
const dataMap = new Map<string, BarChartDataItem>();
|
||||
const yValues = new Set<string>();
|
||||
|
||||
rawResults.forEach((result) => {
|
||||
const dimensionValues = result.groupByDimensionValues;
|
||||
if (!isDefined(dimensionValues) || dimensionValues.length < 2) return;
|
||||
|
||||
const xValue = formatDimensionValue({
|
||||
value: dimensionValues[0],
|
||||
fieldMetadata: groupByFieldX,
|
||||
});
|
||||
const yValue = formatDimensionValue({
|
||||
value: dimensionValues[1],
|
||||
fieldMetadata: groupByFieldY,
|
||||
});
|
||||
|
||||
const aggregateValue = computeAggregateValueFromGroupByResult({
|
||||
rawResult: result,
|
||||
aggregateField,
|
||||
aggregateOperation:
|
||||
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
|
||||
aggregateOperationFromRawResult: aggregateOperation,
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
yValues.add(yValue);
|
||||
|
||||
if (!dataMap.has(xValue)) {
|
||||
dataMap.set(xValue, {
|
||||
[indexByKey]: xValue,
|
||||
});
|
||||
}
|
||||
|
||||
const dataItem = dataMap.get(xValue)!;
|
||||
dataItem[yValue] = aggregateValue;
|
||||
});
|
||||
|
||||
const keys = Array.from(yValues);
|
||||
const series: BarChartSeries[] = keys.map((key) => ({
|
||||
key,
|
||||
label: key,
|
||||
}));
|
||||
|
||||
return {
|
||||
data: Array.from(dataMap.values()),
|
||||
indexBy: indexByKey,
|
||||
keys,
|
||||
series,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user