diff --git a/vite-app/src/GlobalState.tsx b/vite-app/src/GlobalState.tsx index 67fa7fc0..14db70ae 100644 --- a/vite-app/src/GlobalState.tsx +++ b/vite-app/src/GlobalState.tsx @@ -1,5 +1,16 @@ import { makeAutoObservable } from "mobx"; import type { EvaluationRow } from "./types/eval-protocol"; +import type { PivotConfig } from "./types/filters"; +import flattenJson from "./util/flatten-json"; + +// Default pivot configuration +const DEFAULT_PIVOT_CONFIG: PivotConfig = { + selectedRowFields: ["$.eval_metadata.name"], + selectedColumnFields: ["$.input_metadata.completion_params.model"], + selectedValueField: "$.evaluation_result.score", + selectedAggregator: "avg", + filters: [], +}; export class GlobalState { isConnected: boolean = false; @@ -7,11 +18,54 @@ export class GlobalState { dataset: Record = {}; // rollout_id -> expanded expandedRows: Record = {}; + // Pivot configuration + pivotConfig: PivotConfig; constructor() { + // Load pivot config from localStorage or use defaults + this.pivotConfig = this.loadPivotConfig(); makeAutoObservable(this); } + // Load pivot configuration from localStorage + private loadPivotConfig(): PivotConfig { + try { + const stored = localStorage.getItem("pivotConfig"); + if (stored) { + const parsed = JSON.parse(stored); + // Merge with defaults to handle any missing properties + return { ...DEFAULT_PIVOT_CONFIG, ...parsed }; + } + } catch (error) { + console.warn("Failed to load pivot config from localStorage:", error); + } + return { ...DEFAULT_PIVOT_CONFIG }; + } + + // Save pivot configuration to localStorage + private savePivotConfig() { + try { + localStorage.setItem("pivotConfig", JSON.stringify(this.pivotConfig)); + } catch (error) { + console.warn("Failed to save pivot config to localStorage:", error); + } + } + + // Update pivot configuration and save to localStorage + updatePivotConfig(updates: Partial) { + Object.assign(this.pivotConfig, updates); + this.savePivotConfig(); + } + + // Reset pivot configuration to defaults + resetPivotConfig() { + this.pivotConfig = { + ...DEFAULT_PIVOT_CONFIG, + filters: [], // Ensure filters is an empty array of FilterGroups + }; + this.savePivotConfig(); + } + upsertRows(dataset: EvaluationRow[]) { dataset.forEach((row) => { if (!row.execution_metadata?.rollout_id) { @@ -53,6 +107,18 @@ export class GlobalState { ); } + get flattenedDataset() { + return this.sortedDataset.map((row) => flattenJson(row)); + } + + get flattenedDatasetKeys() { + const keySet = new Set(); + this.flattenedDataset.forEach((row) => { + Object.keys(row).forEach((key) => keySet.add(key)); + }); + return Array.from(keySet); + } + get totalCount() { return Object.keys(this.dataset).length; } diff --git a/vite-app/src/components/Button.tsx b/vite-app/src/components/Button.tsx index 88ade8c0..34ba23f3 100644 --- a/vite-app/src/components/Button.tsx +++ b/vite-app/src/components/Button.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { commonStyles } from "../styles/common"; interface ButtonProps extends React.ButtonHTMLAttributes { variant?: "primary" | "secondary"; @@ -10,23 +11,11 @@ const Button = React.forwardRef( { className = "", variant = "secondary", size = "sm", children, ...props }, ref ) => { - const baseClasses = "border text-xs font-medium focus:outline-none"; - - const variantClasses = { - primary: "border-gray-300 bg-gray-100 text-gray-700 hover:bg-gray-200", - secondary: "border-gray-300 bg-gray-100 text-gray-700 hover:bg-gray-200", - }; - - const sizeClasses = { - sm: "px-2 py-0.5", - md: "px-3 py-1", - }; - return ( + )} + + ))} + {fields.length < 3 && ( + + )} + + + ); +}; + +const SingleFieldSelector = ({ + title, + field, + onFieldChange, + availableKeys, +}: { + title: string; + field: string; + onFieldChange: (value: string) => void; + availableKeys: string[]; +}) => ( +
+
{title}:
+ onFieldChange(value)} + options={[ + { value: "", label: "Select a field..." }, + ...(availableKeys?.map((key) => ({ value: key, label: key })) || []), + ]} + size="sm" + className="min-w-48" + /> +
+); + +const AggregatorSelector = ({ + aggregator, + onAggregatorChange, +}: { + aggregator: string; + onAggregatorChange: (value: string) => void; +}) => ( +
+
+ Aggregation Method: +
+ onAggregatorChange(value)} + options={[ + { value: "count", label: "Count" }, + { value: "sum", label: "Sum" }, + { value: "avg", label: "Average" }, + { value: "min", label: "Minimum" }, + { value: "max", label: "Maximum" }, + ]} + size="sm" + className="min-w-48" + /> +
+); + +const FilterSelector = ({ + filters, + onFiltersChange, + availableKeys, +}: { + filters: FilterGroup[]; + onFiltersChange: (filters: FilterGroup[]) => void; + availableKeys: string[]; +}) => { + const addFilterGroup = () => { + onFiltersChange([...filters, { logic: "AND", filters: [] }]); + }; + + const removeFilterGroup = (index: number) => { + onFiltersChange(filters.filter((_, i) => i !== index)); + }; + + const updateFilterGroupLogic = (index: number, logic: "AND" | "OR") => { + const newFilters = [...filters]; + newFilters[index] = { ...newFilters[index], logic }; + onFiltersChange(newFilters); + }; + + const addFilterToGroup = (groupIndex: number) => { + const newFilters = [...filters]; + newFilters[groupIndex].filters.push({ + field: "", + operator: "contains", + value: "", + type: "text", + }); + onFiltersChange(newFilters); + }; + + const removeFilterFromGroup = (groupIndex: number, filterIndex: number) => { + const newFilters = [...filters]; + newFilters[groupIndex].filters.splice(filterIndex, 1); + onFiltersChange(newFilters); + }; + + const updateFilterInGroup = ( + groupIndex: number, + filterIndex: number, + updates: Partial + ) => { + const newFilters = [...filters]; + newFilters[groupIndex].filters[filterIndex] = { + ...newFilters[groupIndex].filters[filterIndex], + ...updates, + }; + onFiltersChange(newFilters); + }; + + return ( +
+
Filters:
+
+ {filters.map((group, groupIndex) => ( +
+
+
+ + Group {groupIndex + 1}: + + + updateFilterGroupLogic(groupIndex, value as "AND" | "OR") + } + options={[ + { value: "AND", label: "AND (all filters must match)" }, + { value: "OR", label: "OR (any filter can match)" }, + ]} + size="sm" + className="min-w-48" + /> +
+ +
+ +
+ {group.filters.map((filter, filterIndex) => { + const fieldType = filter.type || getFieldType(filter.field); + const operators = getOperatorsForField(filter.field, fieldType); + + return ( +
+ { + const newField = value; + const newType = getFieldType(newField); + updateFilterInGroup(groupIndex, filterIndex, { + field: newField, + type: newType, + }); + }} + options={[ + { value: "", label: "Select a field..." }, + ...(availableKeys?.map((key) => ({ + value: key, + label: key, + })) || []), + ]} + size="sm" + className="min-w-48" + /> + + updateFilterInGroup(groupIndex, filterIndex, { + operator: value, + }) + } + options={operators.map((op) => ({ + value: op.value, + label: op.label, + }))} + size="sm" + className="min-w-32" + /> + + updateFilterInGroup(groupIndex, filterIndex, updates) + } + /> + +
+ ); + })} + + +
+
+ ))} + + +
+
+ ); +}; + +const PivotTab = observer(() => { + const { pivotConfig } = state; + + const updateRowFields = (index: number, value: string) => { + const newRowFields = [...pivotConfig.selectedRowFields]; + newRowFields[index] = value; + state.updatePivotConfig({ selectedRowFields: newRowFields }); + }; + + const updateColumnFields = (index: number, value: string) => { + const newColumnFields = [...pivotConfig.selectedColumnFields]; + newColumnFields[index] = value; + state.updatePivotConfig({ selectedColumnFields: newColumnFields }); + }; + + const updateValueField = (value: string) => { + state.updatePivotConfig({ selectedValueField: value }); + }; + + const updateAggregator = (value: string) => { + state.updatePivotConfig({ selectedAggregator: value }); + }; + + const updateFilters = (filters: FilterGroup[]) => { + state.updatePivotConfig({ filters }); + }; + + const createFieldHandler = ( + updater: (index: number, value: string) => void + ) => { + return (index: number, value: string) => { + updater(index, value); + }; + }; + + const createAddHandler = ( + fields: string[], + updater: (fields: string[]) => void + ) => { + return () => { + if (fields.length < 3) { + updater([...fields, ""]); + } + }; + }; + + const createRemoveHandler = ( + fields: string[], + updater: (fields: string[]) => void + ) => { + return (index: number) => { + updater(fields.filter((_, i) => i !== index)); + }; + }; + + const availableKeys = state.flattenedDatasetKeys; + + return ( +
+
+ Answer questions about your dataset by creating pivot tables that + summarize and analyze your data. Select fields for rows, columns, and + values to explore patterns, compare metrics across different dimensions, + and gain insights from your evaluation results. Use filters to focus on + specific subsets of your data. +
+ + {/* Controls Section with Reset Button */} +
+ +
+ + + state.updatePivotConfig({ selectedRowFields: fields }) + )} + onRemoveField={createRemoveHandler( + pivotConfig.selectedRowFields, + (fields) => state.updatePivotConfig({ selectedRowFields: fields }) + )} + availableKeys={availableKeys} + variant="row" + /> + + state.updatePivotConfig({ selectedColumnFields: fields }) + )} + onRemoveField={createRemoveHandler( + pivotConfig.selectedColumnFields, + (fields) => state.updatePivotConfig({ selectedColumnFields: fields }) + )} + availableKeys={availableKeys} + variant="column" + /> + + + + + + + + {/* + Filter Groups allow you to create complex filtering logic: + - Each group can use AND or OR logic internally + - Groups are combined with AND logic (all groups must match) + - Within a group: AND means all filters must match, OR means any filter can match + - Example: Group 1 (AND): field1 = "value1" AND field2 > 10 + - Example: Group 2 (OR): field3 = "value3" OR field4 = "value4" + - Result: (field1 = "value1" AND field2 > 10) AND (field3 = "value3" OR field4 = "value4") + */} + + field !== "" + ) as (keyof (typeof state.flattenedDataset)[number])[] + } + columnFields={ + pivotConfig.selectedColumnFields.filter( + (field) => field !== "" + ) as (keyof (typeof state.flattenedDataset)[number])[] + } + valueField={ + pivotConfig.selectedValueField as keyof (typeof state.flattenedDataset)[number] + } + aggregator={ + pivotConfig.selectedAggregator as + | "count" + | "sum" + | "avg" + | "min" + | "max" + } + showRowTotals + showColumnTotals + filter={createFilterFunction(pivotConfig.filters)} + /> +
+ ); +}); + +export default PivotTab; diff --git a/vite-app/src/components/PivotTable.tsx b/vite-app/src/components/PivotTable.tsx index a5b2e9e9..52b2213d 100644 --- a/vite-app/src/components/PivotTable.tsx +++ b/vite-app/src/components/PivotTable.tsx @@ -60,6 +60,11 @@ export interface PivotTableProps> { * Default: "-". */ emptyValue?: React.ReactNode; + /** + * Optional filter function to apply to records before pivoting. + * Return true to include the record, false to exclude it. + */ + filter?: (record: T) => boolean; } function toKey(parts: unknown[]): string { @@ -83,6 +88,7 @@ export function PivotTable>({ className = "", formatter = (v) => v.toLocaleString(undefined, { maximumFractionDigits: 3 }), emptyValue = "-", + filter, }: PivotTableProps) { const { rowKeyTuples, @@ -97,35 +103,66 @@ export function PivotTable>({ columnFields, valueField, aggregator, + filter, }); + debugger; return ( - {/* Row header labels */} + {/* Row header labels with enhanced styling */} {rowFields.map((f) => ( - {String(f)} + +
+ {String(f)} +
+
))} - {/* Column headers (flattened) */} + {/* Column headers with enhanced styling */} {colKeyTuples.map((tuple, idx) => ( - - {tuple.map((v) => String(v ?? "")).join(" / ")} + 0 ? "bg-green-50" : "bg-gray-50" + } + > +
0 ? "text-green-700" : "text-gray-700" + }`} + > + {tuple.map((v) => String(v ?? "")).join(" / ")} +
))} - {showRowTotals && Total} + {showRowTotals && ( + +
Total
+
+ )}
{rowKeyTuples.map((rTuple, rIdx) => { const rKey = toKey(rTuple); return ( - - {/* Row header cells */} + + {/* Row header cells with enhanced styling */} {rTuple.map((value, i) => ( - - {String(value ?? "")} + +
+ {String(value ?? "")} +
))} {/* Data cells */} @@ -134,15 +171,27 @@ export function PivotTable>({ const cell = cells[rKey]?.[cKey]; const content = cell ? formatter(cell.value) : emptyValue; return ( - - {content} + +
{content}
); })} - {/* Row total */} + {/* Row total with enhanced styling */} {showRowTotals && ( - - {formatter(rowTotals[rKey] ?? 0)} + +
+ {formatter(rowTotals[rKey] ?? 0)} +
)}
@@ -151,22 +200,41 @@ export function PivotTable>({ {showColumnTotals && ( {/* Total label spanning row header columns */} - - Total + +
Total
- {/* Column totals */} + {/* Column totals with enhanced styling */} {colKeyTuples.map((cTuple, cIdx) => { const cKey = toKey(cTuple); return ( - - {formatter(colTotals[cKey] ?? 0)} + +
+ {formatter(colTotals[cKey] ?? 0)} +
); })} - {/* Grand total */} + {/* Grand total with enhanced styling */} {showRowTotals && ( - - {formatter(grandTotal)} + +
+ {formatter(grandTotal)} +
)}
diff --git a/vite-app/src/components/SearchableSelect.tsx b/vite-app/src/components/SearchableSelect.tsx new file mode 100644 index 00000000..2fcca220 --- /dev/null +++ b/vite-app/src/components/SearchableSelect.tsx @@ -0,0 +1,241 @@ +import React, { useState, useRef, useEffect } from "react"; +import { commonStyles } from "../styles/common"; + +interface SearchableSelectProps { + options: { value: string; label: string }[]; + value: string; + onChange: (value: string) => void; + placeholder?: string; + size?: "sm" | "md"; + className?: string; + disabled?: boolean; +} + +const SearchableSelect = React.forwardRef< + HTMLDivElement, + SearchableSelectProps +>( + ( + { + options, + value, + onChange, + placeholder = "Select...", + size = "sm", + className = "", + disabled = false, + }, + ref + ) => { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [filteredOptions, setFilteredOptions] = useState(options); + const [dropdownPosition, setDropdownPosition] = useState<"left" | "right">( + "left" + ); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const containerRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + const filtered = options.filter( + (option) => + option.label.toLowerCase().includes(searchTerm.toLowerCase()) || + option.value.toLowerCase().includes(searchTerm.toLowerCase()) + ); + setFilteredOptions(filtered); + setHighlightedIndex(-1); // Reset highlighted index when options change + }, [searchTerm, options]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + setSearchTerm(""); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => + document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleSelect = (optionValue: string) => { + onChange(optionValue); + setIsOpen(false); + setSearchTerm(""); + setHighlightedIndex(-1); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filteredOptions.length - 1 ? prev + 1 : 0 + ); + break; + case "ArrowUp": + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredOptions.length - 1 + ); + break; + case "Enter": + e.preventDefault(); + if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) { + handleSelect(filteredOptions[highlightedIndex].value); + } + break; + case "Escape": + setIsOpen(false); + setSearchTerm(""); + setHighlightedIndex(-1); + break; + } + }; + + const calculateDropdownPosition = () => { + if (!containerRef.current) return "left"; + + const rect = containerRef.current.getBoundingClientRect(); + const windowWidth = window.innerWidth; + const estimatedDropdownWidth = 300; // Approximate width for dropdown content + + // If dropdown would overflow right edge, position it to the left + if (rect.left + estimatedDropdownWidth > windowWidth) { + return "right"; + } + return "left"; + }; + + const handleToggle = () => { + if (!disabled) { + if (!isOpen) { + setDropdownPosition(calculateDropdownPosition()); + } + setIsOpen(!isOpen); + if (!isOpen) { + setTimeout(() => inputRef.current?.focus(), 0); + } + } + }; + + const selectedOption = options.find((option) => option.value === value); + + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleToggle(); + } + }} + tabIndex={0} + role="combobox" + aria-expanded={isOpen} + aria-haspopup="listbox" + className={` + ${commonStyles.input.base} + ${commonStyles.input.size[size]} + cursor-pointer flex items-center justify-between + ${disabled ? "opacity-50 cursor-not-allowed" : ""} + `} + style={{ boxShadow: commonStyles.input.shadow }} + > + + {selectedOption ? selectedOption.label : placeholder} + + + + +
+ + {isOpen && ( +
+
+ setSearchTerm(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search..." + className={`${commonStyles.input.base} ${commonStyles.input.size.sm} w-full min-w-48`} + style={{ boxShadow: commonStyles.input.shadow }} + role="searchbox" + aria-label="Search options" + /> +
+
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((option, index) => ( +
handleSelect(option.value)} + onMouseEnter={() => setHighlightedIndex(index)} + className={`px-3 py-2 text-xs font-medium cursor-pointer hover:bg-gray-100 text-gray-700 border-b border-gray-100 last:border-b-0 ${ + highlightedIndex === index ? "bg-gray-100" : "" + }`} + role="option" + aria-selected={highlightedIndex === index} + tabIndex={-1} + > + {option.label} +
+ )) + ) : ( +
+ No options found +
+ )} +
+
+ )} +
+ ); + } +); + +SearchableSelect.displayName = "SearchableSelect"; + +export default SearchableSelect; diff --git a/vite-app/src/components/Select.tsx b/vite-app/src/components/Select.tsx index ea6a827e..3de52f26 100644 --- a/vite-app/src/components/Select.tsx +++ b/vite-app/src/components/Select.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { commonStyles } from "../styles/common"; interface SelectProps extends Omit, "size"> { @@ -8,19 +9,11 @@ interface SelectProps const Select = React.forwardRef( ({ className = "", size = "sm", children, ...props }, ref) => { - const baseClasses = - "border text-xs font-medium focus:outline-none bg-white text-gray-700 border-gray-300 hover:border-gray-400 focus:border-gray-500"; - - const sizeClasses = { - sm: "px-2 py-0.5", - md: "px-3 py-1", - }; - return (