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>
@@ -1,14 +1,15 @@
import { BAR_CHART_SETTINGS } from '@/command-menu/pages/page-layout/constants/BarChartSettings';
import { GAUGE_CHART_SETTINGS } from '@/command-menu/pages/page-layout/constants/GaugeChartSettings';
import { LINE_CHART_SETTINGS } from '@/command-menu/pages/page-layout/constants/LineChartSettings';
import { NUMBER_CHART_SETTINGS } from '@/command-menu/pages/page-layout/constants/NumberChartSettings';
import { PIE_CHART_SETTINGS } from '@/command-menu/pages/page-layout/constants/PieChartSettings';
import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { getBarChartSettings } from '@/command-menu/pages/page-layout/utils/getBarChartSettings';
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import {
Icon123,
IconChartBar,
IconChartBarHorizontal,
IconChartLine,
IconChartPie,
type IconComponent,
@@ -24,10 +25,15 @@ export const GRAPH_TYPE_INFORMATION: Record<
settings: ChartSettingsGroup[];
}
> = {
[GraphType.BAR]: {
label: msg`Bar`,
[GraphType.VERTICAL_BAR]: {
label: msg`Vertical Bar`,
icon: IconChartBar,
settings: BAR_CHART_SETTINGS,
settings: getBarChartSettings(GraphType.VERTICAL_BAR),
},
[GraphType.HORIZONTAL_BAR]: {
label: msg`Horizontal Bar`,
icon: IconChartBarHorizontal,
settings: getBarChartSettings(GraphType.HORIZONTAL_BAR),
},
[GraphType.PIE]: {
label: msg`Pie`,
@@ -1,7 +1,8 @@
import { GraphType } from '~/generated-metadata/graphql';
export const GRAPH_TYPE_TO_CONFIG_TYPENAME = {
[GraphType.BAR]: 'BarChartConfiguration',
[GraphType.VERTICAL_BAR]: 'BarChartConfiguration',
[GraphType.HORIZONTAL_BAR]: 'BarChartConfiguration',
[GraphType.LINE]: 'LineChartConfiguration',
[GraphType.PIE]: 'PieChartConfiguration',
[GraphType.NUMBER]: 'NumberChartConfiguration',
@@ -0,0 +1,592 @@
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
AxisNameDisplay,
type BarChartConfiguration,
BarChartGroupMode,
ExtendedAggregateOperations,
FieldMetadataType,
GraphOrderBy,
GraphType,
} from '~/generated-metadata/graphql';
import { useChartSettingsValues } from '../useChartSettingsValues';
const mockObjectMetadataItem: ObjectMetadataItem = {
id: 'obj-1',
nameSingular: 'company',
namePlural: 'companies',
labelSingular: 'Company',
labelPlural: 'Companies',
fields: [
{
id: 'field-company-name',
name: 'name',
label: 'Company Name',
type: FieldMetadataType.TEXT,
},
{
id: 'field-amount',
name: 'amount',
label: 'Amount',
type: FieldMetadataType.NUMBER,
},
{
id: 'field-stage',
name: 'stage',
label: 'Stage',
type: FieldMetadataType.TEXT,
},
{
id: 'field-full-name',
name: 'fullName',
label: 'Full Name',
type: FieldMetadataType.FULL_NAME,
subFields: [
{
id: 'field-full-name-first',
name: 'firstName',
label: 'First Name',
type: FieldMetadataType.TEXT,
},
],
},
],
} as ObjectMetadataItem;
const buildBarChartConfiguration = (
overrides: Partial<BarChartConfiguration>,
): BarChartConfiguration =>
({
__typename: 'BarChartConfiguration',
aggregateFieldMetadataId: 'field-amount',
aggregateOperation: ExtendedAggregateOperations.SUM,
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-company-name',
primaryAxisGroupBySubFieldName: null,
secondaryAxisGroupByFieldMetadataId: null,
secondaryAxisGroupBySubFieldName: null,
primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
secondaryAxisOrderBy: null,
axisNameDisplay: AxisNameDisplay.BOTH,
displayDataLabel: false,
groupMode: 'GROUPED',
...overrides,
}) as BarChartConfiguration;
const renderUseChartSettingsValues = (configuration: ChartConfiguration) => {
return renderHook(
() =>
useChartSettingsValues({
objectMetadataId: mockObjectMetadataItem.id,
configuration,
}),
{
wrapper: ({ children }) => (
<RecoilRoot
initializeState={({ set }) => {
set(objectMetadataItemsState, [mockObjectMetadataItem]);
}}
>
{children}
</RecoilRoot>
),
},
);
};
describe('useChartSettingsValues', () => {
describe('Vertical bar chart (1D - single grouping)', () => {
const verticalBarConfig = buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-company-name',
secondaryAxisGroupByFieldMetadataId: null,
});
it('should return primary field label for DATA_ON_DISPLAY_X', () => {
const { result } = renderUseChartSettingsValues(verticalBarConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
);
expect(value).toBe('Company Name');
});
it('should return aggregate field with operation for DATA_ON_DISPLAY_Y', () => {
const { result } = renderUseChartSettingsValues(verticalBarConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_Y,
);
expect(value).toBe('Amount (Sum)');
});
it('should return object label plural for SOURCE', () => {
const { result } = renderUseChartSettingsValues(verticalBarConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.SOURCE,
);
expect(value).toBe('Companies');
});
it('should return sort label for SORT_BY_X', () => {
const { result } = renderUseChartSettingsValues(verticalBarConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.SORT_BY_X,
);
expect(value).toBeDefined();
expect(typeof value).toBe('string');
});
it('should return displayDataLabel value for DATA_LABELS', () => {
const { result } = renderUseChartSettingsValues(verticalBarConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS,
);
expect(value).toBe(false);
});
it('should return axis name display option for AXIS_NAME', () => {
const { result } = renderUseChartSettingsValues(verticalBarConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.AXIS_NAME,
);
expect(value).toBeDefined();
expect(typeof value).toBe('string');
});
});
describe('Horizontal bar chart (1D - single grouping)', () => {
const horizontalBarConfig = buildBarChartConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-company-name',
secondaryAxisGroupByFieldMetadataId: null,
});
it('should return SAME primary field label for DATA_ON_DISPLAY_X (no swapping)', () => {
const { result } = renderUseChartSettingsValues(horizontalBarConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
);
// Critical test: horizontal should return the SAME value as vertical
expect(value).toBe('Company Name');
});
it('should return SAME aggregate field for DATA_ON_DISPLAY_Y (no swapping)', () => {
const { result } = renderUseChartSettingsValues(horizontalBarConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_Y,
);
// Critical test: horizontal should return the SAME value as vertical
expect(value).toBe('Amount (Sum)');
});
it('should return SAME sort label for SORT_BY_X (no swapping)', () => {
const { result } = renderUseChartSettingsValues(horizontalBarConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.SORT_BY_X,
);
expect(value).toBeDefined();
expect(typeof value).toBe('string');
});
});
describe('Vertical bar chart (2D - with secondary grouping)', () => {
const verticalBar2DConfig = buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-company-name',
secondaryAxisGroupByFieldMetadataId: 'field-stage',
secondaryAxisOrderBy: GraphOrderBy.FIELD_DESC,
});
it('should return primary field label for DATA_ON_DISPLAY_X', () => {
const { result } = renderUseChartSettingsValues(verticalBar2DConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
);
expect(value).toBe('Company Name');
});
it('should return secondary field label for GROUP_BY', () => {
const { result } = renderUseChartSettingsValues(verticalBar2DConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.GROUP_BY,
);
expect(value).toBe('Stage');
});
it('should return secondary sort label for SORT_BY_GROUP_BY_FIELD', () => {
const { result } = renderUseChartSettingsValues(verticalBar2DConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.SORT_BY_GROUP_BY_FIELD,
);
expect(value).toBeDefined();
expect(typeof value).toBe('string');
});
});
describe('Horizontal bar chart (2D - with secondary grouping)', () => {
const horizontalBar2DConfig = buildBarChartConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-company-name',
secondaryAxisGroupByFieldMetadataId: 'field-stage',
secondaryAxisOrderBy: GraphOrderBy.FIELD_DESC,
});
it('should return SAME primary field for DATA_ON_DISPLAY_X (no swapping)', () => {
const { result } = renderUseChartSettingsValues(horizontalBar2DConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
);
expect(value).toBe('Company Name');
});
it('should return SAME secondary field for GROUP_BY (no swapping)', () => {
const { result } = renderUseChartSettingsValues(horizontalBar2DConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.GROUP_BY,
);
expect(value).toBe('Stage');
});
});
describe('Composite field with subfield', () => {
const configWithSubField = buildBarChartConfiguration({
graphType: GraphType.VERTICAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-full-name',
primaryAxisGroupBySubFieldName: 'firstName',
});
it('should return field label with subfield label for DATA_ON_DISPLAY_X', () => {
const { result } = renderUseChartSettingsValues(configWithSubField);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
);
expect(value).toContain('Full Name');
expect(typeof value).toBe('string');
});
});
describe('Stacked bars setting', () => {
it('should return false when groupMode is GROUPED', () => {
const config = buildBarChartConfiguration({
groupMode: BarChartGroupMode.GROUPED,
});
const { result } = renderUseChartSettingsValues(config);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.STACKED_BARS,
);
expect(value).toBe(false);
});
it('should return true when groupMode is STACKED', () => {
const config = buildBarChartConfiguration({
groupMode: BarChartGroupMode.STACKED,
});
const { result } = renderUseChartSettingsValues(config);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.STACKED_BARS,
);
expect(value).toBe(true);
});
});
describe('Colors setting', () => {
it('should return undefined when no color is set', () => {
const config = buildBarChartConfiguration({});
const { result } = renderUseChartSettingsValues(config);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.COLORS,
);
expect(value).toBeUndefined();
});
it('should return capitalized color when color is set', () => {
const config = buildBarChartConfiguration({
color: 'blue',
} as Partial<BarChartConfiguration>);
const { result } = renderUseChartSettingsValues(config);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.COLORS,
);
expect(value).toBe('Blue');
});
});
describe('No configuration', () => {
it('should return undefined function when configuration is undefined', () => {
const { result } = renderUseChartSettingsValues(undefined as any);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.SOURCE,
);
expect(value).toBeUndefined();
});
});
describe('Edge cases - Missing fields', () => {
it('should handle missing primaryAxisGroupByFieldMetadataId gracefully', () => {
const config = buildBarChartConfiguration({
primaryAxisGroupByFieldMetadataId: 'non-existent-field-id',
});
const { result } = renderUseChartSettingsValues(config);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
);
expect(value).toBeUndefined();
});
it('should handle missing aggregateFieldMetadataId gracefully', () => {
const config = buildBarChartConfiguration({
aggregateFieldMetadataId: 'non-existent-aggregate-field',
});
const { result } = renderUseChartSettingsValues(config);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_Y,
);
expect(value).toBe('');
});
it('should handle missing secondaryAxisGroupByFieldMetadataId gracefully', () => {
const config = buildBarChartConfiguration({
secondaryAxisGroupByFieldMetadataId: 'non-existent-secondary-field',
});
const { result } = renderUseChartSettingsValues(config);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.GROUP_BY,
);
expect(value).toBeUndefined();
});
it('should handle missing objectMetadataItem gracefully', () => {
const config = buildBarChartConfiguration({});
const { result } = renderHook(
() =>
useChartSettingsValues({
objectMetadataId: 'non-existent-object-id',
configuration: config,
}),
{
wrapper: ({ children }) => (
<RecoilRoot
initializeState={({ set }) => {
set(objectMetadataItemsState, [mockObjectMetadataItem]);
}}
>
{children}
</RecoilRoot>
),
},
);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.SOURCE,
);
expect(value).toBeUndefined();
});
});
describe('Edge cases - Null/undefined orderBy', () => {
it('should handle null primaryAxisOrderBy gracefully', () => {
const config = buildBarChartConfiguration({
primaryAxisOrderBy: null,
});
const { result } = renderUseChartSettingsValues(config);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.SORT_BY_X,
);
expect(value).toBeUndefined();
});
it('should handle null secondaryAxisOrderBy gracefully', () => {
const config = buildBarChartConfiguration({
secondaryAxisGroupByFieldMetadataId: 'field-stage',
secondaryAxisOrderBy: null,
});
const { result } = renderUseChartSettingsValues(config);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.SORT_BY_GROUP_BY_FIELD,
);
expect(value).toBeUndefined();
});
});
describe('Edge cases - Missing axisNameDisplay', () => {
it('should handle undefined axisNameDisplay gracefully', () => {
const config = buildBarChartConfiguration({
axisNameDisplay: undefined as any,
});
const { result } = renderUseChartSettingsValues(config);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.AXIS_NAME,
);
expect(value).toBeUndefined();
});
});
describe('Edge cases - Composite field with missing subfield', () => {
it('should handle composite field without subfield gracefully', () => {
const config = buildBarChartConfiguration({
primaryAxisGroupByFieldMetadataId: 'field-full-name',
primaryAxisGroupBySubFieldName: null,
});
const { result } = renderUseChartSettingsValues(config);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
);
expect(value).toBe('Full Name');
});
it('should handle invalid subfield name gracefully', () => {
const config = buildBarChartConfiguration({
primaryAxisGroupByFieldMetadataId: 'field-full-name',
primaryAxisGroupBySubFieldName: 'invalidSubField' as any,
});
const { result } = renderUseChartSettingsValues(config);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
);
expect(value).toBeDefined();
expect(typeof value).toBe('string');
});
});
describe('Critical: Proves no swapping occurs', () => {
it('horizontal bar with NULL secondary should still return primary for DATA_ON_DISPLAY_X', () => {
const horizontalConfig = buildBarChartConfiguration({
graphType: GraphType.HORIZONTAL_BAR,
primaryAxisGroupByFieldMetadataId: 'field-company-name',
secondaryAxisGroupByFieldMetadataId: null,
});
const { result } = renderUseChartSettingsValues(horizontalConfig);
const value = result.current.getChartSettingsValues(
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
);
expect(value).toBe('Company Name');
expect(value).not.toBeUndefined();
});
it('should return identical values for all settings regardless of orientation', () => {
const baseConfig = {
primaryAxisGroupByFieldMetadataId: 'field-company-name',
secondaryAxisGroupByFieldMetadataId: 'field-stage',
aggregateFieldMetadataId: 'field-amount',
aggregateOperation: ExtendedAggregateOperations.SUM,
primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
secondaryAxisOrderBy: GraphOrderBy.FIELD_DESC,
axisNameDisplay: AxisNameDisplay.BOTH,
displayDataLabel: true,
};
const verticalConfig = buildBarChartConfiguration({
...baseConfig,
graphType: GraphType.VERTICAL_BAR,
});
const horizontalConfig = buildBarChartConfiguration({
...baseConfig,
graphType: GraphType.HORIZONTAL_BAR,
});
const { result: verticalResult } =
renderUseChartSettingsValues(verticalConfig);
const { result: horizontalResult } =
renderUseChartSettingsValues(horizontalConfig);
// Test all setting IDs return IDENTICAL values
const settingIds = [
CHART_CONFIGURATION_SETTING_IDS.SOURCE,
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_Y,
CHART_CONFIGURATION_SETTING_IDS.GROUP_BY,
CHART_CONFIGURATION_SETTING_IDS.SORT_BY_X,
CHART_CONFIGURATION_SETTING_IDS.SORT_BY_GROUP_BY_FIELD,
CHART_CONFIGURATION_SETTING_IDS.AXIS_NAME,
CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS,
];
settingIds.forEach((settingId) => {
const verticalValue =
verticalResult.current.getChartSettingsValues(settingId);
const horizontalValue =
horizontalResult.current.getChartSettingsValues(settingId);
expect(verticalValue).toEqual(horizontalValue);
});
});
});
});
@@ -9,6 +9,7 @@ import { getAggregateOperationLabel } from '@/object-record/record-board/record-
import { useRecoilValue } from 'recoil';
import { type CompositeFieldSubFieldName } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { type GraphOrderBy } from '~/generated-metadata/graphql';
export const useChartSettingsValues = ({
objectMetadataId,
@@ -37,20 +38,43 @@ export const useChartSettingsValues = ({
};
}
const groupByFieldX =
'groupByFieldMetadataIdX' in configuration
? objectMetadataItem?.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === configuration.groupByFieldMetadataIdX,
)
: undefined;
const isBarOrLineChart =
configuration.__typename === 'BarChartConfiguration' ||
configuration.__typename === 'LineChartConfiguration';
let groupByFieldXId: string | undefined;
let groupByFieldYId: string | undefined;
let groupBySubFieldNameX: CompositeFieldSubFieldName | undefined;
let groupBySubFieldNameY: CompositeFieldSubFieldName | undefined;
let xAxisOrderBy: GraphOrderBy | undefined | null;
let groupByOrderBy: GraphOrderBy | undefined | null;
if (isBarOrLineChart) {
groupByFieldXId = configuration.primaryAxisGroupByFieldMetadataId;
groupByFieldYId = configuration.secondaryAxisGroupByFieldMetadataId;
groupBySubFieldNameX = configuration.primaryAxisGroupBySubFieldName as
| CompositeFieldSubFieldName
| undefined;
groupBySubFieldNameY = configuration.secondaryAxisGroupBySubFieldName as
| CompositeFieldSubFieldName
| undefined;
xAxisOrderBy = configuration.primaryAxisOrderBy;
groupByOrderBy = configuration.secondaryAxisOrderBy;
}
const groupByFieldX = isDefined(groupByFieldXId)
? objectMetadataItem?.fields.find((field) => field.id === groupByFieldXId)
: undefined;
const groupByFieldY = isDefined(groupByFieldYId)
? objectMetadataItem?.fields.find((field) => field.id === groupByFieldYId)
: undefined;
const groupBySubFieldNameXLabel =
'groupBySubFieldNameX' in configuration && isDefined(groupByFieldX)
isDefined(groupBySubFieldNameX) && isDefined(groupByFieldX)
? getFieldLabelWithSubField({
field: groupByFieldX,
subFieldName:
configuration.groupBySubFieldNameX as CompositeFieldSubFieldName,
subFieldName: groupBySubFieldNameX,
})
: undefined;
@@ -61,53 +85,36 @@ export const useChartSettingsValues = ({
const yAxisAggregateOperation = configuration.aggregateOperation;
const groupByFieldY =
'groupByFieldMetadataIdY' in configuration
? objectMetadataItem?.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === configuration.groupByFieldMetadataIdY,
)
: undefined;
const xAxisOrderBy =
'orderByX' in configuration ? configuration.orderByX : undefined;
const xAxisOrderByLabel =
isDefined(xAxisOrderBy) && 'groupByFieldMetadataIdX' in configuration
isDefined(xAxisOrderBy) && isDefined(groupByFieldXId)
? getXSortOptionLabel({
graphOrderBy: xAxisOrderBy,
groupByFieldMetadataIdX: configuration.groupByFieldMetadataIdX,
groupBySubFieldNameX:
configuration.groupBySubFieldNameX as CompositeFieldSubFieldName,
groupByFieldMetadataIdX: groupByFieldXId,
groupBySubFieldNameX: groupBySubFieldNameX,
aggregateFieldMetadataId: configuration.aggregateFieldMetadataId,
aggregateOperation: configuration.aggregateOperation ?? undefined,
})
: undefined;
const groupByOrderBy =
'orderByY' in configuration
? configuration.orderByY
: 'orderBy' in configuration
? configuration.orderBy
: undefined;
if (configuration.__typename === 'PieChartConfiguration') {
groupByOrderBy = configuration.orderBy;
groupByFieldYId = configuration.groupByFieldMetadataId;
groupBySubFieldNameY = configuration.groupBySubFieldName as
| CompositeFieldSubFieldName
| undefined;
}
const finalGroupByFieldYId = groupByFieldYId;
const finalGroupBySubFieldNameY = groupBySubFieldNameY;
const groupByOrderByLabel =
isDefined(groupByOrderBy) &&
getGroupBySortOptionLabel({
graphOrderBy: groupByOrderBy,
groupByFieldMetadataId:
'groupByFieldMetadataIdY' in configuration
? configuration.groupByFieldMetadataIdY
: 'groupByFieldMetadataId' in configuration
? configuration.groupByFieldMetadataId
: undefined,
groupBySubFieldName:
'groupBySubFieldNameY' in configuration
? (configuration.groupBySubFieldNameY as CompositeFieldSubFieldName)
: 'groupBySubFieldName' in configuration
? (configuration.groupBySubFieldName as CompositeFieldSubFieldName)
: undefined,
});
isDefined(groupByOrderBy) && isDefined(finalGroupByFieldYId)
? getGroupBySortOptionLabel({
graphOrderBy: groupByOrderBy,
groupByFieldMetadataId: finalGroupByFieldYId,
groupBySubFieldName: finalGroupBySubFieldNameY,
})
: undefined;
const getChartSettingsValues = (
itemId: CHART_CONFIGURATION_SETTING_IDS,
@@ -119,7 +126,7 @@ export const useChartSettingsValues = ({
return groupBySubFieldNameXLabel ?? groupByFieldX?.label;
case CHART_CONFIGURATION_SETTING_IDS.COLORS:
return 'color' in configuration && isDefined(configuration.color)
? capitalize(configuration.color as string)
? capitalize(configuration.color)
: undefined;
case CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_Y: {
const hasAggregateLabel = isDefined(aggregateField?.label);
@@ -0,0 +1,134 @@
import { AXIS_NAME_SETTING } from '@/command-menu/pages/page-layout/constants/settings/AxisNameSetting';
import { CHART_DATA_SOURCE_SETTING } from '@/command-menu/pages/page-layout/constants/settings/ChartDataSourceSetting';
import { COLORS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/ColorsSetting';
import { DATA_DISPLAY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataDisplayXSetting';
import { DATA_DISPLAY_Y_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataDisplayYSetting';
import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataLabelsSetting';
import { FILTER_SETTING } from '@/command-menu/pages/page-layout/constants/settings/FilterSetting';
import { GROUP_BY_SETTING } from '@/command-menu/pages/page-layout/constants/settings/GroupBySetting';
import { SORT_BY_GROUP_BY_FIELD_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByGroupByFieldSetting';
import { SORT_BY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByXSetting';
import { STACKED_BARS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/StackedBarsSetting';
import { IconAxisX, IconAxisY } from 'twenty-ui/display';
import { GraphType } from '~/generated-metadata/graphql';
import { getBarChartSettings } from '../getBarChartSettings';
describe('getBarChartSettings', () => {
describe('Vertical bar chart', () => {
it('should place primary axis items under "X axis" heading', () => {
const result = getBarChartSettings(GraphType.VERTICAL_BAR);
const xAxisGroup = result.find((group) => group.heading === 'X axis');
expect(xAxisGroup).toBeDefined();
expect(xAxisGroup?.items).toHaveLength(2);
expect(xAxisGroup?.items[0].id).toBe(DATA_DISPLAY_X_SETTING.id);
expect(xAxisGroup?.items[0].label).toBe(DATA_DISPLAY_X_SETTING.label);
expect(xAxisGroup?.items[0].Icon).toBe(IconAxisX);
expect(xAxisGroup?.items[1]).toEqual(SORT_BY_X_SETTING);
});
it('should place secondary axis items under "Y axis" heading', () => {
const result = getBarChartSettings(GraphType.VERTICAL_BAR);
const yAxisGroup = result.find((group) => group.heading === 'Y axis');
expect(yAxisGroup).toBeDefined();
expect(yAxisGroup?.items).toHaveLength(3);
expect(yAxisGroup?.items[0].id).toBe(DATA_DISPLAY_Y_SETTING.id);
expect(yAxisGroup?.items[0].label).toBe(DATA_DISPLAY_Y_SETTING.label);
expect(yAxisGroup?.items[0].Icon).toBe(IconAxisY);
expect(yAxisGroup?.items[1]).toEqual(GROUP_BY_SETTING);
expect(yAxisGroup?.items[2]).toEqual(SORT_BY_GROUP_BY_FIELD_SETTING);
});
it('should have all expected groups in correct order', () => {
const result = getBarChartSettings(GraphType.VERTICAL_BAR);
expect(result).toHaveLength(4);
expect(result[0].heading).toBe('Data');
expect(result[1].heading).toBe('X axis');
expect(result[2].heading).toBe('Y axis');
expect(result[3].heading).toBe('Style');
});
});
describe('Horizontal bar chart', () => {
it('should place SECONDARY axis items under "X axis" heading', () => {
const result = getBarChartSettings(GraphType.HORIZONTAL_BAR);
const xAxisGroup = result.find((group) => group.heading === 'X axis');
expect(xAxisGroup).toBeDefined();
expect(xAxisGroup?.items).toHaveLength(3);
expect(xAxisGroup?.items[0].id).toBe(DATA_DISPLAY_Y_SETTING.id);
expect(xAxisGroup?.items[0].label).toBe(DATA_DISPLAY_Y_SETTING.label);
expect(xAxisGroup?.items[0].Icon).toBe(IconAxisX);
expect(xAxisGroup?.items[1]).toEqual(GROUP_BY_SETTING);
expect(xAxisGroup?.items[2]).toEqual(SORT_BY_GROUP_BY_FIELD_SETTING);
});
it('should place PRIMARY axis items under "Y axis" heading', () => {
const result = getBarChartSettings(GraphType.HORIZONTAL_BAR);
const yAxisGroup = result.find((group) => group.heading === 'Y axis');
expect(yAxisGroup).toBeDefined();
expect(yAxisGroup?.items).toHaveLength(2);
expect(yAxisGroup?.items[0].id).toBe(DATA_DISPLAY_X_SETTING.id);
expect(yAxisGroup?.items[0].label).toBe(DATA_DISPLAY_X_SETTING.label);
expect(yAxisGroup?.items[0].Icon).toBe(IconAxisY);
expect(yAxisGroup?.items[1]).toEqual(SORT_BY_X_SETTING);
});
it('should have all expected groups in correct order', () => {
const result = getBarChartSettings(GraphType.HORIZONTAL_BAR);
expect(result).toHaveLength(4);
expect(result[0].heading).toBe('Data');
expect(result[1].heading).toBe('X axis');
expect(result[2].heading).toBe('Y axis');
expect(result[3].heading).toBe('Style');
});
});
describe('Common groups', () => {
it('should have consistent Data group for both orientations', () => {
const verticalResult = getBarChartSettings(GraphType.VERTICAL_BAR);
const horizontalResult = getBarChartSettings(GraphType.HORIZONTAL_BAR);
const verticalDataGroup = verticalResult.find(
(group) => group.heading === 'Data',
);
const horizontalDataGroup = horizontalResult.find(
(group) => group.heading === 'Data',
);
expect(verticalDataGroup?.items).toEqual(horizontalDataGroup?.items);
expect(verticalDataGroup?.items).toEqual([
CHART_DATA_SOURCE_SETTING,
FILTER_SETTING,
]);
});
it('should have consistent Style group for both orientations', () => {
const verticalResult = getBarChartSettings(GraphType.VERTICAL_BAR);
const horizontalResult = getBarChartSettings(GraphType.HORIZONTAL_BAR);
const verticalStyleGroup = verticalResult.find(
(group) => group.heading === 'Style',
);
const horizontalStyleGroup = horizontalResult.find(
(group) => group.heading === 'Style',
);
expect(verticalStyleGroup?.items).toEqual(horizontalStyleGroup?.items);
expect(verticalStyleGroup?.items).toEqual([
COLORS_SETTING,
AXIS_NAME_SETTING,
STACKED_BARS_SETTING,
DATA_LABELS_SETTING,
]);
});
});
});
@@ -10,31 +10,52 @@ import { SORT_BY_GROUP_BY_FIELD_SETTING } from '@/command-menu/pages/page-layout
import { SORT_BY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByXSetting';
import { STACKED_BARS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/StackedBarsSetting';
import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { IconAxisX, IconAxisY } from 'twenty-ui/display';
import { GraphType } from '~/generated-metadata/graphql';
export const BAR_CHART_SETTINGS: ChartSettingsGroup[] = [
{
heading: 'Data',
items: [CHART_DATA_SOURCE_SETTING, FILTER_SETTING],
},
{
heading: 'X axis',
items: [DATA_DISPLAY_X_SETTING, SORT_BY_X_SETTING],
},
{
heading: 'Y axis',
items: [
DATA_DISPLAY_Y_SETTING,
GROUP_BY_SETTING,
SORT_BY_GROUP_BY_FIELD_SETTING,
],
},
{
heading: 'Style',
items: [
COLORS_SETTING,
AXIS_NAME_SETTING,
STACKED_BARS_SETTING,
DATA_LABELS_SETTING,
],
},
];
export const getBarChartSettings = (
graphType: GraphType.VERTICAL_BAR | GraphType.HORIZONTAL_BAR,
): ChartSettingsGroup[] => {
const isHorizontal = graphType === GraphType.HORIZONTAL_BAR;
const dataDisplayXIcon = isHorizontal ? IconAxisY : IconAxisX;
const dataDisplayYIcon = isHorizontal ? IconAxisX : IconAxisY;
const primaryAxisItems = [
{ ...DATA_DISPLAY_X_SETTING, Icon: dataDisplayXIcon },
SORT_BY_X_SETTING,
];
const secondaryAxisItems = [
{ ...DATA_DISPLAY_Y_SETTING, Icon: dataDisplayYIcon },
GROUP_BY_SETTING,
SORT_BY_GROUP_BY_FIELD_SETTING,
];
const xAxisItems = isHorizontal ? secondaryAxisItems : primaryAxisItems;
const yAxisItems = isHorizontal ? primaryAxisItems : secondaryAxisItems;
return [
{
heading: 'Data',
items: [CHART_DATA_SOURCE_SETTING, FILTER_SETTING],
},
{
heading: 'X axis',
items: xAxisItems,
},
{
heading: 'Y axis',
items: yAxisItems,
},
{
heading: 'Style',
items: [
COLORS_SETTING,
AXIS_NAME_SETTING,
STACKED_BARS_SETTING,
DATA_LABELS_SETTING,
],
},
];
};
@@ -12,9 +12,13 @@ export const getFieldLabelWithSubField = ({
field: FieldMetadataItem | undefined;
subFieldName?: CompositeFieldSubFieldName;
}): string => {
const subFieldNameLabel = isDefined(subFieldName)
? getCompositeSubFieldLabel(field?.type as CompositeFieldType, subFieldName)
: undefined;
const subFieldNameLabel =
isDefined(subFieldName) && isDefined(field)
? getCompositeSubFieldLabel(
field.type as CompositeFieldType,
subFieldName,
)
: undefined;
const fieldLabel =
isDefined(subFieldNameLabel) && isDefined(field?.label)