From 65a0013045be5aa5626fb4c906db6b89c2a10e29 Mon Sep 17 00:00:00 2001 From: omaima Date: Tue, 31 Mar 2026 16:22:03 +0100 Subject: [PATCH 1/6] feat: add typed chart contracts and buildChartConfig utility with bar/line/area support and theme-based color fallback --- ciscode-reactts-developerkit-1.0.0.tgz | Bin 0 -> 5514 bytes package-lock.json | 33 +++-- src/index.ts | 1 + src/types/chart.types.ts | 60 +++++++++ src/types/index.ts | 1 + src/utils/buildChartConfig.test.ts | 168 +++++++++++++++++++++++++ src/utils/buildChartConfig.ts | 88 +++++++++++++ src/utils/index.ts | 1 + 8 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 ciscode-reactts-developerkit-1.0.0.tgz create mode 100644 src/types/chart.types.ts create mode 100644 src/types/index.ts create mode 100644 src/utils/buildChartConfig.test.ts create mode 100644 src/utils/buildChartConfig.ts diff --git a/ciscode-reactts-developerkit-1.0.0.tgz b/ciscode-reactts-developerkit-1.0.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..fc32c0c544989c504e7a460afb5171ed561966b5 GIT binary patch literal 5514 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.19.0" }, @@ -819,6 +821,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -2309,8 +2312,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -2339,6 +2341,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2350,6 +2353,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2389,6 +2393,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -2724,6 +2729,7 @@ "integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "2.1.9", "fflate": "^0.8.2", @@ -2761,6 +2767,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3157,6 +3164,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3712,8 +3720,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -3982,6 +3989,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4046,6 +4054,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5838,6 +5847,7 @@ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", @@ -6217,7 +6227,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6975,6 +6984,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7059,7 +7069,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7075,7 +7084,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -7178,8 +7186,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/read-yaml-file": { "version": "1.1.0", @@ -7556,8 +7563,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "7.7.4", @@ -8376,6 +8382,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8662,6 +8669,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8787,6 +8795,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -9300,6 +9309,7 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", @@ -9710,6 +9720,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/index.ts b/src/index.ts index c55977d..14b0c36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from './components'; export * from './hooks'; +export * from './types'; export * from './utils'; diff --git a/src/types/chart.types.ts b/src/types/chart.types.ts new file mode 100644 index 0000000..e2f0d3e --- /dev/null +++ b/src/types/chart.types.ts @@ -0,0 +1,60 @@ +export interface ChartDataPoint { + label: string; + value: number; +} + +export interface ChartDataset { + id: string; + label: string; + data: ChartDataPoint[]; + color?: string; +} + +export interface ChartTheme { + colors: string[]; + fontFamily?: string; + fontSize?: number; + grid?: { + color?: string; + display?: boolean; + }; + tooltip?: { + enabled?: boolean; + backgroundColor?: string; + titleColor?: string; + bodyColor?: string; + }; + legend?: { + display?: boolean; + position?: 'top' | 'bottom' | 'left' | 'right'; + }; +} + +export type ChartVariant = 'bar' | 'line' | 'area'; + +export interface ChartConfig { + type: 'bar' | 'line'; + data: { + labels: string[]; + datasets: ChartConfigDataset[]; + }; + options: { + responsive: boolean; + plugins: { + tooltip: ChartTheme['tooltip'] & Record; + legend: ChartTheme['legend'] & Record; + }; + scales: { + x: { grid: ChartTheme['grid'] & Record; ticks: Record }; + y: { grid: ChartTheme['grid'] & Record; ticks: Record }; + }; + }; +} + +export interface ChartConfigDataset { + label: string; + data: number[]; + backgroundColor: string | string[]; + borderColor: string; + fill?: boolean; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..9613f86 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1 @@ +export * from './chart.types'; diff --git a/src/utils/buildChartConfig.test.ts b/src/utils/buildChartConfig.test.ts new file mode 100644 index 0000000..e06023d --- /dev/null +++ b/src/utils/buildChartConfig.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { buildChartConfig } from './buildChartConfig'; +import type { ChartDataset, ChartTheme } from '../types/chart.types'; + +const sampleData = [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + { label: 'Mar', value: 30 }, +]; + +const baseTheme: ChartTheme = { + colors: ['#FF0000', '#00FF00', '#0000FF'], + fontFamily: 'Arial', + fontSize: 14, + grid: { color: '#E0E0E0', display: true }, + tooltip: { + enabled: true, + backgroundColor: '#333', + titleColor: '#FFF', + bodyColor: '#CCC', + }, + legend: { display: true, position: 'bottom' }, +}; + +const datasets: ChartDataset[] = [ + { id: 'ds1', label: 'Sales', data: sampleData }, + { id: 'ds2', label: 'Revenue', data: sampleData, color: '#ABCDEF' }, +]; + +describe('buildChartConfig', () => { + describe('bar variant', () => { + const config = buildChartConfig(datasets, baseTheme, 'bar'); + + it('should set type to bar', () => { + expect(config.type).toBe('bar'); + }); + + it('should extract labels from the first dataset', () => { + expect(config.data.labels).toEqual(['Jan', 'Feb', 'Mar']); + }); + + it('should map dataset values', () => { + expect(config.data.datasets[0].data).toEqual([10, 20, 30]); + }); + + it('should fall back to theme.colors when dataset.color is undefined', () => { + expect(config.data.datasets[0].backgroundColor).toBe('#FF0000'); + expect(config.data.datasets[0].borderColor).toBe('#FF0000'); + }); + + it('should use dataset.color when provided', () => { + expect(config.data.datasets[1].backgroundColor).toBe('#ABCDEF'); + expect(config.data.datasets[1].borderColor).toBe('#ABCDEF'); + }); + + it('should not set fill for bar variant', () => { + expect(config.data.datasets[0].fill).toBeUndefined(); + }); + }); + + describe('line variant', () => { + const config = buildChartConfig(datasets, baseTheme, 'line'); + + it('should set type to line', () => { + expect(config.type).toBe('line'); + }); + + it('should not set fill for line variant', () => { + expect(config.data.datasets[0].fill).toBeUndefined(); + }); + }); + + describe('area variant', () => { + const config = buildChartConfig(datasets, baseTheme, 'area'); + + it('should set type to line for area variant', () => { + expect(config.type).toBe('line'); + }); + + it('should set fill true for area datasets', () => { + expect(config.data.datasets[0].fill).toBe(true); + expect(config.data.datasets[1].fill).toBe(true); + }); + + it('should append 33 to hex color for 20% opacity background', () => { + expect(config.data.datasets[0].backgroundColor).toBe('#FF000033'); + }); + + it('should append 33 to explicit dataset color for area', () => { + expect(config.data.datasets[1].backgroundColor).toBe('#ABCDEF33'); + }); + + it('should keep borderColor without opacity suffix', () => { + expect(config.data.datasets[0].borderColor).toBe('#FF0000'); + }); + }); + + describe('theme fields reflected in output', () => { + const config = buildChartConfig(datasets, baseTheme, 'bar'); + + it('should reflect tooltip settings', () => { + expect(config.options.plugins.tooltip.enabled).toBe(true); + expect(config.options.plugins.tooltip.backgroundColor).toBe('#333'); + expect(config.options.plugins.tooltip.titleColor).toBe('#FFF'); + expect(config.options.plugins.tooltip.bodyColor).toBe('#CCC'); + }); + + it('should reflect legend settings', () => { + expect(config.options.plugins.legend.display).toBe(true); + expect(config.options.plugins.legend.position).toBe('bottom'); + }); + + it('should reflect grid settings on both axes', () => { + expect(config.options.scales.x.grid.display).toBe(true); + expect(config.options.scales.x.grid.color).toBe('#E0E0E0'); + expect(config.options.scales.y.grid.display).toBe(true); + expect(config.options.scales.y.grid.color).toBe('#E0E0E0'); + }); + + it('should reflect fontFamily and fontSize in tick fonts', () => { + expect(config.options.scales.x.ticks).toEqual({ font: { family: 'Arial', size: 14 } }); + expect(config.options.scales.y.ticks).toEqual({ font: { family: 'Arial', size: 14 } }); + }); + + it('should set responsive true', () => { + expect(config.options.responsive).toBe(true); + }); + }); + + describe('color cycling', () => { + it('should cycle through theme.colors when there are more datasets than colors', () => { + const threeDs: ChartDataset[] = [ + { id: '1', label: 'A', data: sampleData }, + { id: '2', label: 'B', data: sampleData }, + { id: '3', label: 'C', data: sampleData }, + { id: '4', label: 'D', data: sampleData }, + ]; + const config = buildChartConfig(threeDs, baseTheme, 'bar'); + expect(config.data.datasets[3].backgroundColor).toBe('#FF0000'); + }); + }); + + describe('empty datasets', () => { + it('should produce empty labels for empty datasets array', () => { + const config = buildChartConfig([], baseTheme, 'bar'); + expect(config.data.labels).toEqual([]); + expect(config.data.datasets).toEqual([]); + }); + }); + + describe('default theme values', () => { + const minTheme: ChartTheme = { colors: ['#111'] }; + const config = buildChartConfig(datasets, minTheme, 'line'); + + it('should default tooltip.enabled to true', () => { + expect(config.options.plugins.tooltip.enabled).toBe(true); + }); + + it('should default legend.display to true and position to top', () => { + expect(config.options.plugins.legend.display).toBe(true); + expect(config.options.plugins.legend.position).toBe('top'); + }); + + it('should default grid.display to true', () => { + expect(config.options.scales.x.grid.display).toBe(true); + }); + }); +}); diff --git a/src/utils/buildChartConfig.ts b/src/utils/buildChartConfig.ts new file mode 100644 index 0000000..0197b1c --- /dev/null +++ b/src/utils/buildChartConfig.ts @@ -0,0 +1,88 @@ +import type { + ChartConfig, + ChartConfigDataset, + ChartDataset, + ChartTheme, + ChartVariant, +} from '../types/chart.types'; + +function resolveColor(dataset: ChartDataset, index: number, theme: ChartTheme): string { + return dataset.color ?? theme.colors[index % theme.colors.length]; +} + +function applyAreaOpacity(hex: string): string { + return `${hex}33`; +} + +function buildDataset( + dataset: ChartDataset, + index: number, + theme: ChartTheme, + variant: ChartVariant, +): ChartConfigDataset { + const color = resolveColor(dataset, index, theme); + + const base: ChartConfigDataset = { + label: dataset.label, + data: dataset.data.map((point) => point.value), + backgroundColor: variant === 'area' ? applyAreaOpacity(color) : color, + borderColor: color, + }; + + if (variant === 'area') { + base.fill = true; + } + + return base; +} + +export function buildChartConfig( + datasets: ChartDataset[], + theme: ChartTheme, + variant: ChartVariant, +): ChartConfig { + const labels = datasets.length > 0 ? datasets[0].data.map((point) => point.label) : []; + + const tickFont: Record = {}; + if (theme.fontFamily) tickFont.family = theme.fontFamily; + if (theme.fontSize) tickFont.size = theme.fontSize; + + return { + type: variant === 'area' ? 'line' : variant, + data: { + labels, + datasets: datasets.map((ds, i) => buildDataset(ds, i, theme, variant)), + }, + options: { + responsive: true, + plugins: { + tooltip: { + enabled: theme.tooltip?.enabled ?? true, + backgroundColor: theme.tooltip?.backgroundColor, + titleColor: theme.tooltip?.titleColor, + bodyColor: theme.tooltip?.bodyColor, + }, + legend: { + display: theme.legend?.display ?? true, + position: theme.legend?.position ?? 'top', + }, + }, + scales: { + x: { + grid: { + display: theme.grid?.display ?? true, + color: theme.grid?.color, + }, + ticks: { font: tickFont }, + }, + y: { + grid: { + display: theme.grid?.display ?? true, + color: theme.grid?.color, + }, + ticks: { font: tickFont }, + }, + }, + }, + }; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 6211c64..5cdf899 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,3 +2,4 @@ export const __utils_placeholder = true; export * from './noop'; +export * from './buildChartConfig'; From d56e280d7f0f162dfb49b6ab5eb6ca2ac926b51f Mon Sep 17 00:00:00 2001 From: omaima Date: Wed, 1 Apr 2026 16:21:44 +0100 Subject: [PATCH 2/6] feat: add BarChart component with typed props, stacking/horizontal support, and internal config builder --- ciscode-reactts-developerkit-1.0.0.tgz | Bin 5514 -> 6882 bytes package-lock.json | 33 +++++ package.json | 4 + src/components/BarChart/BarChart.test.tsx | 158 ++++++++++++++++++++++ src/components/BarChart/BarChart.tsx | 57 ++++++++ src/components/BarChart/BarChart.types.ts | 14 ++ src/components/BarChart/index.ts | 2 + src/components/index.ts | 1 + src/types/chart.types.ts | 13 +- 9 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 src/components/BarChart/BarChart.test.tsx create mode 100644 src/components/BarChart/BarChart.tsx create mode 100644 src/components/BarChart/BarChart.types.ts create mode 100644 src/components/BarChart/index.ts diff --git a/ciscode-reactts-developerkit-1.0.0.tgz b/ciscode-reactts-developerkit-1.0.0.tgz index fc32c0c544989c504e7a460afb5171ed561966b5..c0c749f0a8174631d814349abbb30514b0e6c663 100644 GIT binary patch literal 6882 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�~@{(QtN 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 literal 5514 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" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -7168,6 +7191,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", + "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", diff --git a/package.json b/package.json index e1abcf6..bb19b06 100644 --- a/package.json +++ b/package.json @@ -80,5 +80,9 @@ }, "engines": { "node": ">=20" + }, + "dependencies": { + "chart.js": "^4.5.1", + "react-chartjs-2": "^5.3.1" } } diff --git a/src/components/BarChart/BarChart.test.tsx b/src/components/BarChart/BarChart.test.tsx new file mode 100644 index 0000000..0232be1 --- /dev/null +++ b/src/components/BarChart/BarChart.test.tsx @@ -0,0 +1,158 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import type { ChartDataset, ChartTheme } from '../../types/chart.types'; + +// Mock react-chartjs-2 to avoid canvas dependency in jsdom +vi.mock('react-chartjs-2', () => ({ + Bar: (props: { data: unknown; options: unknown }) => ( + + ), +})); + +// Mock chart.js register to avoid side-effects +vi.mock('chart.js', () => ({ + Chart: { register: vi.fn() }, + CategoryScale: 'CategoryScale', + LinearScale: 'LinearScale', + BarElement: 'BarElement', + Tooltip: 'Tooltip', + Legend: 'Legend', +})); + +import { BarChart } from './BarChart'; + +const sampleData: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, +]; + +const threeDatasets: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, + { + id: 'ds2', + label: 'Revenue', + data: [ + { label: 'Jan', value: 30 }, + { label: 'Feb', value: 40 }, + ], + color: '#00FF00', + }, + { + id: 'ds3', + label: 'Costs', + data: [ + { label: 'Jan', value: 5 }, + { label: 'Feb', value: 15 }, + ], + }, +]; + +const theme: ChartTheme = { + colors: ['#FF0000', '#00FF00', '#0000FF'], + fontFamily: 'Arial', + fontSize: 14, + grid: { color: '#ccc', display: true }, + tooltip: { enabled: true, backgroundColor: '#333' }, + legend: { display: true, position: 'bottom' }, +}; + +describe('BarChart', () => { + it('should render with 1 dataset without errors', () => { + render(); + expect(screen.getByTestId('bar-canvas')).toBeInTheDocument(); + }); + + it('should render with 3 datasets without errors', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets).toHaveLength(3); + }); + + it('should wrap chart in div with width 100% and default height 300', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.width).toBe('100%'); + expect(wrapper.style.height).toBe('300px'); + }); + + it('should apply custom height', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.height).toBe('500px'); + }); + + it('should set stacked on both axes when stacked prop is true', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.scales.x.stacked).toBe(true); + expect(options.scales.y.stacked).toBe(true); + }); + + it('should not set stacked when stacked prop is false', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.scales.x.stacked).toBeUndefined(); + expect(options.scales.y.stacked).toBeUndefined(); + }); + + it('should set indexAxis to y when horizontal is true', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.indexAxis).toBe('y'); + }); + + it('should not set indexAxis when horizontal is false', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.indexAxis).toBeUndefined(); + }); + + it('should pass animation enabled by default (no explicit disable)', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.animation).toBeUndefined(); + }); + + it('should reflect theme tooltip and legend settings in options', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.plugins.tooltip.enabled).toBe(true); + expect(options.plugins.tooltip.backgroundColor).toBe('#333'); + expect(options.plugins.legend.display).toBe(true); + expect(options.plugins.legend.position).toBe('bottom'); + }); + + it('should support stacked and horizontal together', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.scales.x.stacked).toBe(true); + expect(options.scales.y.stacked).toBe(true); + expect(options.indexAxis).toBe('y'); + }); +}); diff --git a/src/components/BarChart/BarChart.tsx b/src/components/BarChart/BarChart.tsx new file mode 100644 index 0000000..03d7d47 --- /dev/null +++ b/src/components/BarChart/BarChart.tsx @@ -0,0 +1,57 @@ +import React, { useMemo } from 'react'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Tooltip, + Legend, +} from 'chart.js'; +import { Bar } from 'react-chartjs-2'; +import { buildChartConfig } from '../../utils/buildChartConfig'; +import type { BarChartProps } from './BarChart.types'; + +ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend); + +/** + * BarChart renders a bar chart using react-chartjs-2. + * Supports stacked and horizontal variants via typed props only. + * + * @example + * ```tsx + * + * ``` + */ +export const BarChart: React.FC = ({ + data, + theme, + height = 300, + stacked = false, + horizontal = false, +}) => { + const config = useMemo(() => { + const base = buildChartConfig(data, theme, 'bar'); + + if (stacked) { + base.options.scales.x.stacked = true; + base.options.scales.y.stacked = true; + } + + if (horizontal) { + base.options.indexAxis = 'y'; + } + + return base; + }, [data, theme, stacked, horizontal]); + + return ( +
+ ['options']} + /> +
+ ); +}; + +BarChart.displayName = 'BarChart'; diff --git a/src/components/BarChart/BarChart.types.ts b/src/components/BarChart/BarChart.types.ts new file mode 100644 index 0000000..0a6db58 --- /dev/null +++ b/src/components/BarChart/BarChart.types.ts @@ -0,0 +1,14 @@ +import type { ChartDataset, ChartTheme } from '../../types/chart.types'; + +export interface BarChartProps { + /** Array of datasets to render */ + data: ChartDataset[]; + /** Theme configuration for colors, fonts, grid, tooltip, and legend */ + theme: ChartTheme; + /** Chart height in pixels @default 300 */ + height?: number; + /** Stack bars on top of each other */ + stacked?: boolean; + /** Render horizontal bars (indexAxis 'y') */ + horizontal?: boolean; +} diff --git a/src/components/BarChart/index.ts b/src/components/BarChart/index.ts new file mode 100644 index 0000000..e9b7e02 --- /dev/null +++ b/src/components/BarChart/index.ts @@ -0,0 +1,2 @@ +export { BarChart } from './BarChart'; +export type { BarChartProps } from './BarChart.types'; diff --git a/src/components/index.ts b/src/components/index.ts index 52f8fa8..14fe847 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -2,3 +2,4 @@ export const __components_placeholder = true; export * from './NoopButton'; +export * from './BarChart'; diff --git a/src/types/chart.types.ts b/src/types/chart.types.ts index e2f0d3e..9b01350 100644 --- a/src/types/chart.types.ts +++ b/src/types/chart.types.ts @@ -40,13 +40,22 @@ export interface ChartConfig { }; options: { responsive: boolean; + indexAxis?: 'x' | 'y'; plugins: { tooltip: ChartTheme['tooltip'] & Record; legend: ChartTheme['legend'] & Record; }; scales: { - x: { grid: ChartTheme['grid'] & Record; ticks: Record }; - y: { grid: ChartTheme['grid'] & Record; ticks: Record }; + x: { + grid: ChartTheme['grid'] & Record; + ticks: Record; + stacked?: boolean; + }; + y: { + grid: ChartTheme['grid'] & Record; + ticks: Record; + stacked?: boolean; + }; }; }; } From 6fc8277b97330b14c34d971db1d44cabd53dfd0a Mon Sep 17 00:00:00 2001 From: omaima Date: Thu, 2 Apr 2026 12:59:21 +0100 Subject: [PATCH 3/6] feat: add LineChart and AreaChart with smooth option, area fill, stacking, and shared config builder --- src/components/AreaChart/AreaChart.test.tsx | 176 ++++++++++++++++++++ src/components/AreaChart/AreaChart.tsx | 61 +++++++ src/components/AreaChart/AreaChart.types.ts | 14 ++ src/components/AreaChart/index.ts | 2 + src/components/LineChart/LineChart.test.tsx | 134 +++++++++++++++ src/components/LineChart/LineChart.tsx | 55 ++++++ src/components/LineChart/LineChart.types.ts | 12 ++ src/components/LineChart/index.ts | 2 + src/components/index.ts | 2 + src/types/chart.types.ts | 1 + 10 files changed, 459 insertions(+) create mode 100644 src/components/AreaChart/AreaChart.test.tsx create mode 100644 src/components/AreaChart/AreaChart.tsx create mode 100644 src/components/AreaChart/AreaChart.types.ts create mode 100644 src/components/AreaChart/index.ts create mode 100644 src/components/LineChart/LineChart.test.tsx create mode 100644 src/components/LineChart/LineChart.tsx create mode 100644 src/components/LineChart/LineChart.types.ts create mode 100644 src/components/LineChart/index.ts diff --git a/src/components/AreaChart/AreaChart.test.tsx b/src/components/AreaChart/AreaChart.test.tsx new file mode 100644 index 0000000..a748ce1 --- /dev/null +++ b/src/components/AreaChart/AreaChart.test.tsx @@ -0,0 +1,176 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import type { ChartDataset, ChartTheme } from '../../types/chart.types'; + +vi.mock('react-chartjs-2', () => ({ + Line: (props: { data: unknown; options: unknown }) => ( + + ), +})); + +vi.mock('chart.js', () => ({ + Chart: { register: vi.fn() }, + CategoryScale: 'CategoryScale', + LinearScale: 'LinearScale', + LineElement: 'LineElement', + PointElement: 'PointElement', + Filler: 'Filler', + Tooltip: 'Tooltip', + Legend: 'Legend', +})); + +import { AreaChart } from './AreaChart'; + +const singleDataset: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, +]; + +const multiDatasets: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, + { + id: 'ds2', + label: 'Revenue', + data: [ + { label: 'Jan', value: 30 }, + { label: 'Feb', value: 40 }, + ], + color: '#00FF00', + }, + { + id: 'ds3', + label: 'Costs', + data: [ + { label: 'Jan', value: 5 }, + { label: 'Feb', value: 15 }, + ], + }, +]; + +const theme: ChartTheme = { + colors: ['#FF0000', '#00FF00', '#0000FF'], + fontFamily: 'Arial', + fontSize: 14, + grid: { color: '#ccc', display: true }, + tooltip: { enabled: true, backgroundColor: '#333' }, + legend: { display: true, position: 'bottom' }, +}; + +describe('AreaChart', () => { + it('should render with 1 dataset without errors', () => { + render(); + expect(screen.getByTestId('area-canvas')).toBeInTheDocument(); + }); + + it('should render with multiple datasets without errors', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets).toHaveLength(3); + }); + + it('should set fill true on all datasets (area variant)', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + for (const ds of data.datasets) { + expect(ds.fill).toBe(true); + } + }); + + it('should apply 20% opacity (append 33) to backgroundColor', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets[0].backgroundColor).toBe('#FF000033'); + }); + + it('should use dataset color with 20% opacity when color is provided', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets[1].backgroundColor).toBe('#00FF0033'); + }); + + it('should wrap chart in div with width 100% and default height 300', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.width).toBe('100%'); + expect(wrapper.style.height).toBe('300px'); + }); + + it('should apply custom height', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.height).toBe('450px'); + }); + + it('should set tension 0.4 on all datasets when smooth is true', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + for (const ds of data.datasets) { + expect(ds.tension).toBe(0.4); + } + }); + + it('should not set tension when smooth is false', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets[0].tension).toBeUndefined(); + }); + + it('should set scales.y.stacked true when stacked is true', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.scales.y.stacked).toBe(true); + }); + + it('should not set stacked when stacked prop is false', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.scales.y.stacked).toBeUndefined(); + }); + + it('should support stacked and smooth together', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(options.scales.y.stacked).toBe(true); + for (const ds of data.datasets) { + expect(ds.tension).toBe(0.4); + expect(ds.fill).toBe(true); + } + }); + + it('should reflect theme settings in options', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.plugins.tooltip.enabled).toBe(true); + expect(options.plugins.legend.position).toBe('bottom'); + expect(options.responsive).toBe(true); + }); +}); diff --git a/src/components/AreaChart/AreaChart.tsx b/src/components/AreaChart/AreaChart.tsx new file mode 100644 index 0000000..74c2741 --- /dev/null +++ b/src/components/AreaChart/AreaChart.tsx @@ -0,0 +1,61 @@ +import React, { useMemo } from 'react'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + LineElement, + PointElement, + Filler, + Tooltip, + Legend, +} from 'chart.js'; +import { Line } from 'react-chartjs-2'; +import { buildChartConfig } from '../../utils/buildChartConfig'; +import type { AreaChartProps } from './AreaChart.types'; + +ChartJS.register(CategoryScale, LinearScale, LineElement, PointElement, Filler, Tooltip, Legend); + +/** + * AreaChart renders a filled line chart using react-chartjs-2. + * Uses the 'area' variant from buildChartConfig for fill with 20% opacity. + * Supports smooth curves and stacked mode. + * + * @example + * ```tsx + * + * ``` + */ +export const AreaChart: React.FC = ({ + data, + theme, + height = 300, + smooth = false, + stacked = false, +}) => { + const config = useMemo(() => { + const base = buildChartConfig(data, theme, 'area'); + + if (smooth) { + for (const ds of base.data.datasets) { + ds.tension = 0.4; + } + } + + if (stacked) { + base.options.scales.y.stacked = true; + } + + return base; + }, [data, theme, smooth, stacked]); + + return ( +
+ ['options']} + /> +
+ ); +}; + +AreaChart.displayName = 'AreaChart'; diff --git a/src/components/AreaChart/AreaChart.types.ts b/src/components/AreaChart/AreaChart.types.ts new file mode 100644 index 0000000..096f8b0 --- /dev/null +++ b/src/components/AreaChart/AreaChart.types.ts @@ -0,0 +1,14 @@ +import type { ChartDataset, ChartTheme } from '../../types/chart.types'; + +export interface AreaChartProps { + /** Array of datasets to render */ + data: ChartDataset[]; + /** Theme configuration for colors, fonts, grid, tooltip, and legend */ + theme: ChartTheme; + /** Chart height in pixels @default 300 */ + height?: number; + /** Apply curved line interpolation (tension 0.4) @default false */ + smooth?: boolean; + /** Stack area datasets on top of each other */ + stacked?: boolean; +} diff --git a/src/components/AreaChart/index.ts b/src/components/AreaChart/index.ts new file mode 100644 index 0000000..75803cd --- /dev/null +++ b/src/components/AreaChart/index.ts @@ -0,0 +1,2 @@ +export { AreaChart } from './AreaChart'; +export type { AreaChartProps } from './AreaChart.types'; diff --git a/src/components/LineChart/LineChart.test.tsx b/src/components/LineChart/LineChart.test.tsx new file mode 100644 index 0000000..7c197b5 --- /dev/null +++ b/src/components/LineChart/LineChart.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import type { ChartDataset, ChartTheme } from '../../types/chart.types'; + +vi.mock('react-chartjs-2', () => ({ + Line: (props: { data: unknown; options: unknown }) => ( + + ), +})); + +vi.mock('chart.js', () => ({ + Chart: { register: vi.fn() }, + CategoryScale: 'CategoryScale', + LinearScale: 'LinearScale', + LineElement: 'LineElement', + PointElement: 'PointElement', + Filler: 'Filler', + Tooltip: 'Tooltip', + Legend: 'Legend', +})); + +import { LineChart } from './LineChart'; + +const singleDataset: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, +]; + +const multiDatasets: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, + { + id: 'ds2', + label: 'Revenue', + data: [ + { label: 'Jan', value: 30 }, + { label: 'Feb', value: 40 }, + ], + color: '#00FF00', + }, + { + id: 'ds3', + label: 'Costs', + data: [ + { label: 'Jan', value: 5 }, + { label: 'Feb', value: 15 }, + ], + }, +]; + +const theme: ChartTheme = { + colors: ['#FF0000', '#00FF00', '#0000FF'], + fontFamily: 'Arial', + fontSize: 14, + grid: { color: '#ccc', display: true }, + tooltip: { enabled: true, backgroundColor: '#333' }, + legend: { display: true, position: 'bottom' }, +}; + +describe('LineChart', () => { + it('should render with 1 dataset without errors', () => { + render(); + expect(screen.getByTestId('line-canvas')).toBeInTheDocument(); + }); + + it('should render with multiple datasets without errors', () => { + render(); + const canvas = screen.getByTestId('line-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets).toHaveLength(3); + }); + + it('should wrap chart in div with width 100% and default height 300', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.width).toBe('100%'); + expect(wrapper.style.height).toBe('300px'); + }); + + it('should apply custom height', () => { + const { container } = render(); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.height).toBe('500px'); + }); + + it('should not set tension when smooth is false', () => { + render(); + const canvas = screen.getByTestId('line-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets[0].tension).toBeUndefined(); + }); + + it('should set tension 0.4 on all datasets when smooth is true', () => { + render(); + const canvas = screen.getByTestId('line-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + for (const ds of data.datasets) { + expect(ds.tension).toBe(0.4); + } + }); + + it('should not set fill for line variant', () => { + render(); + const canvas = screen.getByTestId('line-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.datasets[0].fill).toBeUndefined(); + }); + + it('should reflect theme settings in options', () => { + render(); + const canvas = screen.getByTestId('line-canvas'); + const options = JSON.parse(canvas.getAttribute('data-options')!); + expect(options.plugins.tooltip.enabled).toBe(true); + expect(options.plugins.legend.position).toBe('bottom'); + expect(options.responsive).toBe(true); + }); +}); diff --git a/src/components/LineChart/LineChart.tsx b/src/components/LineChart/LineChart.tsx new file mode 100644 index 0000000..b8e2f8d --- /dev/null +++ b/src/components/LineChart/LineChart.tsx @@ -0,0 +1,55 @@ +import React, { useMemo } from 'react'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + LineElement, + PointElement, + Filler, + Tooltip, + Legend, +} from 'chart.js'; +import { Line } from 'react-chartjs-2'; +import { buildChartConfig } from '../../utils/buildChartConfig'; +import type { LineChartProps } from './LineChart.types'; + +ChartJS.register(CategoryScale, LinearScale, LineElement, PointElement, Filler, Tooltip, Legend); + +/** + * LineChart renders a line chart using react-chartjs-2. + * Supports smooth curves via the smooth prop. + * + * @example + * ```tsx + * + * ``` + */ +export const LineChart: React.FC = ({ + data, + theme, + height = 300, + smooth = false, +}) => { + const config = useMemo(() => { + const base = buildChartConfig(data, theme, 'line'); + + if (smooth) { + for (const ds of base.data.datasets) { + ds.tension = 0.4; + } + } + + return base; + }, [data, theme, smooth]); + + return ( +
+ ['options']} + /> +
+ ); +}; + +LineChart.displayName = 'LineChart'; diff --git a/src/components/LineChart/LineChart.types.ts b/src/components/LineChart/LineChart.types.ts new file mode 100644 index 0000000..6e62173 --- /dev/null +++ b/src/components/LineChart/LineChart.types.ts @@ -0,0 +1,12 @@ +import type { ChartDataset, ChartTheme } from '../../types/chart.types'; + +export interface LineChartProps { + /** Array of datasets to render */ + data: ChartDataset[]; + /** Theme configuration for colors, fonts, grid, tooltip, and legend */ + theme: ChartTheme; + /** Chart height in pixels @default 300 */ + height?: number; + /** Apply curved line interpolation (tension 0.4) @default false */ + smooth?: boolean; +} diff --git a/src/components/LineChart/index.ts b/src/components/LineChart/index.ts new file mode 100644 index 0000000..b22f5c7 --- /dev/null +++ b/src/components/LineChart/index.ts @@ -0,0 +1,2 @@ +export { LineChart } from './LineChart'; +export type { LineChartProps } from './LineChart.types'; diff --git a/src/components/index.ts b/src/components/index.ts index 14fe847..d5b415c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,3 +3,5 @@ export const __components_placeholder = true; export * from './NoopButton'; export * from './BarChart'; +export * from './LineChart'; +export * from './AreaChart'; diff --git a/src/types/chart.types.ts b/src/types/chart.types.ts index 9b01350..039a100 100644 --- a/src/types/chart.types.ts +++ b/src/types/chart.types.ts @@ -66,4 +66,5 @@ export interface ChartConfigDataset { backgroundColor: string | string[]; borderColor: string; fill?: boolean; + tension?: number; } From 17d076a8c90031b85a2e9ccbaff2292b786c49da Mon Sep 17 00:00:00 2001 From: omaima Date: Thu, 2 Apr 2026 13:13:21 +0100 Subject: [PATCH 4/6] test: add full test suite with mocked react-chartjs-2, config validation, and 80%+ coverage --- ciscode-reactts-developerkit-1.0.0.tgz | Bin 6882 -> 8656 bytes src/components/AreaChart/AreaChart.test.tsx | 8 ++++++++ src/components/BarChart/BarChart.test.tsx | 8 ++++++++ src/components/LineChart/LineChart.test.tsx | 8 ++++++++ 4 files changed, 24 insertions(+) diff --git a/ciscode-reactts-developerkit-1.0.0.tgz b/ciscode-reactts-developerkit-1.0.0.tgz index c0c749f0a8174631d814349abbb30514b0e6c663..47778b68af3a669565aec3164fa6bed25962f72c 100644 GIT binary patch literal 8656 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 R&ku0K@4)~*ge?Sw{{zl2>CONE literal 6882 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�~@{(QtN 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 diff --git a/src/components/AreaChart/AreaChart.test.tsx b/src/components/AreaChart/AreaChart.test.tsx index a748ce1..72fba88 100644 --- a/src/components/AreaChart/AreaChart.test.tsx +++ b/src/components/AreaChart/AreaChart.test.tsx @@ -173,4 +173,12 @@ describe('AreaChart', () => { expect(options.plugins.legend.position).toBe('bottom'); expect(options.responsive).toBe(true); }); + + it('should handle empty data array without crash', () => { + render(); + const canvas = screen.getByTestId('area-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.labels).toEqual([]); + expect(data.datasets).toEqual([]); + }); }); diff --git a/src/components/BarChart/BarChart.test.tsx b/src/components/BarChart/BarChart.test.tsx index 0232be1..e845ee3 100644 --- a/src/components/BarChart/BarChart.test.tsx +++ b/src/components/BarChart/BarChart.test.tsx @@ -155,4 +155,12 @@ describe('BarChart', () => { expect(options.scales.y.stacked).toBe(true); expect(options.indexAxis).toBe('y'); }); + + it('should handle empty data array without crash', () => { + render(); + const canvas = screen.getByTestId('bar-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.labels).toEqual([]); + expect(data.datasets).toEqual([]); + }); }); diff --git a/src/components/LineChart/LineChart.test.tsx b/src/components/LineChart/LineChart.test.tsx index 7c197b5..6529a5b 100644 --- a/src/components/LineChart/LineChart.test.tsx +++ b/src/components/LineChart/LineChart.test.tsx @@ -131,4 +131,12 @@ describe('LineChart', () => { expect(options.plugins.legend.position).toBe('bottom'); expect(options.responsive).toBe(true); }); + + it('should handle empty data array without crash', () => { + render(); + const canvas = screen.getByTestId('line-canvas'); + const data = JSON.parse(canvas.getAttribute('data-data')!); + expect(data.labels).toEqual([]); + expect(data.datasets).toEqual([]); + }); }); From e3111b0481dd4ce38a7cf32bfa134e7d336e0494 Mon Sep 17 00:00:00 2001 From: omaima Date: Thu, 2 Apr 2026 17:23:17 +0100 Subject: [PATCH 5/6] chore: add README and changeset --- .changeset/initial-release.md | 12 +++ README.md | 192 ++++++++++++++++++++++++++++------ package.json | 6 +- 3 files changed, 174 insertions(+), 36 deletions(-) create mode 100644 .changeset/initial-release.md diff --git a/.changeset/initial-release.md b/.changeset/initial-release.md new file mode 100644 index 0000000..bae87b5 --- /dev/null +++ b/.changeset/initial-release.md @@ -0,0 +1,12 @@ +--- +'@ciscode/ui-chart-kit': minor +--- + +Initial release of @ciscode/ui-chart-kit v0.1.0. + +- ChartDataPoint, ChartDataset, and ChartTheme type contracts +- buildChartConfig utility mapping typed data to Chart.js config +- BarChart component with stacked and horizontal support +- LineChart component with smooth curve support +- AreaChart component with fill at 20% opacity and stacked support +- All components responsive with configurable height diff --git a/README.md b/README.md index 539fe85..d2fcecb 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,170 @@ -# React TypeScript DeveloperKit (Template) +# @ciscode/ui-chart-kit -Template repository for building reusable React TypeScript **npm libraries** -(components + hooks + utilities). +Typed React chart components (Bar, Line, Area) built on Chart.js. +Pass data and a theme — get a fully configured, responsive chart. No raw Chart.js options required. -## What you get +## Installation -- ESM + CJS + Types build (tsup) -- Vitest testing -- ESLint + Prettier (flat config) -- Changesets (manual release flow, no automation PR) -- Husky (pre-commit + pre-push) -- Enforced public API via `src/index.ts` -- Dependency-free styling (Tailwind-compatible by convention only) -- `react` and `react-dom` as peerDependencies +```bash +npm install @ciscode/ui-chart-kit +``` -## Package structure +### Peer dependencies -- `src/components` – reusable UI components -- `src/hooks` – reusable React hooks -- `src/utils` – framework-agnostic utilities -- `src/index.ts` – **only public API** (no deep imports allowed) +| Package | Version | +| ----------- | ------- | +| `react` | ≥ 18 | +| `react-dom` | ≥ 18 | -Anything not exported from `src/index.ts` is considered private. +`chart.js` and `react-chartjs-2` are bundled — you do **not** need to install them separately. -## Scripts +--- -- `npm run build` – build to `dist/` (tsup) -- `npm test` – run tests (vitest) -- `npm run typecheck` – TypeScript typecheck -- `npm run lint` – ESLint -- `npm run format` / `npm run format:write` – Prettier -- `npx changeset` – create a changeset +## Data types -## Release flow (summary) +### `ChartDataPoint` -- Work on a `feature` branch from `develop` -- Merge to `develop` -- Add a changeset for user-facing changes: `npx changeset` -- Promote `develop` → `master` -- Tag `vX.Y.Z` to publish (npm OIDC) +```ts +interface ChartDataPoint { + label: string; + value: number; +} +``` -This repository is a **template**. Teams should clone it and focus only on -library logic, not tooling or release mechanics. +### `ChartDataset` + +```ts +interface ChartDataset { + id: string; + label: string; + data: ChartDataPoint[]; + color?: string; // hex color — falls back to theme.colors when omitted +} +``` + +### `ChartTheme` + +```ts +interface ChartTheme { + colors: string[]; // palette shared across datasets + fontFamily?: string; + fontSize?: number; + grid?: { + color?: string; + display?: boolean; + }; + tooltip?: { + enabled?: boolean; + backgroundColor?: string; + titleColor?: string; + bodyColor?: string; + }; + legend?: { + display?: boolean; + position?: 'top' | 'bottom' | 'left' | 'right'; + }; +} +``` + +--- + +## Components + +### BarChart + +| Prop | Type | Default | Description | +| ------------ | ---------------- | ------- | --------------------------------- | +| `data` | `ChartDataset[]` | — | Datasets to render | +| `theme` | `ChartTheme` | — | Theme (colors, fonts, grid, etc.) | +| `height` | `number` | `300` | Chart height in pixels | +| `stacked` | `boolean` | `false` | Stack bars on top of each other | +| `horizontal` | `boolean` | `false` | Render horizontal bars | + +```tsx +import { BarChart } from '@ciscode/ui-chart-kit'; +import type { ChartDataset, ChartTheme } from '@ciscode/ui-chart-kit'; + +const theme: ChartTheme = { + colors: ['#4F46E5', '#10B981', '#F59E0B'], + fontFamily: 'Inter, sans-serif', + fontSize: 12, + grid: { color: '#E5E7EB', display: true }, + tooltip: { enabled: true, backgroundColor: '#1F2937' }, + legend: { display: true, position: 'top' }, +}; + +const datasets: ChartDataset[] = [ + { + id: 'revenue', + label: 'Revenue', + data: [ + { label: 'Q1', value: 120 }, + { label: 'Q2', value: 180 }, + { label: 'Q3', value: 150 }, + { label: 'Q4', value: 210 }, + ], + }, +]; + +function App() { + return ; +} +``` + +--- + +### LineChart + +| Prop | Type | Default | Description | +| -------- | ---------------- | ------- | --------------------------------------- | +| `data` | `ChartDataset[]` | — | Datasets to render | +| `theme` | `ChartTheme` | — | Theme (colors, fonts, grid, etc.) | +| `height` | `number` | `300` | Chart height in pixels | +| `smooth` | `boolean` | `false` | Curved line interpolation (0.4 tension) | + +```tsx +import { LineChart } from '@ciscode/ui-chart-kit'; + +function App() { + return ; +} +``` + +--- + +### AreaChart + +| Prop | Type | Default | Description | +| --------- | ---------------- | ------- | --------------------------------------- | +| `data` | `ChartDataset[]` | — | Datasets to render | +| `theme` | `ChartTheme` | — | Theme (colors, fonts, grid, etc.) | +| `height` | `number` | `300` | Chart height in pixels | +| `smooth` | `boolean` | `false` | Curved line interpolation (0.4 tension) | +| `stacked` | `boolean` | `false` | Stack areas on top of each other | + +Area fill uses the dataset color at 20 % opacity automatically. + +```tsx +import { AreaChart } from '@ciscode/ui-chart-kit'; + +function App() { + return ; +} +``` + +--- + +## Design decisions + +- **No Chart.js passthrough.** Components expose a curated props API only. + Chart.js configuration is built internally via `buildChartConfig`. + This keeps the public surface small and prevents breaking changes + when Chart.js internals evolve. +- **Colors cycle.** When there are more datasets than `theme.colors` entries, + colors wrap around automatically. +- **Responsive by default.** Every chart renders inside a `div` with + `width: 100%` and the specified `height`. + +## License + +MIT diff --git a/package.json b/package.json index bb19b06..bfac135 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@ciscode/reactts-developerkit", - "version": "1.0.0", - "description": "React TypeScript hybrid library template (components + hooks + utils).", + "name": "@ciscode/ui-chart-kit", + "version": "0.1.0", + "description": "Typed React chart components (Bar, Line, Area) built on Chart.js — no raw Chart.js config required.", "license": "MIT", "private": false, "type": "module", From cc7340dfaad727b85b97a18c33e4f7a4741e2e61 Mon Sep 17 00:00:00 2001 From: omaima Date: Mon, 6 Apr 2026 16:06:58 +0100 Subject: [PATCH 6/6] fix: extract shared test fixtures to resolve SonarCloud duplication --- .vscode/mcp.json | 8 ++ ciscode-ui-chart-kit-0.1.0.tgz | Bin 0 -> 9154 bytes src/__tests__/chart-test-utils.ts | 113 ++++++++++++++++ src/components/AreaChart/AreaChart.test.tsx | 139 ++++---------------- src/components/BarChart/BarChart.test.tsx | 123 ++++------------- src/components/LineChart/LineChart.test.tsx | 113 +++------------- 6 files changed, 186 insertions(+), 310 deletions(-) create mode 100644 .vscode/mcp.json create mode 100644 ciscode-ui-chart-kit-0.1.0.tgz create mode 100644 src/__tests__/chart-test-utils.ts diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..93ca411 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"] + } + } +} diff --git a/ciscode-ui-chart-kit-0.1.0.tgz b/ciscode-ui-chart-kit-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..36feeed25c9e87c5c844271582cdd63fc774883a GIT binary patch literal 9154 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 EUx`b>_5c6? literal 0 HcmV?d00001 diff --git a/src/__tests__/chart-test-utils.ts b/src/__tests__/chart-test-utils.ts new file mode 100644 index 0000000..0e17d99 --- /dev/null +++ b/src/__tests__/chart-test-utils.ts @@ -0,0 +1,113 @@ +import { render, screen } from '@testing-library/react'; +import { expect, it } from 'vitest'; +import React from 'react'; +import type { ChartDataset, ChartTheme } from '../types/chart.types'; + +export const singleDataset: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, +]; + +export const multiDatasets: ChartDataset[] = [ + { + id: 'ds1', + label: 'Sales', + data: [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 20 }, + ], + }, + { + id: 'ds2', + label: 'Revenue', + data: [ + { label: 'Jan', value: 30 }, + { label: 'Feb', value: 40 }, + ], + color: '#00FF00', + }, + { + id: 'ds3', + label: 'Costs', + data: [ + { label: 'Jan', value: 5 }, + { label: 'Feb', value: 15 }, + ], + }, +]; + +export const defaultTheme: ChartTheme = { + colors: ['#FF0000', '#00FF00', '#0000FF'], + fontFamily: 'Arial', + fontSize: 14, + grid: { color: '#ccc', display: true }, + tooltip: { enabled: true, backgroundColor: '#333' }, + legend: { display: true, position: 'bottom' }, +}; + +export function getCanvasData(testId: string) { + const canvas = screen.getByTestId(testId); + return { + canvas, + data: JSON.parse(canvas.getAttribute('data-data')!), + options: JSON.parse(canvas.getAttribute('data-options')!), + }; +} + +export function describeCommonChartBehavior( + ChartComponent: React.ComponentType<{ data: ChartDataset[]; theme: ChartTheme; height?: number }>, + canvasTestId: string, +) { + it('should render with 1 dataset without errors', () => { + render(React.createElement(ChartComponent, { data: singleDataset, theme: defaultTheme })); + expect(screen.getByTestId(canvasTestId)).toBeInTheDocument(); + }); + + it('should render with multiple datasets without errors', () => { + render(React.createElement(ChartComponent, { data: multiDatasets, theme: defaultTheme })); + const { data } = getCanvasData(canvasTestId); + expect(data.datasets).toHaveLength(3); + }); + + it('should wrap chart in div with width 100% and default height 300', () => { + const { container } = render( + React.createElement(ChartComponent, { data: singleDataset, theme: defaultTheme }), + ); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.width).toBe('100%'); + expect(wrapper.style.height).toBe('300px'); + }); + + it('should apply custom height', () => { + const { container } = render( + React.createElement(ChartComponent, { + data: singleDataset, + theme: defaultTheme, + height: 500, + }), + ); + const wrapper = container.firstElementChild as HTMLDivElement; + expect(wrapper.style.height).toBe('500px'); + }); + + it('should reflect theme settings in options', () => { + render(React.createElement(ChartComponent, { data: singleDataset, theme: defaultTheme })); + const { options } = getCanvasData(canvasTestId); + expect(options.plugins.tooltip.enabled).toBe(true); + expect(options.plugins.legend.position).toBe('bottom'); + expect(options.responsive).toBe(true); + }); + + it('should handle empty data array without crash', () => { + render(React.createElement(ChartComponent, { data: [], theme: defaultTheme })); + const { data } = getCanvasData(canvasTestId); + expect(data.labels).toEqual([]); + expect(data.datasets).toEqual([]); + }); +} diff --git a/src/components/AreaChart/AreaChart.test.tsx b/src/components/AreaChart/AreaChart.test.tsx index 72fba88..75274ca 100644 --- a/src/components/AreaChart/AreaChart.test.tsx +++ b/src/components/AreaChart/AreaChart.test.tsx @@ -1,7 +1,13 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import React from 'react'; -import type { ChartDataset, ChartTheme } from '../../types/chart.types'; +import { + singleDataset, + multiDatasets, + defaultTheme, + getCanvasData, + describeCommonChartBehavior, +} from '../../__tests__/chart-test-utils'; vi.mock('react-chartjs-2', () => ({ Line: (props: { data: unknown; options: unknown }) => ( @@ -26,159 +32,64 @@ vi.mock('chart.js', () => ({ import { AreaChart } from './AreaChart'; -const singleDataset: ChartDataset[] = [ - { - id: 'ds1', - label: 'Sales', - data: [ - { label: 'Jan', value: 10 }, - { label: 'Feb', value: 20 }, - ], - }, -]; - -const multiDatasets: ChartDataset[] = [ - { - id: 'ds1', - label: 'Sales', - data: [ - { label: 'Jan', value: 10 }, - { label: 'Feb', value: 20 }, - ], - }, - { - id: 'ds2', - label: 'Revenue', - data: [ - { label: 'Jan', value: 30 }, - { label: 'Feb', value: 40 }, - ], - color: '#00FF00', - }, - { - id: 'ds3', - label: 'Costs', - data: [ - { label: 'Jan', value: 5 }, - { label: 'Feb', value: 15 }, - ], - }, -]; - -const theme: ChartTheme = { - colors: ['#FF0000', '#00FF00', '#0000FF'], - fontFamily: 'Arial', - fontSize: 14, - grid: { color: '#ccc', display: true }, - tooltip: { enabled: true, backgroundColor: '#333' }, - legend: { display: true, position: 'bottom' }, -}; +const CANVAS_TEST_ID = 'area-canvas'; describe('AreaChart', () => { - it('should render with 1 dataset without errors', () => { - render(); - expect(screen.getByTestId('area-canvas')).toBeInTheDocument(); - }); - - it('should render with multiple datasets without errors', () => { - render(); - const canvas = screen.getByTestId('area-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); - expect(data.datasets).toHaveLength(3); - }); + describeCommonChartBehavior(AreaChart, CANVAS_TEST_ID); it('should set fill true on all datasets (area variant)', () => { - render(); - const canvas = screen.getByTestId('area-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); for (const ds of data.datasets) { expect(ds.fill).toBe(true); } }); it('should apply 20% opacity (append 33) to backgroundColor', () => { - render(); - const canvas = screen.getByTestId('area-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); expect(data.datasets[0].backgroundColor).toBe('#FF000033'); }); it('should use dataset color with 20% opacity when color is provided', () => { - render(); - const canvas = screen.getByTestId('area-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); expect(data.datasets[1].backgroundColor).toBe('#00FF0033'); }); - it('should wrap chart in div with width 100% and default height 300', () => { - const { container } = render(); - const wrapper = container.firstElementChild as HTMLDivElement; - expect(wrapper.style.width).toBe('100%'); - expect(wrapper.style.height).toBe('300px'); - }); - - it('should apply custom height', () => { - const { container } = render(); - const wrapper = container.firstElementChild as HTMLDivElement; - expect(wrapper.style.height).toBe('450px'); - }); - it('should set tension 0.4 on all datasets when smooth is true', () => { - render(); - const canvas = screen.getByTestId('area-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); for (const ds of data.datasets) { expect(ds.tension).toBe(0.4); } }); it('should not set tension when smooth is false', () => { - render(); - const canvas = screen.getByTestId('area-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); expect(data.datasets[0].tension).toBeUndefined(); }); it('should set scales.y.stacked true when stacked is true', () => { - render(); - const canvas = screen.getByTestId('area-canvas'); - const options = JSON.parse(canvas.getAttribute('data-options')!); + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); expect(options.scales.y.stacked).toBe(true); }); it('should not set stacked when stacked prop is false', () => { - render(); - const canvas = screen.getByTestId('area-canvas'); - const options = JSON.parse(canvas.getAttribute('data-options')!); + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); expect(options.scales.y.stacked).toBeUndefined(); }); it('should support stacked and smooth together', () => { - render(); - const canvas = screen.getByTestId('area-canvas'); - const options = JSON.parse(canvas.getAttribute('data-options')!); - const data = JSON.parse(canvas.getAttribute('data-data')!); + render(); + const { options, data } = getCanvasData(CANVAS_TEST_ID); expect(options.scales.y.stacked).toBe(true); for (const ds of data.datasets) { expect(ds.tension).toBe(0.4); expect(ds.fill).toBe(true); } }); - - it('should reflect theme settings in options', () => { - render(); - const canvas = screen.getByTestId('area-canvas'); - const options = JSON.parse(canvas.getAttribute('data-options')!); - expect(options.plugins.tooltip.enabled).toBe(true); - expect(options.plugins.legend.position).toBe('bottom'); - expect(options.responsive).toBe(true); - }); - - it('should handle empty data array without crash', () => { - render(); - const canvas = screen.getByTestId('area-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); - expect(data.labels).toEqual([]); - expect(data.datasets).toEqual([]); - }); }); diff --git a/src/components/BarChart/BarChart.test.tsx b/src/components/BarChart/BarChart.test.tsx index e845ee3..da80a97 100644 --- a/src/components/BarChart/BarChart.test.tsx +++ b/src/components/BarChart/BarChart.test.tsx @@ -1,9 +1,14 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import React from 'react'; -import type { ChartDataset, ChartTheme } from '../../types/chart.types'; +import { + singleDataset, + multiDatasets, + defaultTheme, + getCanvasData, + describeCommonChartBehavior, +} from '../../__tests__/chart-test-utils'; -// Mock react-chartjs-2 to avoid canvas dependency in jsdom vi.mock('react-chartjs-2', () => ({ Bar: (props: { data: unknown; options: unknown }) => ( ({ ), })); -// Mock chart.js register to avoid side-effects vi.mock('chart.js', () => ({ Chart: { register: vi.fn() }, CategoryScale: 'CategoryScale', @@ -26,121 +30,46 @@ vi.mock('chart.js', () => ({ import { BarChart } from './BarChart'; -const sampleData: ChartDataset[] = [ - { - id: 'ds1', - label: 'Sales', - data: [ - { label: 'Jan', value: 10 }, - { label: 'Feb', value: 20 }, - ], - }, -]; - -const threeDatasets: ChartDataset[] = [ - { - id: 'ds1', - label: 'Sales', - data: [ - { label: 'Jan', value: 10 }, - { label: 'Feb', value: 20 }, - ], - }, - { - id: 'ds2', - label: 'Revenue', - data: [ - { label: 'Jan', value: 30 }, - { label: 'Feb', value: 40 }, - ], - color: '#00FF00', - }, - { - id: 'ds3', - label: 'Costs', - data: [ - { label: 'Jan', value: 5 }, - { label: 'Feb', value: 15 }, - ], - }, -]; - -const theme: ChartTheme = { - colors: ['#FF0000', '#00FF00', '#0000FF'], - fontFamily: 'Arial', - fontSize: 14, - grid: { color: '#ccc', display: true }, - tooltip: { enabled: true, backgroundColor: '#333' }, - legend: { display: true, position: 'bottom' }, -}; +const CANVAS_TEST_ID = 'bar-canvas'; describe('BarChart', () => { - it('should render with 1 dataset without errors', () => { - render(); - expect(screen.getByTestId('bar-canvas')).toBeInTheDocument(); - }); - - it('should render with 3 datasets without errors', () => { - render(); - const canvas = screen.getByTestId('bar-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); - expect(data.datasets).toHaveLength(3); - }); - - it('should wrap chart in div with width 100% and default height 300', () => { - const { container } = render(); - const wrapper = container.firstElementChild as HTMLDivElement; - expect(wrapper.style.width).toBe('100%'); - expect(wrapper.style.height).toBe('300px'); - }); - - it('should apply custom height', () => { - const { container } = render(); - const wrapper = container.firstElementChild as HTMLDivElement; - expect(wrapper.style.height).toBe('500px'); - }); + describeCommonChartBehavior(BarChart, CANVAS_TEST_ID); it('should set stacked on both axes when stacked prop is true', () => { - render(); - const canvas = screen.getByTestId('bar-canvas'); - const options = JSON.parse(canvas.getAttribute('data-options')!); + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); expect(options.scales.x.stacked).toBe(true); expect(options.scales.y.stacked).toBe(true); }); it('should not set stacked when stacked prop is false', () => { - render(); - const canvas = screen.getByTestId('bar-canvas'); - const options = JSON.parse(canvas.getAttribute('data-options')!); + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); expect(options.scales.x.stacked).toBeUndefined(); expect(options.scales.y.stacked).toBeUndefined(); }); it('should set indexAxis to y when horizontal is true', () => { - render(); - const canvas = screen.getByTestId('bar-canvas'); - const options = JSON.parse(canvas.getAttribute('data-options')!); + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); expect(options.indexAxis).toBe('y'); }); it('should not set indexAxis when horizontal is false', () => { - render(); - const canvas = screen.getByTestId('bar-canvas'); - const options = JSON.parse(canvas.getAttribute('data-options')!); + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); expect(options.indexAxis).toBeUndefined(); }); it('should pass animation enabled by default (no explicit disable)', () => { - render(); - const canvas = screen.getByTestId('bar-canvas'); - const options = JSON.parse(canvas.getAttribute('data-options')!); + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); expect(options.animation).toBeUndefined(); }); it('should reflect theme tooltip and legend settings in options', () => { - render(); - const canvas = screen.getByTestId('bar-canvas'); - const options = JSON.parse(canvas.getAttribute('data-options')!); + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); expect(options.plugins.tooltip.enabled).toBe(true); expect(options.plugins.tooltip.backgroundColor).toBe('#333'); expect(options.plugins.legend.display).toBe(true); @@ -148,18 +77,16 @@ describe('BarChart', () => { }); it('should support stacked and horizontal together', () => { - render(); - const canvas = screen.getByTestId('bar-canvas'); - const options = JSON.parse(canvas.getAttribute('data-options')!); + render(); + const { options } = getCanvasData(CANVAS_TEST_ID); expect(options.scales.x.stacked).toBe(true); expect(options.scales.y.stacked).toBe(true); expect(options.indexAxis).toBe('y'); }); it('should handle empty data array without crash', () => { - render(); - const canvas = screen.getByTestId('bar-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); expect(data.labels).toEqual([]); expect(data.datasets).toEqual([]); }); diff --git a/src/components/LineChart/LineChart.test.tsx b/src/components/LineChart/LineChart.test.tsx index 6529a5b..1d6d890 100644 --- a/src/components/LineChart/LineChart.test.tsx +++ b/src/components/LineChart/LineChart.test.tsx @@ -1,7 +1,13 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import React from 'react'; -import type { ChartDataset, ChartTheme } from '../../types/chart.types'; +import { + singleDataset, + multiDatasets, + defaultTheme, + getCanvasData, + describeCommonChartBehavior, +} from '../../__tests__/chart-test-utils'; vi.mock('react-chartjs-2', () => ({ Line: (props: { data: unknown; options: unknown }) => ( @@ -26,117 +32,28 @@ vi.mock('chart.js', () => ({ import { LineChart } from './LineChart'; -const singleDataset: ChartDataset[] = [ - { - id: 'ds1', - label: 'Sales', - data: [ - { label: 'Jan', value: 10 }, - { label: 'Feb', value: 20 }, - ], - }, -]; - -const multiDatasets: ChartDataset[] = [ - { - id: 'ds1', - label: 'Sales', - data: [ - { label: 'Jan', value: 10 }, - { label: 'Feb', value: 20 }, - ], - }, - { - id: 'ds2', - label: 'Revenue', - data: [ - { label: 'Jan', value: 30 }, - { label: 'Feb', value: 40 }, - ], - color: '#00FF00', - }, - { - id: 'ds3', - label: 'Costs', - data: [ - { label: 'Jan', value: 5 }, - { label: 'Feb', value: 15 }, - ], - }, -]; - -const theme: ChartTheme = { - colors: ['#FF0000', '#00FF00', '#0000FF'], - fontFamily: 'Arial', - fontSize: 14, - grid: { color: '#ccc', display: true }, - tooltip: { enabled: true, backgroundColor: '#333' }, - legend: { display: true, position: 'bottom' }, -}; +const CANVAS_TEST_ID = 'line-canvas'; describe('LineChart', () => { - it('should render with 1 dataset without errors', () => { - render(); - expect(screen.getByTestId('line-canvas')).toBeInTheDocument(); - }); - - it('should render with multiple datasets without errors', () => { - render(); - const canvas = screen.getByTestId('line-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); - expect(data.datasets).toHaveLength(3); - }); - - it('should wrap chart in div with width 100% and default height 300', () => { - const { container } = render(); - const wrapper = container.firstElementChild as HTMLDivElement; - expect(wrapper.style.width).toBe('100%'); - expect(wrapper.style.height).toBe('300px'); - }); - - it('should apply custom height', () => { - const { container } = render(); - const wrapper = container.firstElementChild as HTMLDivElement; - expect(wrapper.style.height).toBe('500px'); - }); + describeCommonChartBehavior(LineChart, CANVAS_TEST_ID); it('should not set tension when smooth is false', () => { - render(); - const canvas = screen.getByTestId('line-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); expect(data.datasets[0].tension).toBeUndefined(); }); it('should set tension 0.4 on all datasets when smooth is true', () => { - render(); - const canvas = screen.getByTestId('line-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); for (const ds of data.datasets) { expect(ds.tension).toBe(0.4); } }); it('should not set fill for line variant', () => { - render(); - const canvas = screen.getByTestId('line-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); + render(); + const { data } = getCanvasData(CANVAS_TEST_ID); expect(data.datasets[0].fill).toBeUndefined(); }); - - it('should reflect theme settings in options', () => { - render(); - const canvas = screen.getByTestId('line-canvas'); - const options = JSON.parse(canvas.getAttribute('data-options')!); - expect(options.plugins.tooltip.enabled).toBe(true); - expect(options.plugins.legend.position).toBe('bottom'); - expect(options.responsive).toBe(true); - }); - - it('should handle empty data array without crash', () => { - render(); - const canvas = screen.getByTestId('line-canvas'); - const data = JSON.parse(canvas.getAttribute('data-data')!); - expect(data.labels).toEqual([]); - expect(data.datasets).toEqual([]); - }); });