diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect.tsx index 022f20e6a7..1dd4a3042d 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect.tsx @@ -5,8 +5,10 @@ import { WidgetType, } from '@/settings/page-layout/mocks/mockWidgets'; import styled from '@emotion/styled'; + import { IconChartBar, + IconChartLine, IconChartPie, IconGauge, IconNumber, @@ -49,6 +51,11 @@ const graphTypeOptions = [ icon: IconNumber, title: 'Number', }, + { + type: GraphType.LINE, + icon: IconChartLine, + title: 'Line Chart', + }, ]; export const CommandMenuPageLayoutGraphTypeSelect = () => { diff --git a/packages/twenty-front/src/modules/dashboards/widgets/graph/components/GraphWidgetLineChart.tsx b/packages/twenty-front/src/modules/dashboards/widgets/graph/components/GraphWidgetLineChart.tsx new file mode 100644 index 0000000000..1852f8f74c --- /dev/null +++ b/packages/twenty-front/src/modules/dashboards/widgets/graph/components/GraphWidgetLineChart.tsx @@ -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 = {}; + 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) => { + 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) => { + 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 ( + + ); + }; + + const renderPointTooltip = (point: Point) => { + 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 ( + + ); + }; + + 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 ( + + + renderPointTooltip(point) + : undefined + } + onClick={(datum) => { + if ('seriesId' in datum) { + handlePointClick(datum as Point); + } + }} + 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', + }, + }, + }} + /> + + + + ); +}; diff --git a/packages/twenty-front/src/modules/dashboards/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx b/packages/twenty-front/src/modules/dashboards/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx new file mode 100644 index 0000000000..b6da10d375 --- /dev/null +++ b/packages/twenty-front/src/modules/dashboards/widgets/graph/components/__stories__/GraphWidgetLineChart.stories.tsx @@ -0,0 +1,862 @@ +import { type Meta, type StoryObj } from '@storybook/react'; + +import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing'; +import { GraphWidgetLineChart } from '../GraphWidgetLineChart'; +import { type ComponentProps } from 'react'; + +const meta: Meta = { + title: 'Modules/Dashboards/Graphs/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; +type ChartArgs = ComponentProps; + +const Container = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +const renderChart = (args: ChartArgs) => ( + + + +); + +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), + }, + ], + }), + }, + ], + }, + }, +}; diff --git a/packages/twenty-front/src/modules/dashboards/widgets/graph/types/GraphColor.ts b/packages/twenty-front/src/modules/dashboards/widgets/graph/types/GraphColor.ts index 7599fceb6d..6e40ef6ef4 100644 --- a/packages/twenty-front/src/modules/dashboards/widgets/graph/types/GraphColor.ts +++ b/packages/twenty-front/src/modules/dashboards/widgets/graph/types/GraphColor.ts @@ -1 +1,10 @@ -export type GraphColor = 'blue' | 'purple' | 'turquoise' | 'orange' | 'pink'; +export type GraphColor = + | 'blue' + | 'purple' + | 'turquoise' + | 'orange' + | 'pink' + | 'yellow' + | 'red' + | 'green' + | 'sky'; diff --git a/packages/twenty-front/src/modules/dashboards/widgets/graph/utils/createGraphColorRegistry.ts b/packages/twenty-front/src/modules/dashboards/widgets/graph/utils/createGraphColorRegistry.ts index 565c4310b4..90ccd73f13 100644 --- a/packages/twenty-front/src/modules/dashboards/widgets/graph/utils/createGraphColorRegistry.ts +++ b/packages/twenty-front/src/modules/dashboards/widgets/graph/utils/createGraphColorRegistry.ts @@ -48,4 +48,36 @@ export const createGraphColorRegistry = ( }, 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, + }, }); diff --git a/packages/twenty-front/src/modules/settings/page-layout/components/GraphWidgetRenderer.tsx b/packages/twenty-front/src/modules/settings/page-layout/components/GraphWidgetRenderer.tsx index eaa2215958..c01effca5c 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/components/GraphWidgetRenderer.tsx +++ b/packages/twenty-front/src/modules/settings/page-layout/components/GraphWidgetRenderer.tsx @@ -1,5 +1,6 @@ import { GraphWidgetBarChart } from '@/dashboards/widgets/graph/components/GraphWidgetBarChart'; import { GraphWidgetGaugeChart } from '@/dashboards/widgets/graph/components/GraphWidgetGaugeChart'; +import { GraphWidgetLineChart } from '@/dashboards/widgets/graph/components/GraphWidgetLineChart'; import { GraphWidgetNumberChart } from '@/dashboards/widgets/graph/components/GraphWidgetNumberChart'; import { GraphWidgetPieChart } from '@/dashboards/widgets/graph/components/GraphWidgetPieChart'; import { GraphType } from '../mocks/mockWidgets'; @@ -69,6 +70,28 @@ export const GraphWidgetRenderer = ({ widget }: GraphWidgetRendererProps) => { /> ); + case GraphType.LINE: + return ( + + ); + default: return null; } diff --git a/packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts b/packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts index f931f28c31..f1369e661b 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts @@ -13,6 +13,7 @@ export enum GraphType { GAUGE = 'GAUGE', PIE = 'PIE', BAR = 'BAR', + LINE = 'LINE', } export const mockPageLayoutWidgets: PageLayoutWidget[] = [ diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetData.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetData.ts index ef12e54118..1bc8c78454 100644 --- a/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetData.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetData.ts @@ -41,6 +41,67 @@ export const getDefaultWidgetData = (graphType: GraphType) => { layout: 'vertical' as const, }; + case GraphType.LINE: + return { + series: [ + { + id: 'revenue', + label: 'Revenue', + color: 'blue', + data: [ + { x: 0, y: 30 }, + { x: 1, y: 50 }, + { x: 2, y: 45 }, + { x: 3, y: 70 }, + { x: 4, y: 65 }, + { x: 5, y: 80 }, + { x: 6, y: 75 }, + { x: 7, y: 85 }, + ], + enableArea: true, + }, + { + id: 'costs', + label: 'Costs', + color: 'red', + data: [ + { x: 0, y: 60 }, + { x: 1, y: 45 }, + { x: 2, y: 55 }, + { x: 3, y: 40 }, + { x: 4, y: 60 }, + { x: 5, y: 50 }, + { x: 6, y: 70 }, + { x: 7, y: 65 }, + ], + enableArea: true, + }, + { + id: 'profit', + label: 'Profit', + color: 'turquoise', + data: [ + { x: 0, y: 45 }, + { x: 1, y: 60 }, + { x: 2, y: 35 }, + { x: 3, y: 55 }, + { x: 4, y: 50 }, + { x: 5, y: 65 }, + { x: 6, y: 40 }, + { x: 7, y: 75 }, + ], + enableArea: true, + }, + ], + enableArea: true, + showLegend: true, + showGrid: true, + enablePoints: false, + curve: 'monotoneX', + displayType: 'shortNumber', + prefix: '$', + }; + default: return {}; } @@ -52,6 +113,7 @@ export const getWidgetTitle = (graphType: GraphType, index: number): string => { [GraphType.GAUGE]: 'Gauge', [GraphType.PIE]: 'Pie Chart', [GraphType.BAR]: 'Bar Chart', + [GraphType.LINE]: 'Line Chart', }; return `${baseNames[graphType] || 'Widget'} ${index + 1}`; @@ -67,6 +129,8 @@ export const getWidgetSize = (graphType: GraphType) => { return { w: 4, h: 4 }; case GraphType.BAR: return { w: 6, h: 4 }; + case GraphType.LINE: + return { w: 6, h: 10 }; default: return { w: 4, h: 4 }; } diff --git a/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx b/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx index d49670f9e8..41e9372830 100644 --- a/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx +++ b/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx @@ -52,7 +52,7 @@ import { Button } from 'twenty-ui/input'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; const StyledGridContainer = styled.div` - background: ${({ theme }) => theme.background.secondary}; + background: ${({ theme }) => theme.background.primary}; box-sizing: border-box; flex: 1; min-height: 100%; diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 920165bebf..82cb9a7435 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -47,6 +47,7 @@ export { IconChartBar, IconChartCandle, IconChartDots3, + IconChartLine, IconChartPie, IconCheck, IconCheckbox, diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index b6c8591e33..68ab6f7c31 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -109,6 +109,7 @@ export { IconChartBar, IconChartCandle, IconChartDots3, + IconChartLine, IconChartPie, IconCheck, IconCheckbox,