Skip to content

Commit 8a86827

Browse files
author
Dylan Huang
committed
add filter functionality
1 parent 0581145 commit 8a86827

File tree

5 files changed

+215
-3
lines changed

5 files changed

+215
-3
lines changed

vite-app/src/GlobalState.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ export class GlobalState {
5959
}
6060

6161
get flattenedDatasetKeys() {
62-
return this.flattenedDataset.map((row) => Object.keys(row));
62+
if (this.flattenedDataset.length === 0) return [];
63+
return Object.keys(this.flattenedDataset[0]);
6364
}
6465

6566
get totalCount() {

vite-app/src/components/PivotTab.tsx

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,120 @@ const AggregatorSelector = ({
125125
</div>
126126
);
127127

128+
const FilterSelector = ({
129+
filters,
130+
onFiltersChange,
131+
availableKeys,
132+
}: {
133+
filters: Array<{ field: string; operator: string; value: string }>;
134+
onFiltersChange: (
135+
filters: Array<{ field: string; operator: string; value: string }>
136+
) => void;
137+
availableKeys: string[];
138+
}) => {
139+
const addFilter = () => {
140+
onFiltersChange([...filters, { field: "", operator: "==", value: "" }]);
141+
};
142+
143+
const removeFilter = (index: number) => {
144+
onFiltersChange(filters.filter((_, i) => i !== index));
145+
};
146+
147+
const updateFilter = (
148+
index: number,
149+
field: string,
150+
operator: string,
151+
value: string
152+
) => {
153+
const newFilters = [...filters];
154+
newFilters[index] = { field, operator, value };
155+
onFiltersChange(newFilters);
156+
};
157+
158+
const operators = [
159+
{ value: "==", label: "equals" },
160+
{ value: "!=", label: "not equals" },
161+
{ value: ">", label: "greater than" },
162+
{ value: "<", label: "less than" },
163+
{ value: ">=", label: "greater than or equal" },
164+
{ value: "<=", label: "less than or equal" },
165+
{ value: "contains", label: "contains" },
166+
{ value: "!contains", label: "not contains" },
167+
];
168+
169+
return (
170+
<div className="mb-4">
171+
<div className="text-xs font-medium text-gray-700 mb-2">Filters:</div>
172+
<div className="space-y-2">
173+
{filters.map((filter, index) => (
174+
<div key={index} className="flex items-center space-x-2">
175+
<Select
176+
value={filter.field}
177+
onChange={(e) =>
178+
updateFilter(
179+
index,
180+
e.target.value,
181+
filter.operator,
182+
filter.value
183+
)
184+
}
185+
size="sm"
186+
className="min-w-48"
187+
>
188+
<option value="">Select a field...</option>
189+
{availableKeys?.map((key) => (
190+
<option key={key} value={key}>
191+
{key}
192+
</option>
193+
))}
194+
</Select>
195+
<Select
196+
value={filter.operator}
197+
onChange={(e) =>
198+
updateFilter(index, filter.field, e.target.value, filter.value)
199+
}
200+
size="sm"
201+
className="min-w-32"
202+
>
203+
{operators.map((op) => (
204+
<option key={op.value} value={op.value}>
205+
{op.label}
206+
</option>
207+
))}
208+
</Select>
209+
<input
210+
type="text"
211+
value={filter.value}
212+
onChange={(e) =>
213+
updateFilter(
214+
index,
215+
filter.field,
216+
filter.operator,
217+
e.target.value
218+
)
219+
}
220+
placeholder="Value"
221+
className="px-2 py-1 text-xs border border-gray-300 rounded focus:outline-none focus:border-gray-500 min-w-32"
222+
/>
223+
<button
224+
onClick={() => removeFilter(index)}
225+
className="text-xs text-red-600 hover:text-red-800 px-2 py-1"
226+
>
227+
Remove
228+
</button>
229+
</div>
230+
))}
231+
<button
232+
onClick={addFilter}
233+
className="text-xs text-blue-600 hover:text-blue-800 px-2 py-1"
234+
>
235+
+ Add Filter
236+
</button>
237+
</div>
238+
</div>
239+
);
240+
};
241+
128242
const PivotTab = observer(() => {
129243
const [selectedRowFields, setSelectedRowFields] = useState<string[]>([
130244
"$.eval_metadata.name",
@@ -136,6 +250,9 @@ const PivotTab = observer(() => {
136250
"$.evaluation_result.score"
137251
);
138252
const [selectedAggregator, setSelectedAggregator] = useState<string>("avg");
253+
const [filters, setFilters] = useState<
254+
Array<{ field: string; operator: string; value: string }>
255+
>([]);
139256

140257
const createFieldHandler = (
141258
setter: React.Dispatch<React.SetStateAction<string[]>>
@@ -165,7 +282,48 @@ const PivotTab = observer(() => {
165282
};
166283
};
167284

168-
const availableKeys = state.flattenedDatasetKeys[0] || [];
285+
const availableKeys = state.flattenedDatasetKeys;
286+
287+
// Create filter function from filter configuration
288+
const createFilterFunction = (
289+
filters: Array<{ field: string; operator: string; value: string }>
290+
) => {
291+
if (filters.length === 0) return undefined;
292+
293+
return (record: any) => {
294+
return filters.every((filter) => {
295+
if (!filter.field || !filter.value) return true; // Skip incomplete filters
296+
297+
const fieldValue = record[filter.field];
298+
const filterValue = filter.value;
299+
300+
switch (filter.operator) {
301+
case "==":
302+
return String(fieldValue) === filterValue;
303+
case "!=":
304+
return String(fieldValue) !== filterValue;
305+
case ">":
306+
return Number(fieldValue) > Number(filterValue);
307+
case "<":
308+
return Number(fieldValue) < Number(filterValue);
309+
case ">=":
310+
return Number(fieldValue) >= Number(filterValue);
311+
case "<=":
312+
return Number(fieldValue) <= Number(filterValue);
313+
case "contains":
314+
return String(fieldValue)
315+
.toLowerCase()
316+
.includes(filterValue.toLowerCase());
317+
case "!contains":
318+
return !String(fieldValue)
319+
.toLowerCase()
320+
.includes(filterValue.toLowerCase());
321+
default:
322+
return true;
323+
}
324+
});
325+
};
326+
};
169327

170328
return (
171329
<div>
@@ -208,6 +366,12 @@ const PivotTab = observer(() => {
208366
onAggregatorChange={setSelectedAggregator}
209367
/>
210368

369+
<FilterSelector
370+
filters={filters}
371+
onFiltersChange={setFilters}
372+
availableKeys={availableKeys}
373+
/>
374+
211375
<PivotTable
212376
data={state.flattenedDataset}
213377
rowFields={
@@ -226,6 +390,7 @@ const PivotTab = observer(() => {
226390
aggregator={selectedAggregator as any}
227391
showRowTotals
228392
showColumnTotals
393+
filter={createFilterFunction(filters)}
229394
/>
230395
</div>
231396
);

vite-app/src/components/PivotTable.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ export interface PivotTableProps<T extends Record<string, unknown>> {
6060
* Default: "-".
6161
*/
6262
emptyValue?: React.ReactNode;
63+
/**
64+
* Optional filter function to apply to records before pivoting.
65+
* Return true to include the record, false to exclude it.
66+
*/
67+
filter?: (record: T) => boolean;
6368
}
6469

6570
function toKey(parts: unknown[]): string {
@@ -83,6 +88,7 @@ export function PivotTable<T extends Record<string, unknown>>({
8388
className = "",
8489
formatter = (v) => v.toLocaleString(undefined, { maximumFractionDigits: 3 }),
8590
emptyValue = "-",
91+
filter,
8692
}: PivotTableProps<T>) {
8793
const {
8894
rowKeyTuples,
@@ -97,6 +103,7 @@ export function PivotTable<T extends Record<string, unknown>>({
97103
columnFields,
98104
valueField,
99105
aggregator,
106+
filter,
100107
});
101108

102109
return (

vite-app/src/util/pivot.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,34 @@ describe('computePivot', () => {
159159
expect(res.cells[rKeyNorth][cKeyGadget].value).toBe(0)
160160
})
161161

162+
it('applies filter before pivoting', () => {
163+
const res = computePivot<Row>({
164+
data: rows,
165+
rowFields: ['region'],
166+
columnFields: ['product'],
167+
valueField: 'amount',
168+
aggregator: 'sum',
169+
filter: (record) => record.region === 'East', // Only include East region
170+
})
171+
172+
// Should only have East region rows
173+
expect(res.rowKeyTuples.map((t) => String(t))).toEqual(['East'])
174+
175+
// Should still have all product columns
176+
expect(res.colKeyTuples.map((t) => String(t))).toEqual(['Gadget', 'Widget'])
177+
178+
// East Gadget: 10 (string convertible) + invalid -> 10
179+
expect(res.cells['East']['Gadget'].value).toBe(10)
180+
// East Widget: 200
181+
expect(res.cells['East']['Widget'].value).toBe(200)
182+
183+
// West region should not be present
184+
expect(res.cells['West']).toBeUndefined()
185+
186+
// Grand total should only include East region data
187+
expect(res.grandTotal).toBe(210) // 10 + 200
188+
})
189+
162190
it('supports custom aggregator', () => {
163191
const maxAgg: Aggregator<Row> = (values) =>
164192
values.length ? Math.max(...values) : 0

vite-app/src/util/pivot.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ export interface ComputePivotParams<T extends Record<string, unknown>> {
103103
* @default "count"
104104
*/
105105
aggregator?: Aggregator<T>;
106+
107+
/**
108+
* Optional filter function to apply to records before pivoting.
109+
* Return true to include the record, false to exclude it.
110+
* This is useful for focusing analysis on specific subsets of data.
111+
*/
112+
filter?: (record: T) => boolean;
106113
}
107114

108115
/**
@@ -236,11 +243,15 @@ export function computePivot<T extends Record<string, unknown>>({
236243
columnFields,
237244
valueField,
238245
aggregator = "count",
246+
filter,
239247
}: ComputePivotParams<T>): PivotComputationResult<T> {
248+
// Apply filter first if provided
249+
const filteredData = filter ? data.filter(filter) : data;
250+
240251
// Filter out records that do not have defined values for all rowFields.
241252
// This avoids creating a row key of "undefined" and ensures such records
242253
// are not returned as part of the cells/row totals.
243-
const dataWithDefinedRows = data.filter((rec) =>
254+
const dataWithDefinedRows = filteredData.filter((rec) =>
244255
rowFields.every((f) => rec[f] !== undefined)
245256
);
246257
const rowKeyTuples: unknown[][] = [];

0 commit comments

Comments
 (0)