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:
+2
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ export const CommandMenuPageLayoutWidgetTypeSelect = () => {
|
||||
if (!isDefined(pageLayoutEditingWidgetId)) {
|
||||
const fieldSelection = buildBarChartFieldSelection();
|
||||
const newWidget = createPageLayoutGraphWidget({
|
||||
graphType: GraphType.BAR,
|
||||
graphType: GraphType.VERTICAL_BAR,
|
||||
fieldSelection,
|
||||
});
|
||||
|
||||
|
||||
+6
-4
@@ -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,
|
||||
},
|
||||
|
||||
+7
-10
@@ -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
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ export const ChartGroupByFieldSelectionDropdownContent = () => {
|
||||
<ChartGroupByFieldSelectionDropdownContentBase<
|
||||
BarChartConfiguration | LineChartConfiguration
|
||||
>
|
||||
fieldMetadataIdKey="groupByFieldMetadataIdY"
|
||||
subFieldNameKey="groupBySubFieldNameY"
|
||||
fieldMetadataIdKey="secondaryAxisGroupByFieldMetadataId"
|
||||
subFieldNameKey="secondaryAxisGroupBySubFieldName"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+13
-18
@@ -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={() => {
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ export const ChartXAxisFieldSelectionDropdownContent = () => {
|
||||
<ChartGroupByFieldSelectionDropdownContentBase<
|
||||
BarChartConfiguration | LineChartConfiguration
|
||||
>
|
||||
fieldMetadataIdKey="groupByFieldMetadataIdX"
|
||||
subFieldNameKey="groupBySubFieldNameX"
|
||||
fieldMetadataIdKey="primaryAxisGroupByFieldMetadataId"
|
||||
subFieldNameKey="primaryAxisGroupBySubFieldName"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+20
-20
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user