Create the Dashboard record show page (#14423)

Closes https://github.com/twentyhq/core-team-issues/issues/1438

- Reorganized PageLayout module
- Created `DashboardRenderer` and `PageLayoutRenderer`
- Created stories for the `PageLayoutRenderer`
- Refactored the Widget components


https://github.com/user-attachments/assets/27e9ac8f-b237-4c21-8494-3fab6d65af3a
This commit is contained in:
Raphaël Bosi
2025-09-11 18:26:26 +02:00
committed by GitHub
parent 140b416b22
commit e80c552ae7
124 changed files with 1528 additions and 808 deletions
@@ -0,0 +1,444 @@
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>
);
};
@@ -0,0 +1,226 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
type RadialBarCustomLayerProps,
ResponsiveRadialBar,
} from '@nivo/radial-bar';
import { useId, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
import { type GraphColor } from '../types/GraphColor';
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 GaugeChartData = {
value: number;
min: number;
max: number;
color?: GraphColor;
to?: string;
label?: string;
};
type GraphWidgetGaugeChartProps = {
data: GaugeChartData;
showValue?: boolean;
showLegend?: boolean;
id: 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 path[fill^="url(#"] {
cursor: pointer;
}
`}
`;
const StyledH1Title = styled(H1Title)`
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -150%);
`;
export const GraphWidgetGaugeChart = ({
data,
showValue = true,
showLegend = true,
id,
displayType,
decimals,
prefix,
suffix,
customFormatter,
}: GraphWidgetGaugeChartProps) => {
const { value, min, max, color = 'blue', to, label = 'Value' } = data;
const theme = useTheme();
const instanceId = useId();
const [isHovered, setIsHovered] = useState(false);
const colorRegistry = createGraphColorRegistry(theme);
const colorScheme = getColorScheme(colorRegistry, color);
const formatOptions: GraphValueFormatOptions = {
displayType,
decimals,
prefix,
suffix,
customFormatter,
};
const formattedValue = formatGraphValue(value, formatOptions);
const normalizedValue = max === min ? 0 : ((value - min) / (max - min)) * 100;
const clampedNormalizedValue = Math.max(0, Math.min(100, normalizedValue));
const chartData = [
{
id: 'gauge',
data: [
{ x: 'value', y: clampedNormalizedValue },
{ x: 'empty', y: 100 - clampedNormalizedValue },
],
},
];
const gradientId = `gaugeGradient-${id}-${instanceId}`;
const gaugeAngle = -90 + (clampedNormalizedValue / 100) * 90;
const gradientDef = createGradientDef(
colorScheme,
gradientId,
isHovered,
gaugeAngle,
);
const defs = [gradientDef];
const handleClick = () => {
if (isDefined(to)) {
window.location.href = to;
}
};
const renderTooltip = () => {
const formattedWithPercentage = `${formattedValue} (${normalizedValue.toFixed(1)}%)`;
return (
<GraphWidgetTooltip
items={[
{
label: label,
formattedValue: formattedWithPercentage,
dotColor: colorScheme.solid,
},
]}
showClickHint={isDefined(to)}
/>
);
};
const renderValueEndLine = (props: RadialBarCustomLayerProps) => {
if (clampedNormalizedValue === 0) {
return null;
}
const { center, bars } = props;
const valueBar = bars?.find((bar) => bar.data.x === 'value');
if (!valueBar) {
return null;
}
const endAngle = valueBar.arc.endAngle - Math.PI / 2;
const arcInnerRadius = valueBar.arc.innerRadius;
const arcOuterRadius = valueBar.arc.outerRadius;
const [centerX, centerY] = center;
const x1 = centerX + Math.cos(endAngle) * arcInnerRadius;
const y1 = centerY + Math.sin(endAngle) * arcInnerRadius;
const x2 = centerX + Math.cos(endAngle) * arcOuterRadius;
const y2 = centerY + Math.sin(endAngle) * arcOuterRadius;
return (
<g>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={colorScheme.solid}
strokeWidth={1}
/>
</g>
);
};
return (
<StyledContainer>
<StyledChartContainer $isClickable={isDefined(to)}>
<ResponsiveRadialBar
data={chartData}
startAngle={-90}
endAngle={90}
innerRadius={0.7}
padding={0.2}
colors={[`url(#${gradientId})`, theme.background.tertiary]}
defs={defs}
fill={[
{
match: (d: { x: string }) => d.x === 'value',
id: gradientId,
},
]}
enableTracks={false}
enableRadialGrid={false}
enableCircularGrid={false}
enableLabels={false}
isInteractive={true}
tooltip={renderTooltip}
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
layers={['bars', renderValueEndLine]}
/>
{showValue && (
<StyledH1Title
title={formattedValue}
fontColor={H1TitleFontColor.Primary}
/>
)}
</StyledChartContainer>
<GraphWidgetLegend
show={showLegend}
items={[
{
id: 'gauge',
label: label,
formattedValue: formattedValue,
color: colorScheme.solid,
},
]}
/>
</StyledContainer>
);
};
@@ -0,0 +1,66 @@
import styled from '@emotion/styled';
export type GraphWidgetLegendItem = {
id: string;
label: string;
formattedValue: string;
color: string;
};
type GraphWidgetLegendProps = {
items: GraphWidgetLegendItem[];
show?: boolean;
};
const StyledLegendContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(3)};
justify-content: center;
padding: ${({ theme }) => theme.spacing(2)} 0;
`;
const StyledLegendItem = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
const StyledLegendLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
`;
const StyledLegendValue = styled.span`
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledDot = styled.div<{ color: string }>`
background: ${({ color }) => color};
border-radius: 50%;
height: 6px;
width: 6px;
flex-shrink: 0;
`;
export const GraphWidgetLegend = ({
items,
show = true,
}: GraphWidgetLegendProps) => {
if (!show || items.length === 0) {
return null;
}
return (
<StyledLegendContainer>
{items.map((item) => (
<StyledLegendItem key={item.id}>
<StyledDot color={item.color} />
<StyledLegendLabel>{item.label}</StyledLegendLabel>
<StyledLegendValue>{item.formattedValue}</StyledLegendValue>
</StyledLegendItem>
))}
</StyledLegendContainer>
);
};
@@ -0,0 +1,380 @@
import styled from '@emotion/styled';
import {
ResponsiveLine,
type LineSeries,
type Point,
type SliceTooltipProps,
} from '@nivo/line';
import { type ScaleLinearSpec, type ScaleSpec } from '@nivo/scales';
import { useId, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useTheme } from '@emotion/react';
import { type GraphColor } from '../types/GraphColor';
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 LineChartDataPoint = {
x: number | string | Date;
y: number | null;
to?: string;
};
type LineChartSeries = {
id: string;
label?: string;
color?: GraphColor;
data: LineChartDataPoint[];
enableArea?: boolean;
};
type GraphWidgetLineChartProps = {
data: LineChartSeries[];
showLegend?: boolean;
showGrid?: boolean;
enablePoints?: boolean;
xAxisLabel?: string;
yAxisLabel?: string;
id: string;
enableArea?: boolean;
stackedArea?: boolean;
curve?:
| 'linear'
| 'monotoneX'
| 'step'
| 'stepBefore'
| 'stepAfter'
| 'natural';
lineWidth?: number;
enableSlices?: 'x' | 'y' | false;
xScale?: ScaleSpec;
yScale?: ScaleSpec;
} & GraphValueFormatOptions;
const getYScaleWithStacking = (
yScale: ScaleSpec | undefined,
stackedArea: boolean | undefined,
): ScaleSpec => {
if (!yScale || yScale.type === 'linear') {
const linearScale: ScaleLinearSpec = {
min: 0,
max: 'auto',
...yScale,
type: 'linear',
stacked: stackedArea,
};
return linearScale;
}
return yScale;
};
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 circle {
cursor: pointer;
}
`}
`;
export const GraphWidgetLineChart = ({
data,
showLegend = true,
showGrid = true,
enablePoints = false,
xAxisLabel,
yAxisLabel,
id,
enableArea = false,
stackedArea = false,
curve = 'monotoneX',
lineWidth = 2,
enableSlices = 'x',
xScale = { type: 'linear' },
yScale = { type: 'linear', min: 0, max: 'auto' },
displayType,
decimals,
prefix,
suffix,
customFormatter,
}: GraphWidgetLineChartProps) => {
const theme = useTheme();
const instanceId = useId();
const colorRegistry = createGraphColorRegistry(theme);
const formatOptions: GraphValueFormatOptions = {
displayType,
decimals,
prefix,
suffix,
customFormatter,
};
const dataMap = useMemo(() => {
const map: Record<string, LineChartSeries> = {};
for (const series of data) {
map[series.id] = series;
}
return map;
}, [data]);
const enrichedSeries = useMemo(() => {
return data.map((series, index) => {
const colorScheme = getColorScheme(colorRegistry, series.color, index);
const shouldEnableArea = series.enableArea ?? enableArea;
const gradientId = `lineGradient-${id}-${instanceId}-${series.id}-${index}`;
return {
...series,
colorScheme,
gradientId,
shouldEnableArea,
label: series.label || series.id,
};
});
}, [data, colorRegistry, id, instanceId, enableArea]);
const nivoData = data.map((series) => ({
id: series.id,
data: series.data.map((point) => ({
x: point.x,
y: point.y,
})),
}));
const defs = enrichedSeries
.filter((series) => series.shouldEnableArea)
.map((series) =>
createGradientDef(
series.colorScheme,
series.gradientId,
false,
90,
theme.name === 'light',
),
);
const fill = enrichedSeries
.filter((series) => series.shouldEnableArea)
.map((series) => ({
match: { id: series.id },
id: series.gradientId,
}));
const colors = enrichedSeries.map((series) => series.colorScheme.solid);
const handlePointClick = (point: Point<LineSeries>) => {
const series = dataMap[point.seriesId];
if (isDefined(series)) {
const dataPoint = series.data[point.indexInSeries];
if (isDefined(dataPoint?.to) === true) {
window.location.href = dataPoint.to;
}
}
};
const hasClickableItems = data.some((series) =>
series.data.some((point) => isDefined(point.to)),
);
const renderSliceTooltip = ({ slice }: SliceTooltipProps<LineSeries>) => {
const tooltipItems = slice.points
.map((point) => {
const enrichedSeriesItem = enrichedSeries.find(
(s) => s.id === point.seriesId,
);
if (!enrichedSeriesItem) return null;
return {
label: enrichedSeriesItem.label,
formattedValue: formatGraphValue(
Number(point.data.y || 0),
formatOptions,
),
dotColor: enrichedSeriesItem.colorScheme.solid,
};
})
.filter(isDefined);
const hasClickablePoint = slice.points.some((point) => {
const series = dataMap[point.seriesId];
if (isDefined(series)) {
const dataPoint = series.data[point.indexInSeries];
return isDefined(dataPoint?.to);
}
return false;
});
return (
<GraphWidgetTooltip
items={tooltipItems}
showClickHint={hasClickablePoint}
/>
);
};
const renderPointTooltip = (point: Point<LineSeries>) => {
const enrichedSeriesItem = enrichedSeries.find(
(s) => s.id === point.seriesId,
);
if (!enrichedSeriesItem) return null;
const series = dataMap[point.seriesId];
const dataPoint = series?.data[point.indexInSeries];
return (
<GraphWidgetTooltip
items={[
{
label: enrichedSeriesItem.label,
formattedValue: formatGraphValue(
Number(point.data.y || 0),
formatOptions,
),
dotColor: enrichedSeriesItem.colorScheme.solid,
},
]}
showClickHint={isDefined(dataPoint?.to)}
/>
);
};
const getAxisBottomConfig = () => ({
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: xAxisLabel,
legendPosition: 'middle' as const,
legendOffset: 40,
});
const getAxisLeftConfig = () => ({
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: yAxisLabel,
legendPosition: 'middle' as const,
legendOffset: -50,
format: (value: number) => formatGraphValue(value, formatOptions),
});
const legendItems = enrichedSeries.map((series) => {
const total = series.data.reduce((sum, point) => sum + (point.y || 0), 0);
return {
id: series.id,
label: series.label,
formattedValue: formatGraphValue(total, formatOptions),
color: series.colorScheme.solid,
};
});
return (
<StyledContainer id={id}>
<StyledChartContainer $isClickable={hasClickableItems}>
<ResponsiveLine
data={nivoData}
margin={{ top: 20, right: 20, bottom: 60, left: 70 }}
xScale={xScale}
yScale={getYScaleWithStacking(yScale, stackedArea)}
curve={curve}
lineWidth={lineWidth}
enableArea={enableArea}
areaBaselineValue={0}
enablePoints={enablePoints}
pointSize={6}
pointBorderWidth={0}
areaOpacity={theme.name === 'dark' ? 0.8 : 1}
colors={colors}
areaBlendMode={theme.name === 'dark' ? 'screen' : 'multiply'}
defs={defs}
fill={fill}
axisTop={null}
axisRight={null}
axisBottom={getAxisBottomConfig()}
axisLeft={getAxisLeftConfig()}
enableGridX={showGrid}
enableGridY={showGrid}
enableSlices={enableSlices}
sliceTooltip={enableSlices === 'x' ? renderSliceTooltip : undefined}
tooltip={
enableSlices === false
? ({ point }) => renderPointTooltip(point)
: undefined
}
onClick={(datum) => {
if ('seriesId' in datum) {
handlePointClick(datum as Point<LineSeries>);
}
}}
useMesh={true}
crosshairType="cross"
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: 12,
},
},
legend: {
text: {
fill: theme.font.color.secondary,
fontSize: 12,
fontWeight: theme.font.weight.regular,
},
},
},
grid: {
line: {
stroke: theme.border.color.light,
strokeWidth: 1,
strokeDasharray: '4 4',
},
},
crosshair: {
line: {
stroke: theme.font.color.tertiary,
strokeWidth: 1,
strokeDasharray: '2 2',
},
},
}}
/>
</StyledChartContainer>
<GraphWidgetLegend show={showLegend} items={legendItems} />
</StyledContainer>
);
};
@@ -0,0 +1,67 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
H1Title,
H1TitleFontColor,
IconTrendingDown,
IconTrendingUp,
} from 'twenty-ui/display';
// props are subjected to change
type GraphWidgetNumberChartProps = {
value: string;
trendPercentage: number;
};
const StyledTrendPercentageValue = styled.span`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledContainer = styled.div`
align-items: center;
display: flex;
height: 100%;
justify-content: space-between;
width: 100%;
`;
const StyledTrendIconContainer = styled.div`
display: flex;
align-items: center;
justify-content: center;
`;
const StyledH1Title = styled(H1Title)`
margin: 0;
`;
export const GraphWidgetNumberChart = ({
value,
trendPercentage,
}: GraphWidgetNumberChartProps) => {
const theme = useTheme();
const formattedPercentage =
trendPercentage >= 0 ? `+${trendPercentage}` : `${trendPercentage}`;
return (
<StyledContainer>
<StyledH1Title title={value} fontColor={H1TitleFontColor.Primary} />
<StyledTrendIconContainer>
<StyledTrendPercentageValue>
{formattedPercentage}%
</StyledTrendPercentageValue>
{trendPercentage >= 0 ? (
<IconTrendingUp
color={theme.color.turquoise40}
size={theme.icon.size.md}
/>
) : (
// question for product - whats the exact red here? cant see it on figma
<IconTrendingDown color={theme.color.red} size={theme.icon.size.md} />
)}
</StyledTrendIconContainer>
</StyledContainer>
);
};
@@ -0,0 +1,232 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
ResponsivePie,
type ComputedDatum,
type DatumId,
type PieCustomLayerProps,
} from '@nivo/pie';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type GraphColor } from '../types/GraphColor';
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 GraphWidgetPieChartProps = {
data: Array<{
id: string;
value: number;
label?: string;
color?: GraphColor;
to?: string;
}>;
showLegend?: boolean;
id: 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 path[fill^="url(#"] {
cursor: pointer;
}
`}
`;
export const GraphWidgetPieChart = ({
data,
showLegend = true,
id,
displayType,
decimals,
prefix,
suffix,
customFormatter,
}: GraphWidgetPieChartProps) => {
const theme = useTheme();
const [hoveredSliceId, setHoveredSliceId] = useState<DatumId | null>(null);
const colorRegistry = createGraphColorRegistry(theme);
const formatOptions: GraphValueFormatOptions = {
displayType,
decimals,
prefix,
suffix,
customFormatter,
};
const totalValue = data.reduce((sum, item) => sum + item.value, 0);
let cumulativeAngle = 0;
const enrichedData = data.map((item, index) => {
const colorScheme = getColorScheme(colorRegistry, item.color, index);
const isHovered = hoveredSliceId === item.id;
const gradientId = `${colorScheme.name}Gradient-${id}-${index}`;
const percentage = totalValue > 0 ? (item.value / totalValue) * 100 : 0;
const sliceAngle = (percentage / 100) * 360;
const middleAngle = cumulativeAngle + sliceAngle / 2;
cumulativeAngle += sliceAngle;
return {
...item,
gradientId,
colorScheme,
isHovered,
percentage,
middleAngle,
};
});
const defs = enrichedData.map((item) =>
createGradientDef(
item.colorScheme,
item.gradientId,
item.isHovered,
item.middleAngle,
),
);
const fill = enrichedData.map((item) => ({
match: { id: item.id },
id: item.gradientId,
}));
const handleSliceClick = (
datum: ComputedDatum<{ id: string; value: number; label?: string }>,
) => {
const clickedItem = data.find((d) => d.id === datum.id);
if (isDefined(clickedItem?.to)) {
window.location.href = clickedItem.to;
}
};
const renderTooltip = (
datum: ComputedDatum<{ id: string; value: number; label?: string }>,
) => {
const item = enrichedData.find((d) => d.id === datum.id);
if (!item) return null;
const dataItem = data.find((d) => d.id === datum.id);
const formattedValue = formatGraphValue(
displayType === 'percentage' ? item.percentage / 100 : item.value,
formatOptions,
);
const formattedWithPercentage = `${formattedValue} (${item.percentage.toFixed(1)}%)`;
return (
<GraphWidgetTooltip
items={[
{
label: item.label || item.id,
formattedValue: formattedWithPercentage,
dotColor: item.colorScheme.solid,
},
]}
showClickHint={isDefined(dataItem?.to)}
/>
);
};
const renderSliceEndLines = (
layerProps: PieCustomLayerProps<{
id: string;
value: number;
label?: string;
}>,
) => {
const { dataWithArc, centerX, centerY, innerRadius, radius } = layerProps;
if (!dataWithArc || !Array.isArray(dataWithArc) || dataWithArc.length < 2) {
return null;
}
return (
<g>
{dataWithArc.map((datum) => {
const enrichedItem = enrichedData.find((d) => d.id === datum.id);
const lineColor = enrichedItem
? enrichedItem.colorScheme.solid
: theme.border.color.strong;
const angle = datum.arc.endAngle - Math.PI / 2;
const x1 = centerX + Math.cos(angle) * innerRadius;
const y1 = centerY + Math.sin(angle) * innerRadius;
const x2 = centerX + Math.cos(angle) * radius;
const y2 = centerY + Math.sin(angle) * radius;
return (
<line
key={`${datum.id}-separator`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={lineColor}
strokeWidth={1}
/>
);
})}
</g>
);
};
const hasClickableItems = data.some((item) => isDefined(item.to));
return (
<StyledContainer id={id}>
<StyledChartContainer $isClickable={hasClickableItems}>
<ResponsivePie
data={data}
innerRadius={0.8}
colors={enrichedData.map((item) => `url(#${item.gradientId})`)}
borderWidth={0}
enableArcLinkLabels={false}
enableArcLabels={false}
tooltip={({ datum }) => renderTooltip(datum)}
onClick={handleSliceClick}
onMouseEnter={(datum) => setHoveredSliceId(datum.id)}
onMouseLeave={() => setHoveredSliceId(null)}
defs={defs}
fill={fill}
layers={['arcs', renderSliceEndLines]}
/>
</StyledChartContainer>
<GraphWidgetLegend
show={showLegend}
items={enrichedData.map((item) => ({
id: item.id,
label: item.label || item.id,
formattedValue: formatGraphValue(
displayType === 'percentage' ? item.percentage / 100 : item.value,
formatOptions,
),
color: item.colorScheme.solid,
}))}
/>
</StyledContainer>
);
};
@@ -0,0 +1,101 @@
import { GraphType } from '@/page-layout/mocks/mockWidgets';
import { getDefaultWidgetData } from '@/page-layout/utils/getDefaultWidgetData';
import { GraphWidgetBarChart } from '@/page-layout/widgets/graph/components/GraphWidgetBarChart';
import { GraphWidgetGaugeChart } from '@/page-layout/widgets/graph/components/GraphWidgetGaugeChart';
import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/components/GraphWidgetLineChart';
import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/components/GraphWidgetNumberChart';
import { GraphWidgetPieChart } from '@/page-layout/widgets/graph/components/GraphWidgetPieChart';
import { type GraphWidget } from '@/page-layout/widgets/graph/types/GraphWidget';
type GraphWidgetRendererProps = {
widget: GraphWidget;
};
export const GraphWidgetRenderer = ({ widget }: GraphWidgetRendererProps) => {
const graphType = widget.configuration?.graphType;
if (!Object.values(GraphType).includes(graphType)) {
return null;
}
const data = widget.data ?? getDefaultWidgetData(graphType);
if (!data) {
return null;
}
switch (graphType as GraphType) {
case GraphType.NUMBER:
return (
<GraphWidgetNumberChart
value={data.value}
trendPercentage={data.trendPercentage}
/>
);
case GraphType.GAUGE:
return (
<GraphWidgetGaugeChart
data={{
value: data.value,
min: data.min,
max: data.max,
label: data.label,
}}
displayType="percentage"
showValue
id={`gauge-chart-${widget.id}`}
/>
);
case GraphType.PIE:
return (
<GraphWidgetPieChart
data={data.items}
showLegend
displayType="percentage"
id={`pie-chart-${widget.id}`}
/>
);
case GraphType.BAR:
return (
<GraphWidgetBarChart
data={data.items}
indexBy={data.indexBy}
keys={data.keys}
seriesLabels={data.seriesLabels}
layout={data.layout}
showLegend
showGrid
displayType="number"
id={`bar-chart-${widget.id}`}
/>
);
case GraphType.LINE:
return (
<GraphWidgetLineChart
id={`line-chart-${widget.id}`}
data={data.series}
enableArea={data.enableArea}
showLegend={data.showLegend}
showGrid={data.showGrid}
enablePoints={data.enablePoints}
xAxisLabel={data.xAxisLabel}
yAxisLabel={data.yAxisLabel}
displayType={data.displayType}
prefix={data.prefix}
suffix={data.suffix}
xScale={data.xScale}
yScale={data.yScale}
curve={data.curve}
stackedArea={data.stackedArea}
enableSlices={data.enableSlices}
/>
);
default:
return null;
}
};
@@ -0,0 +1,80 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconArrowUpRight } from 'twenty-ui/display';
const StyledTooltipContent = styled.div`
background: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
pointer-events: none;
`;
const StyledTooltipRow = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.extraLight};
display: flex;
font-size: ${({ theme }) => theme.font.size.xs};
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledDot = styled.div<{ $color: string }>`
background: ${({ $color }) => $color};
border-radius: 50%;
height: 6px;
width: 6px;
flex-shrink: 0;
`;
const StyledTooltipValue = styled.span`
margin-left: auto;
white-space: nowrap;
`;
const StyledTooltipLink = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
cursor: default;
display: flex;
`;
export type GraphWidgetTooltipItem = {
label: string;
formattedValue: string;
dotColor: string;
};
type GraphWidgetTooltipProps = {
items: GraphWidgetTooltipItem[];
showClickHint?: boolean;
};
export const GraphWidgetTooltip = ({
items,
showClickHint = false,
}: GraphWidgetTooltipProps) => {
const theme = useTheme();
return (
<StyledTooltipContent>
{items.map((item, index) => (
<StyledTooltipRow key={index}>
<StyledDot $color={item.dotColor} />
<span>{item.label}</span>
<StyledTooltipValue>{item.formattedValue}</StyledTooltipValue>
</StyledTooltipRow>
))}
{showClickHint && (
<StyledTooltipLink>
<span>{t`Click to see data`}</span>
<IconArrowUpRight size={theme.icon.size.sm} />
</StyledTooltipLink>
)}
</StyledTooltipContent>
);
};
@@ -0,0 +1,546 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetBarChart } from '../GraphWidgetBarChart';
const meta: Meta<typeof GraphWidgetBarChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetBarChart',
component: GraphWidgetBarChart,
decorators: [ComponentDecorator],
parameters: {
layout: 'centered',
},
argTypes: {
data: {
control: 'object',
},
indexBy: {
control: 'text',
},
keys: {
control: 'object',
},
series: {
control: 'object',
},
displayType: {
control: 'select',
options: ['percentage', 'number', 'shortNumber', 'currency', 'custom'],
},
prefix: {
control: 'text',
},
suffix: {
control: 'text',
},
decimals: {
control: 'number',
},
showLegend: {
control: 'boolean',
},
showGrid: {
control: 'boolean',
},
showValues: {
control: 'boolean',
},
xAxisLabel: {
control: 'text',
},
yAxisLabel: {
control: 'text',
},
id: {
control: 'text',
},
layout: {
control: 'select',
options: ['vertical', 'horizontal'],
},
groupMode: {
control: 'select',
options: ['grouped', 'stacked'],
},
seriesLabels: {
control: 'object',
},
},
};
export default meta;
type Story = StoryObj<typeof GraphWidgetBarChart>;
const Container = ({ children }: { children: React.ReactNode }) => (
<div style={{ width: '500px', height: '300px' }}>{children}</div>
);
export const Default: Story = {
args: {
data: [
{
month: 'Jan',
sales: 120,
leads: 45,
conversions: 12,
to: '/metrics/january',
},
{
month: 'Feb',
sales: 150,
leads: 52,
conversions: 15,
to: '/metrics/february',
},
{
month: 'Mar',
sales: 180,
leads: 48,
conversions: 18,
to: '/metrics/march',
},
{
month: 'Apr',
sales: 140,
leads: 60,
conversions: 14,
to: '/metrics/april',
},
{
month: 'May',
sales: 200,
leads: 55,
conversions: 20,
to: '/metrics/may',
},
],
indexBy: 'month',
keys: ['sales', 'leads', 'conversions'],
showLegend: true,
showGrid: true,
xAxisLabel: 'Month',
yAxisLabel: 'Count',
id: 'bar-chart-default',
},
render: (args) => (
<Container>
<GraphWidgetBarChart
data={args.data}
indexBy={args.indexBy}
keys={args.keys}
showLegend={args.showLegend}
showGrid={args.showGrid}
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
id={args.id}
/>
</Container>
),
};
export const Revenue: Story = {
args: {
data: [
{
quarter: 'Q1',
revenue: 420000,
costs: 280000,
profit: 140000,
to: '/financials/q1',
},
{
quarter: 'Q2',
revenue: 480000,
costs: 320000,
profit: 160000,
to: '/financials/q2',
},
{
quarter: 'Q3',
revenue: 520000,
costs: 340000,
profit: 180000,
to: '/financials/q3',
},
{
quarter: 'Q4',
revenue: 580000,
costs: 360000,
profit: 220000,
to: '/financials/q4',
},
],
indexBy: 'quarter',
keys: ['revenue', 'costs', 'profit'],
seriesLabels: {
revenue: 'Total Revenue',
costs: 'Operating Costs',
profit: 'Net Profit',
},
displayType: 'shortNumber',
prefix: '$',
showLegend: true,
showGrid: true,
xAxisLabel: 'Quarter',
yAxisLabel: 'Amount ($)',
id: 'bar-chart-revenue',
},
render: (args) => (
<Container>
<GraphWidgetBarChart
data={args.data}
indexBy={args.indexBy}
keys={args.keys}
showLegend={args.showLegend}
showGrid={args.showGrid}
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
id={args.id}
/>
</Container>
),
};
export const Stacked: Story = {
args: {
data: [
{
category: 'Website',
desktop: 65,
mobile: 35,
tablet: 15,
to: '/analytics/website',
},
{
category: 'App',
desktop: 25,
mobile: 85,
tablet: 30,
to: '/analytics/app',
},
{
category: 'Email',
desktop: 45,
mobile: 40,
tablet: 20,
to: '/analytics/email',
},
{
category: 'Social',
desktop: 30,
mobile: 75,
tablet: 25,
to: '/analytics/social',
},
],
indexBy: 'category',
keys: ['desktop', 'mobile', 'tablet'],
seriesLabels: {
desktop: 'Desktop',
mobile: 'Mobile',
tablet: 'Tablet',
},
groupMode: 'stacked',
showLegend: true,
showGrid: true,
xAxisLabel: 'Channel',
yAxisLabel: 'Users',
id: 'bar-chart-stacked',
},
render: (args) => (
<Container>
<GraphWidgetBarChart
data={args.data}
indexBy={args.indexBy}
keys={args.keys}
showLegend={args.showLegend}
showGrid={args.showGrid}
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
id={args.id}
/>
</Container>
),
};
export const Horizontal: Story = {
args: {
data: [
{ product: 'Product A', score: 85, to: '/products/a' },
{ product: 'Product B', score: 72, to: '/products/b' },
{ product: 'Product C', score: 90, to: '/products/c' },
{ product: 'Product D', score: 65, to: '/products/d' },
{ product: 'Product E', score: 78, to: '/products/e' },
],
indexBy: 'product',
keys: ['score'],
layout: 'horizontal',
showLegend: false,
showGrid: true,
xAxisLabel: 'Score',
yAxisLabel: 'Product',
suffix: '%',
id: 'bar-chart-horizontal',
},
render: (args) => (
<Container>
<GraphWidgetBarChart
data={args.data}
indexBy={args.indexBy}
keys={args.keys}
layout={args.layout}
showLegend={args.showLegend}
showGrid={args.showGrid}
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
suffix={args.suffix}
id={args.id}
/>
</Container>
),
};
export const WithValues: Story = {
args: {
data: [
{ team: 'Sales', performance: 92, target: 100, to: '/teams/sales' },
{
team: 'Marketing',
performance: 78,
target: 85,
to: '/teams/marketing',
},
{ team: 'Support', performance: 88, target: 90, to: '/teams/support' },
{
team: 'Development',
performance: 95,
target: 95,
to: '/teams/development',
},
],
indexBy: 'team',
keys: ['performance', 'target'],
seriesLabels: {
performance: 'Actual',
target: 'Target',
},
showValues: true,
showLegend: true,
showGrid: true,
xAxisLabel: 'Team',
yAxisLabel: 'Score',
suffix: '%',
id: 'bar-chart-with-values',
},
render: (args) => (
<Container>
<GraphWidgetBarChart
data={args.data}
indexBy={args.indexBy}
keys={args.keys}
showLegend={args.showLegend}
showGrid={args.showGrid}
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
id={args.id}
/>
</Container>
),
};
export const WithCustomColors: Story = {
args: {
data: [
{
month: 'Jan',
sales: 120,
leads: 45,
conversions: 12,
to: '/reports/jan',
},
{
month: 'Feb',
sales: 150,
leads: 52,
conversions: 15,
to: '/reports/feb',
},
{
month: 'Mar',
sales: 180,
leads: 48,
conversions: 18,
to: '/reports/mar',
},
{
month: 'Apr',
sales: 140,
leads: 60,
conversions: 14,
to: '/reports/apr',
},
{
month: 'May',
sales: 200,
leads: 55,
conversions: 20,
to: '/reports/may',
},
],
indexBy: 'month',
keys: ['sales', 'leads', 'conversions'],
series: [
{ key: 'sales', label: 'Total Sales', color: 'orange' },
{ key: 'leads', label: 'New Leads', color: 'turquoise' },
{ key: 'conversions', label: 'Conversions', color: 'pink' },
],
showLegend: true,
showGrid: true,
xAxisLabel: 'Month',
yAxisLabel: 'Count',
id: 'bar-chart-custom-colors',
},
render: (args) => (
<Container>
<GraphWidgetBarChart
data={args.data}
indexBy={args.indexBy}
keys={args.keys}
series={args.series}
showLegend={args.showLegend}
showGrid={args.showGrid}
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
id={args.id}
/>
</Container>
),
};
export const SingleSeries: Story = {
args: {
data: [
{ day: 'Mon', visitors: 1200, to: '/traffic/monday' },
{ day: 'Tue', visitors: 1450, to: '/traffic/tuesday' },
{ day: 'Wed', visitors: 1800, to: '/traffic/wednesday' },
{ day: 'Thu', visitors: 1650, to: '/traffic/thursday' },
{ day: 'Fri', visitors: 2000, to: '/traffic/friday' },
{ day: 'Sat', visitors: 1100 },
{ day: 'Sun', visitors: 900 },
],
indexBy: 'day',
keys: ['visitors'],
showLegend: false,
showGrid: true,
xAxisLabel: 'Day of Week',
yAxisLabel: 'Visitors',
displayType: 'shortNumber',
id: 'bar-chart-single',
},
render: (args) => (
<Container>
<GraphWidgetBarChart
data={args.data}
indexBy={args.indexBy}
keys={args.keys}
showLegend={args.showLegend}
showGrid={args.showGrid}
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
id={args.id}
/>
</Container>
),
};
export const Currency: Story = {
args: {
data: [
{ region: 'North', sales: 45000.5, budget: 50000, to: '/regions/north' },
{ region: 'South', sales: 38000.75, budget: 40000, to: '/regions/south' },
{ region: 'East', sales: 52000.25, budget: 48000, to: '/regions/east' },
{ region: 'West', sales: 41000, budget: 45000, to: '/regions/west' },
],
indexBy: 'region',
keys: ['sales', 'budget'],
seriesLabels: {
sales: 'Actual Sales',
budget: 'Budget',
},
displayType: 'currency',
decimals: 2,
prefix: '$',
showLegend: true,
showGrid: true,
xAxisLabel: 'Region',
yAxisLabel: 'Amount',
id: 'bar-chart-currency',
},
render: (args) => (
<Container>
<GraphWidgetBarChart
data={args.data}
indexBy={args.indexBy}
keys={args.keys}
showLegend={args.showLegend}
showGrid={args.showGrid}
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
id={args.id}
/>
</Container>
),
};
export const Catalog: Story = {
decorators: [CatalogDecorator],
parameters: {
catalog: {
dimensions: [
{
name: 'series',
values: [1, 2, 3],
props: (seriesCount: number) => {
const baseData = [
{ category: 'A', series1: 30, series2: 45, series3: 20 },
{ category: 'B', series1: 40, series2: 35, series3: 25 },
{ category: 'C', series1: 25, series2: 50, series3: 30 },
];
const keys = Array.from(
{ length: seriesCount },
(_, i) => `series${i + 1}`,
);
return {
data: baseData,
keys,
id: `bar-chart-catalog-${seriesCount}`,
};
},
labels: (seriesCount: number) => `${seriesCount} series`,
},
{
name: 'groupMode',
values: ['grouped', 'stacked'],
props: (mode: string) => ({
groupMode: mode as 'grouped' | 'stacked',
}),
},
],
},
},
render: (args) => (
<Container>
<GraphWidgetBarChart
data={args.data}
indexBy="category"
keys={args.keys}
groupMode={args.groupMode}
showLegend={true}
showGrid={true}
id={args.id}
/>
</Container>
),
};
@@ -0,0 +1,327 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetGaugeChart } from '../GraphWidgetGaugeChart';
const meta: Meta<typeof GraphWidgetGaugeChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetGaugeChart',
component: GraphWidgetGaugeChart,
decorators: [ComponentDecorator],
parameters: {
layout: 'centered',
},
argTypes: {
data: {
control: 'object',
},
displayType: {
control: 'select',
options: ['percentage', 'number', 'shortNumber', 'currency', 'custom'],
},
prefix: {
control: 'text',
},
suffix: {
control: 'text',
},
decimals: {
control: 'number',
},
showValue: {
control: 'boolean',
},
showLegend: {
control: 'boolean',
},
id: {
control: 'text',
},
},
};
export default meta;
type Story = StoryObj<typeof GraphWidgetGaugeChart>;
const Container = ({ children }: { children: React.ReactNode }) => (
<div style={{ width: '250px', height: '200px' }}>{children}</div>
);
export const WithCustomColors: Story = {
args: {
data: {
value: 75,
min: 0,
max: 100,
color: 'purple',
to: '/metrics/progress',
label: 'Progress',
},
displayType: 'number',
suffix: '%',
showValue: true,
id: 'gauge-chart-purple',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
data={args.data}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const Default: Story = {
args: {
data: {
value: 0.5,
min: 0,
max: 1,
to: '/metrics/conversion',
label: 'Conversion rate',
},
displayType: 'percentage',
showValue: true,
id: 'gauge-chart-default',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
data={args.data}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const Catalog: Story = {
decorators: [CatalogDecorator],
parameters: {
catalog: {
dimensions: [
{
name: 'value',
values: [0, 25, 50, 75, 100],
props: (value: number) => ({
data: {
value: value / 100,
min: 0,
max: 1,
label: 'Percentage',
to: '/metrics/catalog',
},
displayType: 'percentage' as const,
id: `gauge-chart-catalog-${value}`,
}),
labels: (value: number) => {
const labelMap: Record<number, string> = {
0: 'Empty',
25: 'Quarter',
50: 'Half',
75: 'Three Quarters',
100: 'Full',
};
return labelMap[value] ?? `${value}%`;
},
},
],
},
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
data={args.data}
displayType={args.displayType}
showValue={true}
id="gauge-chart-catalog"
/>
</Container>
),
};
export const WithoutValue: Story = {
args: {
data: {
value: 65,
min: 0,
max: 100,
to: '/metrics/conversion-without-value',
label: 'Conversion rate',
},
displayType: 'percentage',
showValue: false,
id: 'gauge-chart-without-value',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
data={args.data}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const Revenue: Story = {
args: {
data: {
value: 750,
min: 0,
max: 1000,
to: '/financials/revenue',
label: 'Revenue',
},
displayType: 'shortNumber',
showValue: true,
id: 'gauge-chart-revenue',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
data={args.data}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const Temperature: Story = {
args: {
data: {
value: 22,
min: -10,
max: 40,
to: '/sensors/temperature',
label: 'Temperature',
},
suffix: '°C',
showValue: true,
id: 'gauge-chart-temperature',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
data={args.data}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const Storage: Story = {
args: {
data: {
value: 384,
min: 0,
max: 512,
to: '/system/storage',
label: 'Storage Used',
},
suffix: ' GB',
showValue: true,
id: 'gauge-chart-storage',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
data={args.data}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const Rating: Story = {
args: {
data: {
value: 4.2,
min: 0,
max: 5,
to: '/reviews/rating',
label: 'Average Rating',
},
suffix: ' ⭐',
decimals: 1,
showValue: true,
id: 'gauge-chart-rating',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
data={args.data}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const WithoutLegend: Story = {
args: {
data: {
value: 65,
min: 0,
max: 100,
to: '/metrics/conversion-no-legend',
label: 'Conversion rate',
},
displayType: 'percentage',
showValue: true,
id: 'gauge-chart-without-legend',
},
render: (args) => (
<Container>
<GraphWidgetGaugeChart
data={args.data}
displayType={args.displayType}
decimals={args.decimals}
prefix={args.prefix}
suffix={args.suffix}
showValue={args.showValue}
showLegend={false}
id={args.id}
/>
</Container>
),
};
@@ -0,0 +1,64 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetLegend } from '../GraphWidgetLegend';
const meta: Meta<typeof GraphWidgetLegend> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetLegend',
component: GraphWidgetLegend,
decorators: [ComponentDecorator],
parameters: {
layout: 'centered',
},
};
export default meta;
type Story = StoryObj<typeof GraphWidgetLegend>;
export const Default: Story = {
render: () => {
return (
<GraphWidgetLegend
show={true}
items={[
{
id: 'sales',
label: 'Sales',
formattedValue: '$45,231',
color: 'blue',
},
{
id: 'marketing',
label: 'Marketing',
formattedValue: '$12,543',
color: 'green',
},
{
id: 'operations',
label: 'Operations',
formattedValue: '$8,765',
color: 'red',
},
]}
/>
);
},
};
export const SingleItem: Story = {
render: () => {
return (
<GraphWidgetLegend
show={true}
items={[
{
id: 'revenue',
label: 'Revenue',
formattedValue: '750',
color: 'blue',
},
]}
/>
);
},
};
@@ -0,0 +1,862 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { type ComponentProps } from 'react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetLineChart } from '../GraphWidgetLineChart';
const meta: Meta<typeof GraphWidgetLineChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetLineChart',
component: GraphWidgetLineChart,
decorators: [ComponentDecorator],
parameters: {
layout: 'centered',
},
argTypes: {
data: {
control: 'object',
},
displayType: {
control: 'select',
options: ['percentage', 'number', 'shortNumber', 'currency', 'custom'],
},
prefix: {
control: 'text',
},
suffix: {
control: 'text',
},
decimals: {
control: 'number',
},
showLegend: {
control: 'boolean',
},
showGrid: {
control: 'boolean',
},
enablePoints: {
control: 'boolean',
},
xAxisLabel: {
control: 'text',
},
yAxisLabel: {
control: 'text',
},
enableArea: {
control: 'boolean',
},
stackedArea: {
control: 'boolean',
},
curve: {
control: 'select',
options: [
'linear',
'monotoneX',
'step',
'stepBefore',
'stepAfter',
'natural',
],
},
lineWidth: {
control: 'number',
},
enableSlices: {
control: 'select',
options: ['x', 'y', false],
},
},
};
export default meta;
type Story = StoryObj<typeof GraphWidgetLineChart>;
type ChartArgs = ComponentProps<typeof GraphWidgetLineChart>;
const Container = ({ children }: { children: React.ReactNode }) => (
<div style={{ width: '700px', height: '500px' }}>{children}</div>
);
const renderChart = (args: ChartArgs) => (
<Container>
<GraphWidgetLineChart
id={args.id}
data={args.data}
showLegend={args.showLegend}
showGrid={args.showGrid}
enablePoints={args.enablePoints}
xAxisLabel={args.xAxisLabel}
yAxisLabel={args.yAxisLabel}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
enableArea={args.enableArea}
stackedArea={args.stackedArea}
curve={args.curve}
lineWidth={args.lineWidth}
enableSlices={args.enableSlices}
xScale={args.xScale}
yScale={args.yScale}
/>
</Container>
);
const generateLinearData = (points: number = 10) => {
return Array.from({ length: points }, (_, i) => ({
x: i,
y: Math.floor(Math.random() * 100) + 20,
}));
};
export const Default: Story = {
args: {
id: 'line-chart-default',
data: [
{
id: 'series1',
label: 'Revenue',
color: 'blue',
data: generateLinearData(12),
},
{
id: 'series2',
label: 'Profit',
color: 'turquoise',
data: generateLinearData(12),
},
],
showLegend: true,
showGrid: true,
enablePoints: false,
xAxisLabel: 'Month',
yAxisLabel: 'Value',
displayType: 'shortNumber',
prefix: '$',
},
render: renderChart,
};
export const WithArea: Story = {
render: renderChart,
args: {
id: 'line-chart-area',
data: [
{
id: 'sales',
label: 'Sales',
color: 'purple',
data: generateLinearData(12),
},
{
id: 'costs',
label: 'Costs',
color: 'orange',
data: generateLinearData(12),
},
],
enableArea: true,
showLegend: true,
showGrid: true,
enablePoints: false,
xAxisLabel: 'Period',
yAxisLabel: 'Amount',
displayType: 'currency',
},
};
export const StackedArea: Story = {
render: renderChart,
args: {
id: 'line-chart-stacked',
data: [
{
id: 'product-a',
label: 'Product A',
color: 'blue',
data: generateLinearData(8),
},
{
id: 'product-b',
label: 'Product B',
color: 'turquoise',
data: generateLinearData(8),
},
{
id: 'product-c',
label: 'Product C',
color: 'purple',
data: generateLinearData(8),
},
],
enableArea: true,
stackedArea: true,
showLegend: true,
showGrid: true,
enablePoints: false,
curve: 'monotoneX',
xAxisLabel: 'Quarter',
yAxisLabel: 'Revenue',
yScale: {
type: 'linear',
min: 0,
max: 'auto',
},
displayType: 'shortNumber',
prefix: '$',
},
};
export const WithPoints: Story = {
render: renderChart,
args: {
id: 'line-chart-points',
data: [
{
id: 'performance',
label: 'Performance',
color: 'pink',
data: generateLinearData(8),
},
],
showLegend: true,
showGrid: true,
enablePoints: true,
lineWidth: 3,
xAxisLabel: 'Week',
yAxisLabel: 'Score',
displayType: 'percentage',
},
};
export const StepChart: Story = {
render: renderChart,
args: {
id: 'line-chart-step',
data: [
{
id: 'inventory',
label: 'Inventory Level',
color: 'orange',
data: generateLinearData(10),
},
],
curve: 'step',
showLegend: true,
showGrid: true,
enablePoints: false,
lineWidth: 2,
xAxisLabel: 'Day',
yAxisLabel: 'Units',
displayType: 'number',
},
};
export const LogScaleDemo: Story = {
render: renderChart,
args: {
id: 'line-chart-log-scale',
data: [
{
id: 'exponential',
label: 'Exponential Growth',
color: 'purple',
data: [
{ x: 0, y: 10 },
{ x: 1, y: 100 },
{ x: 2, y: 1000 },
{ x: 3, y: 10000 },
{ x: 4, y: 100000 },
{ x: 5, y: 1000000 },
],
},
{
id: 'linear',
label: 'Linear Growth',
color: 'turquoise',
data: [
{ x: 0, y: 50 },
{ x: 1, y: 100 },
{ x: 2, y: 150 },
{ x: 3, y: 200 },
{ x: 4, y: 250 },
{ x: 5, y: 300 },
],
},
],
showLegend: true,
showGrid: true,
enablePoints: true,
xAxisLabel: 'Time',
yAxisLabel: 'Value (log scale)',
yScale: {
type: 'log',
base: 10,
min: 'auto',
max: 'auto',
},
displayType: 'shortNumber',
},
};
export const WithNullValues: Story = {
render: renderChart,
args: {
id: 'line-chart-nulls',
data: [
{
id: 'incomplete',
label: 'With Gaps',
color: 'blue',
data: [
{ x: 0, y: 30 },
{ x: 1, y: 40 },
{ x: 2, y: null },
{ x: 3, y: null },
{ x: 4, y: 60 },
{ x: 5, y: 70 },
{ x: 6, y: 65 },
],
},
],
showLegend: true,
showGrid: true,
enablePoints: true,
xAxisLabel: 'Time',
yAxisLabel: 'Measurement',
displayType: 'number',
},
};
export const InteractiveWithLinks: Story = {
render: renderChart,
args: {
id: 'line-chart-interactive',
data: [
{
id: 'clickable',
label: 'Click Points',
color: 'turquoise',
data: [
{ x: 0, y: 30, to: '#point-0' },
{ x: 1, y: 45, to: '#point-1' },
{ x: 2, y: 38, to: '#point-2' },
{ x: 3, y: 52, to: '#point-3' },
{ x: 4, y: 48, to: '#point-4' },
{ x: 5, y: 60, to: '#point-5' },
],
},
],
showLegend: true,
showGrid: true,
enablePoints: true,
enableSlices: false,
xAxisLabel: 'Step',
yAxisLabel: 'Progress',
displayType: 'percentage',
},
};
export const MultiSeriesMixed: Story = {
render: renderChart,
args: {
id: 'line-chart-mixed',
data: [
{
id: 'actual',
label: 'Actual',
color: 'blue',
data: generateLinearData(12),
enableArea: true,
},
{
id: 'forecast',
label: 'Forecast',
color: 'purple',
data: generateLinearData(12),
enableArea: false,
},
{
id: 'target',
label: 'Target',
color: 'orange',
data: generateLinearData(12).map((d) => ({ ...d, y: 75 })),
enableArea: false,
},
],
enableArea: false,
showLegend: true,
showGrid: true,
enablePoints: false,
curve: 'monotoneX',
xAxisLabel: 'Month',
yAxisLabel: 'Value',
displayType: 'shortNumber',
enableSlices: 'x',
},
};
export const OverlappingGradientBlend: Story = {
render: renderChart,
args: {
id: 'line-chart-blend',
data: [
{
id: 'red-series',
label: 'Red Wave',
color: 'red',
data: [
{ x: 0, y: 50 },
{ x: 1, y: 70 },
{ x: 2, y: 65 },
{ x: 3, y: 80 },
{ x: 4, y: 75 },
{ x: 5, y: 90 },
{ x: 6, y: 85 },
{ x: 7, y: 95 },
],
},
{
id: 'blue-series',
label: 'Blue Wave',
color: 'blue',
data: [
{ x: 0, y: 40 },
{ x: 1, y: 60 },
{ x: 2, y: 70 },
{ x: 3, y: 75 },
{ x: 4, y: 80 },
{ x: 5, y: 70 },
{ x: 6, y: 65 },
{ x: 7, y: 60 },
],
},
{
id: 'green-series',
label: 'Green Wave',
color: 'turquoise',
data: [
{ x: 0, y: 30 },
{ x: 1, y: 45 },
{ x: 2, y: 55 },
{ x: 3, y: 60 },
{ x: 4, y: 65 },
{ x: 5, y: 60 },
{ x: 6, y: 55 },
{ x: 7, y: 50 },
],
},
],
enableArea: true,
showLegend: true,
showGrid: true,
enablePoints: false,
curve: 'monotoneX',
xAxisLabel: 'Time',
yAxisLabel: 'Value',
displayType: 'number',
},
};
export const HighContrastOverlap: Story = {
render: renderChart,
args: {
id: 'line-chart-contrast',
data: [
{
id: 'yellow-series',
label: 'Yellow',
color: 'yellow',
data: [
{ x: 0, y: 60 },
{ x: 1, y: 80 },
{ x: 2, y: 85 },
{ x: 3, y: 90 },
{ x: 4, y: 85 },
{ x: 5, y: 80 },
{ x: 6, y: 75 },
],
},
{
id: 'purple-series',
label: 'Purple',
color: 'purple',
data: [
{ x: 0, y: 50 },
{ x: 1, y: 70 },
{ x: 2, y: 80 },
{ x: 3, y: 85 },
{ x: 4, y: 90 },
{ x: 5, y: 85 },
{ x: 6, y: 70 },
],
},
],
enableArea: true,
showLegend: true,
showGrid: true,
enablePoints: true,
curve: 'natural',
xAxisLabel: 'Day',
yAxisLabel: 'Score',
displayType: 'number',
},
};
export const CurveComparison: Story = {
render: renderChart,
args: {
id: 'line-chart-curves',
data: [
{
id: 'dataset',
label: 'Same Data',
color: 'orange',
data: [
{ x: 0, y: 20 },
{ x: 1, y: 60 },
{ x: 2, y: 40 },
{ x: 3, y: 80 },
{ x: 4, y: 30 },
{ x: 5, y: 70 },
{ x: 6, y: 50 },
],
},
],
showLegend: true,
showGrid: true,
enablePoints: true,
curve: 'linear',
xAxisLabel: 'X Axis',
yAxisLabel: 'Y Axis',
displayType: 'number',
},
};
export const StepInterpolations: Story = {
render: renderChart,
args: {
id: 'line-chart-steps',
data: [
{
id: 'step-normal',
label: 'Step',
color: 'blue',
data: [
{ x: 0, y: 30 },
{ x: 1, y: 50 },
{ x: 2, y: 45 },
{ x: 3, y: 60 },
{ x: 4, y: 55 },
{ x: 5, y: 70 },
],
},
{
id: 'step-before',
label: 'Step Before',
color: 'purple',
data: [
{ x: 0, y: 25 },
{ x: 1, y: 45 },
{ x: 2, y: 40 },
{ x: 3, y: 55 },
{ x: 4, y: 50 },
{ x: 5, y: 65 },
],
},
{
id: 'step-after',
label: 'Step After',
color: 'turquoise',
data: [
{ x: 0, y: 20 },
{ x: 1, y: 40 },
{ x: 2, y: 35 },
{ x: 3, y: 50 },
{ x: 4, y: 45 },
{ x: 5, y: 60 },
],
},
],
showLegend: true,
showGrid: true,
enablePoints: true,
curve: 'step',
xAxisLabel: 'Time',
yAxisLabel: 'Value',
displayType: 'number',
},
};
export const NaturalVsMonotone: Story = {
render: renderChart,
args: {
id: 'line-chart-smooth',
data: [
{
id: 'natural',
label: 'Natural Curve',
color: 'pink',
data: [
{ x: 0, y: 40 },
{ x: 1, y: 70 },
{ x: 2, y: 50 },
{ x: 3, y: 80 },
{ x: 4, y: 45 },
{ x: 5, y: 75 },
{ x: 6, y: 60 },
],
},
{
id: 'monotone',
label: 'Monotone X',
color: 'orange',
data: [
{ x: 0, y: 35 },
{ x: 1, y: 65 },
{ x: 2, y: 45 },
{ x: 3, y: 75 },
{ x: 4, y: 40 },
{ x: 5, y: 70 },
{ x: 6, y: 55 },
],
},
],
enableArea: true,
showLegend: true,
showGrid: true,
enablePoints: true,
curve: 'natural',
xAxisLabel: 'Sample',
yAxisLabel: 'Measurement',
displayType: 'number',
},
};
export const SliceTooltipDemo: Story = {
render: renderChart,
args: {
id: 'line-chart-slice-tooltip',
data: [
{
id: 'revenue',
label: 'Revenue',
color: 'blue',
data: [
{ x: 'Jan', y: 4500 },
{ x: 'Feb', y: 5200 },
{ x: 'Mar', y: 4800 },
{ x: 'Apr', y: 6100 },
{ x: 'May', y: 5500 },
{ x: 'Jun', y: 7200 },
],
},
{
id: 'costs',
label: 'Costs',
color: 'red',
data: [
{ x: 'Jan', y: 3200 },
{ x: 'Feb', y: 3500 },
{ x: 'Mar', y: 3100 },
{ x: 'Apr', y: 3800 },
{ x: 'May', y: 3600 },
{ x: 'Jun', y: 4200 },
],
},
{
id: 'profit',
label: 'Profit',
color: 'turquoise',
data: [
{ x: 'Jan', y: 1300 },
{ x: 'Feb', y: 1700 },
{ x: 'Mar', y: 1700 },
{ x: 'Apr', y: 2300 },
{ x: 'May', y: 1900 },
{ x: 'Jun', y: 3000 },
],
},
],
showLegend: true,
showGrid: true,
enablePoints: false,
enableSlices: 'x',
xScale: { type: 'point' },
xAxisLabel: 'Month',
yAxisLabel: 'Amount ($)',
displayType: 'shortNumber',
prefix: '$',
},
};
export const PointTooltipDemo: Story = {
render: renderChart,
args: {
id: 'line-chart-point-tooltip',
data: [
{
id: 'revenue',
label: 'Revenue',
color: 'blue',
data: [
{ x: 'Jan', y: 4500 },
{ x: 'Feb', y: 5200 },
{ x: 'Mar', y: 4800 },
{ x: 'Apr', y: 6100 },
{ x: 'May', y: 5500 },
{ x: 'Jun', y: 7200 },
],
},
{
id: 'costs',
label: 'Costs',
color: 'red',
data: [
{ x: 'Jan', y: 3200 },
{ x: 'Feb', y: 3500 },
{ x: 'Mar', y: 3100 },
{ x: 'Apr', y: 3800 },
{ x: 'May', y: 3600 },
{ x: 'Jun', y: 4200 },
],
},
{
id: 'profit',
label: 'Profit',
color: 'turquoise',
data: [
{ x: 'Jan', y: 1300 },
{ x: 'Feb', y: 1700 },
{ x: 'Mar', y: 1700 },
{ x: 'Apr', y: 2300 },
{ x: 'May', y: 1900 },
{ x: 'Jun', y: 3000 },
],
},
],
showLegend: true,
showGrid: true,
enablePoints: true,
enableSlices: false,
xScale: { type: 'point' },
xAxisLabel: 'Month',
yAxisLabel: 'Amount ($)',
displayType: 'shortNumber',
prefix: '$',
},
};
export const IntenseOverlapRGB: Story = {
render: renderChart,
args: {
id: 'line-chart-rgb',
data: [
{
id: 'red',
label: 'Red Channel',
color: 'red',
data: [
{ x: 0, y: 70 },
{ x: 1, y: 85 },
{ x: 2, y: 75 },
{ x: 3, y: 90 },
{ x: 4, y: 80 },
{ x: 5, y: 85 },
],
},
{
id: 'green',
label: 'Green Channel',
color: 'turquoise',
data: [
{ x: 0, y: 65 },
{ x: 1, y: 75 },
{ x: 2, y: 85 },
{ x: 3, y: 80 },
{ x: 4, y: 75 },
{ x: 5, y: 70 },
],
},
{
id: 'blue',
label: 'Blue Channel',
color: 'blue',
data: [
{ x: 0, y: 60 },
{ x: 1, y: 70 },
{ x: 2, y: 80 },
{ x: 3, y: 75 },
{ x: 4, y: 85 },
{ x: 5, y: 80 },
],
},
],
enableArea: true,
showLegend: true,
showGrid: true,
enablePoints: false,
curve: 'monotoneX',
xAxisLabel: 'Position',
yAxisLabel: 'Intensity',
displayType: 'percentage',
},
};
export const Catalog: Story = {
render: renderChart,
args: {
id: 'line-chart-catalog',
data: [
{
id: 'series1',
label: 'Series 1',
color: 'blue',
data: generateLinearData(8),
},
{
id: 'series2',
label: 'Series 2',
color: 'purple',
data: generateLinearData(8),
},
],
showLegend: true,
showGrid: true,
enablePoints: false,
enableArea: true,
},
decorators: [CatalogDecorator],
parameters: {
pseudo: { hover: ['.content'] },
catalog: {
dimensions: [
{
name: 'colors',
values: [
'blue',
'purple',
'turquoise',
'orange',
'pink',
'red',
'yellow',
'green',
'sky',
],
props: (color: string) => ({
data: [
{
id: 'series',
label: `${color} Series`,
color,
data: generateLinearData(8),
},
],
}),
},
],
},
},
};
@@ -0,0 +1,69 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetNumberChart } from '../GraphWidgetNumberChart';
const meta: Meta<typeof GraphWidgetNumberChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetNumberChart',
component: GraphWidgetNumberChart,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof GraphWidgetNumberChart>;
export const Default: Story = {
args: {
value: '1,234',
trendPercentage: 12.5,
},
};
export const NegativeTrend: Story = {
args: {
value: '892',
trendPercentage: -8.3,
},
};
export const PositiveTrend: Story = {
args: {
value: '5,678',
trendPercentage: 24.7,
},
};
export const ZeroTrend: Story = {
args: {
value: '3,456',
trendPercentage: 0,
},
};
export const LargeValue: Story = {
args: {
value: '1,234,567',
trendPercentage: 15.2,
},
};
export const SmallValue: Story = {
args: {
value: '42.75',
trendPercentage: 3.1,
},
};
export const LargePositiveChange: Story = {
args: {
value: '10,000',
trendPercentage: 150,
},
};
export const LargeNegativeChange: Story = {
args: {
value: '250',
trendPercentage: -75,
},
};
@@ -0,0 +1,430 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetPieChart } from '../GraphWidgetPieChart';
const meta: Meta<typeof GraphWidgetPieChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetPieChart',
component: GraphWidgetPieChart,
decorators: [ComponentDecorator],
parameters: {
layout: 'centered',
},
argTypes: {
data: {
control: 'object',
},
displayType: {
control: 'select',
options: ['percentage', 'number', 'shortNumber', 'currency', 'custom'],
},
prefix: {
control: 'text',
},
suffix: {
control: 'text',
},
decimals: {
control: 'number',
},
showLegend: {
control: 'boolean',
},
id: {
control: 'text',
},
},
};
export default meta;
type Story = StoryObj<typeof GraphWidgetPieChart>;
const Container = ({ children }: { children: React.ReactNode }) => (
<div style={{ width: '300px', height: '300px' }}>{children}</div>
);
export const WithCustomColors: Story = {
args: {
data: [
{
id: 'segment1',
value: 30,
label: 'Segment A',
color: 'blue',
to: '/segments/a',
},
{
id: 'segment2',
value: 25,
label: 'Segment B',
color: 'purple',
to: '/segments/b',
},
{
id: 'segment3',
value: 20,
label: 'Segment C',
color: 'turquoise',
to: '/segments/c',
},
{
id: 'segment4',
value: 15,
label: 'Segment D',
color: 'orange',
to: '/segments/d',
},
{
id: 'segment5',
value: 10,
label: 'Segment E',
color: 'pink',
to: '/segments/e',
},
],
showLegend: true,
id: 'pie-chart-custom-colors',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const Default: Story = {
args: {
data: [
{
id: 'qualified',
value: 35,
label: 'Qualified',
to: '/leads/qualified',
},
{
id: 'contacted',
value: 25,
label: 'Contacted',
to: '/leads/contacted',
},
{
id: 'unqualified',
value: 20,
label: 'Unqualified',
to: '/leads/unqualified',
},
{ id: 'proposal', value: 15, label: 'Proposal', to: '/leads/proposal' },
{
id: 'negotiation',
value: 5,
label: 'Negotiation',
to: '/leads/negotiation',
},
],
showLegend: true,
id: 'pie-chart-default',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const Revenue: Story = {
args: {
data: [
{
id: 'product-a',
value: 420000,
label: 'Product A',
to: '/products/a/revenue',
},
{
id: 'product-b',
value: 380000,
label: 'Product B',
to: '/products/b/revenue',
},
{
id: 'product-c',
value: 250000,
label: 'Product C',
to: '/products/c/revenue',
},
{
id: 'product-d',
value: 180000,
label: 'Product D',
to: '/products/d/revenue',
},
],
prefix: '$',
displayType: 'shortNumber',
showLegend: true,
id: 'pie-chart-revenue',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const TaskStatus: Story = {
args: {
data: [
{
id: 'completed',
value: 45,
label: 'Completed',
to: '/tasks/completed',
},
{
id: 'in-progress',
value: 30,
label: 'In Progress',
to: '/tasks/in-progress',
},
{ id: 'todo', value: 25, label: 'To Do', to: '/tasks/todo' },
],
displayType: 'percentage',
showLegend: true,
id: 'pie-chart-task-status',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const TwoSlices: Story = {
args: {
data: [
{ id: 'active', value: 75, label: 'Active', to: '/users/active' },
{ id: 'inactive', value: 25, label: 'Inactive', to: '/users/inactive' },
],
displayType: 'percentage',
showLegend: true,
id: 'pie-chart-two-slices',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const ManySlices: Story = {
args: {
data: [
{ id: 'category-1', value: 20, label: 'Category 1', to: '/categories/1' },
{ id: 'category-2', value: 18, label: 'Category 2', to: '/categories/2' },
{ id: 'category-3', value: 16, label: 'Category 3', to: '/categories/3' },
{ id: 'category-4', value: 14, label: 'Category 4', to: '/categories/4' },
{ id: 'category-5', value: 12, label: 'Category 5', to: '/categories/5' },
{ id: 'category-6', value: 10, label: 'Category 6', to: '/categories/6' },
{ id: 'category-7', value: 6, label: 'Category 7' },
{ id: 'category-8', value: 4, label: 'Category 8' },
],
showLegend: true,
id: 'pie-chart-many-slices',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const WithoutLegend: Story = {
args: {
data: [
{ id: 'web', value: 45, label: 'Web', to: '/platforms/web' },
{ id: 'mobile', value: 35, label: 'Mobile', to: '/platforms/mobile' },
{ id: 'desktop', value: 20, label: 'Desktop', to: '/platforms/desktop' },
],
displayType: 'percentage',
showLegend: false,
id: 'pie-chart-without-legend',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const MarketShare: Story = {
args: {
data: [
{ id: 'brand-a', value: 35.5, label: 'Brand A', to: '/market/brand-a' },
{ id: 'brand-b', value: 28.2, label: 'Brand B', to: '/market/brand-b' },
{ id: 'brand-c', value: 18.7, label: 'Brand C', to: '/market/brand-c' },
{ id: 'others', value: 17.6, label: 'Others', to: '/market/others' },
],
displayType: 'percentage',
showLegend: true,
id: 'pie-chart-market-share',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const Storage: Story = {
args: {
data: [
{
id: 'documents',
value: 125,
label: 'Documents',
to: '/storage/documents',
},
{ id: 'media', value: 280, label: 'Media', to: '/storage/media' },
{
id: 'applications',
value: 95,
label: 'Applications',
to: '/storage/applications',
},
{ id: 'system', value: 50, label: 'System', to: '/storage/system' },
],
suffix: ' GB',
showLegend: true,
id: 'pie-chart-storage',
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType={args.displayType}
prefix={args.prefix}
suffix={args.suffix}
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
/>
</Container>
),
};
export const Catalog: Story = {
decorators: [CatalogDecorator],
parameters: {
catalog: {
dimensions: [
{
name: 'slices',
values: [2, 3, 5],
props: (sliceCount: number) => {
const dataMap: Record<
number,
Array<{ id: string; value: number; label?: string }>
> = {
2: [
{ id: 'yes', value: 65, label: 'Yes' },
{ id: 'no', value: 35, label: 'No' },
],
3: [
{ id: 'gold', value: 45, label: 'Gold' },
{ id: 'silver', value: 35, label: 'Silver' },
{ id: 'bronze', value: 20, label: 'Bronze' },
],
5: [
{ id: 'item-1', value: 30, label: 'Item 1' },
{ id: 'item-2', value: 25, label: 'Item 2' },
{ id: 'item-3', value: 20, label: 'Item 3' },
{ id: 'item-4', value: 15, label: 'Item 4' },
{ id: 'item-5', value: 10, label: 'Item 5' },
],
};
return {
data: dataMap[sliceCount] || dataMap[3],
id: `pie-chart-catalog-${sliceCount}`,
};
},
labels: (sliceCount: number) => `${sliceCount} slices`,
},
],
},
},
render: (args) => (
<Container>
<GraphWidgetPieChart
data={args.data}
displayType="percentage"
showLegend={true}
id={args.id}
/>
</Container>
),
};
@@ -0,0 +1,65 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetTooltip } from '../GraphWidgetTooltip';
const meta: Meta<typeof GraphWidgetTooltip> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetTooltip',
component: GraphWidgetTooltip,
decorators: [ComponentDecorator],
parameters: {
layout: 'centered',
},
};
export default meta;
type Story = StoryObj<typeof GraphWidgetTooltip>;
export const Default: Story = {
args: {
items: [
{
label: 'Revenue',
formattedValue: '$45,231',
dotColor: 'blue',
},
],
showClickHint: false,
},
};
export const WithClickHint: Story = {
args: {
items: [
{
label: 'Sales',
formattedValue: '1,234 units',
dotColor: 'green',
},
],
showClickHint: true,
},
};
export const MultipleItems: Story = {
args: {
items: [
{
label: 'Q1',
formattedValue: '$12,345',
dotColor: 'blue',
},
{
label: 'Q2',
formattedValue: '$23,456',
dotColor: 'green',
},
{
label: 'Q3',
formattedValue: '$34,567',
dotColor: 'red',
},
],
showClickHint: false,
},
};