[Dashboards] - refactor - bar chart (#14496)
This is the first PR from the split of #14458 - refactoring only the BarChart widget.
This commit is contained in:
-444
@@ -1,444 +0,0 @@
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
ResponsiveBar,
|
||||
type BarCustomLayerProps,
|
||||
type BarDatum,
|
||||
type ComputedBarDatum,
|
||||
type ComputedDatum,
|
||||
} from '@nivo/bar';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { type GraphColor } from '../types/GraphColor';
|
||||
import { type GraphColorScheme } from '../types/GraphColorScheme';
|
||||
import { createGradientDef } from '../utils/createGradientDef';
|
||||
import { createGraphColorRegistry } from '../utils/createGraphColorRegistry';
|
||||
import { getColorScheme } from '../utils/getColorScheme';
|
||||
import {
|
||||
formatGraphValue,
|
||||
type GraphValueFormatOptions,
|
||||
} from '../utils/graphFormatters';
|
||||
import { GraphWidgetLegend } from './GraphWidgetLegend';
|
||||
import { GraphWidgetTooltip } from './GraphWidgetTooltip';
|
||||
|
||||
type BarChartDataItem = BarDatum & {
|
||||
to?: string;
|
||||
};
|
||||
|
||||
type BarChartSeries = {
|
||||
key: string;
|
||||
label?: string;
|
||||
color?: GraphColor;
|
||||
};
|
||||
|
||||
type BarConfig = {
|
||||
key: string;
|
||||
indexValue: string | number;
|
||||
gradientId: string;
|
||||
colorScheme: GraphColorScheme;
|
||||
};
|
||||
|
||||
type GraphWidgetBarChartProps = {
|
||||
data: BarChartDataItem[];
|
||||
indexBy: string;
|
||||
keys: string[];
|
||||
series?: BarChartSeries[];
|
||||
showLegend?: boolean;
|
||||
showGrid?: boolean;
|
||||
showValues?: boolean;
|
||||
xAxisLabel?: string;
|
||||
yAxisLabel?: string;
|
||||
id: string;
|
||||
layout?: 'vertical' | 'horizontal';
|
||||
groupMode?: 'grouped' | 'stacked';
|
||||
seriesLabels?: Record<string, string>;
|
||||
} & GraphValueFormatOptions;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledChartContainer = styled.div<{ $isClickable?: boolean }>`
|
||||
flex: 1;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
${({ $isClickable }) =>
|
||||
$isClickable &&
|
||||
`
|
||||
svg g[transform] rect[fill^="url(#gradient-"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const getAxisBottomConfig = (
|
||||
layout: 'vertical' | 'horizontal',
|
||||
xAxisLabel?: string,
|
||||
yAxisLabel?: string,
|
||||
formatOptions?: GraphValueFormatOptions,
|
||||
) => {
|
||||
return layout === 'vertical'
|
||||
? {
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
legend: xAxisLabel,
|
||||
legendPosition: 'middle' as const,
|
||||
legendOffset: 40,
|
||||
}
|
||||
: {
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
legend: yAxisLabel,
|
||||
legendPosition: 'middle' as const,
|
||||
legendOffset: 40,
|
||||
format: (value: number) => formatGraphValue(value, formatOptions || {}),
|
||||
};
|
||||
};
|
||||
|
||||
const getAxisLeftConfig = (
|
||||
layout: 'vertical' | 'horizontal',
|
||||
xAxisLabel?: string,
|
||||
yAxisLabel?: string,
|
||||
formatOptions?: GraphValueFormatOptions,
|
||||
) => {
|
||||
return layout === 'vertical'
|
||||
? {
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
legend: yAxisLabel,
|
||||
legendPosition: 'middle' as const,
|
||||
legendOffset: -50,
|
||||
format: (value: number) => formatGraphValue(value, formatOptions || {}),
|
||||
}
|
||||
: {
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
legend: xAxisLabel,
|
||||
legendPosition: 'middle' as const,
|
||||
legendOffset: -50,
|
||||
};
|
||||
};
|
||||
|
||||
const calculateBarEndLineCoordinates = (
|
||||
bar: ComputedBarDatum<BarChartDataItem>,
|
||||
layout: 'vertical' | 'horizontal',
|
||||
) => {
|
||||
if (layout === 'vertical') {
|
||||
return {
|
||||
x1: bar.x,
|
||||
x2: bar.x + bar.width,
|
||||
y1: bar.y,
|
||||
y2: bar.y,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
x1: bar.x + bar.width,
|
||||
x2: bar.x + bar.width,
|
||||
y1: bar.y,
|
||||
y2: bar.y + bar.height,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const GraphWidgetBarChart = ({
|
||||
data,
|
||||
indexBy,
|
||||
keys,
|
||||
series,
|
||||
showLegend = true,
|
||||
showGrid = true,
|
||||
showValues = false,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
id,
|
||||
layout = 'vertical',
|
||||
groupMode = 'grouped',
|
||||
seriesLabels,
|
||||
displayType,
|
||||
decimals,
|
||||
prefix,
|
||||
suffix,
|
||||
customFormatter,
|
||||
}: GraphWidgetBarChartProps) => {
|
||||
const theme = useTheme();
|
||||
const instanceId = useId();
|
||||
const [hoveredBar, setHoveredBar] = useState<{
|
||||
key: string;
|
||||
indexValue: string | number;
|
||||
} | null>(null);
|
||||
|
||||
const colorRegistry = createGraphColorRegistry(theme);
|
||||
|
||||
const formatOptions: GraphValueFormatOptions = {
|
||||
displayType,
|
||||
decimals,
|
||||
prefix,
|
||||
suffix,
|
||||
customFormatter,
|
||||
};
|
||||
|
||||
const seriesConfigMap = useMemo(() => {
|
||||
const map = new Map<string, BarChartSeries>();
|
||||
series?.forEach((s) => map.set(s.key, s));
|
||||
return map;
|
||||
}, [series]);
|
||||
|
||||
const barConfigs = useMemo((): BarConfig[] => {
|
||||
return data.flatMap((dataPoint, dataIndex) => {
|
||||
const indexValue = dataPoint[indexBy];
|
||||
return keys.map((key, keyIndex): BarConfig => {
|
||||
const seriesConfig = seriesConfigMap.get(key);
|
||||
const colorScheme = getColorScheme(
|
||||
colorRegistry,
|
||||
seriesConfig?.color,
|
||||
keyIndex,
|
||||
);
|
||||
const gradientId = `gradient-${id}-${instanceId}-${key}-${dataIndex}-${keyIndex}`;
|
||||
|
||||
return {
|
||||
key,
|
||||
indexValue,
|
||||
gradientId,
|
||||
colorScheme,
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [data, indexBy, keys, colorRegistry, id, instanceId, seriesConfigMap]);
|
||||
|
||||
const enrichedKeys = useMemo(() => {
|
||||
return keys.map((key, index) => {
|
||||
const seriesConfig = seriesConfigMap.get(key);
|
||||
const colorScheme = getColorScheme(
|
||||
colorRegistry,
|
||||
seriesConfig?.color,
|
||||
index,
|
||||
);
|
||||
return {
|
||||
key,
|
||||
colorScheme,
|
||||
label: seriesConfig?.label || seriesLabels?.[key] || key,
|
||||
};
|
||||
});
|
||||
}, [keys, colorRegistry, seriesConfigMap, seriesLabels]);
|
||||
|
||||
const defs = barConfigs.map((bar) => {
|
||||
const isHovered =
|
||||
hoveredBar?.key === bar.key && hoveredBar?.indexValue === bar.indexValue;
|
||||
return createGradientDef(
|
||||
bar.colorScheme,
|
||||
bar.gradientId,
|
||||
isHovered,
|
||||
layout === 'horizontal' ? 0 : 90,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
const getBarColor = (datum: ComputedDatum<BarDatum>) => {
|
||||
const bar = barConfigs.find(
|
||||
(b) => b.key === datum.id && b.indexValue === datum.indexValue,
|
||||
);
|
||||
if (!bar) {
|
||||
return theme.border.color.light;
|
||||
}
|
||||
return `url(#${bar.gradientId})`;
|
||||
};
|
||||
|
||||
const handleBarClick = (datum: ComputedDatum<BarDatum>) => {
|
||||
const dataItem = data.find((d) => d[indexBy] === datum.indexValue);
|
||||
if (isDefined(dataItem?.to)) {
|
||||
window.location.href = dataItem.to;
|
||||
}
|
||||
};
|
||||
|
||||
const renderTooltip = (datum: ComputedDatum<BarDatum>) => {
|
||||
const hoveredKey = hoveredBar?.key;
|
||||
if (!isDefined(hoveredKey)) return null;
|
||||
|
||||
const enrichedKey = enrichedKeys.find((item) => item.key === hoveredKey);
|
||||
if (!enrichedKey) return null;
|
||||
|
||||
const dataItem = data.find((d) => d[indexBy] === datum.indexValue);
|
||||
const seriesValue = Number(datum.data[hoveredKey] || 0);
|
||||
const tooltipItem = {
|
||||
label: enrichedKey.label,
|
||||
formattedValue: formatGraphValue(seriesValue, formatOptions),
|
||||
dotColor: enrichedKey.colorScheme.solid,
|
||||
};
|
||||
|
||||
return (
|
||||
<GraphWidgetTooltip
|
||||
items={[tooltipItem]}
|
||||
showClickHint={isDefined(dataItem?.to)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const axisBottomConfig = getAxisBottomConfig(
|
||||
layout,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
formatOptions,
|
||||
);
|
||||
const axisLeftConfig = getAxisLeftConfig(
|
||||
layout,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
formatOptions,
|
||||
);
|
||||
|
||||
const hasClickableItems = data.some((item) => isDefined(item.to));
|
||||
|
||||
const renderBarEndLines = (props: BarCustomLayerProps<BarChartDataItem>) => {
|
||||
const { bars } = props;
|
||||
|
||||
if (!bars || bars.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<g>
|
||||
{bars.map((bar: ComputedBarDatum<BarChartDataItem>, index: number) => {
|
||||
const enrichedKey = enrichedKeys.find((k) => k.key === bar.data.id);
|
||||
if (!enrichedKey) {
|
||||
return null;
|
||||
}
|
||||
const lineColor = enrichedKey.colorScheme.solid;
|
||||
const { x1, y1, x2, y2 } = calculateBarEndLineCoordinates(
|
||||
bar,
|
||||
layout,
|
||||
);
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${bar.data.id}-${bar.data.indexValue}-endline-${index}`}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={lineColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer id={id}>
|
||||
<StyledChartContainer $isClickable={hasClickableItems}>
|
||||
<ResponsiveBar
|
||||
data={data}
|
||||
keys={keys}
|
||||
indexBy={indexBy}
|
||||
margin={{ top: 20, right: 20, bottom: 60, left: 70 }}
|
||||
padding={0.3}
|
||||
groupMode={groupMode}
|
||||
layout={layout}
|
||||
valueScale={{ type: 'linear' }}
|
||||
indexScale={{ type: 'band', round: true }}
|
||||
colors={getBarColor}
|
||||
defs={defs}
|
||||
layers={[
|
||||
'grid',
|
||||
'axes',
|
||||
'bars',
|
||||
renderBarEndLines,
|
||||
'markers',
|
||||
'legends',
|
||||
]}
|
||||
axisTop={null}
|
||||
axisRight={null}
|
||||
axisBottom={axisBottomConfig}
|
||||
axisLeft={axisLeftConfig}
|
||||
enableGridX={layout === 'horizontal' && showGrid}
|
||||
enableGridY={layout === 'vertical' && showGrid}
|
||||
gridXValues={layout === 'horizontal' ? 5 : undefined}
|
||||
gridYValues={layout === 'vertical' ? 5 : undefined}
|
||||
enableLabel={showValues}
|
||||
labelSkipWidth={12}
|
||||
labelSkipHeight={12}
|
||||
labelTextColor={theme.font.color.primary}
|
||||
label={(d) => formatGraphValue(Number(d.value), formatOptions)}
|
||||
tooltip={(props) => renderTooltip(props)}
|
||||
onClick={handleBarClick}
|
||||
onMouseEnter={(datum) => {
|
||||
if (isDefined(datum.id) && isDefined(datum.indexValue)) {
|
||||
setHoveredBar({
|
||||
key: String(datum.id),
|
||||
indexValue: datum.indexValue,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setHoveredBar(null)}
|
||||
theme={{
|
||||
axis: {
|
||||
domain: {
|
||||
line: {
|
||||
stroke: theme.border.color.light,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
line: {
|
||||
stroke: theme.border.color.light,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
text: {
|
||||
fill: theme.font.color.secondary,
|
||||
fontSize: 11,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
text: {
|
||||
fill: theme.font.color.light,
|
||||
fontSize: 12,
|
||||
fontWeight: theme.font.weight.medium,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
line: {
|
||||
stroke: theme.border.color.light,
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '4 4',
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
text: {
|
||||
fontSize: 11,
|
||||
fontWeight: theme.font.weight.medium,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</StyledChartContainer>
|
||||
<GraphWidgetLegend
|
||||
show={showLegend}
|
||||
items={enrichedKeys.map((item) => {
|
||||
const total = data.reduce(
|
||||
(sum, d) => sum + Number(d[item.key] || 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
id: item.key,
|
||||
label: item.label,
|
||||
formattedValue: formatGraphValue(total, formatOptions),
|
||||
color: item.colorScheme.solid,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+5
-5
@@ -6,11 +6,11 @@ import { type GraphWidget } from '@/page-layout/widgets/graph/types/GraphWidget'
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
const GraphWidgetBarChart = lazy(() =>
|
||||
import('@/page-layout/widgets/graph/components/GraphWidgetBarChart').then(
|
||||
(module) => ({
|
||||
default: module.GraphWidgetBarChart,
|
||||
}),
|
||||
),
|
||||
import(
|
||||
'@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart'
|
||||
).then((module) => ({
|
||||
default: module.GraphWidgetBarChart,
|
||||
})),
|
||||
);
|
||||
|
||||
const GraphWidgetLineChart = lazy(() =>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react';
|
||||
|
||||
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
|
||||
import { GraphWidgetBarChart } from '../GraphWidgetBarChart';
|
||||
import { GraphWidgetBarChart } from '../../graphWidgetBarChart/components/GraphWidgetBarChart';
|
||||
|
||||
const meta: Meta<typeof GraphWidgetBarChart> = {
|
||||
title: 'Modules/PageLayout/Widgets/GraphWidgetBarChart',
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
|
||||
import { calculateBarChartEndLineCoordinates } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartEndLineCoordinates';
|
||||
import { type ComputedBarDatum } from '@nivo/bar';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type BarChartEndLinesProps = {
|
||||
bars: readonly ComputedBarDatum<BarChartDataItem>[];
|
||||
enrichedKeys: BarChartEnrichedKey[];
|
||||
layout: 'vertical' | 'horizontal';
|
||||
};
|
||||
|
||||
export const BarChartEndLines = ({
|
||||
bars,
|
||||
enrichedKeys,
|
||||
layout,
|
||||
}: BarChartEndLinesProps) => {
|
||||
return (
|
||||
<g>
|
||||
{bars.map((bar: ComputedBarDatum<BarChartDataItem>, index: number) => {
|
||||
const enrichedKey = enrichedKeys.find((k) => k.key === bar.data.id);
|
||||
if (!isDefined(enrichedKey)) {
|
||||
return null;
|
||||
}
|
||||
const lineColor = enrichedKey.colorScheme.solid;
|
||||
const { x1, y1, x2, y2 } = calculateBarChartEndLineCoordinates(
|
||||
bar,
|
||||
layout,
|
||||
);
|
||||
|
||||
return (
|
||||
<line
|
||||
key={`${bar.data.id}-${bar.data.indexValue}-endline-${index}`}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={lineColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
|
||||
import { GraphWidgetTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip';
|
||||
import { BarChartEndLines } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartEndLines';
|
||||
import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData';
|
||||
import { useBarChartHandlers } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartHandlers';
|
||||
import { useBarChartTheme } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme';
|
||||
import { useBarChartTooltip } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTooltip';
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
|
||||
import { getBarChartAxisBottomConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisBottomConfig';
|
||||
import { getBarChartAxisLeftConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisLeftConfig';
|
||||
import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor';
|
||||
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
|
||||
import {
|
||||
formatGraphValue,
|
||||
type GraphValueFormatOptions,
|
||||
} from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { ResponsiveBar, type BarCustomLayerProps } from '@nivo/bar';
|
||||
import { useId } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type GraphWidgetBarChartProps = {
|
||||
data: BarChartDataItem[];
|
||||
indexBy: string;
|
||||
keys: string[];
|
||||
series?: BarChartSeries[];
|
||||
showLegend?: boolean;
|
||||
showGrid?: boolean;
|
||||
showValues?: boolean;
|
||||
xAxisLabel?: string;
|
||||
yAxisLabel?: string;
|
||||
id: string;
|
||||
layout?: 'vertical' | 'horizontal';
|
||||
groupMode?: 'grouped' | 'stacked';
|
||||
seriesLabels?: Record<string, string>;
|
||||
} & GraphValueFormatOptions;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledChartContainer = styled.div<{ $isClickable?: boolean }>`
|
||||
flex: 1;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
${({ $isClickable }) =>
|
||||
$isClickable &&
|
||||
`
|
||||
svg g[transform] rect[fill^="url(#gradient-"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const GraphWidgetBarChart = ({
|
||||
data,
|
||||
indexBy,
|
||||
keys,
|
||||
series,
|
||||
showLegend = true,
|
||||
showGrid = true,
|
||||
showValues = false,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
id,
|
||||
layout = 'vertical',
|
||||
groupMode = 'grouped',
|
||||
seriesLabels,
|
||||
displayType,
|
||||
decimals,
|
||||
prefix,
|
||||
suffix,
|
||||
customFormatter,
|
||||
}: GraphWidgetBarChartProps) => {
|
||||
const theme = useTheme();
|
||||
const instanceId = useId();
|
||||
const colorRegistry = createGraphColorRegistry(theme);
|
||||
|
||||
const formatOptions: GraphValueFormatOptions = {
|
||||
displayType,
|
||||
decimals,
|
||||
prefix,
|
||||
suffix,
|
||||
customFormatter,
|
||||
};
|
||||
|
||||
const { hoveredBar, setHoveredBar, handleBarClick, hasClickableItems } =
|
||||
useBarChartHandlers({
|
||||
data,
|
||||
indexBy,
|
||||
});
|
||||
|
||||
const chartTheme = useBarChartTheme();
|
||||
|
||||
const { barConfigs, enrichedKeys, defs } = useBarChartData({
|
||||
data,
|
||||
indexBy,
|
||||
keys,
|
||||
series,
|
||||
colorRegistry,
|
||||
id,
|
||||
instanceId,
|
||||
seriesLabels,
|
||||
hoveredBar,
|
||||
layout,
|
||||
});
|
||||
|
||||
const { renderTooltip: getTooltipData } = useBarChartTooltip({
|
||||
hoveredBar,
|
||||
enrichedKeys,
|
||||
data,
|
||||
indexBy,
|
||||
formatOptions,
|
||||
});
|
||||
|
||||
const axisBottomConfig = getBarChartAxisBottomConfig(
|
||||
layout,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
formatOptions,
|
||||
);
|
||||
|
||||
const axisLeftConfig = getBarChartAxisLeftConfig(
|
||||
layout,
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
formatOptions,
|
||||
);
|
||||
|
||||
const renderTooltip = (datum: Parameters<typeof getTooltipData>[0]) => {
|
||||
const tooltipData = getTooltipData(datum);
|
||||
if (!isDefined(tooltipData)) return null;
|
||||
|
||||
return (
|
||||
<GraphWidgetTooltip
|
||||
items={[tooltipData.tooltipItem]}
|
||||
showClickHint={tooltipData.showClickHint}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const barEndLinesLayer = (props: BarCustomLayerProps<BarChartDataItem>) => {
|
||||
return (
|
||||
<BarChartEndLines
|
||||
bars={props.bars}
|
||||
enrichedKeys={enrichedKeys}
|
||||
layout={layout}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer id={id}>
|
||||
<StyledChartContainer $isClickable={hasClickableItems}>
|
||||
<ResponsiveBar
|
||||
data={data}
|
||||
keys={keys}
|
||||
indexBy={indexBy}
|
||||
margin={{ top: 20, right: 20, bottom: 60, left: 70 }}
|
||||
padding={0.3}
|
||||
groupMode={groupMode}
|
||||
layout={layout}
|
||||
valueScale={{ type: 'linear' }}
|
||||
indexScale={{ type: 'band', round: true }}
|
||||
colors={(datum) => getBarChartColor(datum, barConfigs, theme)}
|
||||
defs={defs}
|
||||
layers={[
|
||||
'grid',
|
||||
'axes',
|
||||
'bars',
|
||||
barEndLinesLayer,
|
||||
'markers',
|
||||
'legends',
|
||||
]}
|
||||
axisTop={null}
|
||||
axisRight={null}
|
||||
axisBottom={axisBottomConfig}
|
||||
axisLeft={axisLeftConfig}
|
||||
enableGridX={layout === 'horizontal' && showGrid}
|
||||
enableGridY={layout === 'vertical' && showGrid}
|
||||
gridXValues={layout === 'horizontal' ? 5 : undefined}
|
||||
gridYValues={layout === 'vertical' ? 5 : undefined}
|
||||
enableLabel={showValues}
|
||||
labelSkipWidth={12}
|
||||
labelSkipHeight={12}
|
||||
labelTextColor={theme.font.color.primary}
|
||||
label={(d) => formatGraphValue(Number(d.value), formatOptions)}
|
||||
tooltip={(props) => renderTooltip(props)}
|
||||
onClick={handleBarClick}
|
||||
onMouseEnter={(datum) => {
|
||||
if (isDefined(datum.id) && isDefined(datum.indexValue)) {
|
||||
setHoveredBar({
|
||||
key: String(datum.id),
|
||||
indexValue: datum.indexValue,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setHoveredBar(null)}
|
||||
theme={chartTheme}
|
||||
/>
|
||||
</StyledChartContainer>
|
||||
<GraphWidgetLegend
|
||||
show={showLegend}
|
||||
items={enrichedKeys.map((item) => {
|
||||
const total = data.reduce(
|
||||
(sum, d) => sum + Number(d[item.key] || 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
id: item.key,
|
||||
label: item.label,
|
||||
formattedValue: formatGraphValue(total, formatOptions),
|
||||
color: item.colorScheme.solid,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
|
||||
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useBarChartData } from '../useBarChartData';
|
||||
|
||||
describe('useBarChartData', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockColorRegistry: GraphColorRegistry = {
|
||||
green: {
|
||||
name: 'green',
|
||||
gradient: {
|
||||
normal: ['green1', 'green2'],
|
||||
hover: ['green3', 'green4'],
|
||||
},
|
||||
solid: 'greenSolid',
|
||||
},
|
||||
purple: {
|
||||
name: 'purple',
|
||||
gradient: {
|
||||
normal: ['purple1', 'purple2'],
|
||||
hover: ['purple3', 'purple4'],
|
||||
},
|
||||
solid: 'purpleSolid',
|
||||
},
|
||||
};
|
||||
|
||||
const mockData: BarChartDataItem[] = [
|
||||
{ month: 'Jan', sales: 100, costs: 80 },
|
||||
{ month: 'Feb', sales: 120, costs: 90 },
|
||||
{ month: 'Mar', sales: 150, costs: 100 },
|
||||
];
|
||||
|
||||
const mockSeries: BarChartSeries[] = [
|
||||
{ key: 'sales', label: 'Sales', color: 'green' },
|
||||
{ key: 'costs', label: 'Costs', color: 'purple' },
|
||||
];
|
||||
|
||||
it('should create series config map', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: ['sales', 'costs'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.seriesConfigMap.get('sales')).toEqual(mockSeries[0]);
|
||||
expect(result.current.seriesConfigMap.get('costs')).toEqual(mockSeries[1]);
|
||||
});
|
||||
|
||||
it('should generate bar configs for each data point and key', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: ['sales', 'costs'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.barConfigs).toHaveLength(6);
|
||||
expect(result.current.barConfigs[0]).toMatchObject({
|
||||
key: 'sales',
|
||||
indexValue: 'Jan',
|
||||
gradientId: 'gradient-test-chart-instance-1-sales-0-0',
|
||||
colorScheme: mockColorRegistry.green,
|
||||
});
|
||||
expect(result.current.barConfigs[1]).toMatchObject({
|
||||
key: 'costs',
|
||||
indexValue: 'Jan',
|
||||
gradientId: 'gradient-test-chart-instance-1-costs-0-1',
|
||||
colorScheme: mockColorRegistry.purple,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create enriched keys with labels', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: ['sales', 'costs'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.enrichedKeys).toEqual([
|
||||
{
|
||||
key: 'sales',
|
||||
colorScheme: mockColorRegistry.green,
|
||||
label: 'Sales',
|
||||
},
|
||||
{
|
||||
key: 'costs',
|
||||
colorScheme: mockColorRegistry.purple,
|
||||
label: 'Costs',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use series labels when series config is not provided', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: ['sales', 'costs'],
|
||||
series: undefined,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
seriesLabels: { sales: 'Revenue', costs: 'Expenses' },
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.enrichedKeys[0].label).toBe('Revenue');
|
||||
expect(result.current.enrichedKeys[1].label).toBe('Expenses');
|
||||
});
|
||||
|
||||
it('should handle hover state for bars', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: ['sales', 'costs'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: { key: 'sales', indexValue: 'Feb' },
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
const hoveredDef = result.current.defs.find(
|
||||
(def) => def.id === 'gradient-test-chart-instance-1-sales-1-0',
|
||||
);
|
||||
expect(hoveredDef?.colors).toEqual([
|
||||
{ offset: 0, color: 'green4' },
|
||||
{ offset: 100, color: 'green3' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should generate vertical gradients for vertical layout', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: ['sales'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
const def = result.current.defs[0];
|
||||
expect(def.y1).toBe('0%');
|
||||
expect(def.y2).toBe('100%');
|
||||
});
|
||||
|
||||
it('should generate horizontal gradients for horizontal layout', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: ['sales'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'horizontal',
|
||||
}),
|
||||
);
|
||||
|
||||
const def = result.current.defs[0];
|
||||
expect(def.x1).toBe('0%');
|
||||
expect(def.x2).toBe('100%');
|
||||
});
|
||||
|
||||
it('should handle empty data', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
data: [],
|
||||
indexBy: 'month',
|
||||
keys: ['sales', 'costs'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.barConfigs).toEqual([]);
|
||||
expect(result.current.defs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty keys', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: [],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.barConfigs).toEqual([]);
|
||||
expect(result.current.enrichedKeys).toEqual([]);
|
||||
expect(result.current.defs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should fall back to key name when no label is provided', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: ['sales', 'costs'],
|
||||
series: undefined,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId: 'instance-1',
|
||||
seriesLabels: undefined,
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.enrichedKeys[0].label).toBe('sales');
|
||||
expect(result.current.enrichedKeys[1].label).toBe('costs');
|
||||
});
|
||||
|
||||
it('should recalculate when instanceId changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ instanceId }) =>
|
||||
useBarChartData({
|
||||
data: mockData,
|
||||
indexBy: 'month',
|
||||
keys: ['sales', 'costs'],
|
||||
series: mockSeries,
|
||||
colorRegistry: mockColorRegistry,
|
||||
id: 'test-chart',
|
||||
instanceId,
|
||||
hoveredBar: null,
|
||||
layout: 'vertical',
|
||||
}),
|
||||
{ initialProps: { instanceId: 'instance-1' } },
|
||||
);
|
||||
|
||||
const firstBarConfigs = result.current.barConfigs;
|
||||
|
||||
rerender({ instanceId: 'instance-2' });
|
||||
|
||||
expect(result.current.barConfigs).not.toBe(firstBarConfigs);
|
||||
expect(result.current.barConfigs[0].gradientId).toContain('instance-2');
|
||||
});
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { type BarChartConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartConfig';
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
|
||||
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
|
||||
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
|
||||
import { createGradientDef } from '@/page-layout/widgets/graph/utils/createGradientDef';
|
||||
import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type UseBarChartDataProps = {
|
||||
data: BarChartDataItem[];
|
||||
indexBy: string;
|
||||
keys: string[];
|
||||
series?: BarChartSeries[];
|
||||
colorRegistry: GraphColorRegistry;
|
||||
id: string;
|
||||
instanceId: string;
|
||||
seriesLabels?: Record<string, string>;
|
||||
hoveredBar: { key: string; indexValue: string | number } | null;
|
||||
layout: 'vertical' | 'horizontal';
|
||||
};
|
||||
|
||||
export const useBarChartData = ({
|
||||
data,
|
||||
indexBy,
|
||||
keys,
|
||||
series,
|
||||
colorRegistry,
|
||||
id,
|
||||
instanceId,
|
||||
seriesLabels,
|
||||
hoveredBar,
|
||||
layout,
|
||||
}: UseBarChartDataProps) => {
|
||||
const seriesConfigMap = useMemo(
|
||||
() => new Map<string, BarChartSeries>(series?.map((s) => [s.key, s]) || []),
|
||||
[series],
|
||||
);
|
||||
const barConfigs = useMemo((): BarChartConfig[] => {
|
||||
return data.flatMap((dataPoint, dataIndex) => {
|
||||
const indexValue = dataPoint[indexBy];
|
||||
return keys.map((key, keyIndex): BarChartConfig => {
|
||||
const seriesConfig = seriesConfigMap.get(key);
|
||||
const colorScheme = getColorScheme(
|
||||
colorRegistry,
|
||||
seriesConfig?.color,
|
||||
keyIndex,
|
||||
);
|
||||
const gradientId = `gradient-${id}-${instanceId}-${key}-${dataIndex}-${keyIndex}`;
|
||||
|
||||
return {
|
||||
key,
|
||||
indexValue,
|
||||
gradientId,
|
||||
colorScheme,
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [data, indexBy, keys, colorRegistry, id, instanceId, seriesConfigMap]);
|
||||
|
||||
const enrichedKeys: BarChartEnrichedKey[] = keys.map((key, index) => {
|
||||
const seriesConfig = seriesConfigMap.get(key);
|
||||
const colorScheme = getColorScheme(
|
||||
colorRegistry,
|
||||
seriesConfig?.color,
|
||||
index,
|
||||
);
|
||||
return {
|
||||
key,
|
||||
colorScheme,
|
||||
label: seriesConfig?.label || seriesLabels?.[key] || key,
|
||||
};
|
||||
});
|
||||
|
||||
const defs = barConfigs.map((bar) => {
|
||||
const isHovered =
|
||||
hoveredBar?.key === bar.key && hoveredBar?.indexValue === bar.indexValue;
|
||||
return createGradientDef(
|
||||
bar.colorScheme,
|
||||
bar.gradientId,
|
||||
isHovered,
|
||||
layout === 'horizontal' ? 0 : 90,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
seriesConfigMap,
|
||||
barConfigs,
|
||||
enrichedKeys,
|
||||
defs,
|
||||
};
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type UseBarChartHandlersProps = {
|
||||
data: BarChartDataItem[];
|
||||
indexBy: string;
|
||||
};
|
||||
|
||||
export const useBarChartHandlers = ({
|
||||
data,
|
||||
indexBy,
|
||||
}: UseBarChartHandlersProps) => {
|
||||
const [hoveredBar, setHoveredBar] = useState<{
|
||||
key: string;
|
||||
indexValue: string | number;
|
||||
} | null>(null);
|
||||
|
||||
const handleBarClick = (datum: ComputedDatum<BarDatum>) => {
|
||||
const dataItem = data.find((d) => d[indexBy] === datum.indexValue);
|
||||
if (isDefined(dataItem?.to)) {
|
||||
window.location.href = dataItem.to;
|
||||
}
|
||||
};
|
||||
|
||||
const hasClickableItems = data.some((item) => isDefined(item.to));
|
||||
|
||||
return {
|
||||
hoveredBar,
|
||||
setHoveredBar,
|
||||
handleBarClick,
|
||||
hasClickableItems,
|
||||
};
|
||||
};
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
|
||||
export const useBarChartTheme = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return {
|
||||
axis: {
|
||||
domain: {
|
||||
line: {
|
||||
stroke: theme.border.color.light,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
},
|
||||
ticks: {
|
||||
line: {
|
||||
stroke: theme.border.color.light,
|
||||
strokeWidth: 1,
|
||||
},
|
||||
text: {
|
||||
fill: theme.font.color.secondary,
|
||||
fontSize: 11,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
text: {
|
||||
fill: theme.font.color.light,
|
||||
fontSize: 12,
|
||||
fontWeight: theme.font.weight.medium,
|
||||
},
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
line: {
|
||||
stroke: theme.border.color.light,
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: '4 4',
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
text: {
|
||||
fontSize: 11,
|
||||
fontWeight: theme.font.weight.medium,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
|
||||
import {
|
||||
formatGraphValue,
|
||||
type GraphValueFormatOptions,
|
||||
} from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type UseBarChartTooltipProps = {
|
||||
hoveredBar: { key: string; indexValue: string | number } | null;
|
||||
enrichedKeys: BarChartEnrichedKey[];
|
||||
data: BarChartDataItem[];
|
||||
indexBy: string;
|
||||
formatOptions: GraphValueFormatOptions;
|
||||
};
|
||||
|
||||
export const useBarChartTooltip = ({
|
||||
hoveredBar,
|
||||
enrichedKeys,
|
||||
data,
|
||||
indexBy,
|
||||
formatOptions,
|
||||
}: UseBarChartTooltipProps) => {
|
||||
const renderTooltip = (datum: ComputedDatum<BarDatum>) => {
|
||||
const hoveredKey = hoveredBar?.key;
|
||||
if (!isDefined(hoveredKey)) return null;
|
||||
|
||||
const enrichedKey = enrichedKeys.find((item) => item.key === hoveredKey);
|
||||
if (!enrichedKey) return null;
|
||||
|
||||
const dataItem = data.find((d) => d[indexBy] === datum.indexValue);
|
||||
const seriesValue = Number(datum.data[hoveredKey] || 0);
|
||||
const tooltipItem = {
|
||||
label: enrichedKey.label,
|
||||
formattedValue: formatGraphValue(seriesValue, formatOptions),
|
||||
dotColor: enrichedKey.colorScheme.solid,
|
||||
};
|
||||
|
||||
return {
|
||||
tooltipItem,
|
||||
showClickHint: isDefined(dataItem?.to),
|
||||
};
|
||||
};
|
||||
|
||||
return { renderTooltip };
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
|
||||
|
||||
export type BarChartConfig = {
|
||||
key: string;
|
||||
indexValue: string | number;
|
||||
gradientId: string;
|
||||
colorScheme: GraphColorScheme;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { type BarDatum } from '@nivo/bar';
|
||||
|
||||
export type BarChartDataItem = BarDatum & {
|
||||
to?: string;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
|
||||
|
||||
export type BarChartEnrichedKey = {
|
||||
key: string;
|
||||
colorScheme: GraphColorScheme;
|
||||
label: string;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
|
||||
|
||||
export type BarChartSeries = {
|
||||
key: string;
|
||||
label?: string;
|
||||
color?: GraphColor;
|
||||
};
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import { type ComputedBarDatum } from '@nivo/bar';
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { calculateBarChartEndLineCoordinates } from '../calculateBarChartEndLineCoordinates';
|
||||
describe('calculateBarChartEndLineCoordinates', () => {
|
||||
const createMockBar = (
|
||||
overrides?: Partial<ComputedBarDatum<BarChartDataItem>>,
|
||||
): ComputedBarDatum<BarChartDataItem> =>
|
||||
({
|
||||
x: 100,
|
||||
y: 50,
|
||||
width: 40,
|
||||
height: 80,
|
||||
color: 'url(#gradient-test)',
|
||||
data: {
|
||||
id: 'sales',
|
||||
value: 100,
|
||||
index: 0,
|
||||
indexValue: 'Q1',
|
||||
data: { Q1: 100 } as BarChartDataItem,
|
||||
formattedValue: '100',
|
||||
hidden: false,
|
||||
},
|
||||
label: 'Sales',
|
||||
...overrides,
|
||||
}) as ComputedBarDatum<BarChartDataItem>;
|
||||
describe('vertical layout', () => {
|
||||
it('should calculate horizontal line coordinates at the top of vertical bars', () => {
|
||||
const mockBar = createMockBar();
|
||||
const result = calculateBarChartEndLineCoordinates(mockBar, 'vertical');
|
||||
expect(result).toEqual({
|
||||
x1: 100,
|
||||
x2: 140,
|
||||
y1: 50,
|
||||
y2: 50,
|
||||
});
|
||||
});
|
||||
it('should handle bars at origin position', () => {
|
||||
const barAtOrigin = createMockBar({ x: 0, y: 0 });
|
||||
const result = calculateBarChartEndLineCoordinates(
|
||||
barAtOrigin,
|
||||
'vertical',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
x1: 0,
|
||||
x2: 40,
|
||||
y1: 0,
|
||||
y2: 0,
|
||||
});
|
||||
});
|
||||
it('should handle bars with negative positions', () => {
|
||||
const negativeBar = createMockBar({ x: -50, y: -20 });
|
||||
const result = calculateBarChartEndLineCoordinates(
|
||||
negativeBar,
|
||||
'vertical',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
x1: -50,
|
||||
x2: -10,
|
||||
y1: -20,
|
||||
y2: -20,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('horizontal layout', () => {
|
||||
it('should calculate vertical line coordinates at the end of horizontal bars', () => {
|
||||
const mockBar = createMockBar();
|
||||
const result = calculateBarChartEndLineCoordinates(mockBar, 'horizontal');
|
||||
expect(result).toEqual({
|
||||
x1: 140,
|
||||
x2: 140,
|
||||
y1: 50,
|
||||
y2: 130,
|
||||
});
|
||||
});
|
||||
it('should handle bars with different dimensions', () => {
|
||||
const wideBar = createMockBar({ width: 100, height: 20 });
|
||||
const result = calculateBarChartEndLineCoordinates(wideBar, 'horizontal');
|
||||
expect(result).toEqual({
|
||||
x1: 200,
|
||||
x2: 200,
|
||||
y1: 50,
|
||||
y2: 70,
|
||||
});
|
||||
});
|
||||
it('should handle very thin bars', () => {
|
||||
const thinBar = createMockBar({ width: 1, height: 200 });
|
||||
const result = calculateBarChartEndLineCoordinates(thinBar, 'horizontal');
|
||||
expect(result).toEqual({
|
||||
x1: 101,
|
||||
x2: 101,
|
||||
y1: 50,
|
||||
y2: 250,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
|
||||
import { type ComputedBarDatum } from '@nivo/bar';
|
||||
|
||||
export const calculateBarChartEndLineCoordinates = (
|
||||
bar: ComputedBarDatum<BarChartDataItem>,
|
||||
layout: 'vertical' | 'horizontal',
|
||||
) => {
|
||||
if (layout === 'vertical') {
|
||||
return {
|
||||
x1: bar.x,
|
||||
x2: bar.x + bar.width,
|
||||
y1: bar.y,
|
||||
y2: bar.y,
|
||||
};
|
||||
}
|
||||
return {
|
||||
x1: bar.x + bar.width,
|
||||
x2: bar.x + bar.width,
|
||||
y1: bar.y,
|
||||
y2: bar.y + bar.height,
|
||||
};
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
formatGraphValue,
|
||||
type GraphValueFormatOptions,
|
||||
} from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
|
||||
export const getBarChartAxisBottomConfig = (
|
||||
layout: 'vertical' | 'horizontal',
|
||||
xAxisLabel?: string,
|
||||
yAxisLabel?: string,
|
||||
formatOptions?: GraphValueFormatOptions,
|
||||
) => {
|
||||
return layout === 'vertical'
|
||||
? {
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
legend: xAxisLabel,
|
||||
legendPosition: 'middle' as const,
|
||||
legendOffset: 40,
|
||||
}
|
||||
: {
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
legend: yAxisLabel,
|
||||
legendPosition: 'middle' as const,
|
||||
legendOffset: 40,
|
||||
format: (value: number) => formatGraphValue(value, formatOptions || {}),
|
||||
};
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
formatGraphValue,
|
||||
type GraphValueFormatOptions,
|
||||
} from '@/page-layout/widgets/graph/utils/graphFormatters';
|
||||
|
||||
export const getBarChartAxisLeftConfig = (
|
||||
layout: 'vertical' | 'horizontal',
|
||||
xAxisLabel?: string,
|
||||
yAxisLabel?: string,
|
||||
formatOptions?: GraphValueFormatOptions,
|
||||
) => {
|
||||
return layout === 'vertical'
|
||||
? {
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
legend: yAxisLabel,
|
||||
legendPosition: 'middle' as const,
|
||||
legendOffset: -50,
|
||||
format: (value: number) => formatGraphValue(value, formatOptions || {}),
|
||||
}
|
||||
: {
|
||||
tickSize: 0,
|
||||
tickPadding: 5,
|
||||
tickRotation: 0,
|
||||
legend: xAxisLabel,
|
||||
legendPosition: 'middle' as const,
|
||||
legendOffset: -50,
|
||||
};
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { type BarChartConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartConfig';
|
||||
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type ThemeType } from 'twenty-ui/theme';
|
||||
|
||||
export const getBarChartColor = (
|
||||
datum: ComputedDatum<BarDatum>,
|
||||
barConfigs: BarChartConfig[],
|
||||
theme: ThemeType,
|
||||
) => {
|
||||
const bar = barConfigs.find(
|
||||
(b) => b.key === datum.id && b.indexValue === datum.indexValue,
|
||||
);
|
||||
if (!isDefined(bar)) {
|
||||
return theme.border.color.light;
|
||||
}
|
||||
return `url(#${bar.gradientId})`;
|
||||
};
|
||||
Reference in New Issue
Block a user