Split bar graph into two distinct horizontal and vertical bars (#15061)

### Summary

Split BAR into VERTICAL_BAR and HORIZONTAL_BAR as separate chart types.

### The Problem

Initially wanted a simple vertical/horizontal toggle for bar charts, but
ran into a GraphQL union type
constraint: union types can't have the same field name with different
nullability.

- Vertical bars need groupByFieldMetadataIdX as required (categories on
X)
- Horizontal bars need groupByFieldMetadataIdY as required (categories
on Y)
  - GraphQL schema generation fails with this setup

### The Solution

Use semantic primaryAxis and secondaryAxis naming that's
orientation-agnostic:

- primaryAxisGroupByFieldMetadataId = main grouping field (e.g.,
"Company Name")
- secondaryAxisGroupByFieldMetadataId = optional secondary grouping
(e.g., "Stage")

These fields have consistent meaning regardless of orientation. The
visual mapping happens at the UI layer:
  - Vertical bars: primary data renders on X-axis, secondary on Y-axis
  - Horizontal bars: primary data renders on Y-axis, secondary on X-axis

Both chart types share the same DTO structure with consistent
nullability.

### What Changed

  - Split GraphType.BAR → VERTICAL_BAR | HORIZONTAL_BAR
- Renamed fields: primaryAxisGroupByFieldMetadataId,
secondaryAxisGroupByFieldMetadataId (+ subfield
  variants)
- useChartSettingsValues(): Maps semantic fields to setting values (no
swapping)
- getBarChartSettings(): Dynamically arranges settings panel based on
orientation
- transformGroupByDataToBarChartData(): Maps semantic fields to Nivo's
layout prop
  
  video QA
  

https://github.com/user-attachments/assets/479061b5-712e-4ca6-9858-95273d1f16c1
This commit is contained in:
nitin
2025-10-15 15:08:48 +05:30
committed by GitHub
parent d21be90c5d
commit 436084f8f4
48 changed files with 1453 additions and 412 deletions
@@ -1,28 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateGroupByQueryVariablesFromBarChartConfiguration should generate variables with composite field 1`] = `
{
"groupBy": [
{
"name": {
"firstName": true,
},
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration should generate variables with two groupBy fields 1`] = `
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Horizontal bar configuration should generate variables with secondary axis 1`] = `
{
"groupBy": [
{
"stage": true,
},
{
"ownerId": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Horizontal bar configuration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Vertical bar configuration should generate variables with composite field 1`] = `
{
"groupBy": [
{
"name": {
"firstName": true,
},
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Vertical bar configuration should generate variables with single groupBy field 1`] = `
{
"groupBy": [
{
"stage": true,
},
],
}
`;
exports[`generateGroupByQueryVariablesFromBarChartConfiguration Vertical bar configuration should generate variables with two groupBy fields 1`] = `
{
"groupBy": [
{
@@ -1,7 +1,9 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
type BarChartConfiguration,
ExtendedAggregateOperations,
FieldMetadataType,
GraphType,
} from '~/generated-metadata/graphql';
import { generateGroupByQueryVariablesFromBarChartConfiguration } from '../generateGroupByQueryVariablesFromBarChartConfiguration';
@@ -34,52 +36,96 @@ describe('generateGroupByQueryVariablesFromBarChartConfiguration', () => {
],
} as ObjectMetadataItem;
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: {
groupByFieldMetadataIdX: 'field-1',
groupBySubFieldNameX: null,
} as BarChartConfiguration,
const buildConfiguration = (
overrides: Partial<BarChartConfiguration>,
): BarChartConfiguration =>
({
__typename: 'BarChartConfiguration',
aggregateFieldMetadataId: 'aggregate-field',
aggregateOperation: ExtendedAggregateOperations.COUNT,
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
...overrides,
}) as BarChartConfiguration;
describe('Vertical bar configuration', () => {
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
expect(result).toMatchSnapshot();
});
it('should generate variables with two groupBy fields', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
secondaryAxisGroupByFieldMetadataId: 'field-2',
secondaryAxisGroupBySubFieldName: null,
}),
});
it('should generate variables with two groupBy fields', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: {
groupByFieldMetadataIdX: 'field-1',
groupBySubFieldNameX: null,
groupByFieldMetadataIdY: 'field-2',
groupBySubFieldNameY: null,
} as BarChartConfiguration,
expect(result).toMatchSnapshot();
});
expect(result).toMatchSnapshot();
it('should generate variables with composite field', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-4',
primaryAxisGroupBySubFieldName: 'firstName',
}),
});
expect(result).toMatchSnapshot();
});
});
it('should generate variables with composite field', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: {
groupByFieldMetadataIdX: 'field-4',
groupBySubFieldNameX: 'firstName',
} as BarChartConfiguration,
describe('Horizontal bar configuration', () => {
it('should generate variables with single groupBy field', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
primaryAxisGroupBySubFieldName: null,
}),
});
expect(result).toMatchSnapshot();
});
expect(result).toMatchSnapshot();
it('should generate variables with secondary axis', () => {
const result = generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: buildConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-1',
secondaryAxisGroupByFieldMetadataId: 'field-2',
}),
});
expect(result).toMatchSnapshot();
});
});
it('should throw error when groupBy field X not found', () => {
it('should throw error when primary axis field not found', () => {
expect(() =>
generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem: mockObjectMetadataItem,
barChartConfiguration: {
groupByFieldMetadataIdX: 'invalid-field',
groupBySubFieldNameX: null,
} as BarChartConfiguration,
barChartConfiguration: buildConfiguration({
primaryAxisGroupByFieldMetadataId: 'invalid-field',
}),
}),
).toThrow('Field with id invalid-field not found in object metadata');
});
@@ -27,20 +27,22 @@ export const areChartConfigurationFieldsValidForQuery = (
switch (configuration.__typename) {
case 'BarChartConfiguration':
case 'LineChartConfiguration': {
const hasRequiredXFields =
case 'LineChartConfiguration':
return (
fieldExists(
configuration.aggregateFieldMetadataId,
objectMetadataItem,
) &&
fieldExists(configuration.groupByFieldMetadataIdX, objectMetadataItem);
const hasValidYField =
!isDefined(configuration.groupByFieldMetadataIdY) ||
fieldExists(configuration.groupByFieldMetadataIdY, objectMetadataItem);
return hasRequiredXFields && hasValidYField;
}
fieldExists(
configuration.primaryAxisGroupByFieldMetadataId,
objectMetadataItem,
) &&
(!isDefined(configuration.secondaryAxisGroupByFieldMetadataId) ||
fieldExists(
configuration.secondaryAxisGroupByFieldMetadataId,
objectMetadataItem,
))
);
case 'PieChartConfiguration':
return (
@@ -25,17 +25,29 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
objectMetadataItem: ObjectMetadataItem;
barChartConfiguration: BarChartConfiguration;
}) => {
const groupByFieldXId =
barChartConfiguration.primaryAxisGroupByFieldMetadataId;
const groupByFieldYId =
barChartConfiguration.secondaryAxisGroupByFieldMetadataId;
const groupBySubFieldNameX =
barChartConfiguration.primaryAxisGroupBySubFieldName ?? undefined;
const groupBySubFieldNameY =
barChartConfiguration.secondaryAxisGroupBySubFieldName ?? undefined;
const groupByFieldX = objectMetadataItem.fields.find(
(field) => field.id === barChartConfiguration.groupByFieldMetadataIdX,
(field) => field.id === groupByFieldXId,
);
const groupByFieldY = objectMetadataItem.fields.find(
(field) => field.id === barChartConfiguration.groupByFieldMetadataIdY,
);
const groupByFieldY = isDefined(groupByFieldYId)
? objectMetadataItem.fields.find((field) => field.id === groupByFieldYId)
: undefined;
if (!isDefined(groupByFieldX)) {
if (!isDefined(groupByFieldX) || !isDefined(groupByFieldXId)) {
throw new Error(
`Field with id ${barChartConfiguration.groupByFieldMetadataIdX} not found in object metadata`,
`Field with id ${groupByFieldXId} not found in object metadata`,
);
}
@@ -46,7 +58,7 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
groupBy.push(
buildGroupByFieldObject({
field: groupByFieldX,
subFieldName: barChartConfiguration.groupBySubFieldNameX,
subFieldName: groupBySubFieldNameX,
}),
);
@@ -54,7 +66,7 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
groupBy.push(
buildGroupByFieldObject({
field: groupByFieldY,
subFieldName: barChartConfiguration.groupBySubFieldNameY,
subFieldName: groupBySubFieldNameY,
}),
);
}
@@ -62,18 +74,21 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
const orderBy: Array<Record<string, string>> = [];
// TODO: Add orderBy back in when the backend is ready
// if (isDefined(barChartConfiguration.orderByX)) {
// if (isDefined(barChartConfiguration.primaryAxisOrderBy)) {
// orderBy.push({
// [groupByFieldX.name]: mapOrderByToDirection(
// barChartConfiguration.orderByX,
// barChartConfiguration.primaryAxisOrderBy!,
// ),
// });
// }
// if (isDefined(groupByFieldY) && isDefined(barChartConfiguration.orderByY)) {
// if (
// isDefined(groupByFieldY) &&
// isDefined(barChartConfiguration.secondaryAxisOrderBy)
// ) {
// orderBy.push({
// [groupByFieldY.name]: mapOrderByToDirection(
// barChartConfiguration.orderByY,
// barChartConfiguration.secondaryAxisOrderBy!,
// ),
// });
// }
@@ -9,6 +9,7 @@ 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,
@@ -29,6 +30,7 @@ type TransformGroupByDataToBarChartDataResult = {
xAxisLabel?: string;
yAxisLabel?: string;
showDataLabels: boolean;
layout?: 'vertical' | 'horizontal';
};
const EMPTY_BAR_CHART_RESULT: TransformGroupByDataToBarChartDataResult = {
@@ -39,6 +41,7 @@ const EMPTY_BAR_CHART_RESULT: TransformGroupByDataToBarChartDataResult = {
xAxisLabel: undefined,
yAxisLabel: undefined,
showDataLabels: false,
layout: 'vertical',
};
export const transformGroupByDataToBarChartData = ({
@@ -53,13 +56,17 @@ export const transformGroupByDataToBarChartData = ({
const groupByFieldX = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.groupByFieldMetadataIdX,
field.id === configuration.primaryAxisGroupByFieldMetadataId,
);
const groupByFieldY = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.groupByFieldMetadataIdY,
);
const groupByFieldY = isDefined(
configuration.secondaryAxisGroupByFieldMetadataId,
)
? objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.secondaryAxisGroupByFieldMetadataId,
)
: undefined;
const aggregateField = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
@@ -67,12 +74,21 @@ export const transformGroupByDataToBarChartData = ({
);
if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) {
return EMPTY_BAR_CHART_RESULT;
return {
...EMPTY_BAR_CHART_RESULT,
layout:
configuration.graphType === GraphType.HORIZONTAL_BAR
? 'horizontal'
: 'vertical',
};
}
const primaryAxisSubFieldName =
configuration.primaryAxisGroupBySubFieldName ?? undefined;
const indexByKey = getFieldKey({
field: groupByFieldX,
subFieldName: configuration.groupBySubFieldNameX,
subFieldName: primaryAxisSubFieldName,
});
const queryName = getGroupByQueryName(objectMetadataItem);
@@ -82,6 +98,10 @@ export const transformGroupByDataToBarChartData = ({
return {
...EMPTY_BAR_CHART_RESULT,
indexBy: indexByKey,
layout:
configuration.graphType === GraphType.HORIZONTAL_BAR
? 'horizontal'
: 'vertical',
};
}
@@ -110,6 +130,7 @@ export const transformGroupByDataToBarChartData = ({
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
})
: transformOneDimensionalGroupByToBarChartData({
rawResults,
@@ -118,12 +139,19 @@ export const transformGroupByDataToBarChartData = ({
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
});
const layout =
configuration.graphType === GraphType.HORIZONTAL_BAR
? 'horizontal'
: 'vertical';
return {
...baseResult,
xAxisLabel,
yAxisLabel,
showDataLabels,
layout,
};
};
@@ -20,6 +20,7 @@ type TransformOneDimensionalGroupByToBarChartDataParams = {
configuration: BarChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
};
type TransformOneDimensionalGroupByToBarChartDataResult = {
@@ -36,10 +37,11 @@ export const transformOneDimensionalGroupByToBarChartData = ({
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
}: TransformOneDimensionalGroupByToBarChartDataParams): TransformOneDimensionalGroupByToBarChartDataResult => {
const indexByKey = getFieldKey({
field: groupByFieldX,
subFieldName: configuration.groupBySubFieldNameX,
subFieldName: primaryAxisSubFieldName ?? undefined,
});
// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
@@ -52,7 +54,8 @@ export const transformOneDimensionalGroupByToBarChartData = ({
? formatDimensionValue({
value: dimensionValues[0],
fieldMetadata: groupByFieldX,
subFieldName: configuration.groupBySubFieldNameX ?? undefined,
subFieldName:
configuration.primaryAxisGroupBySubFieldName ?? undefined,
})
: '';
@@ -19,6 +19,7 @@ type TransformTwoDimensionalGroupByToBarChartDataParams = {
configuration: BarChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
};
type TransformTwoDimensionalGroupByToBarChartDataResult = {
@@ -36,10 +37,11 @@ export const transformTwoDimensionalGroupByToBarChartData = ({
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
}: TransformTwoDimensionalGroupByToBarChartDataParams): TransformTwoDimensionalGroupByToBarChartDataResult => {
const indexByKey = getFieldKey({
field: groupByFieldX,
subFieldName: configuration.groupBySubFieldNameX,
subFieldName: primaryAxisSubFieldName ?? undefined,
});
const dataMap = new Map<string, BarChartDataItem>();
@@ -53,12 +55,12 @@ export const transformTwoDimensionalGroupByToBarChartData = ({
const xValue = formatDimensionValue({
value: dimensionValues[0],
fieldMetadata: groupByFieldX,
subFieldName: configuration.groupBySubFieldNameX ?? undefined,
subFieldName: configuration.primaryAxisGroupBySubFieldName ?? undefined,
});
const yValue = formatDimensionValue({
value: dimensionValues[1],
fieldMetadata: groupByFieldY,
subFieldName: configuration.groupBySubFieldNameY ?? undefined,
subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined,
});
// TODO: Add a limit to the query instead of checking here (issue: twentyhq/core-team-issues#1600)