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
@@ -6,7 +6,8 @@ import { t } from '@lingui/core/macro';
import { MenuPicker } from 'twenty-ui/navigation';
const graphTypeOptions = [
GraphType.BAR,
GraphType.VERTICAL_BAR,
GraphType.HORIZONTAL_BAR,
GraphType.NUMBER,
GraphType.PIE,
GraphType.LINE,
@@ -34,7 +34,7 @@ export const CommandMenuPageLayoutWidgetTypeSelect = () => {
if (!isDefined(pageLayoutEditingWidgetId)) {
const fieldSelection = buildBarChartFieldSelection();
const newWidget = createPageLayoutGraphWidget({
graphType: GraphType.BAR,
graphType: GraphType.VERTICAL_BAR,
fieldSelection,
});
@@ -86,10 +86,12 @@ export const ChartDataSourceDropdownContent = () => {
objectMetadataId,
configToUpdate: {
aggregateFieldMetadataId: null,
groupByFieldMetadataIdX: null,
groupByFieldMetadataIdY: null,
groupBySubFieldNameX: null,
groupBySubFieldNameY: null,
primaryAxisGroupByFieldMetadataId: null,
primaryAxisGroupBySubFieldName: null,
secondaryAxisGroupByFieldMetadataId: null,
secondaryAxisGroupBySubFieldName: null,
primaryAxisOrderBy: null,
secondaryAxisOrderBy: null,
groupByFieldMetadataId: null,
groupBySubFieldName: null,
},
@@ -14,7 +14,7 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
import { isFieldMetadataDateKind } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
@@ -27,22 +27,23 @@ export const ChartFieldSelectionForAggregateOperationDropdownContent = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const configuration = widgetInEditMode?.configuration;
if (
widgetInEditMode?.configuration?.__typename !== 'BarChartConfiguration' &&
widgetInEditMode?.configuration?.__typename !== 'LineChartConfiguration'
configuration?.__typename !== 'BarChartConfiguration' &&
configuration?.__typename !== 'LineChartConfiguration'
) {
throw new Error('Invalid configuration type');
}
const currentFieldMetadataId =
widgetInEditMode.configuration.groupByFieldMetadataIdY;
const currentFieldMetadataId = configuration.aggregateFieldMetadataId;
const [selectedFieldMetadataId, setSelectedFieldMetadataId] = useState(
currentFieldMetadataId,
);
const sourceObjectMetadataItem = objectMetadataItems.find(
(item) => item.id === widgetInEditMode.objectMetadataId,
(item) => item.id === widgetInEditMode?.objectMetadataId,
);
const dropdownId = useAvailableComponentInstanceIdOrThrow(
@@ -68,10 +69,6 @@ export const ChartFieldSelectionForAggregateOperationDropdownContent = () => {
const { getIcon } = useIcons();
if (!isDefined(sourceObjectMetadataItem)) {
return null;
}
if (isSubMenuOpen) {
return (
<ChartAggregateOperationSelectionDropdownContent
@@ -9,8 +9,8 @@ export const ChartGroupByFieldSelectionDropdownContent = () => {
<ChartGroupByFieldSelectionDropdownContentBase<
BarChartConfiguration | LineChartConfiguration
>
fieldMetadataIdKey="groupByFieldMetadataIdY"
subFieldNameKey="groupBySubFieldNameY"
fieldMetadataIdKey="secondaryAxisGroupByFieldMetadataId"
subFieldNameKey="secondaryAxisGroupBySubFieldName"
/>
);
};
@@ -13,21 +13,20 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { MenuItemSelect } from 'twenty-ui/navigation';
import {
type BarChartConfiguration,
type GraphOrderBy,
type LineChartConfiguration,
} from '~/generated/graphql';
import { type GraphOrderBy } from '~/generated/graphql';
export const ChartSortByGroupByFieldDropdownContent = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const configuration = widgetInEditMode?.configuration as
| BarChartConfiguration
| LineChartConfiguration;
const configuration = widgetInEditMode?.configuration;
const currentOrderBy = configuration.orderByY;
if (
configuration?.__typename !== 'BarChartConfiguration' &&
configuration?.__typename !== 'LineChartConfiguration'
) {
throw new Error('Invalid configuration type');
}
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
@@ -45,9 +44,7 @@ export const ChartSortByGroupByFieldDropdownContent = () => {
const handleSelectSortOption = (orderBy: GraphOrderBy) => {
updateCurrentWidgetConfig({
configToUpdate: {
orderByY: orderBy,
},
configToUpdate: { secondaryAxisOrderBy: orderBy },
});
closeDropdown();
};
@@ -78,13 +75,11 @@ export const ChartSortByGroupByFieldDropdownContent = () => {
text={getGroupBySortOptionLabel({
graphOrderBy: sortOption.value,
groupByFieldMetadataId:
'groupByFieldMetadataIdY' in configuration
? configuration.groupByFieldMetadataIdY
: 'groupByFieldMetadataId' in configuration
? configuration.groupByFieldMetadataId
: undefined,
configuration.secondaryAxisGroupByFieldMetadataId,
})}
selected={currentOrderBy === sortOption.value}
selected={
configuration.secondaryAxisOrderBy === sortOption.value
}
focused={selectedItemId === sortOption.value}
LeftIcon={sortOption.icon}
onClick={() => {
@@ -9,8 +9,8 @@ export const ChartXAxisFieldSelectionDropdownContent = () => {
<ChartGroupByFieldSelectionDropdownContentBase<
BarChartConfiguration | LineChartConfiguration
>
fieldMetadataIdKey="groupByFieldMetadataIdX"
subFieldNameKey="groupBySubFieldNameX"
fieldMetadataIdKey="primaryAxisGroupByFieldMetadataId"
subFieldNameKey="primaryAxisGroupBySubFieldName"
/>
);
};
@@ -11,24 +11,22 @@ import { SelectableListItem } from '@/ui/layout/selectable-list/components/Selec
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { type CompositeFieldSubFieldName } from 'twenty-shared/types';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { type GraphOrderBy } from '~/generated/graphql';
export const ChartXAxisSortBySelectionDropdownContent = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const configuration = widgetInEditMode?.configuration;
if (
widgetInEditMode?.configuration?.__typename !== 'BarChartConfiguration' &&
widgetInEditMode?.configuration?.__typename !== 'LineChartConfiguration'
configuration?.__typename !== 'BarChartConfiguration' &&
configuration?.__typename !== 'LineChartConfiguration'
) {
throw new Error('Invalid configuration type');
}
const configuration = widgetInEditMode?.configuration;
const currentOrderByX = configuration.orderByX;
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
@@ -40,22 +38,19 @@ export const ChartXAxisSortBySelectionDropdownContent = () => {
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { closeDropdown } = useCloseDropdown();
const handleSelectSortOption = (orderByX: GraphOrderBy) => {
updateCurrentWidgetConfig({
configToUpdate: {
orderByX,
},
});
closeDropdown();
};
const { getXSortOptionLabel } = useGraphXSortOptionLabels({
objectMetadataId: widgetInEditMode?.objectMetadataId,
});
const handleSelect = (orderBy: GraphOrderBy) => {
updateCurrentWidgetConfig({
configToUpdate: { primaryAxisOrderBy: orderBy },
});
closeDropdown();
};
return (
<DropdownMenuItemsContainer>
<SelectableList
@@ -68,23 +63,28 @@ export const ChartXAxisSortBySelectionDropdownContent = () => {
key={sortOption.value}
itemId={sortOption.value}
onEnter={() => {
handleSelectSortOption(sortOption.value);
handleSelect(sortOption.value);
}}
>
<MenuItemSelect
text={getXSortOptionLabel({
graphOrderBy: sortOption.value,
groupByFieldMetadataIdX: configuration.groupByFieldMetadataIdX,
groupByFieldMetadataIdX:
configuration.primaryAxisGroupByFieldMetadataId,
groupBySubFieldNameX:
configuration.primaryAxisGroupBySubFieldName as
| CompositeFieldSubFieldName
| undefined,
aggregateFieldMetadataId:
configuration.aggregateFieldMetadataId ?? undefined,
aggregateOperation:
configuration.aggregateOperation ?? undefined,
})}
selected={currentOrderByX === sortOption.value}
selected={configuration.primaryAxisOrderBy === sortOption.value}
focused={selectedItemId === sortOption.value}
LeftIcon={sortOption.icon}
onClick={() => {
handleSelectSortOption(sortOption.value);
handleSelect(sortOption.value);
}}
/>
</SelectableListItem>