[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:
+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
|
||||
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
import { GraphOrderBy } from '~/generated/graphql';
|
||||
import { sortBarChartDataBySecondaryDimensionSum } from '../sortBarChartDataBySecondaryDimensionSum';
|
||||
|
||||
describe('sortBarChartDataBySecondaryDimensionSum', () => {
|
||||
const mockData = [
|
||||
{ city: 'Paris', Open: 5, Closed: 10 },
|
||||
{ city: 'London', Open: 20, Closed: 2 },
|
||||
{ city: 'Berlin', Open: 8, Closed: 7 },
|
||||
];
|
||||
|
||||
const keys = ['Open', 'Closed'];
|
||||
|
||||
describe('VALUE_DESC sorting', () => {
|
||||
it('should sort by totals in descending order', () => {
|
||||
const result = sortBarChartDataBySecondaryDimensionSum({
|
||||
data: mockData,
|
||||
keys,
|
||||
orderBy: GraphOrderBy.VALUE_DESC,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ city: 'London', Open: 20, Closed: 2 },
|
||||
{ city: 'Paris', Open: 5, Closed: 10 },
|
||||
{ city: 'Berlin', Open: 8, Closed: 7 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VALUE_ASC sorting', () => {
|
||||
it('should sort by totals in ascending order', () => {
|
||||
const result = sortBarChartDataBySecondaryDimensionSum({
|
||||
data: mockData,
|
||||
keys,
|
||||
orderBy: GraphOrderBy.VALUE_ASC,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ city: 'Paris', Open: 5, Closed: 10 },
|
||||
{ city: 'Berlin', Open: 8, Closed: 7 },
|
||||
{ city: 'London', Open: 20, Closed: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FIELD_ASC/FIELD_DESC (non-value sorting)', () => {
|
||||
it('should not sort when orderBy is FIELD_ASC', () => {
|
||||
const result = sortBarChartDataBySecondaryDimensionSum({
|
||||
data: mockData,
|
||||
keys,
|
||||
orderBy: GraphOrderBy.FIELD_ASC,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
|
||||
it('should not sort when orderBy is FIELD_DESC', () => {
|
||||
const result = sortBarChartDataBySecondaryDimensionSum({
|
||||
data: mockData,
|
||||
keys,
|
||||
orderBy: GraphOrderBy.FIELD_DESC,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle data with missing keys in some items', () => {
|
||||
const dataWithMissing = [
|
||||
{ city: 'Paris', Open: 5 } as any,
|
||||
{ city: 'London', Open: 20, Closed: 2 },
|
||||
];
|
||||
|
||||
const result = sortBarChartDataBySecondaryDimensionSum({
|
||||
data: dataWithMissing,
|
||||
keys,
|
||||
orderBy: GraphOrderBy.VALUE_DESC,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ city: 'London', Open: 20, Closed: 2 },
|
||||
{ city: 'Paris', Open: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle data with zero values', () => {
|
||||
const dataWithZeros = [
|
||||
{ city: 'Paris', Open: 0, Closed: 10 },
|
||||
{ city: 'London', Open: 20, Closed: 0 },
|
||||
];
|
||||
|
||||
const result = sortBarChartDataBySecondaryDimensionSum({
|
||||
data: dataWithZeros,
|
||||
keys,
|
||||
orderBy: GraphOrderBy.VALUE_DESC,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ city: 'London', Open: 20, Closed: 0 },
|
||||
{ city: 'Paris', Open: 0, Closed: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty data array', () => {
|
||||
const result = sortBarChartDataBySecondaryDimensionSum({
|
||||
data: [],
|
||||
keys,
|
||||
orderBy: GraphOrderBy.VALUE_DESC,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle single item', () => {
|
||||
const singleItem = [{ city: 'Paris', Open: 5, Closed: 10 }];
|
||||
|
||||
const result = sortBarChartDataBySecondaryDimensionSum({
|
||||
data: singleItem,
|
||||
keys,
|
||||
orderBy: GraphOrderBy.VALUE_DESC,
|
||||
});
|
||||
|
||||
expect(result).toEqual(singleItem);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stability with equal totals', () => {
|
||||
it('should maintain original order for items with equal totals', () => {
|
||||
const dataWithEqualTotals = [
|
||||
{ city: 'Paris', Open: 10, Closed: 5 },
|
||||
{ city: 'Berlin', Open: 7, Closed: 8 },
|
||||
{ city: 'Madrid', Open: 9, Closed: 6 },
|
||||
];
|
||||
|
||||
const result = sortBarChartDataBySecondaryDimensionSum({
|
||||
data: dataWithEqualTotals,
|
||||
keys,
|
||||
orderBy: GraphOrderBy.VALUE_DESC,
|
||||
});
|
||||
|
||||
expect(result).toEqual(dataWithEqualTotals);
|
||||
});
|
||||
});
|
||||
});
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
AggregateOperations,
|
||||
GraphOrderBy,
|
||||
GraphType,
|
||||
type BarChartConfiguration,
|
||||
} from '~/generated/graphql';
|
||||
import { transformTwoDimensionalGroupByToBarChartData } from '../transformTwoDimensionalGroupByToBarChartData';
|
||||
|
||||
describe('transformTwoDimensionalGroupByToBarChartData', () => {
|
||||
const mockGroupByFieldX = {
|
||||
id: 'field-x',
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE,
|
||||
label: 'Created At',
|
||||
} as FieldMetadataItem;
|
||||
|
||||
const mockGroupByFieldY = {
|
||||
id: 'field-y',
|
||||
name: 'stage',
|
||||
type: FieldMetadataType.SELECT,
|
||||
label: 'Stage',
|
||||
} as FieldMetadataItem;
|
||||
|
||||
const mockAggregateField = {
|
||||
id: 'field-aggregate',
|
||||
name: 'amount',
|
||||
type: FieldMetadataType.NUMBER,
|
||||
label: 'Amount',
|
||||
} as FieldMetadataItem;
|
||||
|
||||
const mockObjectMetadataItem = {
|
||||
id: 'object-1',
|
||||
nameSingular: 'opportunity',
|
||||
namePlural: 'opportunities',
|
||||
fields: [mockGroupByFieldX, mockGroupByFieldY, mockAggregateField],
|
||||
} as ObjectMetadataItem;
|
||||
|
||||
const mockConfiguration: BarChartConfiguration = {
|
||||
__typename: 'BarChartConfiguration',
|
||||
graphType: GraphType.VERTICAL_BAR,
|
||||
aggregateFieldMetadataId: 'field-aggregate',
|
||||
aggregateOperation: AggregateOperations.SUM,
|
||||
primaryAxisGroupByFieldMetadataId: 'field-x',
|
||||
secondaryAxisGroupByFieldMetadataId: 'field-y',
|
||||
secondaryAxisOrderBy: GraphOrderBy.FIELD_DESC,
|
||||
};
|
||||
|
||||
it('should order keys correctly despite unordered raw results', () => {
|
||||
// This test demonstrates the key ordering issue described in the user query
|
||||
// Raw results where "CUSTOMER" appears before "NEW" in the data stream despite orderBy,
|
||||
// bc there is no "NEW" group before october 21st and the results are primarily ordered by date ASC
|
||||
// but we want them ordered alphabetically-reversed
|
||||
const rawResults: GroupByRawResult[] = [
|
||||
{
|
||||
groupByDimensionValues: ['2025-10-16T00:00:00.000Z', 'SCREENING'],
|
||||
sumAmount: 75000000000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2025-10-16T00:00:00.000Z', 'PROPOSAL'],
|
||||
sumAmount: 380000000000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2025-10-17T00:00:00.000Z', 'CUSTOMER'],
|
||||
sumAmount: 720000000000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2025-10-21T00:00:00.000Z', 'PROPOSAL'],
|
||||
sumAmount: 580000000000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2025-10-21T00:00:00.000Z', 'NEW'],
|
||||
sumAmount: 125000000000,
|
||||
},
|
||||
];
|
||||
|
||||
const result = transformTwoDimensionalGroupByToBarChartData({
|
||||
rawResults,
|
||||
groupByFieldX: mockGroupByFieldX,
|
||||
groupByFieldY: mockGroupByFieldY,
|
||||
aggregateField: mockAggregateField,
|
||||
configuration: mockConfiguration,
|
||||
aggregateOperation: 'sumAmount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
});
|
||||
|
||||
expect(result.keys).toEqual(['SCREENING', 'PROPOSAL', 'NEW', 'CUSTOMER']);
|
||||
expect(result.series).toEqual([
|
||||
{ key: 'SCREENING', label: 'SCREENING' },
|
||||
{ key: 'PROPOSAL', label: 'PROPOSAL' },
|
||||
{ key: 'NEW', label: 'NEW' },
|
||||
{ key: 'CUSTOMER', label: 'CUSTOMER' },
|
||||
]);
|
||||
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0]).toEqual({
|
||||
createdAt: 'Oct 16, 2025',
|
||||
SCREENING: 75000000000,
|
||||
PROPOSAL: 380000000000,
|
||||
});
|
||||
expect(result.data[1]).toEqual({
|
||||
createdAt: 'Oct 17, 2025',
|
||||
CUSTOMER: 720000000000,
|
||||
});
|
||||
expect(result.data[2]).toEqual({
|
||||
createdAt: 'Oct 21, 2025',
|
||||
PROPOSAL: 580000000000,
|
||||
NEW: 125000000000,
|
||||
});
|
||||
});
|
||||
});
|
||||
+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 }),
|
||||
};
|
||||
};
|
||||
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { GraphOrderBy } from '~/generated/graphql';
|
||||
|
||||
type SortBarChartDataBySecondaryDimensionSumParams = {
|
||||
data: BarChartDataItem[];
|
||||
keys: string[];
|
||||
orderBy: GraphOrderBy;
|
||||
};
|
||||
|
||||
export const sortBarChartDataBySecondaryDimensionSum = ({
|
||||
data,
|
||||
keys,
|
||||
orderBy,
|
||||
}: SortBarChartDataBySecondaryDimensionSumParams): BarChartDataItem[] => {
|
||||
if (
|
||||
orderBy !== GraphOrderBy.VALUE_ASC &&
|
||||
orderBy !== GraphOrderBy.VALUE_DESC
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const dataWithSecondaryDimensionSums = data.map((barChartDataItem) => {
|
||||
const secondaryDimensionSum = keys.reduce((sumAccumulator, segmentKey) => {
|
||||
const segmentValue = barChartDataItem[segmentKey];
|
||||
if (isDefined(segmentValue) && typeof segmentValue === 'number') {
|
||||
return sumAccumulator + segmentValue;
|
||||
}
|
||||
return sumAccumulator;
|
||||
}, 0);
|
||||
|
||||
return { barChartDataItem, secondaryDimensionSum };
|
||||
});
|
||||
|
||||
dataWithSecondaryDimensionSums.sort((a, b) => {
|
||||
if (orderBy === GraphOrderBy.VALUE_ASC) {
|
||||
return a.secondaryDimensionSum - b.secondaryDimensionSum;
|
||||
} else {
|
||||
return b.secondaryDimensionSum - a.secondaryDimensionSum;
|
||||
}
|
||||
});
|
||||
|
||||
return dataWithSecondaryDimensionSums.map(
|
||||
({ barChartDataItem }) => barChartDataItem,
|
||||
);
|
||||
};
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
|
||||
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
|
||||
import { getGroupByQueryName } from '@/page-layout/utils/getGroupByQueryName';
|
||||
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 { 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,
|
||||
type BarChartConfiguration,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
type TransformGroupByDataToBarChartDataParams = {
|
||||
groupByData: Record<string, GroupByRawResult[]> | null | undefined;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
configuration: BarChartConfiguration;
|
||||
aggregateOperation: string;
|
||||
};
|
||||
|
||||
type TransformGroupByDataToBarChartDataResult = {
|
||||
data: BarChartDataItem[];
|
||||
indexBy: string;
|
||||
keys: string[];
|
||||
series: BarChartSeries[];
|
||||
xAxisLabel?: string;
|
||||
yAxisLabel?: string;
|
||||
showDataLabels: boolean;
|
||||
layout?: BarChartLayout;
|
||||
hasTooManyGroups: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_BAR_CHART_RESULT: TransformGroupByDataToBarChartDataResult = {
|
||||
data: [],
|
||||
indexBy: '',
|
||||
keys: [],
|
||||
series: [],
|
||||
xAxisLabel: undefined,
|
||||
yAxisLabel: undefined,
|
||||
showDataLabels: false,
|
||||
layout: BarChartLayout.VERTICAL,
|
||||
hasTooManyGroups: false,
|
||||
};
|
||||
|
||||
export const transformGroupByDataToBarChartData = ({
|
||||
groupByData,
|
||||
objectMetadataItem,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
}: TransformGroupByDataToBarChartDataParams): TransformGroupByDataToBarChartDataResult => {
|
||||
if (!isDefined(groupByData)) {
|
||||
return EMPTY_BAR_CHART_RESULT;
|
||||
}
|
||||
|
||||
const groupByFieldX = objectMetadataItem.fields.find(
|
||||
(field: FieldMetadataItem) =>
|
||||
field.id === configuration.primaryAxisGroupByFieldMetadataId,
|
||||
);
|
||||
|
||||
const groupByFieldY = isDefined(
|
||||
configuration.secondaryAxisGroupByFieldMetadataId,
|
||||
)
|
||||
? objectMetadataItem.fields.find(
|
||||
(field: FieldMetadataItem) =>
|
||||
field.id === configuration.secondaryAxisGroupByFieldMetadataId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const aggregateField = objectMetadataItem.fields.find(
|
||||
(field: FieldMetadataItem) =>
|
||||
field.id === configuration.aggregateFieldMetadataId,
|
||||
);
|
||||
|
||||
if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) {
|
||||
return {
|
||||
...EMPTY_BAR_CHART_RESULT,
|
||||
layout:
|
||||
configuration.graphType === GraphType.HORIZONTAL_BAR
|
||||
? BarChartLayout.HORIZONTAL
|
||||
: BarChartLayout.VERTICAL,
|
||||
};
|
||||
}
|
||||
|
||||
const primaryAxisSubFieldName =
|
||||
configuration.primaryAxisGroupBySubFieldName ?? undefined;
|
||||
|
||||
const indexByKey = getFieldKey({
|
||||
field: groupByFieldX,
|
||||
subFieldName: primaryAxisSubFieldName,
|
||||
});
|
||||
|
||||
const queryName = getGroupByQueryName(objectMetadataItem);
|
||||
const rawResults = groupByData[queryName];
|
||||
|
||||
if (!isDefined(rawResults) || !Array.isArray(rawResults)) {
|
||||
return {
|
||||
...EMPTY_BAR_CHART_RESULT,
|
||||
indexBy: indexByKey,
|
||||
layout:
|
||||
configuration.graphType === GraphType.HORIZONTAL_BAR
|
||||
? BarChartLayout.HORIZONTAL
|
||||
: BarChartLayout.VERTICAL,
|
||||
};
|
||||
}
|
||||
|
||||
const filteredResults = filterGroupByResults({
|
||||
rawResults,
|
||||
filterOptions: {
|
||||
rangeMin: configuration.rangeMin ?? undefined,
|
||||
rangeMax: configuration.rangeMax ?? undefined,
|
||||
omitNullValues: configuration.omitNullValues ?? false,
|
||||
},
|
||||
aggregateField,
|
||||
aggregateOperation:
|
||||
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
|
||||
aggregateOperationFromRawResult: aggregateOperation,
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
const showXAxis =
|
||||
configuration.axisNameDisplay === AxisNameDisplay.X ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
|
||||
|
||||
const showYAxis =
|
||||
configuration.axisNameDisplay === AxisNameDisplay.Y ||
|
||||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
|
||||
|
||||
const xAxisLabel = showXAxis ? groupByFieldX.label : undefined;
|
||||
|
||||
const yAxisLabel = showYAxis
|
||||
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
|
||||
: undefined;
|
||||
|
||||
const showDataLabels = configuration.displayDataLabel ?? false;
|
||||
|
||||
const baseResult = isDefined(groupByFieldY)
|
||||
? transformTwoDimensionalGroupByToBarChartData({
|
||||
rawResults: filteredResults,
|
||||
groupByFieldX,
|
||||
groupByFieldY,
|
||||
aggregateField,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
objectMetadataItem,
|
||||
primaryAxisSubFieldName,
|
||||
})
|
||||
: transformOneDimensionalGroupByToBarChartData({
|
||||
rawResults: filteredResults,
|
||||
groupByFieldX,
|
||||
aggregateField,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
objectMetadataItem,
|
||||
primaryAxisSubFieldName,
|
||||
});
|
||||
|
||||
const layout =
|
||||
configuration.graphType === GraphType.HORIZONTAL_BAR
|
||||
? BarChartLayout.HORIZONTAL
|
||||
: BarChartLayout.VERTICAL;
|
||||
|
||||
return {
|
||||
...baseResult,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
showDataLabels,
|
||||
layout,
|
||||
};
|
||||
};
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
|
||||
import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant';
|
||||
import { 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 { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
|
||||
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
|
||||
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type BarChartConfiguration } from '~/generated/graphql';
|
||||
|
||||
type TransformOneDimensionalGroupByToBarChartDataParams = {
|
||||
rawResults: GroupByRawResult[];
|
||||
groupByFieldX: FieldMetadataItem;
|
||||
aggregateField: FieldMetadataItem;
|
||||
configuration: BarChartConfiguration;
|
||||
aggregateOperation: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
primaryAxisSubFieldName?: string | null;
|
||||
};
|
||||
|
||||
type TransformOneDimensionalGroupByToBarChartDataResult = {
|
||||
data: BarChartDataItem[];
|
||||
indexBy: string;
|
||||
keys: string[];
|
||||
series: BarChartSeries[];
|
||||
hasTooManyGroups: boolean;
|
||||
};
|
||||
|
||||
export const transformOneDimensionalGroupByToBarChartData = ({
|
||||
rawResults,
|
||||
groupByFieldX,
|
||||
aggregateField,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
objectMetadataItem,
|
||||
primaryAxisSubFieldName,
|
||||
}: TransformOneDimensionalGroupByToBarChartDataParams): TransformOneDimensionalGroupByToBarChartDataResult => {
|
||||
const indexByKey = getFieldKey({
|
||||
field: groupByFieldX,
|
||||
subFieldName: primaryAxisSubFieldName ?? undefined,
|
||||
});
|
||||
|
||||
const aggregateValueKey =
|
||||
indexByKey === aggregateField.name
|
||||
? `${aggregateField.name}-aggregate`
|
||||
: aggregateField.name;
|
||||
|
||||
// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
|
||||
const limitedResults = rawResults.slice(0, BAR_CHART_MAXIMUM_NUMBER_OF_BARS);
|
||||
|
||||
const data: BarChartDataItem[] = limitedResults.map((result) => {
|
||||
const dimensionValues = result.groupByDimensionValues;
|
||||
|
||||
const xValue = isDefined(dimensionValues?.[0])
|
||||
? formatDimensionValue({
|
||||
value: dimensionValues[0],
|
||||
fieldMetadata: groupByFieldX,
|
||||
dateGranularity:
|
||||
configuration.primaryAxisDateGranularity ?? undefined,
|
||||
subFieldName:
|
||||
configuration.primaryAxisGroupBySubFieldName ?? undefined,
|
||||
})
|
||||
: '';
|
||||
|
||||
const aggregateValue = computeAggregateValueFromGroupByResult({
|
||||
rawResult: result,
|
||||
aggregateField,
|
||||
aggregateOperation:
|
||||
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
|
||||
aggregateOperationFromRawResult: aggregateOperation,
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
return {
|
||||
[indexByKey]: xValue,
|
||||
[aggregateValueKey]: aggregateValue,
|
||||
};
|
||||
});
|
||||
|
||||
const series: BarChartSeries[] = [
|
||||
{
|
||||
key: aggregateValueKey,
|
||||
label: aggregateField.label,
|
||||
color: (configuration.color ?? GRAPH_DEFAULT_COLOR) as GraphColor,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
data,
|
||||
indexBy: indexByKey,
|
||||
keys: [aggregateValueKey],
|
||||
series,
|
||||
hasTooManyGroups: rawResults.length > BAR_CHART_MAXIMUM_NUMBER_OF_BARS,
|
||||
};
|
||||
};
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
|
||||
import { 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 { 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,
|
||||
type BarChartConfiguration,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
type TransformTwoDimensionalGroupByToBarChartDataParams = {
|
||||
rawResults: GroupByRawResult[];
|
||||
groupByFieldX: FieldMetadataItem;
|
||||
groupByFieldY: FieldMetadataItem;
|
||||
aggregateField: FieldMetadataItem;
|
||||
configuration: BarChartConfiguration;
|
||||
aggregateOperation: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
primaryAxisSubFieldName?: string | null;
|
||||
};
|
||||
|
||||
type TransformTwoDimensionalGroupByToBarChartDataResult = {
|
||||
data: BarChartDataItem[];
|
||||
indexBy: string;
|
||||
keys: string[];
|
||||
series: BarChartSeries[];
|
||||
hasTooManyGroups: boolean;
|
||||
};
|
||||
|
||||
export const transformTwoDimensionalGroupByToBarChartData = ({
|
||||
rawResults,
|
||||
groupByFieldX,
|
||||
groupByFieldY,
|
||||
aggregateField,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
objectMetadataItem,
|
||||
primaryAxisSubFieldName,
|
||||
}: TransformTwoDimensionalGroupByToBarChartDataParams): TransformTwoDimensionalGroupByToBarChartDataResult => {
|
||||
const indexByKey = getFieldKey({
|
||||
field: groupByFieldX,
|
||||
subFieldName: primaryAxisSubFieldName ?? undefined,
|
||||
});
|
||||
|
||||
const dataMap = new Map<string, BarChartDataItem>();
|
||||
const xValues = new Set<string>();
|
||||
const yValues = new Set<string>();
|
||||
|
||||
let hasTooManyGroups = false;
|
||||
|
||||
rawResults.forEach((result) => {
|
||||
const dimensionValues = result.groupByDimensionValues;
|
||||
if (!isDefined(dimensionValues) || dimensionValues.length < 2) return;
|
||||
|
||||
const xValue = formatDimensionValue({
|
||||
value: dimensionValues[0],
|
||||
fieldMetadata: groupByFieldX,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity ?? undefined,
|
||||
subFieldName: configuration.primaryAxisGroupBySubFieldName ?? undefined,
|
||||
});
|
||||
const yValue = formatDimensionValue({
|
||||
value: dimensionValues[1],
|
||||
fieldMetadata: groupByFieldY,
|
||||
dateGranularity:
|
||||
configuration.secondaryAxisGroupByDateGranularity ?? undefined,
|
||||
subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined,
|
||||
});
|
||||
|
||||
// TODO: Add a limit to the query instead of checking here (issue: twentyhq/core-team-issues#1600)
|
||||
const isNewX = !xValues.has(xValue);
|
||||
const isNewY = !yValues.has(yValue);
|
||||
|
||||
if (configuration.groupMode === BarChartGroupMode.STACKED) {
|
||||
if (isNewX && xValues.size >= BAR_CHART_MAXIMUM_NUMBER_OF_BARS) {
|
||||
hasTooManyGroups = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (configuration.groupMode === BarChartGroupMode.GROUPED) {
|
||||
const totalUniqueDimensions = xValues.size * yValues.size;
|
||||
const additionalDimensions =
|
||||
(isNewX ? 1 : 0) * yValues.size + (isNewY ? 1 : 0) * xValues.size;
|
||||
|
||||
if (
|
||||
totalUniqueDimensions + additionalDimensions >
|
||||
BAR_CHART_MAXIMUM_NUMBER_OF_BARS
|
||||
) {
|
||||
hasTooManyGroups = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const aggregateValue = computeAggregateValueFromGroupByResult({
|
||||
rawResult: result,
|
||||
aggregateField,
|
||||
aggregateOperation:
|
||||
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
|
||||
aggregateOperationFromRawResult: aggregateOperation,
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
if (!isDefined(aggregateValue)) return;
|
||||
|
||||
xValues.add(xValue);
|
||||
yValues.add(yValue);
|
||||
|
||||
if (!dataMap.has(xValue)) {
|
||||
dataMap.set(xValue, {
|
||||
[indexByKey]: xValue,
|
||||
});
|
||||
}
|
||||
|
||||
const dataItem = dataMap.get(xValue)!;
|
||||
dataItem[yValue] = aggregateValue;
|
||||
});
|
||||
|
||||
// Sorting needed because yValues may be unordered despite BE orderBy, if there are empty groups
|
||||
const keys = getSortedKeys({
|
||||
orderByY: configuration.secondaryAxisOrderBy,
|
||||
yValues: Array.from(yValues),
|
||||
});
|
||||
|
||||
const series: BarChartSeries[] = keys.map((key) => ({
|
||||
key,
|
||||
label: key,
|
||||
color: configuration.color as GraphColor,
|
||||
}));
|
||||
|
||||
const unsortedData = Array.from(dataMap.values());
|
||||
const data = isDefined(configuration.primaryAxisOrderBy)
|
||||
? sortBarChartDataBySecondaryDimensionSum({
|
||||
data: unsortedData,
|
||||
keys,
|
||||
orderBy: configuration.primaryAxisOrderBy,
|
||||
})
|
||||
: unsortedData;
|
||||
|
||||
return {
|
||||
data,
|
||||
indexBy: indexByKey,
|
||||
keys,
|
||||
series,
|
||||
hasTooManyGroups,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user