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 && ( + + )} ); };