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:
+444
@@ -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>
|
||||
);
|
||||
};
|
||||
+226
@@ -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>
|
||||
);
|
||||
};
|
||||
+66
@@ -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>
|
||||
);
|
||||
};
|
||||
+380
@@ -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>
|
||||
);
|
||||
};
|
||||
+67
@@ -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>
|
||||
);
|
||||
};
|
||||
+232
@@ -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>
|
||||
);
|
||||
};
|
||||
+101
@@ -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;
|
||||
}
|
||||
};
|
||||
+80
@@ -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>
|
||||
);
|
||||
};
|
||||
+546
@@ -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>
|
||||
),
|
||||
};
|
||||
+327
@@ -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>
|
||||
),
|
||||
};
|
||||
+64
@@ -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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
+862
@@ -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),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
+69
@@ -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,
|
||||
},
|
||||
};
|
||||
+430
@@ -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>
|
||||
),
|
||||
};
|
||||
+65
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export type GraphColor =
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
| 'turquoise'
|
||||
| 'orange'
|
||||
| 'pink'
|
||||
| 'yellow'
|
||||
| 'red'
|
||||
| 'green'
|
||||
| 'sky';
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { type GraphColorScheme } from './GraphColorScheme';
|
||||
|
||||
export type GraphColorRegistry = {
|
||||
[key: string]: GraphColorScheme;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export type GraphColorScheme = {
|
||||
name: string;
|
||||
gradient: {
|
||||
normal: [string, string];
|
||||
hover: [string, string];
|
||||
};
|
||||
solid: string;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type GraphType } from '@/page-layout/mocks/mockWidgets';
|
||||
import { type PageLayoutWidgetWithData } from '@/page-layout/types/pageLayoutTypes';
|
||||
import { type WidgetType } from '~/generated/graphql';
|
||||
|
||||
export type GraphWidget = PageLayoutWidgetWithData & {
|
||||
type: WidgetType.GRAPH;
|
||||
configuration: {
|
||||
graphType: GraphType;
|
||||
};
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
export const calculateAngularGradient = (angle: number) => {
|
||||
const gradientAngle = (angle * Math.PI) / 180 + Math.PI / 2;
|
||||
|
||||
const dx = Math.sin(gradientAngle);
|
||||
const dy = -Math.cos(gradientAngle);
|
||||
|
||||
return {
|
||||
x1: `${50 - dx * 50}%`,
|
||||
y1: `${50 - dy * 50}%`,
|
||||
x2: `${50 + dx * 50}%`,
|
||||
y2: `${50 + dy * 50}%`,
|
||||
};
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { type GraphColorScheme } from '../types/GraphColorScheme';
|
||||
import { calculateAngularGradient } from './calculateAngularGradient';
|
||||
|
||||
export const createGradientDef = (
|
||||
colorScheme: GraphColorScheme,
|
||||
id: string,
|
||||
isHovered: boolean = false,
|
||||
angle?: number,
|
||||
reverseGradient: boolean = false,
|
||||
) => {
|
||||
const colors = isHovered
|
||||
? colorScheme.gradient.hover
|
||||
: colorScheme.gradient.normal;
|
||||
|
||||
const coords =
|
||||
angle !== undefined
|
||||
? calculateAngularGradient(angle)
|
||||
: { x1: '0%', y1: '0%', x2: '0%', y2: '100%' };
|
||||
|
||||
return {
|
||||
id,
|
||||
type: 'linearGradient' as const,
|
||||
...coords,
|
||||
colors: reverseGradient
|
||||
? [
|
||||
{ offset: 0, color: colors[1] },
|
||||
{ offset: 100, color: colors[0] },
|
||||
]
|
||||
: [
|
||||
{ offset: 0, color: colors[0] },
|
||||
{ offset: 100, color: colors[1] },
|
||||
],
|
||||
};
|
||||
};
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { type ThemeType } from 'twenty-ui/theme';
|
||||
|
||||
import { type GraphColorRegistry } from '../types/GraphColorRegistry';
|
||||
|
||||
export const createGraphColorRegistry = (
|
||||
theme: ThemeType,
|
||||
): GraphColorRegistry => ({
|
||||
blue: {
|
||||
name: 'blue',
|
||||
gradient: {
|
||||
normal: [theme.adaptiveColors.blue1, theme.adaptiveColors.blue2],
|
||||
hover: [theme.adaptiveColors.blue3, theme.adaptiveColors.blue4],
|
||||
},
|
||||
solid: theme.color.blue,
|
||||
},
|
||||
purple: {
|
||||
name: 'purple',
|
||||
gradient: {
|
||||
normal: [theme.adaptiveColors.purple1, theme.adaptiveColors.purple2],
|
||||
hover: [theme.adaptiveColors.purple3, theme.adaptiveColors.purple4],
|
||||
},
|
||||
solid: theme.color.purple,
|
||||
},
|
||||
turquoise: {
|
||||
name: 'turquoise',
|
||||
gradient: {
|
||||
normal: [
|
||||
theme.adaptiveColors.turquoise1,
|
||||
theme.adaptiveColors.turquoise2,
|
||||
],
|
||||
hover: [theme.adaptiveColors.turquoise3, theme.adaptiveColors.turquoise4],
|
||||
},
|
||||
solid: theme.color.turquoise,
|
||||
},
|
||||
orange: {
|
||||
name: 'orange',
|
||||
gradient: {
|
||||
normal: [theme.adaptiveColors.orange1, theme.adaptiveColors.orange2],
|
||||
hover: [theme.adaptiveColors.orange3, theme.adaptiveColors.orange4],
|
||||
},
|
||||
solid: theme.color.orange,
|
||||
},
|
||||
pink: {
|
||||
name: 'pink',
|
||||
gradient: {
|
||||
normal: [theme.adaptiveColors.pink1, theme.adaptiveColors.pink2],
|
||||
hover: [theme.adaptiveColors.pink3, theme.adaptiveColors.pink4],
|
||||
},
|
||||
solid: theme.color.pink,
|
||||
},
|
||||
yellow: {
|
||||
name: 'yellow',
|
||||
gradient: {
|
||||
normal: [theme.adaptiveColors.yellow1, theme.adaptiveColors.yellow2],
|
||||
hover: [theme.adaptiveColors.yellow3, theme.adaptiveColors.yellow4],
|
||||
},
|
||||
solid: theme.color.yellow,
|
||||
},
|
||||
red: {
|
||||
name: 'red',
|
||||
gradient: {
|
||||
normal: [theme.adaptiveColors.red1, theme.adaptiveColors.red2],
|
||||
hover: [theme.adaptiveColors.red3, theme.adaptiveColors.red4],
|
||||
},
|
||||
solid: theme.color.red,
|
||||
},
|
||||
green: {
|
||||
name: 'green',
|
||||
gradient: {
|
||||
normal: [theme.adaptiveColors.green1, theme.adaptiveColors.green2],
|
||||
hover: [theme.adaptiveColors.green3, theme.adaptiveColors.green4],
|
||||
},
|
||||
solid: theme.color.green,
|
||||
},
|
||||
sky: {
|
||||
name: 'sky',
|
||||
gradient: {
|
||||
normal: [theme.adaptiveColors.sky1, theme.adaptiveColors.sky2],
|
||||
hover: [theme.adaptiveColors.sky3, theme.adaptiveColors.sky4],
|
||||
},
|
||||
solid: theme.color.sky,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type GraphColor } from '../types/GraphColor';
|
||||
import { type GraphColorRegistry } from '../types/GraphColorRegistry';
|
||||
import { type GraphColorScheme } from '../types/GraphColorScheme';
|
||||
import { getColorSchemeByIndex } from './getColorSchemeByIndex';
|
||||
|
||||
export const getColorScheme = (
|
||||
registry: GraphColorRegistry,
|
||||
colorName?: GraphColor,
|
||||
fallbackIndex?: number,
|
||||
): GraphColorScheme => {
|
||||
if (isDefined(colorName) && isDefined(registry[colorName])) {
|
||||
return registry[colorName];
|
||||
}
|
||||
return getColorSchemeByIndex(registry, fallbackIndex || 0);
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { type GraphColorRegistry } from '../types/GraphColorRegistry';
|
||||
import { type GraphColorScheme } from '../types/GraphColorScheme';
|
||||
|
||||
export const getColorSchemeByIndex = (
|
||||
registry: GraphColorRegistry,
|
||||
index: number,
|
||||
): GraphColorScheme => {
|
||||
const schemes = Object.values(registry);
|
||||
return schemes[index % schemes.length];
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { type GraphColorRegistry } from '../types/GraphColorRegistry';
|
||||
import { type GraphColorScheme } from '../types/GraphColorScheme';
|
||||
|
||||
export const getColorSchemeByName = (
|
||||
registry: GraphColorRegistry,
|
||||
name: string,
|
||||
): GraphColorScheme | undefined => {
|
||||
return registry[name];
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { formatAmount } from '~/utils/format/formatAmount';
|
||||
import { formatNumber } from '~/utils/format/number';
|
||||
|
||||
export type GraphValueFormatOptions = {
|
||||
displayType?: 'percentage' | 'number' | 'shortNumber' | 'currency' | 'custom';
|
||||
decimals?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
customFormatter?: (value: number) => string;
|
||||
};
|
||||
|
||||
export const formatGraphValue = (
|
||||
value: number,
|
||||
options?: GraphValueFormatOptions,
|
||||
): string => {
|
||||
const {
|
||||
displayType = 'number',
|
||||
decimals,
|
||||
prefix = '',
|
||||
suffix = '',
|
||||
customFormatter,
|
||||
} = options || {};
|
||||
|
||||
if (displayType === 'custom' && isDefined(customFormatter)) {
|
||||
return customFormatter(value);
|
||||
}
|
||||
|
||||
switch (displayType) {
|
||||
case 'percentage':
|
||||
return `${formatNumber(value * 100, decimals)}%`;
|
||||
|
||||
case 'shortNumber':
|
||||
return `${prefix}${formatAmount(value)}${suffix}`;
|
||||
|
||||
case 'currency': {
|
||||
const currencyPrefix = prefix || '$';
|
||||
return `${currencyPrefix}${formatNumber(value, decimals)}${suffix}`;
|
||||
}
|
||||
|
||||
case 'number':
|
||||
default:
|
||||
return `${prefix}${formatNumber(value, decimals)}${suffix}`;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user