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:
Raphaël Bosi
2025-10-09 14:54:33 +02:00
committed by GitHub
parent 973b4fef90
commit 875dc1a6b8
50 changed files with 1653 additions and 70 deletions
@@ -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"`;
@@ -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
}
}
"
`;
@@ -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,
},
],
}
`;
@@ -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 });
});
});
@@ -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());
});
});
@@ -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();
},
);
});
});
@@ -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();
},
);
});
@@ -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');
});
});
@@ -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');
});
});
@@ -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 };
};
@@ -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}`);
}
};
@@ -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,
});
};
@@ -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,
},
});
@@ -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();
}
};
@@ -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);
}
};
@@ -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 ')}` : ''}
}
}
`;
};
@@ -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;
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};