unlock relation date fields on dashboards (#16207)

This commit is contained in:
nitin
2025-12-01 22:18:52 +05:30
committed by GitHub
parent 32f387a966
commit a8e7d4dfc3
22 changed files with 818 additions and 25 deletions
@@ -96,6 +96,7 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => {
isGroupByEnabled as boolean,
configuration,
objectMetadataItem,
objectMetadataItems,
),
)
.map((item) => item.id),
@@ -144,6 +145,7 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => {
isGroupByEnabled as boolean,
configuration,
objectMetadataItem,
objectMetadataItems,
),
);
@@ -16,7 +16,7 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { useMemo, useState } from 'react';
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, useIcons } from 'twenty-ui/display';
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
@@ -70,11 +70,7 @@ export const ChartGroupByFieldSelectionRelationFieldView = ({
return filterBySearchQuery({
items: targetObjectMetadataItem.fields.filter(
(field) =>
!field.isSystem &&
!isFieldRelation(field) &&
// TODO: Backend doesn't fully support date fields for relation fields yet so we hide them for now. https://github.com/twentyhq/core-team-issues/issues/1935
!isFieldMetadataDateKind(field.type),
(field) => !field.isSystem && !isFieldRelation(field),
),
searchQuery,
getSearchableValues: (field) => [field.label, field.name],
@@ -385,6 +385,28 @@ describe('shouldHideChartSetting', () => {
});
describe('DATE_GRANULARITY (Pie Chart)', () => {
const relationField: any = {
id: 'relation-field-id',
name: 'company',
label: 'Company',
type: FieldMetadataType.RELATION,
relation: { targetObjectMetadata: { nameSingular: 'company' } },
};
const targetObjectMetadata: any = {
id: 'company-id',
nameSingular: 'company',
namePlural: 'companies',
fields: [
{
id: 'company-created-at',
name: 'createdAt',
label: 'Created At',
type: FieldMetadataType.DATE,
},
],
};
it('should show when group by field is a date field', () => {
const pieChartConfig: ChartConfiguration = {
__typename: 'PieChartConfiguration',
@@ -435,6 +457,30 @@ describe('shouldHideChartSetting', () => {
expect(result).toBe(true);
});
it('should show when group by field is a relation date subfield', () => {
const objectMetadataItemWithRelation: ObjectMetadataItem = {
...mockObjectMetadataItem,
fields: [...mockObjectMetadataItem.fields, relationField],
} as any;
const pieChartConfig: ChartConfiguration = {
__typename: 'PieChartConfiguration',
groupByFieldMetadataId: relationField.id,
groupBySubFieldName: 'createdAt',
} as any;
const result = shouldHideChartSetting(
mockDateGranularityItem,
'object-id',
true,
pieChartConfig,
objectMetadataItemWithRelation,
[objectMetadataItemWithRelation, targetObjectMetadata] as any,
);
expect(result).toBe(false);
});
});
});
});
@@ -3,12 +3,15 @@ import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layou
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { isNestedFieldDateType } from '@/page-layout/widgets/graph/utils/isNestedFieldDateType';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
const shouldHideDateGranularityBasedOnFieldType = (
fieldMetadataId: string | undefined | null,
subFieldName: string | undefined | null,
objectMetadataItem: ObjectMetadataItem,
objectMetadataItems: ObjectMetadataItem[],
): boolean => {
if (!isDefined(fieldMetadataId)) {
return true;
@@ -22,8 +25,8 @@ const shouldHideDateGranularityBasedOnFieldType = (
return true;
}
if (isFieldRelation(field)) {
return true;
if (isFieldRelation(field) && isDefined(subFieldName)) {
return !isNestedFieldDateType(field, subFieldName, objectMetadataItems);
}
return !isFieldMetadataDateKind(field.type);
@@ -35,6 +38,7 @@ export const shouldHideChartSetting = (
isGroupByEnabled: boolean,
configuration?: ChartConfiguration,
objectMetadataItem?: ObjectMetadataItem,
objectMetadataItems?: ObjectMetadataItem[],
): boolean => {
const hasNoObjectMetadata = !isNonEmptyString(objectMetadataId);
const dependsOnSource = item?.dependsOn?.includes(
@@ -53,7 +57,9 @@ export const shouldHideChartSetting = (
if (isBarOrLineChart) {
return shouldHideDateGranularityBasedOnFieldType(
configuration.primaryAxisGroupByFieldMetadataId,
configuration.primaryAxisGroupBySubFieldName,
objectMetadataItem,
objectMetadataItems ?? [],
);
}
}
@@ -66,7 +72,9 @@ export const shouldHideChartSetting = (
if (isBarOrLineChart) {
return shouldHideDateGranularityBasedOnFieldType(
configuration.secondaryAxisGroupByFieldMetadataId,
configuration.secondaryAxisGroupBySubFieldName,
objectMetadataItem,
objectMetadataItems ?? [],
);
}
}
@@ -75,7 +83,9 @@ export const shouldHideChartSetting = (
if (configuration.__typename === 'PieChartConfiguration') {
return shouldHideDateGranularityBasedOnFieldType(
configuration.groupByFieldMetadataId,
configuration.groupBySubFieldName,
objectMetadataItem,
objectMetadataItems ?? [],
);
}
}
@@ -0,0 +1,97 @@
import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData';
import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { GraphType } from '~/generated-metadata/graphql';
import { AxisNameDisplay } from '~/generated/graphql';
jest.mock(
'@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData',
() => ({
fillDateGapsInBarChartData: jest.fn(({ data }) => ({
data,
wasTruncated: true,
})),
}),
);
jest.mock(
'@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData',
() => ({
transformOneDimensionalGroupByToBarChartData: jest.fn(() => ({
data: [],
indexBy: 'x',
keys: ['value'],
series: [],
hasTooManyGroups: false,
formattedToRawLookup: new Map(),
})),
}),
);
jest.mock('@/page-layout/widgets/graph/utils/filterGroupByResults', () => ({
filterGroupByResults: jest.fn((args) => args.rawResults),
}));
const { fillDateGapsInBarChartData } = jest.requireMock(
'@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData',
) as { fillDateGapsInBarChartData: jest.Mock };
describe('transformGroupByDataToBarChartData', () => {
it('fills date gaps when grouping by a relation date subfield with granularity', () => {
const groupByField = {
id: 'group-by-field',
name: 'company',
type: FieldMetadataType.RELATION,
};
const aggregateField = {
id: 'aggregate-field',
name: 'count',
type: FieldMetadataType.NUMBER,
};
const objectMetadataItem = {
id: 'obj-1',
nameSingular: 'company',
namePlural: 'companies',
fields: [groupByField, aggregateField],
} as any;
const configuration = {
__typename: 'BarChartConfiguration',
aggregateFieldMetadataId: aggregateField.id,
aggregateOperation: 'COUNT',
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: groupByField.id,
primaryAxisGroupBySubFieldName: 'createdAt',
primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
axisNameDisplay: AxisNameDisplay.BOTH,
} as any;
const groupByData = {
companiesGroupBy: [
{ groupByDimensionValues: ['2024-01-01T00:00:00.000Z'], COUNT: 1 },
],
};
fillDateGapsInBarChartData.mockClear();
const result = transformGroupByDataToBarChartData({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation: 'COUNT',
});
expect(fillDateGapsInBarChartData).toHaveBeenCalledTimes(1);
expect(fillDateGapsInBarChartData).toHaveBeenCalledWith(
expect.objectContaining({
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
hasSecondDimension: false,
}),
);
expect(result.hasTooManyGroups).toBe(true);
});
});
@@ -150,11 +150,14 @@ export const transformGroupByDataToBarChartData = ({
const showLegend = configuration.displayLegend ?? true;
const isDateField = isFieldMetadataDateKind(groupByFieldX.type);
const isNestedDateField =
!isDateField && isDefined(configuration.primaryAxisDateGranularity);
const shouldApplyDateGapFill = isDateField || isNestedDateField;
const omitNullValues = configuration.omitNullValues ?? false;
const dateGapFillResult =
isDateField && !omitNullValues
shouldApplyDateGapFill && !omitNullValues
? fillDateGapsInBarChartData({
data: filteredResults,
keys: [aggregateField.name],
@@ -0,0 +1,153 @@
import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant';
import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartMaximumNumberOfSlices.constant';
import { transformGroupByDataToPieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData';
import {
AggregateOperations,
FieldMetadataType,
GraphType,
} from '~/generated-metadata/graphql';
import { type PieChartConfiguration } from '~/generated/graphql';
jest.mock(
'@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues',
() => ({
formatPrimaryDimensionValues: jest.fn(),
}),
);
const { formatPrimaryDimensionValues } = jest.requireMock(
'@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues',
) as { formatPrimaryDimensionValues: jest.Mock };
describe('transformGroupByDataToPieChartData', () => {
it('keeps null group buckets aligned with their aggregate values', () => {
const groupByField = {
id: 'group-by-field',
name: 'status',
type: FieldMetadataType.TEXT,
label: 'Status',
};
const aggregateField = {
id: 'aggregate-field',
name: 'id',
type: FieldMetadataType.UUID,
label: 'Id',
};
const objectMetadataItem = {
id: 'obj-1',
nameSingular: 'company',
namePlural: 'companies',
fields: [groupByField, aggregateField],
} as any;
const configuration = {
__typename: 'PieChartConfiguration',
aggregateFieldMetadataId: aggregateField.id,
aggregateOperation: AggregateOperations.COUNT,
graphType: GraphType.PIE,
groupByFieldMetadataId: groupByField.id,
displayLegend: true,
} as PieChartConfiguration;
const groupByData = {
companiesGroupBy: [
{ groupByDimensionValues: [null], COUNT: 2 },
{ groupByDimensionValues: ['Active'], COUNT: 5 },
],
};
formatPrimaryDimensionValues.mockReturnValue([
{
formattedPrimaryDimensionValue: 'Not Set',
rawPrimaryDimensionValue: null,
},
{
formattedPrimaryDimensionValue: 'Active',
rawPrimaryDimensionValue: 'Active',
},
]);
const result = transformGroupByDataToPieChartData({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation: 'COUNT',
});
expect(result.data).toEqual([
{ id: 'Not Set', value: 2, color: GRAPH_DEFAULT_COLOR },
{ id: 'Active', value: 5, color: GRAPH_DEFAULT_COLOR },
]);
expect(result.formattedToRawLookup.get('Not Set')).toBeNull();
expect(result.formattedToRawLookup.get('Active')).toBe('Active');
expect(result.hasTooManyGroups).toBe(false);
});
it('honors color configuration, hides legend, and flags too many groups', () => {
const groupByField = {
id: 'group-by-field',
name: 'status',
type: FieldMetadataType.TEXT,
label: 'Status',
};
const aggregateField = {
id: 'aggregate-field',
name: 'id',
type: FieldMetadataType.UUID,
label: 'Id',
};
const objectMetadataItem = {
id: 'obj-1',
nameSingular: 'company',
namePlural: 'companies',
fields: [groupByField, aggregateField],
} as any;
const configuration = {
__typename: 'PieChartConfiguration',
aggregateFieldMetadataId: aggregateField.id,
aggregateOperation: AggregateOperations.COUNT,
graphType: GraphType.PIE,
groupByFieldMetadataId: groupByField.id,
displayLegend: false,
color: 'red',
} as PieChartConfiguration;
const totalResults = PIE_CHART_MAXIMUM_NUMBER_OF_SLICES + 1;
const groupByData = {
companiesGroupBy: Array.from({ length: totalResults }).map(
(_unused, index) => ({
groupByDimensionValues: [`Group ${index}`],
COUNT: index + 1,
}),
),
};
formatPrimaryDimensionValues.mockReturnValue(
Array.from({ length: totalResults }).map((_unused, index) => ({
formattedPrimaryDimensionValue: `Group ${index}`,
rawPrimaryDimensionValue: `Group ${index}`,
})),
);
const result = transformGroupByDataToPieChartData({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation: 'COUNT',
});
expect(result.showLegend).toBe(false);
expect(result.hasTooManyGroups).toBe(true);
expect(result.data).toHaveLength(PIE_CHART_MAXIMUM_NUMBER_OF_SLICES);
expect(result.data[0]).toEqual({
id: 'Group 0',
value: 1,
color: 'red',
});
});
});
@@ -1,5 +1,6 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { generateGroupByAggregateQuery } from '@/object-record/record-aggregate/utils/generateGroupByAggregateQuery';
import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields';
import { useGraphWidgetQueryCommon } from '@/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon';
@@ -33,6 +34,8 @@ export const useGraphWidgetGroupByQuery = ({
configuration,
});
const { objectMetadataItems } = useObjectMetadataItems();
if (!isDefined(aggregateField)) {
throw new Error('Aggregate field not found');
}
@@ -63,6 +66,7 @@ export const useGraphWidgetGroupByQuery = ({
const groupByQueryVariables = isPieChart(configuration)
? generateGroupByQueryVariablesFromPieChartConfiguration({
objectMetadataItem,
objectMetadataItems,
chartConfiguration: configuration,
aggregateOperation: aggregateOperation,
limit,
@@ -70,6 +74,7 @@ export const useGraphWidgetGroupByQuery = ({
})
: generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem,
objectMetadataItems,
chartConfiguration: configuration as
| BarChartConfiguration
| LineChartConfiguration,
@@ -42,6 +42,26 @@ describe('buildGroupByFieldObject', () => {
});
});
it('should return date granularity for relation date field', () => {
const field = {
name: 'company',
type: FieldMetadataType.RELATION,
} as any;
const result = buildGroupByFieldObject({
field,
subFieldName: 'createdAt',
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
isNestedDateField: true,
});
expect(result).toEqual({
company: {
createdAt: { granularity: ObjectRecordGroupByDateGranularity.MONTH },
},
});
});
it('should return nested object for composite fields with subfield', () => {
const field = {
name: 'name',
@@ -0,0 +1,58 @@
import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
jest.mock('@/page-layout/widgets/graph/utils/formatDimensionValue', () => ({
formatDimensionValue: jest.fn((args: { value: unknown }) => {
return `formatted-${String(args.value)}`;
}),
}));
const { formatDimensionValue } = jest.requireMock(
'@/page-layout/widgets/graph/utils/formatDimensionValue',
) as { formatDimensionValue: jest.Mock };
describe('formatPrimaryDimensionValues', () => {
it('includes buckets where the primary dimension value is null', () => {
const result = formatPrimaryDimensionValues({
groupByRawResults: [
{ groupByDimensionValues: [null] },
{ groupByDimensionValues: ['Active'] },
],
primaryAxisGroupByField: {
name: 'status',
type: FieldMetadataType.TEXT,
} as any,
});
expect(result).toHaveLength(2);
expect(result[0].rawPrimaryDimensionValue).toBeNull();
expect(result[0].formattedPrimaryDimensionValue).toBe('formatted-null');
expect(result[1].formattedPrimaryDimensionValue).toBe('formatted-Active');
});
it('passes granularity and subfield to the formatter', () => {
formatDimensionValue.mockClear();
formatPrimaryDimensionValues({
groupByRawResults: [
{ groupByDimensionValues: ['2024-01-15T00:00:00.000Z'] },
],
primaryAxisGroupByField: {
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
} as any,
primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
primaryAxisGroupBySubFieldName: 'createdAt',
});
expect(formatDimensionValue).toHaveBeenCalledWith({
value: '2024-01-15T00:00:00.000Z',
fieldMetadata: expect.objectContaining({ name: 'createdAt' }),
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
subFieldName: 'createdAt',
});
});
});
@@ -1,6 +1,9 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { generateGroupByQueryVariablesFromBarOrLineChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration';
import { FieldMetadataType } from 'twenty-shared/types';
import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import {
AggregateOperations,
type BarChartConfiguration,
@@ -67,6 +70,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
const result =
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
@@ -81,6 +85,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
const result =
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
@@ -97,6 +102,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
const result =
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-4',
@@ -111,6 +117,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
const result =
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-3',
@@ -128,6 +135,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
const result =
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
@@ -142,6 +150,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
const result =
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
@@ -152,6 +161,98 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
expect(result).toMatchSnapshot();
});
});
it('applies date granularity when primary axis uses a relation date subfield', () => {
const relationField = {
id: 'field-rel',
name: 'company',
type: FieldMetadataType.RELATION,
relation: { targetObjectMetadata: { nameSingular: 'company' } },
};
const objectMetadataItem: ObjectMetadataItem = {
id: 'obj-main',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [relationField],
} as ObjectMetadataItem;
const targetObjectMetadata: ObjectMetadataItem = {
id: 'obj-company',
nameSingular: 'company',
namePlural: 'companies',
fields: [
{
id: 'company-created-at',
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
},
],
} as ObjectMetadataItem;
const result =
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem,
objectMetadataItems: [objectMetadataItem, targetObjectMetadata],
chartConfiguration: buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: relationField.id,
primaryAxisGroupBySubFieldName: 'createdAt',
}),
});
expect(result.groupBy[0]).toEqual({
company: {
createdAt: { granularity: ObjectRecordGroupByDateGranularity.DAY },
},
});
});
it('applies date granularity for relation date subfield in line charts as well', () => {
const relationField = {
id: 'field-rel',
name: 'company',
type: FieldMetadataType.RELATION,
relation: { targetObjectMetadata: { nameSingular: 'company' } },
};
const objectMetadataItem: ObjectMetadataItem = {
id: 'obj-main',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [relationField],
} as ObjectMetadataItem;
const targetObjectMetadata: ObjectMetadataItem = {
id: 'obj-company',
nameSingular: 'company',
namePlural: 'companies',
fields: [
{
id: 'company-created-at',
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
},
],
} as ObjectMetadataItem;
const result =
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem,
objectMetadataItems: [objectMetadataItem, targetObjectMetadata],
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: relationField.id,
primaryAxisGroupBySubFieldName: 'createdAt',
}),
});
expect(result.groupBy[0]).toEqual({
company: {
createdAt: { granularity: ObjectRecordGroupByDateGranularity.DAY },
},
});
});
});
describe('Line Chart Configuration', () => {
@@ -159,6 +260,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
const result =
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-1',
@@ -173,6 +275,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
const result =
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-1',
@@ -189,6 +292,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
const result =
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-3',
@@ -204,6 +308,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
const result =
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-4',
@@ -218,6 +323,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
const result =
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildLineChartConfiguration({
graphType: GraphType.LINE,
primaryAxisGroupByFieldMetadataId: 'field-1',
@@ -234,6 +340,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
expect(() =>
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildBarChartConfiguration({
primaryAxisGroupByFieldMetadataId: 'invalid-field',
}),
@@ -245,6 +352,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
expect(() =>
generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildLineChartConfiguration({
primaryAxisGroupByFieldMetadataId: 'invalid-field',
}),
@@ -6,6 +6,7 @@ import {
GraphType,
type PieChartConfiguration,
} from '~/generated-metadata/graphql';
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { generateGroupByQueryVariablesFromPieChartConfiguration } from '../generateGroupByQueryVariablesFromPieChartConfiguration';
describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => {
@@ -53,6 +54,7 @@ describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => {
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromPieChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildPieChartConfiguration({
groupByFieldMetadataId: 'field-1',
groupBySubFieldName: null,
@@ -65,6 +67,7 @@ describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => {
it('should generate variables with composite field', () => {
const result = generateGroupByQueryVariablesFromPieChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildPieChartConfiguration({
groupByFieldMetadataId: 'field-4',
groupBySubFieldName: 'firstName',
@@ -77,6 +80,7 @@ describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => {
it('should generate variables with date field and granularity', () => {
const result = generateGroupByQueryVariablesFromPieChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildPieChartConfiguration({
groupByFieldMetadataId: 'field-3',
groupBySubFieldName: null,
@@ -90,6 +94,7 @@ describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => {
it('should generate variables with limit', () => {
const result = generateGroupByQueryVariablesFromPieChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildPieChartConfiguration({
groupByFieldMetadataId: 'field-1',
}),
@@ -103,6 +108,7 @@ describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => {
it('should generate variables with orderBy', () => {
const result = generateGroupByQueryVariablesFromPieChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildPieChartConfiguration({
groupByFieldMetadataId: 'field-1',
orderBy: GraphOrderBy.VALUE_ASC,
@@ -115,11 +121,58 @@ describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => {
});
});
describe('Relation date subfield', () => {
it('applies date granularity when grouping by a relation date subfield', () => {
const relationField = {
id: 'field-rel',
name: 'company',
type: FieldMetadataType.RELATION,
relation: { targetObjectMetadata: { nameSingular: 'company' } },
};
const mainObjectMetadata: ObjectMetadataItem = {
id: 'obj-main',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [relationField],
} as ObjectMetadataItem;
const targetObjectMetadata: ObjectMetadataItem = {
id: 'obj-company',
nameSingular: 'company',
namePlural: 'companies',
fields: [
{
id: 'company-created-at',
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
},
],
} as ObjectMetadataItem;
const result = generateGroupByQueryVariablesFromPieChartConfiguration({
objectMetadataItem: mainObjectMetadata,
objectMetadataItems: [mainObjectMetadata, targetObjectMetadata],
chartConfiguration: buildPieChartConfiguration({
groupByFieldMetadataId: relationField.id,
groupBySubFieldName: 'createdAt',
}),
});
expect(result.groupBy[0]).toEqual({
company: {
createdAt: { granularity: ObjectRecordGroupByDateGranularity.DAY },
},
});
});
});
describe('Error Handling', () => {
it('should throw error when groupBy field not found', () => {
expect(() =>
generateGroupByQueryVariablesFromPieChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
objectMetadataItems: [],
chartConfiguration: buildPieChartConfiguration({
groupByFieldMetadataId: 'invalid-field',
}),
@@ -0,0 +1,58 @@
import { getRelationFieldOrderBy } from '@/page-layout/widgets/graph/utils/getRelationFieldOrderBy';
import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
OrderByDirection,
} from 'twenty-shared/types';
describe('getRelationFieldOrderBy', () => {
const relationField = {
name: 'company',
type: FieldMetadataType.RELATION,
} as any;
it('returns relation id ordering when no subfield provided', () => {
const orderBy = getRelationFieldOrderBy(
relationField,
null,
OrderByDirection.AscNullsLast,
);
expect(orderBy).toEqual({ companyId: OrderByDirection.AscNullsLast });
});
it('adds granularity when nested relation field is a date field', () => {
const orderBy = getRelationFieldOrderBy(
relationField,
'createdAt',
OrderByDirection.DescNullsLast,
undefined,
true,
);
expect(orderBy).toEqual({
company: {
createdAt: {
orderBy: OrderByDirection.DescNullsLast,
granularity: ObjectRecordGroupByDateGranularity.DAY,
},
},
});
});
it('returns nested relation ordering without granularity for non-date subfield', () => {
const orderBy = getRelationFieldOrderBy(
relationField,
'name',
OrderByDirection.AscNullsLast,
undefined,
false,
);
expect(orderBy).toEqual({
company: {
name: OrderByDirection.AscNullsLast,
},
});
});
});
@@ -0,0 +1,57 @@
import { isNestedFieldDateType } from '@/page-layout/widgets/graph/utils/isNestedFieldDateType';
import { FieldMetadataType } from 'twenty-shared/types';
describe('isNestedFieldDateType', () => {
const companyObject = {
id: 'company-id',
nameSingular: 'company',
namePlural: 'companies',
fields: [
{ id: 'createdAt', name: 'createdAt', type: FieldMetadataType.DATE_TIME },
{ id: 'name', name: 'name', type: FieldMetadataType.TEXT },
],
} as any;
const relationField = {
name: 'company',
type: FieldMetadataType.RELATION,
relation: { targetObjectMetadata: { nameSingular: 'company' } },
} as any;
it('returns true for a relation subfield that is a date type', () => {
const result = isNestedFieldDateType(relationField, 'createdAt', [
companyObject,
]);
expect(result).toBe(true);
});
it('returns false when the nested subfield is not a date type', () => {
const result = isNestedFieldDateType(relationField, 'name', [
companyObject,
]);
expect(result).toBe(false);
});
it('returns false when subFieldName is missing', () => {
const result = isNestedFieldDateType(relationField, undefined, [
companyObject,
]);
expect(result).toBe(false);
});
it('returns false for non-relation fields', () => {
const nonRelationField = {
name: 'status',
type: FieldMetadataType.TEXT,
} as any;
const result = isNestedFieldDateType(nonRelationField, 'createdAt', [
companyObject,
]);
expect(result).toBe(false);
});
});
@@ -20,11 +20,13 @@ export const buildGroupByFieldObject = ({
subFieldName,
dateGranularity,
firstDayOfTheWeek,
isNestedDateField,
}: {
field: FieldMetadataItem;
subFieldName?: string | null;
dateGranularity?: ObjectRecordGroupByDateGranularity;
firstDayOfTheWeek?: number | null;
isNestedDateField?: boolean;
}): GroupByFieldObject => {
const isRelation = isFieldRelation(field) || isFieldMorphRelation(field);
const isComposite = isCompositeFieldType(field.type);
@@ -39,6 +41,16 @@ export const buildGroupByFieldObject = ({
const nestedFieldName = parts[0];
const nestedSubFieldName = parts[1];
if (isNestedDateField === true || isDefined(dateGranularity)) {
return {
[field.name]: {
[nestedFieldName]: {
granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY,
},
},
};
}
if (isDefined(nestedSubFieldName)) {
return {
[field.name]: {
@@ -88,6 +88,26 @@ export const formatDimensionValue = ({
return formatDateByGranularity(new Date(String(value)), dateGranularity);
}
case FieldMetadataType.RELATION: {
if (isDefined(dateGranularity)) {
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,
);
}
return String(value);
}
case FieldMetadataType.NUMBER:
case FieldMetadataType.CURRENCY: {
if (
@@ -2,7 +2,6 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { isDefined } from 'twenty-shared/utils';
import { type ObjectRecordGroupByDateGranularity } from '~/generated/graphql';
type FormatPrimaryDimensionValuesParameters = {
@@ -27,12 +26,8 @@ export const formatPrimaryDimensionValues = ({
(accumulator, rawResult) => {
const groupByDimensionValues = rawResult.groupByDimensionValues;
if (!isDefined(groupByDimensionValues?.[0])) {
return accumulator;
}
const rawPrimaryDimensionValue =
groupByDimensionValues[0] as RawDimensionValue;
const rawPrimaryDimensionValue = (groupByDimensionValues?.[0] ??
null) as RawDimensionValue;
const formattedPrimaryDimensionValue = formatDimensionValue({
value: rawPrimaryDimensionValue,
@@ -1,6 +1,7 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
import { getGroupByOrderBy } from '@/page-layout/widgets/graph/utils/getGroupByOrderBy';
import { isNestedFieldDateType } from '@/page-layout/widgets/graph/utils/isNestedFieldDateType';
import {
type AggregateOrderByWithGroupByField,
type ObjectRecordOrderByForCompositeField,
@@ -20,12 +21,14 @@ import {
export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({
objectMetadataItem,
objectMetadataItems,
chartConfiguration,
aggregateOperation,
limit,
firstDayOfTheWeek,
}: {
objectMetadataItem: ObjectMetadataItem;
objectMetadataItems: ObjectMetadataItem[];
chartConfiguration: BarChartConfiguration | LineChartConfiguration;
aggregateOperation?: string;
limit?: number;
@@ -58,33 +61,50 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({
const isFieldXDate = isFieldMetadataDateKind(groupByFieldX.type);
const isFieldXNestedDate = isNestedFieldDateType(
groupByFieldX,
groupBySubFieldNameX,
objectMetadataItems,
);
const shouldApplyDateGranularityX = isFieldXDate || isFieldXNestedDate;
const groupBy: Array<GroupByFieldObject> = [];
groupBy.push(
buildGroupByFieldObject({
field: groupByFieldX,
subFieldName: groupBySubFieldNameX,
dateGranularity: isFieldXDate
dateGranularity: shouldApplyDateGranularityX
? (chartConfiguration.primaryAxisDateGranularity ??
GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
firstDayOfTheWeek,
isNestedDateField: isFieldXNestedDate,
}),
);
if (isDefined(groupByFieldY)) {
const isFieldYDate = isFieldMetadataDateKind(groupByFieldY.type);
const isFieldYNestedDate = isNestedFieldDateType(
groupByFieldY,
groupBySubFieldNameY,
objectMetadataItems,
);
const shouldApplyDateGranularityY = isFieldYDate || isFieldYNestedDate;
groupBy.push(
buildGroupByFieldObject({
field: groupByFieldY,
subFieldName: groupBySubFieldNameY,
dateGranularity: isFieldYDate
dateGranularity: shouldApplyDateGranularityY
? (chartConfiguration.secondaryAxisGroupByDateGranularity ??
GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
firstDayOfTheWeek,
isNestedDateField: isFieldYNestedDate,
}),
);
}
@@ -104,7 +124,7 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({
groupByField: groupByFieldX,
groupBySubFieldName: chartConfiguration.primaryAxisGroupBySubFieldName,
aggregateOperation,
dateGranularity: isFieldXDate
dateGranularity: shouldApplyDateGranularityX
? (chartConfiguration.primaryAxisDateGranularity ??
GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
@@ -117,6 +137,15 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({
) {
const isFieldYDateForOrderBy = isFieldMetadataDateKind(groupByFieldY.type);
const isFieldYNestedDateForOrderBy = isNestedFieldDateType(
groupByFieldY,
groupBySubFieldNameY,
objectMetadataItems,
);
const shouldApplyDateGranularityYForOrderBy =
isFieldYDateForOrderBy || isFieldYNestedDateForOrderBy;
orderBy.push(
getGroupByOrderBy({
graphOrderBy: chartConfiguration.secondaryAxisOrderBy,
@@ -124,7 +153,7 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({
groupBySubFieldName:
chartConfiguration.secondaryAxisGroupBySubFieldName,
aggregateOperation,
dateGranularity: isFieldYDateForOrderBy
dateGranularity: shouldApplyDateGranularityYForOrderBy
? (chartConfiguration.secondaryAxisGroupByDateGranularity ??
GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
@@ -1,6 +1,7 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
import { getGroupByOrderBy } from '@/page-layout/widgets/graph/utils/getGroupByOrderBy';
import { isNestedFieldDateType } from '@/page-layout/widgets/graph/utils/isNestedFieldDateType';
import {
type AggregateOrderByWithGroupByField,
type ObjectRecordOrderByForCompositeField,
@@ -17,12 +18,14 @@ import {
export const generateGroupByQueryVariablesFromPieChartConfiguration = ({
objectMetadataItem,
objectMetadataItems,
chartConfiguration,
aggregateOperation,
limit,
firstDayOfTheWeek,
}: {
objectMetadataItem: ObjectMetadataItem;
objectMetadataItems: ObjectMetadataItem[];
chartConfiguration: PieChartConfiguration;
aggregateOperation?: string;
limit?: number;
@@ -45,15 +48,23 @@ export const generateGroupByQueryVariablesFromPieChartConfiguration = ({
const isFieldDate = isFieldMetadataDateKind(groupByField.type);
const isNestedDate = isNestedFieldDateType(
groupByField,
groupBySubFieldName,
objectMetadataItems,
);
const shouldApplyDateGranularity = isFieldDate || isNestedDate;
const groupBy: Array<GroupByFieldObject> = [
buildGroupByFieldObject({
field: groupByField,
subFieldName: groupBySubFieldName,
dateGranularity: isFieldDate
dateGranularity: shouldApplyDateGranularity
? (dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
firstDayOfTheWeek,
isNestedDateField: isNestedDate,
}),
];
@@ -72,7 +83,7 @@ export const generateGroupByQueryVariablesFromPieChartConfiguration = ({
groupByField,
groupBySubFieldName,
aggregateOperation,
dateGranularity: isFieldDate
dateGranularity: shouldApplyDateGranularity
? (dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
}),
@@ -51,6 +51,7 @@ export const getFieldOrderBy = (
groupByField,
groupBySubFieldName,
direction,
dateGranularity,
);
}
@@ -1,5 +1,7 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
import {
type ObjectRecordGroupByDateGranularity,
type ObjectRecordOrderByForRelationField,
type ObjectRecordOrderByForScalarField,
type OrderByDirection,
@@ -10,6 +12,8 @@ export const getRelationFieldOrderBy = (
groupByField: FieldMetadataItem,
groupBySubFieldName: string | null | undefined,
direction: OrderByDirection,
dateGranularity?: ObjectRecordGroupByDateGranularity,
isNestedDateField?: boolean,
): ObjectRecordOrderByForScalarField | ObjectRecordOrderByForRelationField => {
if (!isDefined(groupBySubFieldName)) {
return {
@@ -19,6 +23,17 @@ export const getRelationFieldOrderBy = (
const [nestedFieldName, nestedSubFieldName] = groupBySubFieldName.split('.');
if (isNestedDateField === true || isDefined(dateGranularity)) {
return {
[groupByField.name]: {
[nestedFieldName]: {
orderBy: direction,
granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY,
},
},
};
}
if (!isDefined(nestedSubFieldName)) {
return {
[groupByField.name]: {
@@ -0,0 +1,44 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
export const isNestedFieldDateType = (
field: FieldMetadataItem,
subFieldName: string | undefined,
objectMetadataItems: ObjectMetadataItem[],
): boolean => {
if (!isDefined(subFieldName)) {
return false;
}
if (!isFieldRelation(field)) {
return false;
}
const targetObjectNameSingular =
field.relation?.targetObjectMetadata?.nameSingular;
if (!isDefined(targetObjectNameSingular)) {
return false;
}
const targetObjectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === targetObjectNameSingular,
);
if (!isDefined(targetObjectMetadataItem)) {
return false;
}
const nestedFieldName = subFieldName.split('.')[0];
const nestedField = targetObjectMetadataItem.fields.find(
(f) => f.name === nestedFieldName,
);
if (!isDefined(nestedField)) {
return false;
}
return isFieldMetadataDateKind(nestedField.type);
};