-
Notifications
You must be signed in to change notification settings - Fork 0
fix(charts): forward trendEnabled and thresholdZones, and pick the numeric KPI column (#1397) #1473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| import { readFileSync, existsSync, readdirSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
|
|
||
| /** | ||
| * Ratchet: every option offered in the widget editor must be read by the | ||
| * plugin that advertises it (#1397). | ||
| * | ||
| * Two options — `trendEnabled` (single-value) and `thresholdZones` (gauge) — | ||
| * shipped fully built at both ends and inert in between: present in the editor, | ||
| * implemented in the component library, and never forwarded by the plugin. | ||
| * Setting them did nothing, with no error, and the seeded reference dashboard | ||
| * shipped tiles demonstrating both. A control that lies is worse than a missing | ||
| * one. | ||
| * | ||
| * **Why source text and not the Zod schema.** #1397 proposed asserting that | ||
| * every option key exists in the plugin's `settingsSchema`. That would not have | ||
| * caught either bug: these schemas end in `.passthrough()`, so an unknown key | ||
| * survives the parse untouched — `gaugeSettingsSchema.parse({thresholdZones})` | ||
| * returns it intact. The value died at the plugin's explicit prop mapping, | ||
| * a seam the schema never sees. Conversely a key can be in the schema and still | ||
| * be dropped there. So the check has to be "does the plugin actually read this", | ||
| * and the cheapest honest proxy is a reference in its source. | ||
| * | ||
| * **Why not import `getChartOptions`.** It lives in `@neoboard/components`, | ||
| * whose barrel pulls in `@neo4j-nvl` — a browser-only WebGL dependency that | ||
| * cannot load in the node test environment. Reading the option definitions from | ||
| * source keeps this test fast and dependency-free; the shape it parses is | ||
| * guarded by `finds option keys for every chart type` below, so a refactor that | ||
| * changed it would fail loudly rather than silently pass. | ||
| * | ||
| * This catches "advertised but never wired". It cannot catch "wired but | ||
| * misused" — that needs a per-option behavioural test. | ||
| */ | ||
|
|
||
| const OPTIONS_DIR = join( | ||
| __dirname, | ||
| "../../../../../component/src/components/composed/chart-options", | ||
| ); | ||
| const PLUGIN_DIR = join(__dirname, "../../../plugins"); | ||
|
|
||
| /** Files in chart-options/ that describe no chart type. */ | ||
| const NON_CHART_FILES = new Set(["index", "shared", "validate-iframe-url"]); | ||
|
|
||
| function chartTypesWithOptions(): string[] { | ||
| return readdirSync(OPTIONS_DIR) | ||
| .filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts")) | ||
| .map((f) => f.slice(0, -3)) | ||
| .filter((t) => !NON_CHART_FILES.has(t)) | ||
| .filter((t) => existsSync(join(PLUGIN_DIR, t))) | ||
| .sort(); | ||
| } | ||
|
|
||
| function optionKeys(type: string): string[] { | ||
| const src = readFileSync(join(OPTIONS_DIR, `${type}.ts`), "utf8"); | ||
| return [...src.matchAll(/^\s*key:\s*"([^"]+)"/gm)].map((m) => m[1]); | ||
| } | ||
|
|
||
| const APP_SRC = join(__dirname, "../../.."); | ||
|
|
||
| function readIfPresent(path: string): string { | ||
| return existsSync(path) ? readFileSync(path, "utf8") : ""; | ||
| } | ||
|
|
||
| /** | ||
| * A plugin's source, plus any `@/components/*` module it renders. | ||
| * | ||
| * Several plugins are thin wrappers that hand their settings straight to a | ||
| * shared renderer — `table` → `TableRenderer`, `form` → `FormWidgetRenderer` — | ||
| * and those renderers are where the options are actually read. Without | ||
| * following that one hop, every such plugin reports its entire option list as | ||
| * unforwarded, which is how this check first reported 29 problems where there | ||
| * were far fewer. | ||
| */ | ||
| function pluginSource(type: string): string { | ||
| const own = ["component.tsx", "settings.ts", "transform.ts"] | ||
| .map((f) => join(PLUGIN_DIR, type, f)) | ||
| .map(readIfPresent) | ||
| .join("\n"); | ||
|
|
||
| const delegated = [...own.matchAll(/from\s+"@\/components\/([\w./-]+)"/g)] | ||
| .flatMap((m) => [`${m[1]}.tsx`, `${m[1]}.ts`]) | ||
| .map((rel) => join(APP_SRC, "components", rel)) | ||
| .map(readIfPresent) | ||
| .join("\n"); | ||
|
|
||
| return `${own}\n${delegated}`; | ||
| } | ||
|
|
||
| /** | ||
| * Options that are still not forwarded. Every entry is a live instance of the | ||
| * #1397 bug and is tracked in a follow-up issue. This list may only shrink — | ||
| * adding to it means shipping another control that lies. | ||
| * | ||
| * `parameter-select` is a different case and is listed for a different reason: | ||
| * its options are consumed by dashboard-level code (`lib/shared/url-params.ts`, | ||
| * `lib/parameter/apply-param-defaults.ts`) rather than by the plugin's render | ||
| * path, so this check cannot see them. `syncToUrl` genuinely works (#1388); | ||
| * `defaultValue` genuinely does not, because `extractParamDefaults` has zero | ||
| * callers — which is #1421, not this issue. Note the limit that exposes: a key | ||
| * referenced only from dead code would satisfy a text search. This ratchet | ||
| * catches "advertised but never wired", not "wired to nothing". | ||
| */ | ||
| const KNOWN_UNFORWARDED: Record<string, string[]> = { | ||
| graph: ["nodeSize", "showRelationshipLabels", "physics"], | ||
| json: ["fontSize", "showCopyButton", "theme"], | ||
| line: ["samplingThreshold", "samplingMethod"], | ||
| map: ["markerSize", "showPopup"], | ||
| "parameter-select": ["defaultValue", "syncToUrl"], | ||
| }; | ||
|
|
||
| describe("chart option forwarding ratchet (#1397)", () => { | ||
| const types = chartTypesWithOptions(); | ||
|
|
||
| it("finds chart types to check", () => { | ||
| expect(types.length).toBeGreaterThan(5); | ||
| }); | ||
|
|
||
| it("finds option keys for every chart type", () => { | ||
| // Guards the source-parsing above: if the option-definition shape ever | ||
| // changes, every type would yield zero keys and the ratchet would pass | ||
| // vacuously. Fail loudly instead. | ||
| const empty = types.filter((t) => optionKeys(t).length === 0); | ||
| expect(empty).toEqual([]); | ||
| }); | ||
|
|
||
| it.each(types)("%s reads every option it advertises", (type) => { | ||
| const src = pluginSource(type); | ||
| const allowed = new Set(KNOWN_UNFORWARDED[type] ?? []); | ||
| const unforwarded = optionKeys(type).filter( | ||
| (k) => !allowed.has(k) && !new RegExp(`\\b${k}\\b`).test(src), | ||
| ); | ||
| expect(unforwarded).toEqual([]); | ||
| }); | ||
|
|
||
| it("the allowlist names only options that are genuinely unforwarded", () => { | ||
| // Stops the list rotting: once an entry is fixed it must be removed, so the | ||
| // ratchet keeps tightening instead of quietly permitting a working key. | ||
| for (const [type, keys] of Object.entries(KNOWN_UNFORWARDED)) { | ||
| const src = pluginSource(type); | ||
| for (const key of keys) { | ||
| expect( | ||
| new RegExp(`\\b${key}\\b`).test(src), | ||
| `${type}.${key} is allowlisted but is now read — remove it`, | ||
| ).toBe(false); | ||
| } | ||
| } | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| import { describe, it, expect, vi } from "vitest"; | ||
| import { render, screen } from "@testing-library/react"; | ||
| import { gaugePlugin } from "../gauge"; | ||
| import { singleValuePlugin } from "../single-value"; | ||
| import { transformToValueData } from "../single-value/transform"; | ||
|
|
||
| /** | ||
| * #1397 — `thresholdZones` and `trendEnabled` were advertised in the widget | ||
| * editor, implemented in the component library, and never handed from the | ||
| * plugin to the chart. These assert the exact seam that was broken: the props | ||
| * the plugin actually passes. | ||
| */ | ||
|
|
||
| vi.mock("next/dynamic", () => ({ | ||
| default: () => { | ||
| const Stub = (props: Record<string, unknown>) => ( | ||
| <div | ||
| data-testid="chart" | ||
| data-threshold-zones={String(props.thresholdZones ?? "")} | ||
| data-trend-direction={ | ||
| (props.trend as { direction?: string } | undefined)?.direction ?? "" | ||
| } | ||
| data-trend-label={ | ||
| (props.trend as { label?: string } | undefined)?.label ?? "" | ||
| } | ||
| data-value={String(props.value ?? "")} | ||
| /> | ||
| ); | ||
| Stub.displayName = "ChartStub"; | ||
| return Stub; | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock("@neoboard/components", () => ({ | ||
| Skeleton: () => null, | ||
| getChartOptions: () => [], | ||
| })); | ||
|
|
||
| const GaugeComponent = gaugePlugin.component; | ||
| const SingleValueComponent = singleValuePlugin.component; | ||
|
|
||
| describe("gauge thresholdZones forwarding (#1397)", () => { | ||
| const zones = | ||
| '[{"value":30,"color":"#ef4444"},{"value":100,"color":"#22c55e"}]'; | ||
|
|
||
| it("passes thresholdZones through to the chart", () => { | ||
| render(<GaugeComponent data={[]} settings={{ thresholdZones: zones }} />); | ||
| expect( | ||
| screen.getByTestId("chart").getAttribute("data-threshold-zones"), | ||
| ).toBe(zones); | ||
| }); | ||
|
|
||
| it("passes nothing when the option is unset", () => { | ||
| render(<GaugeComponent data={[]} settings={{}} />); | ||
| expect( | ||
| screen.getByTestId("chart").getAttribute("data-threshold-zones"), | ||
| ).toBe(""); | ||
| }); | ||
| }); | ||
|
|
||
| describe("single-value trendEnabled forwarding (#1397)", () => { | ||
| /** The shape the editor's own instruction produces: two rows, label + value. */ | ||
| const twoRows = [ | ||
| { label: "2026-03", value: 100 }, | ||
| { label: "2026-02", value: 80 }, | ||
| ]; | ||
|
|
||
| it("renders an upward trend when enabled and a previous row exists", () => { | ||
| render( | ||
| <SingleValueComponent | ||
| data={transformToValueData(twoRows)} | ||
| settings={{ trendEnabled: true }} | ||
| />, | ||
| ); | ||
| const el = screen.getByTestId("chart"); | ||
| expect(el.getAttribute("data-trend-direction")).toBe("up"); | ||
| expect(el.getAttribute("data-trend-label")).toBe("25.0%"); | ||
| }); | ||
|
|
||
| it("renders a downward trend when the value fell", () => { | ||
| render( | ||
| <SingleValueComponent | ||
| data={transformToValueData([...twoRows].reverse())} | ||
| settings={{ trendEnabled: true }} | ||
| />, | ||
| ); | ||
| expect( | ||
| screen.getByTestId("chart").getAttribute("data-trend-direction"), | ||
| ).toBe("down"); | ||
| }); | ||
|
|
||
| it("renders no trend when the option is off", () => { | ||
| render( | ||
| <SingleValueComponent | ||
| data={transformToValueData(twoRows)} | ||
| settings={{ trendEnabled: false }} | ||
| />, | ||
| ); | ||
| expect( | ||
| screen.getByTestId("chart").getAttribute("data-trend-direction"), | ||
| ).toBe(""); | ||
| }); | ||
|
|
||
| it("renders no trend when there is only one row to compare", () => { | ||
| render( | ||
| <SingleValueComponent | ||
| data={transformToValueData([{ label: "2026-03", value: 100 }])} | ||
| settings={{ trendEnabled: true }} | ||
| />, | ||
| ); | ||
| expect( | ||
| screen.getByTestId("chart").getAttribute("data-trend-direction"), | ||
| ).toBe(""); | ||
| }); | ||
|
|
||
| // The headline symptom of #1397: following the editor's "requires 2 rows" | ||
| // instruction rendered the date column as the KPI, e.g. `$2026-03`. | ||
| it("shows the numeric column as the headline value, not the label", () => { | ||
| render( | ||
| <SingleValueComponent | ||
| data={transformToValueData(twoRows)} | ||
| settings={{ trendEnabled: true }} | ||
| />, | ||
| ); | ||
| expect(screen.getByTestId("chart").getAttribute("data-value")).toBe("100"); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,11 @@ | |
| import type { StylingRule } from "@neoboard/components"; | ||
| import { normalizeValue } from "@/lib/shared/normalize-value"; | ||
| import { defineChartPlugin } from "../registry"; | ||
| import { transformToValueData, validateValueData } from "./transform"; | ||
| import { | ||
| transformToValueData, | ||
| validateValueData, | ||
| type SingleValueData, | ||
| } from "./transform"; | ||
| import { type PluginProps } from "../utils"; | ||
| import { singleValueSettingsSchema } from "./settings"; | ||
| import { safeParseSettings } from "@/lib/plugin/safe-parse-settings"; | ||
|
|
@@ -24,6 +28,34 @@ | |
| { ssr: false, loading: () => <Skeleton className="w-full h-full" /> }, | ||
| ); | ||
|
|
||
| /** | ||
| * Percentage change against the previous period, or undefined when the trend is | ||
| * off or there is nothing to compare. A previous value of 0 yields a direction | ||
| * without a percentage — dividing by it would render `Infinity%`. | ||
| */ | ||
| function buildTrend( | ||
| enabled: boolean, | ||
| value: string | number, | ||
| previous: number | undefined, | ||
| ): { direction: "up" | "down" | "neutral"; label?: string } | undefined { | ||
| if (!enabled || typeof value !== "number" || previous === undefined) { | ||
| return undefined; | ||
| } | ||
| const delta = value - previous; | ||
| const direction = delta > 0 ? "up" : delta < 0 ? "down" : "neutral"; | ||
|
Check warning on line 45 in app/src/plugins/single-value/component.tsx
|
||
| if (previous === 0) { | ||
| return { | ||
| direction, | ||
| label: direction === "neutral" ? "no change" : undefined, | ||
| }; | ||
| } | ||
| const pct = Math.abs(delta / previous) * 100; | ||
| return { | ||
| direction, | ||
| label: direction === "neutral" ? "no change" : `${pct.toFixed(1)}%`, | ||
|
Comment on lines
+46
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Test the zero-baseline trend branch. When Add a jsdom test with a zero previous value. Assert the direction and that no As per coding guidelines, “Every new behavior, bug fix, and edge case must have a test, written before implementation, following Red → Green → Refactor.” 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| }; | ||
| } | ||
|
|
||
| function SingleValuePluginComponent({ | ||
| data, | ||
| settings: raw, | ||
|
|
@@ -35,11 +67,24 @@ | |
| raw, | ||
| "single-value", | ||
| ); | ||
| const rawData = data ?? 0; | ||
| const val = | ||
| typeof rawData === "number" || typeof rawData === "string" | ||
| ? rawData | ||
| : (normalizeValue(rawData) ?? String(rawData)); | ||
| // `transform` yields { value, previous }; older callers may still hand over a | ||
| // bare scalar, so both shapes are accepted. | ||
| const parsed: SingleValueData = | ||
| data !== null && typeof data === "object" && "value" in data | ||
| ? (data as SingleValueData) | ||
| : { value: (normalizeValue(data) ?? 0) as string | number }; | ||
|
|
||
| const val = parsed.value; | ||
|
|
||
| // The chart takes a computed { direction, label }, not a boolean — so the | ||
| // option could never have been forwarded as-is. It needs the previous row, | ||
| // which is why the transform now carries it (#1397). | ||
| const trend = buildTrend( | ||
| settings.trendEnabled === true, | ||
| val, | ||
| parsed.previous, | ||
| ); | ||
|
|
||
| return ( | ||
| <SingleValueChart | ||
| value={ | ||
|
|
@@ -51,6 +96,7 @@ | |
| fontSize={settings.fontSize} | ||
| numberFormat={settings.numberFormat} | ||
| decimalPlaces={settings.decimalPlaces} | ||
| trend={trend} | ||
| stylingRules={stylingRules as StylingRule[] | undefined} | ||
| paramValues={paramValues} | ||
| /> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make the ratchet inspect executable option reads.
pluginSource()includessettings.tsand raw comments.thresholdZonesandtrendEnablednow occur in their schemas, so Line 131 passes even if their chart prop mapping is removed. Comments can also satisfy the current word match.Exclude declarations and comments from this check. Assert a concrete settings read or chart-prop mapping, preferably with an AST-based predicate. Apply the same predicate to the allowlist expiry check.
🤖 Prompt for AI Agents