Add pure filter/lock logic with Vitest tests
Adds src/app/table-filters.ts (DataRow type, FilterType, distinctValues, inferFilterType, matchesFilter, nextLockedRow) with 16 Vitest tests covering type inference thresholds, date-range encoding, and row-lock toggling. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
81
src/app/table-filters.test.ts
Normal file
81
src/app/table-filters.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CATEGORY_THRESHOLD,
|
||||
distinctValues,
|
||||
inferFilterType,
|
||||
matchesFilter,
|
||||
nextLockedRow,
|
||||
} from "./table-filters";
|
||||
|
||||
const D = (rows: Record<string, unknown>[]) => rows;
|
||||
|
||||
describe("distinctValues", () => {
|
||||
it("returns [] for empty data", () => {
|
||||
expect(distinctValues("x", [])).toEqual([]);
|
||||
});
|
||||
it("skips null/undefined/empty and dedupes", () => {
|
||||
const data = D([{ x: "a" }, { x: "b" }, { x: "a" }, { x: null }, { x: "" }, { x: undefined }]);
|
||||
expect(distinctValues("x", data).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
it("stringifies numbers", () => {
|
||||
expect(distinctValues("x", D([{ x: 1 }, { x: 2 }, { x: 1 }])).sort()).toEqual(["1", "2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferFilterType", () => {
|
||||
it("date when all non-empty values are YYYY-MM-DD", () => {
|
||||
const data = D([{ d: "2024-01-01" }, { d: "2025-12-31" }, { d: null }]);
|
||||
expect(inferFilterType("d", data)).toBe("date");
|
||||
});
|
||||
it("category when distinct count <= threshold", () => {
|
||||
const data = D([{ x: "A" }, { x: "B" }, { x: "C" }]);
|
||||
expect(inferFilterType("x", data)).toBe("category");
|
||||
});
|
||||
it("text when distinct count exceeds threshold", () => {
|
||||
const data = D(Array.from({ length: CATEGORY_THRESHOLD + 1 }, (_, i) => ({ x: String(i) })));
|
||||
expect(inferFilterType("x", data)).toBe("text");
|
||||
});
|
||||
it("text fallback when no data", () => {
|
||||
expect(inferFilterType("x", [])).toBe("text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesFilter", () => {
|
||||
it("text: case-insensitive substring", () => {
|
||||
expect(matchesFilter("text", "Pressure Gauge", "press")).toBe(true);
|
||||
expect(matchesFilter("text", "Pressure Gauge", "xyz")).toBe(false);
|
||||
});
|
||||
it("text: treats null/undefined as empty", () => {
|
||||
expect(matchesFilter("text", null, "anything")).toBe(false);
|
||||
expect(matchesFilter("text", null, "")).toBe(true);
|
||||
});
|
||||
it("category: exact equality", () => {
|
||||
expect(matchesFilter("category", "A", "A")).toBe(true);
|
||||
expect(matchesFilter("category", "A", "B")).toBe(false);
|
||||
});
|
||||
it("date: within closed range 'start|end'", () => {
|
||||
expect(matchesFilter("date", "2024-06-15", "2024-01-01|2024-12-31")).toBe(true);
|
||||
expect(matchesFilter("date", "2023-06-15", "2024-01-01|2024-12-31")).toBe(false);
|
||||
});
|
||||
it("date: open-ended range (empty side allowed)", () => {
|
||||
expect(matchesFilter("date", "2030-01-01", "|2024-12-31")).toBe(false);
|
||||
expect(matchesFilter("date", "2020-01-01", "2024-01-01|")).toBe(false);
|
||||
expect(matchesFilter("date", "2024-06-15", "2024-01-01|")).toBe(true);
|
||||
});
|
||||
it("date: empty cell never matches a non-empty filter", () => {
|
||||
expect(matchesFilter("date", "", "2024-01-01|2024-12-31")).toBe(false);
|
||||
expect(matchesFilter("date", null, "|")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextLockedRow", () => {
|
||||
it("locks when nothing locked", () => {
|
||||
expect(nextLockedRow(null, "5")).toBe("5");
|
||||
});
|
||||
it("unlocks when clicking the locked row", () => {
|
||||
expect(nextLockedRow("5", "5")).toBe(null);
|
||||
});
|
||||
it("switches when clicking a different row", () => {
|
||||
expect(nextLockedRow("5", "7")).toBe("7");
|
||||
});
|
||||
});
|
||||
75
src/app/table-filters.ts
Normal file
75
src/app/table-filters.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export interface DataRow {
|
||||
[key: string]: string | number | null;
|
||||
}
|
||||
|
||||
export type FilterType = "date" | "category" | "text";
|
||||
|
||||
export const CATEGORY_THRESHOLD = 50;
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** Non-empty distinct stringified values of a column. */
|
||||
export function distinctValues(
|
||||
key: string,
|
||||
data: Record<string, unknown>[]
|
||||
): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const row of data) {
|
||||
const v = row[key];
|
||||
if (v === null || v === undefined) continue;
|
||||
const s = String(v);
|
||||
if (s !== "") set.add(s);
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
|
||||
/** Infer filter UI type from the column's actual data. */
|
||||
export function inferFilterType(
|
||||
key: string,
|
||||
data: Record<string, unknown>[]
|
||||
): FilterType {
|
||||
const vals = distinctValues(key, data);
|
||||
if (vals.length === 0) return "text";
|
||||
if (vals.every((v) => DATE_RE.test(v))) return "date";
|
||||
if (vals.length <= CATEGORY_THRESHOLD) return "category";
|
||||
return "text";
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a cell match a single filter key?
|
||||
* - text: case-insensitive substring
|
||||
* - category: exact equality
|
||||
* - date: range encoded as "start|end" (either side may be ""), ISO string compare
|
||||
*/
|
||||
export function matchesFilter(
|
||||
type: FilterType,
|
||||
cellValue: unknown,
|
||||
filterValue: string
|
||||
): boolean {
|
||||
const cell =
|
||||
cellValue === null || cellValue === undefined ? "" : String(cellValue);
|
||||
|
||||
if (type === "text") {
|
||||
return cell.toLowerCase().includes(filterValue.toLowerCase());
|
||||
}
|
||||
if (type === "category") {
|
||||
return cell === filterValue;
|
||||
}
|
||||
// date
|
||||
if (cell === "") return false;
|
||||
const sep = filterValue.indexOf("|");
|
||||
if (sep < 0) return cell === filterValue; // defensive: single date exact
|
||||
const start = filterValue.slice(0, sep);
|
||||
const end = filterValue.slice(sep + 1);
|
||||
if (start && cell < start) return false;
|
||||
if (end && cell > end) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Toggle row-lock: click locked row -> unlock; click other -> switch. */
|
||||
export function nextLockedRow(
|
||||
prev: string | null,
|
||||
clickedKey: string
|
||||
): string | null {
|
||||
return prev === clickedKey ? null : clickedKey;
|
||||
}
|
||||
Reference in New Issue
Block a user