From fdb4e4ef10781093514d1bc41561f83c71db9bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Fri, 21 Nov 2025 10:24:19 +0100 Subject: [PATCH] [DASHBOARDS] Use a time scale when the primary axis is a date on the Bar Chart (#15932) Closes https://github.com/twentyhq/core-team-issues/issues/1891 Create empty buckets according to the date granularity Video QA: https://github.com/user-attachments/assets/86c0f817-35b3-4bab-b093-d11491684b82 Note: We would also need to create empty buckets for cyclic granularities (DAY_OF_THE_WEEK, MONTH_OF_THE_YEAR, QUARTER_OF_THE_YEAR). TODO: - Always order the cyclic granularities Monday -> Sunday (take firstDayOfTheWeek into account), January -> December, Q1 -> Q4. For now they are returned by the backend in alphabetical order, which doesn't make much sense - Remove the translation into the user's locale of these granularities from the backend because otherwise we can't reconstruct the missing days or month in the frontend since they will be translated --------- Co-authored-by: Lucas Bordeau --- .../page-layout/components/ChartSettings.tsx | 30 +++++- ...GranularitiesWithoutGapFilling.constant.ts | 8 ++ .../hooks/useGraphBarChartWidgetData.ts | 8 +- .../types/FillDateGapsResult.ts | 6 ++ .../fillDateGapsInBarChartData.test.ts | 96 +++++++++++++++++++ .../generateDateGroupsInRange.test.ts | 71 ++++++++++++++ ...arChartDataBySecondaryDimensionSum.test.ts | 0 ...woDimensionalGroupByToBarChartData.test.ts | 0 .../utils/createEmptyDateGroup.ts | 20 ++++ .../utils/fillDateGapsInBarChartData.ts | 42 ++++++++ ...illDateGapsInOneDimensionalBarChartData.ts | 60 ++++++++++++ ...illDateGapsInTwoDimensionalBarChartData.ts | 72 ++++++++++++++ .../utils/generateDateGroupsInRange.ts | 64 +++++++++++++ .../utils/getDateGroupsFromData.ts | 25 +++++ ...sortBarChartDataBySecondaryDimensionSum.ts | 0 .../transformGroupByDataToBarChartData.ts | 30 +++++- ...formOneDimensionalGroupByToBarChartData.ts | 0 ...formTwoDimensionalGroupByToBarChartData.ts | 2 +- .../graph/hooks/useGraphWidgetGroupByQuery.ts | 4 +- .../generateGroupByQuery.test.ts.snap | 6 ++ .../graph/utils/generateGroupByQuery.ts | 2 + ...pByQueryVariablesFromChartConfiguration.ts | 3 + .../workspace-resolvers-builder.interface.ts | 1 + .../components/SidePanelInformationBanner.tsx | 30 +++++- 24 files changed, 567 insertions(+), 13 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartDateGranularitiesWithoutGapFilling.constant.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/FillDateGapsResult.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/fillDateGapsInBarChartData.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/generateDateGroupsInRange.test.ts rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetBarChart}/utils/__tests__/sortBarChartDataBySecondaryDimensionSum.test.ts (100%) rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetBarChart}/utils/__tests__/transformTwoDimensionalGroupByToBarChartData.test.ts (100%) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData.ts rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetBarChart}/utils/sortBarChartDataBySecondaryDimensionSum.ts (100%) rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetBarChart}/utils/transformGroupByDataToBarChartData.ts (81%) rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetBarChart}/utils/transformOneDimensionalGroupByToBarChartData.ts (100%) rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetBarChart}/utils/transformTwoDimensionalGroupByToBarChartData.ts (98%) diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx index bb565310a1d..42a4d215860 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx @@ -27,7 +27,11 @@ import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; import { SidePanelInformationBanner } from 'twenty-ui/display'; -import { GraphType, type PageLayoutWidget } from '~/generated/graphql'; +import { + FieldMetadataType, + GraphType, + type PageLayoutWidget, +} from '~/generated/graphql'; const StyledSidePanelInformationBanner = styled(SidePanelInformationBanner)` margin-top: ${({ theme }) => theme.spacing(2)}; @@ -118,6 +122,20 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => { .map((item) => item.id), ); + const primaryAxisFieldMetadataId = + configuration.__typename === 'BarChartConfiguration' || + configuration.__typename === 'LineChartConfiguration' + ? configuration.primaryAxisGroupByFieldMetadataId + : null; + + const primaryAxisField = objectMetadataItem?.fields?.find( + (field) => field.id === primaryAxisFieldMetadataId, + ); + + const isPrimaryAxisDate = + primaryAxisField?.type === FieldMetadataType.DATE || + primaryAxisField?.type === FieldMetadataType.DATE_TIME; + return ( { )} {chartSettings.map((group) => { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartDateGranularitiesWithoutGapFilling.constant.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartDateGranularitiesWithoutGapFilling.constant.ts new file mode 100644 index 00000000000..61839184412 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartDateGranularitiesWithoutGapFilling.constant.ts @@ -0,0 +1,8 @@ +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; + +export const BAR_CHART_DATE_GRANULARITIES_WITHOUT_GAP_FILLING = new Set([ + ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK, + ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR, + ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR, + ObjectRecordGroupByDateGranularity.NONE, +]); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts index 981d97481ce..079e4a44aa7 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts @@ -1,9 +1,10 @@ import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMaximumNumberOfBars.constant'; import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; import { type BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout'; import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; +import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData'; import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery'; -import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/utils/transformGroupByDataToBarChartData'; import { useMemo } from 'react'; import { type BarChartConfiguration } from '~/generated/graphql'; @@ -26,6 +27,9 @@ type UseGraphBarChartWidgetDataResult = { hasTooManyGroups: boolean; }; +// TODO: Remove this once backend returns total group count +const EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS = 1; + export const useGraphBarChartWidgetData = ({ objectMetadataItemId, configuration, @@ -42,6 +46,8 @@ export const useGraphBarChartWidgetData = ({ } = useGraphWidgetGroupByQuery({ objectMetadataItemId, configuration, + limit: + BAR_CHART_MAXIMUM_NUMBER_OF_BARS + EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS, }); const transformedData = useMemo( diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/FillDateGapsResult.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/FillDateGapsResult.ts new file mode 100644 index 00000000000..77106000ff7 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/types/FillDateGapsResult.ts @@ -0,0 +1,6 @@ +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; + +export type FillDateGapsResult = { + data: GroupByRawResult[]; + wasTruncated: boolean; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/fillDateGapsInBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/fillDateGapsInBarChartData.test.ts new file mode 100644 index 00000000000..93b613a37b9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/fillDateGapsInBarChartData.test.ts @@ -0,0 +1,96 @@ +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { fillDateGapsInBarChartData } from '../fillDateGapsInBarChartData'; + +describe('fillDateGapsInBarChartData', () => { + describe('one-dimensional data', () => { + it('fills gaps in date data with zero values', () => { + const data = [ + { + groupByDimensionValues: ['2024-01-01T00:00:00.000Z'], + count: 5, + }, + { + groupByDimensionValues: ['2024-01-03T00:00:00.000Z'], + count: 3, + }, + ]; + + const result = fillDateGapsInBarChartData({ + data, + keys: ['count'], + dateGranularity: ObjectRecordGroupByDateGranularity.DAY, + }); + + expect(result.data).toHaveLength(3); + expect(result.data[0]).toEqual({ + groupByDimensionValues: ['2024-01-01T00:00:00.000Z'], + count: 5, + }); + expect(result.data[1]).toEqual({ + groupByDimensionValues: ['2024-01-02T00:00:00.000Z'], + count: 0, + }); + expect(result.data[2]).toEqual({ + groupByDimensionValues: ['2024-01-03T00:00:00.000Z'], + count: 3, + }); + expect(result.wasTruncated).toBe(false); + }); + + it('returns empty data unchanged', () => { + const result = fillDateGapsInBarChartData({ + data: [], + keys: ['count'], + dateGranularity: ObjectRecordGroupByDateGranularity.DAY, + }); + + expect(result.data).toEqual([]); + expect(result.wasTruncated).toBe(false); + }); + }); + + describe('two-dimensional data', () => { + it('fills gaps for all second dimension values', () => { + const data = [ + { + groupByDimensionValues: ['2024-01-01T00:00:00.000Z', 'A'], + count: 5, + }, + { + groupByDimensionValues: ['2024-01-03T00:00:00.000Z', 'A'], + count: 3, + }, + { + groupByDimensionValues: ['2024-01-01T00:00:00.000Z', 'B'], + count: 2, + }, + ]; + + const result = fillDateGapsInBarChartData({ + data, + keys: ['count'], + dateGranularity: ObjectRecordGroupByDateGranularity.DAY, + hasSecondDimension: true, + }); + + expect(result.data).toHaveLength(6); + expect( + result.data.filter((r) => r.groupByDimensionValues[1] === 'A'), + ).toHaveLength(3); + expect( + result.data.filter((r) => r.groupByDimensionValues[1] === 'B'), + ).toHaveLength(3); + expect( + result.data.find( + (r) => + r.groupByDimensionValues[0] === '2024-01-02T00:00:00.000Z' && + r.groupByDimensionValues[1] === 'A', + ), + ).toEqual({ + groupByDimensionValues: ['2024-01-02T00:00:00.000Z', 'A'], + count: 0, + }); + expect(result.wasTruncated).toBe(false); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/generateDateGroupsInRange.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/generateDateGroupsInRange.test.ts new file mode 100644 index 00000000000..9086f38c111 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/generateDateGroupsInRange.test.ts @@ -0,0 +1,71 @@ +import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMaximumNumberOfBars.constant'; +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { generateDateGroupsInRange } from '../generateDateGroupsInRange'; + +describe('generateDateGroupsInRange', () => { + it('generates daily date groups', () => { + const result = generateDateGroupsInRange({ + startDate: new Date('2024-01-01'), + endDate: new Date('2024-01-07'), + granularity: ObjectRecordGroupByDateGranularity.DAY, + }); + + expect(result.dates).toHaveLength(7); + expect(result.dates[0]).toEqual(new Date('2024-01-01')); + expect(result.dates[6]).toEqual(new Date('2024-01-07')); + expect(result.wasTruncated).toBe(false); + }); + + it('generates monthly date groups', () => { + const result = generateDateGroupsInRange({ + startDate: new Date('2024-01-01'), + endDate: new Date('2024-06-01'), + granularity: ObjectRecordGroupByDateGranularity.MONTH, + }); + + expect(result.dates).toHaveLength(6); + expect(result.dates[0]).toEqual(new Date('2024-01-01')); + expect(result.dates[5]).toEqual(new Date('2024-06-01')); + expect(result.wasTruncated).toBe(false); + }); + + it('generates quarterly date groups', () => { + const result = generateDateGroupsInRange({ + startDate: new Date('2024-01-01'), + endDate: new Date('2024-12-31'), + granularity: ObjectRecordGroupByDateGranularity.QUARTER, + }); + + expect(result.dates).toHaveLength(4); + expect(result.dates[0]).toEqual(new Date('2024-01-01')); + expect(result.dates[3]).toEqual(new Date('2024-10-01')); + expect(result.wasTruncated).toBe(false); + }); + + it('generates yearly date groups', () => { + const result = generateDateGroupsInRange({ + startDate: new Date('2020-01-01'), + endDate: new Date('2024-12-31'), + granularity: ObjectRecordGroupByDateGranularity.YEAR, + }); + + expect(result.dates).toHaveLength(5); + expect(result.dates[0]).toEqual(new Date('2020-01-01')); + expect(result.dates[4]).toEqual(new Date('2024-01-01')); + expect(result.wasTruncated).toBe(false); + }); + + it('truncates when exceeding maximum number of bars', () => { + const result = generateDateGroupsInRange({ + startDate: new Date('2024-01-01'), + endDate: new Date('2025-12-31'), + granularity: ObjectRecordGroupByDateGranularity.DAY, + }); + + expect(result.dates.length).toBeLessThanOrEqual( + BAR_CHART_MAXIMUM_NUMBER_OF_BARS, + ); + expect(result.dates.length).toBe(BAR_CHART_MAXIMUM_NUMBER_OF_BARS); + expect(result.wasTruncated).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortBarChartDataBySecondaryDimensionSum.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/sortBarChartDataBySecondaryDimensionSum.test.ts similarity index 100% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortBarChartDataBySecondaryDimensionSum.test.ts rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/sortBarChartDataBySecondaryDimensionSum.test.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformTwoDimensionalGroupByToBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformTwoDimensionalGroupByToBarChartData.test.ts similarity index 100% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformTwoDimensionalGroupByToBarChartData.test.ts rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformTwoDimensionalGroupByToBarChartData.test.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup.ts new file mode 100644 index 00000000000..82dd175cbff --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup.ts @@ -0,0 +1,20 @@ +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; + +export type DimensionValue = string | Date | number | null; + +export const createEmptyDateGroup = ( + dimensionValues: DimensionValue[], + keys: string[], +): GroupByRawResult => { + const newItem: GroupByRawResult = { + groupByDimensionValues: dimensionValues.map((value) => + value instanceof Date ? value.toISOString() : value, + ), + }; + + for (const key of keys) { + newItem[key] = 0; + } + + return newItem; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData.ts new file mode 100644 index 00000000000..eaa3713357c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData.ts @@ -0,0 +1,42 @@ +import { BAR_CHART_DATE_GRANULARITIES_WITHOUT_GAP_FILLING } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartDateGranularitiesWithoutGapFilling.constant'; +import { fillDateGapsInOneDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData'; +import { fillDateGapsInTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData'; +import { type SupportedDateGranularity } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; + +type FillDateGapsParams = { + data: GroupByRawResult[]; + keys: string[]; + dateGranularity: ObjectRecordGroupByDateGranularity; + hasSecondDimension?: boolean; +}; + +export const fillDateGapsInBarChartData = ({ + data, + keys, + dateGranularity, + hasSecondDimension = false, +}: FillDateGapsParams): { data: GroupByRawResult[]; wasTruncated: boolean } => { + if (data.length === 0) { + return { data, wasTruncated: false }; + } + + if (BAR_CHART_DATE_GRANULARITIES_WITHOUT_GAP_FILLING.has(dateGranularity)) { + return { data, wasTruncated: false }; + } + + if (hasSecondDimension) { + return fillDateGapsInTwoDimensionalBarChartData({ + data, + keys, + dateGranularity: dateGranularity as SupportedDateGranularity, + }); + } + + return fillDateGapsInOneDimensionalBarChartData({ + data, + keys, + dateGranularity: dateGranularity as SupportedDateGranularity, + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData.ts new file mode 100644 index 00000000000..653ffb71a05 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData.ts @@ -0,0 +1,60 @@ +import { type FillDateGapsResult } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/FillDateGapsResult'; +import { createEmptyDateGroup } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup'; +import { + getDateGroupsFromData, + type SupportedDateGranularity, +} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { isDefined } from 'twenty-shared/utils'; + +type OneDimensionalFillParams = { + data: GroupByRawResult[]; + keys: string[]; + dateGranularity: SupportedDateGranularity; +}; + +export const fillDateGapsInOneDimensionalBarChartData = ({ + data, + keys, + dateGranularity, +}: OneDimensionalFillParams): FillDateGapsResult => { + const existingDateGroupsMap = new Map(); + const parsedDates: Date[] = []; + + for (const item of data) { + const dateValue = item.groupByDimensionValues?.[0]; + + if (!isDefined(dateValue)) { + continue; + } + + const parsedDate = new Date(String(dateValue)); + + if (isNaN(parsedDate.getTime())) { + continue; + } + + parsedDates.push(parsedDate); + existingDateGroupsMap.set(parsedDate.toISOString(), item); + } + + if (parsedDates.length === 0) { + return { data, wasTruncated: false }; + } + + const { dates: allDates, wasTruncated } = getDateGroupsFromData( + parsedDates, + dateGranularity, + ); + + const filledData = allDates.map((date) => { + const key = date.toISOString(); + const existingDateGroup = existingDateGroupsMap.get(key); + + return isDefined(existingDateGroup) + ? existingDateGroup + : createEmptyDateGroup([date], keys); + }); + + return { data: filledData, wasTruncated }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData.ts new file mode 100644 index 00000000000..3d72ca1045c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData.ts @@ -0,0 +1,72 @@ +import { type FillDateGapsResult } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/FillDateGapsResult'; +import { + createEmptyDateGroup, + type DimensionValue, +} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup'; +import { + getDateGroupsFromData, + type SupportedDateGranularity, +} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { isDefined } from 'twenty-shared/utils'; + +type TwoDimensionalFillParams = { + data: GroupByRawResult[]; + keys: string[]; + dateGranularity: SupportedDateGranularity; +}; + +export const fillDateGapsInTwoDimensionalBarChartData = ({ + data, + keys, + dateGranularity, +}: TwoDimensionalFillParams): FillDateGapsResult => { + const existingDateGroupsMap = new Map(); + const parsedDates: Date[] = []; + const uniqueSecondDimensionValues = new Set(); + + for (const item of data) { + const dateValue = item.groupByDimensionValues?.[0]; + + if (!isDefined(dateValue)) { + continue; + } + + const parsedDate = new Date(String(dateValue)); + + if (isNaN(parsedDate.getTime())) { + continue; + } + + parsedDates.push(parsedDate); + + const secondDimensionValue = (item.groupByDimensionValues?.[1] ?? + null) as DimensionValue; + uniqueSecondDimensionValues.add(secondDimensionValue); + + const key = `${parsedDate.toISOString()}_${String(secondDimensionValue)}`; + existingDateGroupsMap.set(key, item); + } + + if (parsedDates.length === 0) { + return { data, wasTruncated: false }; + } + + const { dates: allDates, wasTruncated } = getDateGroupsFromData( + parsedDates, + dateGranularity, + ); + + const filledData = allDates.flatMap((date) => + Array.from(uniqueSecondDimensionValues).map((secondDimensionValue) => { + const key = `${date.toISOString()}_${String(secondDimensionValue)}`; + const existingDateGroup = existingDateGroupsMap.get(key); + + return isDefined(existingDateGroup) + ? existingDateGroup + : createEmptyDateGroup([date, secondDimensionValue], keys); + }), + ); + + return { data: filledData, wasTruncated }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange.ts new file mode 100644 index 00000000000..ea62f349135 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange.ts @@ -0,0 +1,64 @@ +import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMaximumNumberOfBars.constant'; +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { assertUnreachable } from 'twenty-shared/utils'; + +type GenerateDateRangeParams = { + startDate: Date; + endDate: Date; + granularity: + | ObjectRecordGroupByDateGranularity.DAY + | ObjectRecordGroupByDateGranularity.MONTH + | ObjectRecordGroupByDateGranularity.QUARTER + | ObjectRecordGroupByDateGranularity.YEAR; +}; + +type GenerateDateRangeResult = { + dates: Date[]; + wasTruncated: boolean; +}; + +export const generateDateGroupsInRange = ({ + startDate, + endDate, + granularity, +}: GenerateDateRangeParams): GenerateDateRangeResult => { + const dates: Date[] = []; + + let iterations = 0; + let wasTruncated = false; + + let currentDateCursor = new Date(startDate); + + while (currentDateCursor <= endDate) { + if (iterations >= BAR_CHART_MAXIMUM_NUMBER_OF_BARS) { + wasTruncated = true; + break; + } + + dates.push(new Date(currentDateCursor)); + iterations++; + + switch (granularity) { + case ObjectRecordGroupByDateGranularity.DAY: + currentDateCursor.setDate(currentDateCursor.getDate() + 1); + break; + + case ObjectRecordGroupByDateGranularity.MONTH: + currentDateCursor.setMonth(currentDateCursor.getMonth() + 1); + break; + + case ObjectRecordGroupByDateGranularity.QUARTER: + currentDateCursor.setMonth(currentDateCursor.getMonth() + 3); + break; + + case ObjectRecordGroupByDateGranularity.YEAR: + currentDateCursor.setFullYear(currentDateCursor.getFullYear() + 1); + break; + + default: + assertUnreachable(granularity); + } + } + + return { dates, wasTruncated }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData.ts new file mode 100644 index 00000000000..6c1605066c7 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData.ts @@ -0,0 +1,25 @@ +import { generateDateGroupsInRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange'; +import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; + +export type SupportedDateGranularity = + | ObjectRecordGroupByDateGranularity.DAY + | ObjectRecordGroupByDateGranularity.MONTH + | ObjectRecordGroupByDateGranularity.QUARTER + | ObjectRecordGroupByDateGranularity.YEAR; + +export const getDateGroupsFromData = ( + parsedDates: Date[], + dateGranularity: SupportedDateGranularity, +): { dates: Date[]; wasTruncated: boolean } => { + const timestamps = parsedDates.map((date) => date.getTime()); + const minDate = new Date(Math.min(...timestamps)); + const maxDate = new Date(Math.max(...timestamps)); + + const result = generateDateGroupsInRange({ + startDate: minDate, + endDate: maxDate, + granularity: dateGranularity, + }); + + return { dates: result.dates, wasTruncated: result.wasTruncated }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortBarChartDataBySecondaryDimensionSum.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum.ts similarity index 100% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortBarChartDataBySecondaryDimensionSum.ts rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts similarity index 81% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToBarChartData.ts rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts index 9436b8f0d2f..377c3c8f2d9 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts @@ -3,18 +3,21 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel'; import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations'; import { getGroupByQueryName } from '@/page-layout/utils/getGroupByQueryName'; +import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant'; import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout'; import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; +import { fillDateGapsInBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData'; +import { transformOneDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData'; +import { transformTwoDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults'; 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 { GraphType } from '~/generated-metadata/graphql'; import { AxisNameDisplay, + FieldMetadataType, type BarChartConfiguration, } from '~/generated/graphql'; @@ -140,9 +143,27 @@ export const transformGroupByDataToBarChartData = ({ const showDataLabels = configuration.displayDataLabel ?? false; + const isDateField = + groupByFieldX.type === FieldMetadataType.DATE || + groupByFieldX.type === FieldMetadataType.DATE_TIME; + + const dateGapFillResult = isDateField + ? fillDateGapsInBarChartData({ + data: filteredResults, + keys: [aggregateField.name], + dateGranularity: + configuration.primaryAxisDateGranularity ?? + GRAPH_DEFAULT_DATE_GRANULARITY, + hasSecondDimension: isDefined(groupByFieldY), + }) + : { data: filteredResults, wasTruncated: false }; + + const filteredResultsWithDateGaps = dateGapFillResult.data; + const dateRangeWasTruncated = dateGapFillResult.wasTruncated; + const baseResult = isDefined(groupByFieldY) ? transformTwoDimensionalGroupByToBarChartData({ - rawResults: filteredResults, + rawResults: filteredResultsWithDateGaps, groupByFieldX, groupByFieldY, aggregateField, @@ -152,7 +173,7 @@ export const transformGroupByDataToBarChartData = ({ primaryAxisSubFieldName, }) : transformOneDimensionalGroupByToBarChartData({ - rawResults: filteredResults, + rawResults: filteredResultsWithDateGaps, groupByFieldX, aggregateField, configuration, @@ -172,5 +193,6 @@ export const transformGroupByDataToBarChartData = ({ yAxisLabel, showDataLabels, layout, + hasTooManyGroups: baseResult.hasTooManyGroups || dateRangeWasTruncated, }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts similarity index 100% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToBarChartData.ts rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts similarity index 98% rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToBarChartData.ts rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts index 3baee1b2714..4c95d03ff5d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts @@ -4,13 +4,13 @@ import { type ExtendedAggregateOperations } from '@/object-record/record-table/t import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMaximumNumberOfBars.constant'; import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem'; import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; +import { sortBarChartDataBySecondaryDimensionSum } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum'; 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 { getSortedKeys } from '@/page-layout/widgets/graph/utils/getSortedKeys'; -import { sortBarChartDataBySecondaryDimensionSum } from '@/page-layout/widgets/graph/utils/sortBarChartDataBySecondaryDimensionSum'; import { isDefined } from 'twenty-shared/utils'; import { BarChartGroupMode, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery.ts index ce13a282685..7a0df2b8533 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery.ts @@ -12,9 +12,11 @@ import { isDefined } from 'twenty-shared/utils'; export const useGraphWidgetGroupByQuery = ({ objectMetadataItemId, configuration, + limit = DEFAULT_NUMBER_OF_GROUPS_LIMIT, }: { objectMetadataItemId: string; configuration: GroupByChartConfiguration; + limit?: number; }) => { const { objectMetadataItem, aggregateField, gqlOperationFilter } = useGraphWidgetQueryCommon({ @@ -48,12 +50,12 @@ export const useGraphWidgetGroupByQuery = ({ objectMetadataItem, chartConfiguration: configuration, aggregateOperation: aggregateOperation, + limit, }); const variables = { ...groupByQueryVariables, filter: gqlOperationFilter, - limit: DEFAULT_NUMBER_OF_GROUPS_LIMIT, }; const query = generateGroupByQuery({ diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap index 77ba4e531eb..2e4c896a780 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap @@ -7,12 +7,14 @@ exports[`generateGroupByQuery should generate valid GraphQL query for empty aggr $filter: PersonFilterInput $orderBy: [PersonOrderByWithGroupByInput!] $viewId: UUID + $limit: Int ) { peopleGroupBy( groupBy: $groupBy filter: $filter orderBy: $orderBy viewId: $viewId + limit: $limit ) { groupByDimensionValues } @@ -27,12 +29,14 @@ exports[`generateGroupByQuery should generate valid GraphQL query for multiple a $filter: OpportunityFilterInput $orderBy: [OpportunityOrderByWithGroupByInput!] $viewId: UUID + $limit: Int ) { opportunitiesGroupBy( groupBy: $groupBy filter: $filter orderBy: $orderBy viewId: $viewId + limit: $limit ) { groupByDimensionValues totalCount @@ -50,12 +54,14 @@ exports[`generateGroupByQuery should generate valid GraphQL query for single agg $filter: OpportunityFilterInput $orderBy: [OpportunityOrderByWithGroupByInput!] $viewId: UUID + $limit: Int ) { opportunitiesGroupBy( groupBy: $groupBy filter: $filter orderBy: $orderBy viewId: $viewId + limit: $limit ) { groupByDimensionValues sumAmountAmountMicros diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts index d46d1fa919a..61bab0e1414 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts @@ -21,12 +21,14 @@ export const generateGroupByQuery = ({ $filter: ${capitalizedSingular}FilterInput $orderBy: [${capitalizedSingular}OrderByWithGroupByInput!] $viewId: UUID + $limit: Int ) { ${queryFieldName}( groupBy: $groupBy filter: $filter orderBy: $orderBy viewId: $viewId + limit: $limit ) { groupByDimensionValues${aggregateOperations.length > 0 ? `\n ${aggregateOperations.join('\n ')}` : ''} } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromChartConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromChartConfiguration.ts index 4f3658bf3d8..95613d1fbee 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromChartConfiguration.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromChartConfiguration.ts @@ -14,10 +14,12 @@ export const generateGroupByQueryVariablesFromChartConfiguration = ({ objectMetadataItem, chartConfiguration, aggregateOperation, + limit, }: { objectMetadataItem: ObjectMetadataItem; chartConfiguration: GroupByChartConfiguration; aggregateOperation?: string; + limit?: number; }) => { const groupByFieldXId = chartConfiguration.primaryAxisGroupByFieldMetadataId; @@ -107,5 +109,6 @@ export const generateGroupByQueryVariablesFromChartConfiguration = ({ return { groupBy, ...(orderBy.length > 0 && { orderBy }), + ...(isDefined(limit) && { limit }), }; }; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts index 72d4ddd3582..f7c88999bd9 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts @@ -64,6 +64,7 @@ export interface GroupByResolverArgs { viewId?: string; orderBy?: OrderByWithGroupBy; orderByForRecords?: ObjectRecordOrderBy; + limit?: number; } export interface UpdateOneResolverArgs< diff --git a/packages/twenty-ui/src/display/banner/components/SidePanelInformationBanner.tsx b/packages/twenty-ui/src/display/banner/components/SidePanelInformationBanner.tsx index 60e617a948c..1e4cba49fe9 100644 --- a/packages/twenty-ui/src/display/banner/components/SidePanelInformationBanner.tsx +++ b/packages/twenty-ui/src/display/banner/components/SidePanelInformationBanner.tsx @@ -1,5 +1,9 @@ import styled from '@emotion/styled'; -import { IconInfoCircle } from '../../icon/components/TablerIcons'; +import { + IconAlertTriangle, + IconInfoCircle, +} from '../../icon/components/TablerIcons'; +import { AppTooltip } from '../../tooltip/AppTooltip'; const StyledBanner = styled.div` align-items: center; @@ -37,18 +41,38 @@ const StyledMessage = styled.p` export type SidePanelInformationBannerProps = { message: string; className?: string; + variant?: 'default' | 'warning'; + tooltipMessage?: string; }; export const SidePanelInformationBanner = ({ message, className, + variant = 'default', + tooltipMessage, }: SidePanelInformationBannerProps) => { + const tooltipId = 'side-panel-information-banner-tooltip'; + return ( - + - + {variant === 'default' ? ( + + ) : ( + + )} {message} + {tooltipMessage && ( + + )} ); };