Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
14313d5
feat(component): add number formatting to single value and tooltips
alfredorubin96 Mar 22, 2026
7dd15c4
feat(component): add DataZoom support for bar and line charts
alfredorubin96 Mar 22, 2026
b826fac
feat(component): auto-rotate and truncate axis labels for bar chart
alfredorubin96 Mar 22, 2026
0672191
feat(component): add reference lines (markLine) for bar and line charts
alfredorubin96 Mar 22, 2026
0a584cd
feat(component): add donut center text and Top-N grouping for pie chart
alfredorubin96 Mar 22, 2026
ec704b6
feat(component): accessibility improvements — ARIA, keyboard nav, con…
alfredorubin96 Mar 23, 2026
980f138
fix(ci): add missing ECharts component mocks for CI
alfredorubin96 Mar 23, 2026
670bd51
fix(ci): add missing ECharts component mocks for CI
alfredorubin96 Mar 23, 2026
22975a5
fix(ci): add missing ECharts component mocks for CI
alfredorubin96 Mar 23, 2026
a58b1cf
fix(ci): add missing ECharts component mocks for CI
alfredorubin96 Mar 23, 2026
c5edb20
fix(ci): add missing ECharts component mocks for CI
alfredorubin96 Mar 23, 2026
d6305d8
fix(component,app): bracket wrapping in code editor + radar chart scale
alfredorubin96 Mar 23, 2026
5c166f6
chore: add Chart Catalog seed dashboard with full feature showcase
alfredorubin96 Mar 23, 2026
6dd50c9
fix(component): resolve nested button hydration error in CrossFilterTag
alfredorubin96 Mar 23, 2026
f31270f
chore: expand Chart Catalog to cover ALL chart options comprehensively
alfredorubin96 Mar 23, 2026
943834a
chore: add Map Chart page with geo data (filming locations + birthpla…
alfredorubin96 Mar 23, 2026
b7513f0
fix: markdown newlines and iframe URL in Chart Catalog seed
alfredorubin96 Mar 23, 2026
89dbb1d
fix: markdown table rendering, graph performance, and query typo
alfredorubin96 Mar 23, 2026
57b05f0
fix(component): prevent graph chart nodes clumping on initial render
alfredorubin96 Mar 23, 2026
bbea43b
feat(component): add number formatting to single value and tooltips
alfredorubin96 Mar 22, 2026
8e4a745
fix(ci): add missing ECharts component mocks for CI
alfredorubin96 Mar 23, 2026
3b02566
fix: aria description merge order and dependency array
alfredorubin96 Mar 24, 2026
6c2eab5
fix: normalize -1 axis label rotation sentinel to undefined
alfredorubin96 Mar 24, 2026
691453d
fix: resolve TS error, tooltip undefined seriesName, remove inert opt…
alfredorubin96 Mar 24, 2026
3d46ef3
fix: tooltip undefined seriesName, remove inert decimalPlaces options
alfredorubin96 Mar 24, 2026
675f7e9
fix: implement markLine for LineChart, add buildMarkLineFromRefs tests
alfredorubin96 Mar 24, 2026
1a29765
fix: resolve TypeScript compilation errors
alfredorubin96 Mar 24, 2026
357b181
fix: radar max fallback, seed data shape, and markdown ReDoS
alfredorubin96 Mar 24, 2026
e4fcf26
fix(e2e): use keyboard fallback when CM6 editor reports readonly
alfredorubin96 Mar 24, 2026
1c04d9c
fix(e2e): use keyboard fallback when CM6 editor reports readonly
alfredorubin96 Mar 24, 2026
33fcfe1
fix: resolve TypeScript compilation errors
alfredorubin96 Mar 24, 2026
1bfa403
Merge remote-tracking branch 'origin/feat/issue-134-datazoom' into re…
alfredorubin96 Mar 24, 2026
d59ad47
Merge remote-tracking branch 'origin/feat/issue-137-axis-labels' into…
alfredorubin96 Mar 24, 2026
9017b78
Merge remote-tracking branch 'origin/feat/issue-136-markline' into re…
alfredorubin96 Mar 24, 2026
e3448e6
feat: batch 1 — chart improvements (number format, DataZoom, axis lab…
alfredorubin96 Mar 24, 2026
8592737
Merge remote-tracking branch 'origin/fix/editor-brackets-radar-scale'…
alfredorubin96 Mar 24, 2026
0abfc99
Merge remote-tracking branch 'origin/release/a11y-and-fixes' into rel…
alfredorubin96 Mar 24, 2026
9be23f7
fix: enrich chart click point with original row data for click actions
alfredorubin96 Mar 24, 2026
beb9cbd
fix: wire missing chart props, fix E2E editor flakiness, add Chart Im…
alfredorubin96 Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,9 @@
return;
}

// Strategy 2: Keyboard fallback (for environments where cmView is not accessible)
if (dispatched === "no-view") {
// Strategy 2: Keyboard fallback (for environments where cmView is not accessible
// or when the view is temporarily readonly during initialization)
if (dispatched === "no-view" || dispatched === "readonly") {
await expect(cm).toHaveAttribute("contenteditable", "true", { timeout: 2_000 });
await cm.click();
await page.keyboard.press("ControlOrMeta+a");
Expand All @@ -162,9 +163,9 @@
return;
}

// Retry-worthy states: no-editor, readonly, dispatch-failed
// Retry-worthy states: no-editor, dispatch-failed
throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`);
}).toPass({ timeout: 20_000 });

Check failure on line 168 in app/e2e/fixtures.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Playwright)

[chromium] › e2e/design-system.spec.ts:110:7 › Design system — Deep Ocean palette & accessibility › Colorblind Mode option appears in Style tab for bar chart

2) [chromium] › e2e/design-system.spec.ts:110:7 › Design system — Deep Ocean palette & accessibility › Colorblind Mode option appears in Style tab for bar chart Error: Keyboard fallback: text not inserted Call Log: - Timeout 20000ms exceeded while waiting on the predicate at fixtures.ts:168 166 | // Retry-worthy states: no-editor, dispatch-failed 167 | throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); > 168 | }).toPass({ timeout: 20_000 }); | ^ 169 | } 170 | 171 | /** at typeInEditor (/home/runner/work/neoboard/neoboard/app/e2e/fixtures.ts:168:6) at addBarChartWithData (/home/runner/work/neoboard/neoboard/app/e2e/design-system.spec.ts:40:23) at /home/runner/work/neoboard/neoboard/app/e2e/design-system.spec.ts:113:20
}

/**
Expand Down
68 changes: 68 additions & 0 deletions component/src/charts/__tests__/axis-label-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import { buildCategoryAxisLabel } from "../chart-utils";

describe("buildCategoryAxisLabel", () => {
it("returns default config for small category count", () => {
const result = buildCategoryAxisLabel(5);
expect(result.rotate).toBe(0);
expect(result.formatter).toBeUndefined();
});

it("rotates labels at 30° when categories >= 8", () => {
const result = buildCategoryAxisLabel(8);
expect(result.rotate).toBe(30);
});

it("rotates labels at 45° when categories >= 15", () => {
const result = buildCategoryAxisLabel(15);
expect(result.rotate).toBe(45);
});

it("truncates labels longer than 15 chars with ellipsis", () => {
const result = buildCategoryAxisLabel(10);
expect(result.formatter).toBeDefined();
const fmt = result.formatter as (value: string) => string;
expect(fmt("Short")).toBe("Short");
expect(fmt("This is a very long label text")).toBe("This is a very\u2026");
});

it("respects custom maxLength", () => {
const result = buildCategoryAxisLabel(10, { maxLabelLength: 8 });
const fmt = result.formatter as (value: string) => string;
expect(fmt("12345678")).toBe("12345678");
expect(fmt("123456789")).toBe("1234567\u2026");
});

it("respects rotation override", () => {
const result = buildCategoryAxisLabel(100, { rotateOverride: 60 });
expect(result.rotate).toBe(60);
});

it("returns rotate 0 with override of 0", () => {
const result = buildCategoryAxisLabel(20, { rotateOverride: 0 });
expect(result.rotate).toBe(0);
});

it("always includes tooltip config for full text", () => {
const result = buildCategoryAxisLabel(10);
expect(result.tooltip).toEqual({ show: true });
});

it("returns show: false when compact is true", () => {
const result = buildCategoryAxisLabel(10, { compact: true });
expect(result.show).toBe(false);
});

it("normalizes -1 sentinel to automatic rotation", () => {
// -1 is the "automatic" sentinel from the UI; it should fall through
// to the category-count heuristic, not produce rotate: -1
const few = buildCategoryAxisLabel(5, { rotateOverride: -1 });
expect(few.rotate).toBe(0);

const medium = buildCategoryAxisLabel(10, { rotateOverride: -1 });
expect(medium.rotate).toBe(30);

const many = buildCategoryAxisLabel(20, { rotateOverride: -1 });
expect(many.rotate).toBe(45);
});
});
28 changes: 28 additions & 0 deletions component/src/charts/__tests__/base-chart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ vi.mock("echarts/components", () => ({
DataZoomComponent: vi.fn(),
AriaComponent: vi.fn(),
RadarComponent: vi.fn(),
MarkLineComponent: vi.fn(),
GraphicComponent: vi.fn(),
}));

describe("BaseChart", () => {
Expand Down Expand Up @@ -207,4 +209,30 @@ describe("BaseChart", () => {
{ notMerge: true },
);
});

// --- DataZoom ---

it("does not include dataZoom by default", () => {
render(<BaseChart options={{ title: { text: "Test" } }} />);
const call = mockSetOption.mock.calls[0][0];
expect(call.dataZoom).toBeUndefined();
});

it("injects dataZoom config when enableDataZoom is true", () => {
render(<BaseChart options={{ title: { text: "Test" } }} enableDataZoom />);
const call = mockSetOption.mock.calls[0][0];
expect(call.dataZoom).toBeDefined();
expect(Array.isArray(call.dataZoom)).toBe(true);
expect(call.dataZoom).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "inside" }),
]),
);
});

it("does not inject dataZoom when enableDataZoom is false", () => {
render(<BaseChart options={{ title: { text: "Test" } }} enableDataZoom={false} />);
const call = mockSetOption.mock.calls[0][0];
expect(call.dataZoom).toBeUndefined();
});
});
109 changes: 109 additions & 0 deletions component/src/charts/__tests__/format-number.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, it, expect } from "vitest";
import { formatNumber, buildTooltipFormatter } from "../chart-utils";

describe("formatNumber", () => {
it("returns plain number by default", () => {
expect(formatNumber(1234)).toBe("1234");
});

it("respects decimalPlaces", () => {
expect(formatNumber(3.14159, { decimalPlaces: 2 })).toBe("3.14");
});

it("pads with zeros when decimalPlaces exceeds precision", () => {
expect(formatNumber(5, { decimalPlaces: 2 })).toBe("5.00");
});

it("applies comma formatting", () => {
expect(formatNumber(1234567, { numberFormat: "comma" })).toBe("1,234,567");
});

it("applies comma formatting with decimalPlaces", () => {
expect(formatNumber(1234567.891, { numberFormat: "comma", decimalPlaces: 2 })).toBe("1,234,567.89");
});

it("applies compact notation", () => {
const result = formatNumber(1500000, { numberFormat: "compact" });
expect(result).toMatch(/1\.5M/i);
});

it("applies compact notation with decimalPlaces", () => {
const result = formatNumber(1234, { numberFormat: "compact", decimalPlaces: 1 });
expect(result).toMatch(/1\.2K/i);
});

it("applies percent format", () => {
expect(formatNumber(75, { numberFormat: "percent" })).toBe("75%");
});

it("applies percent format with decimalPlaces", () => {
expect(formatNumber(75.678, { numberFormat: "percent", decimalPlaces: 1 })).toBe("75.7%");
});

it("adds prefix", () => {
expect(formatNumber(100, { prefix: "$" })).toBe("$100");
});

it("adds suffix", () => {
expect(formatNumber(100, { suffix: " items" })).toBe("100 items");
});

it("combines prefix, suffix, decimalPlaces, and comma", () => {
expect(formatNumber(9876.5, { prefix: "$", suffix: "M", numberFormat: "comma", decimalPlaces: 1 })).toBe("$9,876.5M");
});

it("handles zero", () => {
expect(formatNumber(0, { decimalPlaces: 2 })).toBe("0.00");
});

it("handles negative numbers", () => {
expect(formatNumber(-42.567, { decimalPlaces: 1 })).toBe("-42.6");
});

it("returns string values unchanged", () => {
expect(formatNumber("N/A" as unknown as number)).toBe("N/A");
});
});

describe("buildTooltipFormatter", () => {
it("returns a function", () => {
const formatter = buildTooltipFormatter({});
expect(typeof formatter).toBe("function");
});

it("formats a single value with config", () => {
const formatter = buildTooltipFormatter({ decimalPlaces: 1, prefix: "$" });
// ECharts tooltip params shape for axis trigger
const result = formatter({
seriesName: "Revenue",
value: 1234.56,
name: "Jan",
marker: '<span style="color:#3b82f6">●</span>',
});
expect(result).toContain("$1,234.6");
expect(result).toContain("Revenue");
});

it("handles array params (axis trigger with multiple series)", () => {
const formatter = buildTooltipFormatter({ decimalPlaces: 0 });
const result = formatter([
{ seriesName: "A", value: 100.7, name: "Jan", marker: "●" },
{ seriesName: "B", value: 200.3, name: "Jan", marker: "●" },
]);
expect(result).toContain("101");
expect(result).toContain("200");
});

it("omits seriesName label when seriesName is undefined", () => {
const formatter = buildTooltipFormatter({});
const result = formatter({ value: 42, name: "Jan" });
expect(result).not.toContain("undefined");
expect(result).toContain("<b>");
});

it("omits seriesName label when seriesName is empty string", () => {
const formatter = buildTooltipFormatter({});
const result = formatter({ seriesName: "", value: 42, name: "Jan" });
expect(result).not.toContain(": <b>");
});
});
27 changes: 27 additions & 0 deletions component/src/charts/__tests__/line-chart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,31 @@ describe("LineChart", () => {
const optionsCall = mockSetOption.mock.calls[0][0];
expect(optionsCall.series[0].step).toBeUndefined();
});

// --- Reference lines ---

it("attaches markLine to the first series when referenceLines is provided", () => {
const refs = JSON.stringify([{ value: 50, label: "Target", color: "#ff0000" }]);
render(<LineChart data={sampleData} referenceLines={refs} />);
const optionsCall = mockSetOption.mock.calls[0][0];
expect(optionsCall.series[0].markLine).toBeDefined();
expect(optionsCall.series[0].markLine.data).toHaveLength(1);
expect(optionsCall.series[0].markLine.data[0].yAxis).toBe(50);
expect(optionsCall.series[0].markLine.data[0].label.formatter).toBe("Target");
expect(optionsCall.series[0].markLine.data[0].lineStyle.color).toBe("#ff0000");
});

it("does not attach markLine when referenceLines is not provided", () => {
render(<LineChart data={sampleData} />);
const optionsCall = mockSetOption.mock.calls[0][0];
expect(optionsCall.series[0].markLine).toBeUndefined();
});

it("only attaches markLine to the first series in multi-series", () => {
const refs = JSON.stringify([{ value: 100 }]);
render(<LineChart data={multiSeriesData} referenceLines={refs} />);
const optionsCall = mockSetOption.mock.calls[0][0];
expect(optionsCall.series[0].markLine).toBeDefined();
expect(optionsCall.series[1].markLine).toBeUndefined();
});
});
44 changes: 44 additions & 0 deletions component/src/charts/__tests__/pie-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { groupTopN } from "../chart-utils";
import type { PieChartDataPoint } from "../types";

describe("groupTopN", () => {
const data: PieChartDataPoint[] = [
{ name: "A", value: 100 },
{ name: "B", value: 80 },
{ name: "C", value: 60 },
{ name: "D", value: 40 },
{ name: "E", value: 20 },
];

it("returns all data when topN is 0 (disabled)", () => {
expect(groupTopN(data, 0)).toEqual(data);
});

it("returns all data when topN >= data length", () => {
expect(groupTopN(data, 5)).toEqual(data);
expect(groupTopN(data, 10)).toEqual(data);
});

it("groups remaining items into Other when topN < data length", () => {
const result = groupTopN(data, 3);
expect(result).toHaveLength(4);
expect(result[0].name).toBe("A");
expect(result[1].name).toBe("B");
expect(result[2].name).toBe("C");
expect(result[3].name).toBe("Other");
expect(result[3].value).toBe(60); // 40 + 20
});

it("handles topN of 1", () => {
const result = groupTopN(data, 1);
expect(result).toHaveLength(2);
expect(result[0].name).toBe("A");
expect(result[1].name).toBe("Other");
expect(result[1].value).toBe(200); // 80+60+40+20
});

it("returns empty array for empty input", () => {
expect(groupTopN([], 5)).toEqual([]);
});
});
Comment on lines +5 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add an unsorted topN case.

groupTopN() assumes descending input, but component/src/charts/pie-chart.tsx:75-78 passes raw data when sortSlices is false. With the default sortSlices=false, topN keeps the first N rows, not the largest N slices. This suite only uses pre-sorted fixtures, so it will not catch the bug.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@component/src/charts/__tests__/pie-utils.test.ts` around lines 5 - 44, The
test-suite misses an unsorted input case and groupTopN assumes input is
pre-sorted; add a test that passes an unsorted data array to groupTopN to catch
this, and fix groupTopN to select the top N by value regardless of input order
(e.g., inside groupTopN make a shallow copy and sort by value DESC to pick topN,
then aggregate the rest into "Other"); reference groupTopN in tests and
pie-chart.tsx's sortSlices flag to ensure behavior is consistent when
sortSlices=false.

Loading
Loading