Skip to content

Commit cb32a50

Browse files
author
Dylan Huang
committed
TODO: test the pivot table logic
1 parent 39c5f88 commit cb32a50

File tree

8 files changed

+1198
-9
lines changed

8 files changed

+1198
-9
lines changed

vite-app/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
"dev": "vite",
88
"build": "tsc && vite build",
99
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10-
"preview": "vite preview"
10+
"preview": "vite preview",
11+
"test": "vitest",
12+
"test:run": "vitest run",
13+
"test:coverage": "vitest run --coverage"
1114
},
1215
"dependencies": {
1316
"mobx": "^6.13.7",
@@ -23,13 +26,15 @@
2326
"@types/react": "^19.1.8",
2427
"@types/react-dom": "^19.1.6",
2528
"@vitejs/plugin-react": "^4.6.0",
29+
"@vitest/coverage-v8": "^3.2.4",
2630
"eslint": "^9.30.1",
2731
"eslint-plugin-react-hooks": "^5.2.0",
2832
"eslint-plugin-react-refresh": "^0.4.20",
2933
"globals": "^16.3.0",
3034
"tailwindcss": "^4.1.11",
3135
"typescript": "~5.8.3",
3236
"typescript-eslint": "^8.35.1",
33-
"vite": "^7.0.4"
37+
"vite": "^7.0.4",
38+
"vitest": "^3.2.4"
3439
}
3540
}

vite-app/pnpm-lock.yaml

Lines changed: 606 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vite-app/src/components/Dashboard.tsx

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { observer } from "mobx-react";
2+
import { useMemo, useState } from "react";
23
import { state } from "../App";
34
import Button from "./Button";
45
import { EvaluationTable } from "./EvaluationTable";
6+
import PivotTable from "./PivotTable";
7+
import flattenJson from "../util/flatten-json";
58

69
interface DashboardProps {
710
onRefresh: () => void;
@@ -49,6 +52,14 @@ const Dashboard = observer(({ onRefresh }: DashboardProps) => {
4952
const expandAll = () => state.setAllRowsExpanded(true);
5053
const collapseAll = () => state.setAllRowsExpanded(false);
5154

55+
const [activeTab, setActiveTab] = useState<"table" | "pivot">("table");
56+
57+
const flattened = useMemo(() => {
58+
const flattenedDataset = state.sortedDataset.map((row) => flattenJson(row));
59+
console.log(flattenedDataset);
60+
return flattenedDataset;
61+
}, [state.sortedDataset]);
62+
5263
return (
5364
<div className="text-sm">
5465
{/* Summary Stats */}
@@ -59,11 +70,19 @@ const Dashboard = observer(({ onRefresh }: DashboardProps) => {
5970
</h2>
6071
{state.totalCount > 0 && (
6172
<div className="flex gap-2">
62-
<Button onClick={expandAll} size="sm" variant="secondary">
63-
Expand All
73+
<Button
74+
onClick={() => setActiveTab("table")}
75+
size="sm"
76+
variant="secondary"
77+
>
78+
Table
6479
</Button>
65-
<Button onClick={collapseAll} size="sm" variant="secondary">
66-
Collapse All
80+
<Button
81+
onClick={() => setActiveTab("pivot")}
82+
size="sm"
83+
variant="secondary"
84+
>
85+
Pivot
6786
</Button>
6887
</div>
6988
)}
@@ -73,14 +92,51 @@ const Dashboard = observer(({ onRefresh }: DashboardProps) => {
7392
<span className="font-semibold text-gray-700">Total Rows:</span>{" "}
7493
{state.totalCount}
7594
</div>
95+
{activeTab === "table" && state.totalCount > 0 && (
96+
<div className="flex gap-2">
97+
<Button onClick={expandAll} size="sm" variant="secondary">
98+
Expand All
99+
</Button>
100+
<Button onClick={collapseAll} size="sm" variant="secondary">
101+
Collapse All
102+
</Button>
103+
</div>
104+
)}
76105
</div>
77106
</div>
78107

79108
{/* Show empty state or main table */}
80109
{state.totalCount === 0 ? (
81110
<EmptyState onRefresh={onRefresh} />
82-
) : (
111+
) : activeTab === "table" ? (
83112
<EvaluationTable />
113+
) : (
114+
<div className="bg-white border border-gray-200 p-3">
115+
<div className="text-xs text-gray-600 mb-2">
116+
Showing pivot of flattened rows (JSONPath keys). Defaults: rows by
117+
eval name and status; columns by model; values average score.
118+
</div>
119+
<PivotTable
120+
// Flattened object list
121+
data={flattened}
122+
// Row keys
123+
rowFields={[
124+
"$.eval_metadata.name" as keyof (typeof flattened)[number],
125+
"$.eval_metadata.status" as keyof (typeof flattened)[number],
126+
]}
127+
// Column keys
128+
columnFields={[
129+
"$.input_metadata.completion_params.model" as keyof (typeof flattened)[number],
130+
]}
131+
// Value and aggregation
132+
valueField={
133+
"$.evaluation_result.score" as keyof (typeof flattened)[number]
134+
}
135+
aggregator="avg"
136+
showRowTotals
137+
showColumnTotals
138+
/>
139+
</div>
84140
)}
85141
</div>
86142
);
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import React from "react";
2+
import { computePivot } from "../util/pivot";
3+
4+
/**
5+
* Props for PivotTable.
6+
*/
7+
export interface PivotTableProps<T extends Record<string, unknown>> {
8+
/**
9+
* Source list of records to pivot.
10+
* Each record must expose the fields referenced by rowFields/columnFields/valueField.
11+
*/
12+
data: T[];
13+
/**
14+
* Ordered list of record keys used to group rows.
15+
* Example: ["region", "rep"] or flattened JSONPath keys if using a flattener.
16+
*/
17+
rowFields: (keyof T)[];
18+
/**
19+
* Ordered list of record keys used to group columns.
20+
* Example: ["product"] or flattened JSONPath keys if using a flattener.
21+
*/
22+
columnFields: (keyof T)[];
23+
/**
24+
* Record key containing the numeric value to aggregate per cell.
25+
* If omitted, aggregator defaults to counting records ("count").
26+
*/
27+
valueField?: keyof T;
28+
/**
29+
* Aggregation strategy. Built-ins: "count" | "sum" | "avg". Custom function allowed.
30+
* Default: "count". When using "sum"/"avg" or a custom function, numeric values are
31+
* extracted from valueField (if provided) and coerced via Number(). Non-finite values are ignored.
32+
*/
33+
aggregator?: Parameters<typeof computePivot<T>>[0]["aggregator"];
34+
/**
35+
* Whether to render a right-most total column per row. Default: true.
36+
*/
37+
showRowTotals?: boolean;
38+
/**
39+
* Whether to render a bottom total row per column (plus grand total if showRowTotals). Default: true.
40+
*/
41+
showColumnTotals?: boolean;
42+
/**
43+
* Optional extra class names applied to the wrapping container.
44+
*/
45+
className?: string;
46+
/**
47+
* Formatter applied to aggregated numeric values before rendering.
48+
* Default: toLocaleString with up to 3 fraction digits.
49+
*/
50+
formatter?: (value: number) => React.ReactNode;
51+
/**
52+
* Value to render when a cell has no data for the given row/column intersection.
53+
* Default: "-".
54+
*/
55+
emptyValue?: React.ReactNode;
56+
}
57+
58+
function toKey(parts: unknown[]): string {
59+
return parts.map((p) => String(p)).join("||");
60+
}
61+
62+
// removed local aggregation helpers; logic is in util/pivot.ts for testability
63+
64+
/**
65+
* Compact, generic pivot table component that renders a pivoted summary of arbitrary records.
66+
* Styling matches other components: white background, subtle borders, compact paddings.
67+
*/
68+
export function PivotTable<T extends Record<string, unknown>>({
69+
data,
70+
rowFields,
71+
columnFields,
72+
valueField,
73+
aggregator = "count",
74+
showRowTotals = true,
75+
showColumnTotals = true,
76+
className = "",
77+
formatter = (v) => v.toLocaleString(undefined, { maximumFractionDigits: 3 }),
78+
emptyValue = "-",
79+
}: PivotTableProps<T>) {
80+
const {
81+
rowKeyTuples,
82+
colKeyTuples,
83+
cells,
84+
rowTotals,
85+
colTotals,
86+
grandTotal,
87+
} = computePivot<T>({
88+
data,
89+
rowFields,
90+
columnFields,
91+
valueField,
92+
aggregator,
93+
});
94+
95+
return (
96+
<div
97+
className={`bg-white border border-gray-200 overflow-x-auto ${className}`}
98+
>
99+
<table className="w-full min-w-max">
100+
<thead className="bg-gray-50 border-b border-gray-200">
101+
<tr>
102+
{/* Row header labels */}
103+
{rowFields.map((f) => (
104+
<th
105+
key={String(f)}
106+
className="px-3 py-2 text-left text-xs font-semibold text-gray-700"
107+
>
108+
{String(f)}
109+
</th>
110+
))}
111+
{/* Column headers (flattened) */}
112+
{colKeyTuples.map((tuple, idx) => (
113+
<th
114+
key={`col-${idx}`}
115+
className="px-3 py-2 text-right text-xs font-semibold text-gray-700 whitespace-nowrap"
116+
>
117+
{tuple.map((v) => String(v ?? "")).join(" / ")}
118+
</th>
119+
))}
120+
{showRowTotals && (
121+
<th className="px-3 py-2 text-right text-xs font-semibold text-gray-700">
122+
Total
123+
</th>
124+
)}
125+
</tr>
126+
</thead>
127+
<tbody className="divide-y divide-gray-200">
128+
{rowKeyTuples.map((rTuple, rIdx) => {
129+
const rKey = toKey(rTuple);
130+
return (
131+
<tr key={`row-${rIdx}`} className="text-xs">
132+
{/* Row header cells */}
133+
{rTuple.map((value, i) => (
134+
<td
135+
key={`rh-${i}`}
136+
className="px-3 py-2 text-gray-900 whitespace-nowrap"
137+
>
138+
{String(value ?? "")}
139+
</td>
140+
))}
141+
{/* Data cells */}
142+
{colKeyTuples.map((cTuple, cIdx) => {
143+
const cKey = toKey(cTuple);
144+
const cell = cells[rKey]?.[cKey];
145+
const content = cell ? formatter(cell.value) : emptyValue;
146+
return (
147+
<td
148+
key={`c-${cIdx}`}
149+
className="px-3 py-2 text-right text-gray-900 whitespace-nowrap"
150+
>
151+
{content}
152+
</td>
153+
);
154+
})}
155+
{/* Row total */}
156+
{showRowTotals && (
157+
<td className="px-3 py-2 text-right text-gray-900 whitespace-nowrap font-medium">
158+
{formatter(rowTotals[rKey] ?? 0)}
159+
</td>
160+
)}
161+
</tr>
162+
);
163+
})}
164+
{showColumnTotals && (
165+
<tr className="bg-gray-50">
166+
{/* Total label spanning row header columns */}
167+
<td
168+
colSpan={Math.max(1, rowFields.length)}
169+
className="px-3 py-2 text-left text-xs font-semibold text-gray-700"
170+
>
171+
Total
172+
</td>
173+
{/* Column totals */}
174+
{colKeyTuples.map((cTuple, cIdx) => {
175+
const cKey = toKey(cTuple);
176+
return (
177+
<td
178+
key={`ct-${cIdx}`}
179+
className="px-3 py-2 text-right text-gray-900 whitespace-nowrap font-medium"
180+
>
181+
{formatter(colTotals[cKey] ?? 0)}
182+
</td>
183+
);
184+
})}
185+
{/* Grand total */}
186+
{showRowTotals && (
187+
<td className="px-3 py-2 text-right text-gray-900 whitespace-nowrap font-semibold">
188+
{formatter(grandTotal)}
189+
</td>
190+
)}
191+
</tr>
192+
)}
193+
</tbody>
194+
</table>
195+
</div>
196+
);
197+
}
198+
199+
export default PivotTable;

0 commit comments

Comments
 (0)