[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 <bordeau.lucas@gmail.com>
This commit is contained in:
+27
-3
@@ -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 (
|
||||
<CommandMenuList commandGroups={[]} selectableItemIds={visibleItemIds}>
|
||||
<ChartTypeSelectionSection
|
||||
@@ -128,9 +146,15 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => {
|
||||
<StyledSidePanelInformationBanner
|
||||
message={
|
||||
currentGraphType === GraphType.LINE
|
||||
? t`Max ${LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS} data points per chart. Consider adding a filter`
|
||||
: t`Max ${BAR_CHART_MAXIMUM_NUMBER_OF_BARS} bars per chart. Consider adding a filter`
|
||||
? t`Undisplayed data: max ${LINE_CHART_MAXIMUM_NUMBER_OF_DATA_POINTS} data points per chart.`
|
||||
: t`Undisplayed data: max ${BAR_CHART_MAXIMUM_NUMBER_OF_BARS} bars per chart.`
|
||||
}
|
||||
tooltipMessage={
|
||||
isPrimaryAxisDate
|
||||
? t`Consider adding a filter or changing the date granularity to display more data.`
|
||||
: t`Consider adding a filter to display more data.`
|
||||
}
|
||||
variant="warning"
|
||||
/>
|
||||
)}
|
||||
{chartSettings.map((group) => {
|
||||
|
||||
+8
@@ -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,
|
||||
]);
|
||||
+7
-1
@@ -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(
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
|
||||
export type FillDateGapsResult = {
|
||||
data: GroupByRawResult[];
|
||||
wasTruncated: boolean;
|
||||
};
|
||||
+96
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+71
@@ -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);
|
||||
});
|
||||
});
|
||||
+20
@@ -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;
|
||||
};
|
||||
+42
@@ -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,
|
||||
});
|
||||
};
|
||||
+60
@@ -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<string, GroupByRawResult>();
|
||||
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 };
|
||||
};
|
||||
+72
@@ -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<string, GroupByRawResult>();
|
||||
const parsedDates: Date[] = [];
|
||||
const uniqueSecondDimensionValues = new Set<DimensionValue>();
|
||||
|
||||
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 };
|
||||
};
|
||||
+64
@@ -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 };
|
||||
};
|
||||
+25
@@ -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 };
|
||||
};
|
||||
+26
-4
@@ -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,
|
||||
};
|
||||
};
|
||||
+1
-1
@@ -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,
|
||||
+3
-1
@@ -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({
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+2
@@ -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 ')}` : ''}
|
||||
}
|
||||
|
||||
+3
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
+1
@@ -64,6 +64,7 @@ export interface GroupByResolverArgs<Filter = ObjectRecordFilter> {
|
||||
viewId?: string;
|
||||
orderBy?: OrderByWithGroupBy;
|
||||
orderByForRecords?: ObjectRecordOrderBy;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface UpdateOneResolverArgs<
|
||||
|
||||
@@ -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 (
|
||||
<StyledBanner className={className}>
|
||||
<StyledBanner
|
||||
className={className}
|
||||
data-tooltip-id={tooltipMessage ? tooltipId : undefined}
|
||||
>
|
||||
<StyledIconContainer>
|
||||
<IconInfoCircle size={16} />
|
||||
{variant === 'default' ? (
|
||||
<IconInfoCircle size={16} />
|
||||
) : (
|
||||
<IconAlertTriangle size={16} />
|
||||
)}
|
||||
</StyledIconContainer>
|
||||
<StyledMessage>{message}</StyledMessage>
|
||||
{tooltipMessage && (
|
||||
<AppTooltip
|
||||
anchorSelect={`[data-tooltip-id='${tooltipId}']`}
|
||||
content={tooltipMessage}
|
||||
place="bottom"
|
||||
/>
|
||||
)}
|
||||
</StyledBanner>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user