From d130d46e7e63e9ecc40937742a1b401ce8adb1ce Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 20:30:46 -0700 Subject: [PATCH 1/9] docs: spec for filter engine operator model (sub-project 1/3) Typed operator-based filters (text/number/enum/date), AND-combined, replacing the substring-only Record model. Headless engine only; UI and docs/hero adoption are later sub-projects. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-18-filter-engine-model-design.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-18-filter-engine-model-design.md diff --git a/docs/superpowers/specs/2026-06-18-filter-engine-model-design.md b/docs/superpowers/specs/2026-06-18-filter-engine-model-design.md new file mode 100644 index 0000000..051347f --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-filter-engine-model-design.md @@ -0,0 +1,200 @@ +# Filter engine operator model — design (sub-project 1 of 3) + +**Date:** 2026-06-18 +**Branch:** `claude/filter-operators` (off `main` after #176) +**Status:** approved (pending written-spec review) + +## Context + +Filtering today is intentionally minimal: `state.filters: Record`, +case-insensitive substring, AND-combined (`packages/grid-core/src/derived-rows.ts:49-86`, +state in `create-grid-core.ts:90`, methods `setFilter`/`clearFilters`/`replaceFilters`). +The #1 ranked v1 gap is a real operator-based filter model with a built-in UI. + +This is **sub-project 1 of 3** for that effort: + +1. **Engine operator model** (this spec) — headless typed filters in `grid-core` + `core`. +2. Built-in header-menu filter UI (`@pretable/react` + `@pretable/ui`) — later spec. +3. Docs + hero adoption — later spec. + +Everything else depends on this, so it ships first. No backcompat (pre-1.0, no external +consumers): the string filter model is **replaced**, not aliased. + +## Goal + +Replace the substring-only filter model with a typed, operator-based one covering four +column-type families (text, number, enum/set, date), AND-combined across columns, with +per-column configuration and the engine helpers the future UI needs. Fully headless — +no React, no DOM, no UI in this sub-project. + +## Non-goals + +- OR / boolean-tree logic (deferred to a future "advanced query" feature). Multi-value + `isAnyOf` (OR within a column) + `between` (ranges) cover the common cases. +- Any React/UI, the header menu, `onFiltersChange` (sub-project 2). +- Docs and hero migration beyond what's needed to keep the repo compiling (sub-project 3). +- Per-column custom predicate functions / pluggable operators (possible later; not now). + +## Filter model + +```ts +/** @public */ +export type FilterOperator = + // text + | "contains" | "notContains" | "equals" | "notEquals" | "startsWith" | "endsWith" + // number + | "gt" | "gte" | "lt" | "lte" | "between" + // enum / set + | "isAnyOf" | "isNoneOf" + // date + | "on" | "before" | "after" | "dateBetween" + // shared (any type) + | "isEmpty" | "isNotEmpty"; + +/** @public */ +export type FilterValue = + | string // text ops, single date (ISO yyyy-mm-dd) for on/before/after + | number // number comparisons + | [number, number] // between + | [string, string] // dateBetween (ISO start, ISO end) + | string[] // isAnyOf / isNoneOf + | null; // isEmpty / isNotEmpty (no operand) + +/** @public — one column's filter. `value` omitted for isEmpty/isNotEmpty. */ +export interface ColumnFilter { + operator: FilterOperator; + value?: FilterValue; +} + +// snapshot.filters becomes: Record // AND across columns +``` + +Notes: +- `equals`/`notEquals` live in the **text** family (case-insensitive string equality). + Numeric equality uses the number family's `equals` semantics via the column's + `filterType` (the operator string `equals` is shared text+number; evaluation branches + on `filterType`). To avoid ambiguity, evaluation is driven by `filterType`, not by the + operator name alone. +- Dates are carried as ISO `yyyy-mm-dd` strings (UI sub-project owns the picker). The + engine parses with `Date.parse` and compares by day-resolution timestamp. + +## Per-column configuration + +Additions/changes to `PretableColumn` (`packages/grid-core/src/types.ts:66-91`): + +```ts +/** @public */ +export type FilterType = "text" | "number" | "date" | "enum"; + +interface PretableColumn { + // ...existing... + filterType?: FilterType; // default "text" + filterable?: boolean; // EXISTING — keep; default true (a false value means + // the engine ignores any filter targeting this column) + filterOptions?: { value: string; label?: string }[]; // enum only; if omitted, the + // distinct values are auto-derived from the rows. +} +``` + +- Filtering reads the cell via the existing `value` accessor (`readCellValue`, + `derived-rows.ts:139`), falling back to `row[columnId]`. +- `filterable: false` → that column's entry in `filters` is ignored (like a non-existent + column today). The UI sub-project will also hide the affordance. + +## Evaluation (replace `matchesFilters` / `resolveFilters`) + +In `packages/grid-core/src/derived-rows.ts`: + +- `resolveFilters(filters, columnMap)` resolves each `[columnId, ColumnFilter]` to the + column (skip if missing or `filterable === false`), carrying `filterType` and operator. +- `matchesFilters(row, resolved)` evaluates each, AND-combined (early-exit on first + false). A single pure `evaluateFilter(cellValue, filterType, operator, value)` does the + per-operator work: + - **empty semantics:** `isEmpty` ≡ value is `null`, `undefined`, or `""` (after + `String().trim()` for text; for number, also `NaN` after coercion); `isNotEmpty` = negation. + - **text:** `String(cell).toLowerCase()` vs `String(value).toLowerCase()` — + `contains`/`notContains` (`includes`), `equals`/`notEquals` (`===`), + `startsWith`/`endsWith`. + - **number:** `Number(cell)`; if `NaN`, the row fails any comparison (except + `isEmpty`). `gt/gte/lt/lte`; `between` inclusive on `[min, max]` (tolerate reversed + bounds by normalizing min≤max); `equals/notEquals` numeric. + - **enum:** `isAnyOf` → `value.includes(String(cell))`; `isNoneOf` → negation. Empty + selection array ⇒ no constraint (row passes), so an empty checklist doesn't hide + everything. + - **date:** parse cell + operand(s) to day-resolution ms; `on` (same day), + `before` (`<`), `after` (`>`), `dateBetween` inclusive. Unparseable cell fails + (except `isEmpty`). +- A filter whose `value` is "blank" (empty string, empty array, `undefined` for a + non-empty operator) is treated as **inactive** (row passes) — mirrors today's "drop + empty needle" behavior so a half-entered filter doesn't blank the grid. + +## Engine API (replace string methods — no aliases) + +In `packages/grid-core/src/create-grid-core.ts` and the public `PretableGrid` +(`packages/core/src/pretable-grid.ts:37-41`, exported via `public_api.ts`): + +- `setColumnFilter(columnId: string, filter: ColumnFilter | null): void` — set/replace + one column's filter; `null` (or a blank/inactive filter) removes it. Replaces the old + `setFilter(columnId, value: string)`. Emits only on change (keep existing equality-guard + behavior, compared structurally). +- `replaceFilters(next: Record): void` — atomic replace; drops + inactive entries; emits only if changed. +- `clearFilters(): void` — unchanged behavior, retyped. +- `distinctColumnValues(columnId: string): string[]` — **new.** Returns sorted, de-duped + `String(value)` of the column across the **source** rows (pre-filter), for enum + auto-derive. Skips null/undefined/`""`. Used by the UI sub-project; lives in the engine + because it needs row access. +- `snapshot.filters` retyped `Record` (`grid-core/src/types.ts:210`). + +## Files + +- `packages/grid-core/src/types.ts` — `FilterOperator`, `FilterValue`, `ColumnFilter`, + `FilterType`; column field additions; `snapshot.filters` retype; `PretableEngine` + method signatures. +- `packages/grid-core/src/derived-rows.ts` — new `evaluateFilter` + rewritten + `resolveFilters`/`matchesFilters`. +- `packages/grid-core/src/create-grid-core.ts` — `setColumnFilter`, retyped + `replaceFilters`/`clearFilters`, `distinctColumnValues`, state retype. +- `packages/core/src/pretable-grid.ts` + `packages/core/src/create-grid.ts` — forward the + new/retyped methods on the public `PretableGrid`. +- `packages/core/src/public_api.ts` + `packages/react/src/public_api.ts` — export the new + types (`FilterOperator`, `FilterValue`, `ColumnFilter`, `FilterType`). +- `packages/react/src/use-pretable.ts` — retype `PretableSurfaceState.filters` to + `Record` and the controlled-apply block (`grid.replaceFilters`). + No UI yet; this only keeps types coherent and the controlled prop working. +- `apps/website/app/components/heroGrid/filters.ts` + `HeroGrid.tsx` — migrate + `buildFilters` to emit `ColumnFilter`s (e.g. `{operator:"contains",value}` for search, + `{operator:"isAnyOf",value:[sector]}` for sector) so the website compiles. The hero's + *UX* redesign is sub-project 3; this is the minimal keep-it-building change. +- `*.api.md` (api-extractor reports) for `core` and `react` — regenerated via `pnpm api`. + +## Testing + +`packages/grid-core/src/__tests__/` (new `filter-operators.test.ts` + extend existing): +- **text:** contains/notContains/equals/notEquals/startsWith/endsWith, case-insensitive. +- **number:** gt/gte/lt/lte/equals/notEquals; between inclusive + reversed-bounds; NaN cell fails. +- **enum:** isAnyOf/isNoneOf; empty selection = no constraint. +- **date:** on/before/after/dateBetween (inclusive); unparseable cell fails. +- **shared:** isEmpty/isNotEmpty across types (null/undefined/""/NaN). +- **combination:** multiple columns AND; non-existent + `filterable:false` columns ignored; + blank/inactive filter passes all rows. +- **distinctColumnValues:** sorted, de-duped, skips empties, reads via `value` accessor, + uses source (pre-filter) rows. +- **API methods:** `setColumnFilter` set/replace/remove + emit-only-on-change; + `replaceFilters` drops inactive + emits-on-change; `clearFilters`. + +Plus: `pnpm api` (report freshness gate), repo-wide `pnpm -r typecheck`, `pnpm -r lint`, +`pnpm -r test`, `pnpm format`. The website must still build/typecheck after the +`buildFilters` migration. + +## Risks + +- **API report churn:** new exported types change `core.api.md`/`react.api.md`; the + required "API Extractor — report freshness" gate will fail unless `pnpm api` is run and + the reports committed. Build the plan with that as an explicit step. +- **Coercion surprises:** number/date coercion of arbitrary cell values. Mitigated by + explicit NaN/unparseable → fail rules and thorough per-operator tests. +- **Operator/`filterType` coupling:** evaluation keys on `filterType`, so a column with + `filterType:"number"` must receive number-family operators. The UI enforces this by only + offering valid operators; the engine treats an out-of-family operator as a no-match + (documented), not a throw. From 36b8d543b45102518239da61ad5a906810b61738 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 20:38:39 -0700 Subject: [PATCH 2/9] docs: implementation plan for filter engine operator model (1/3) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-18-filter-engine-model.md | 650 ++++++++++++++++++ 1 file changed, 650 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-18-filter-engine-model.md diff --git a/docs/superpowers/plans/2026-06-18-filter-engine-model.md b/docs/superpowers/plans/2026-06-18-filter-engine-model.md new file mode 100644 index 0000000..992075f --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-filter-engine-model.md @@ -0,0 +1,650 @@ +# Filter Engine Operator Model — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the substring-only filter model (`Record`) with a typed, operator-based model (text/number/enum/date), AND-combined across columns, headless in `@pretable/core` + `grid-core`. + +**Architecture:** A pure `evaluateFilter` function does per-operator matching, keyed on each column's `filterType`. `deriveVisibleRows` uses it. The engine state, methods, and snapshot are retyped to `Record`; `setFilter(id, string)` becomes `setColumnFilter(id, ColumnFilter | null)`; a new `distinctColumnValues` helper powers enum auto-derive. Public surface (`core`, `react` types) and the website's `buildFilters` are migrated. No backcompat aliases. + +**Tech Stack:** TypeScript, Vitest, api-extractor (required "report freshness" CI gate). `packages/*` is vanilla (no UI here at all). Commands: `pnpm -r typecheck`, `pnpm -r lint`, `pnpm -r test`, `pnpm format` (check; `format:write` fixes), `pnpm api` (regen api reports), `pnpm --filter @pretable/grid-core test`. + +**Key facts (verified against code):** +- Filter state: `create-grid-core.ts:90` `let filters: Record = {}`; methods at `:121` (`setFilter`), `:144` (`clearFilters`), `:152` (`replaceFilters`); `filtersEqual` at `:990`; snapshot build `:868-907` (`filters: { ...filters }`, `cachedDerivedFilters`). +- Evaluation: `derived-rows.ts` — `deriveVisibleRows` (filters param `Record`), `resolveFilters`, `matchesFilters`, `readCellValue` (uses `column.value` then `row[id]`). +- Types: `grid-core/src/types.ts` — `PretableColumn` (:66, has `filterable?`, `value?`), `PretableGridSnapshot.filters` (:210), `PretableEngine` (:220, has `setFilter/clearFilters/replaceFilters`). +- Public: `core/src/pretable-grid.ts:39-41` (interface methods), `core/src/create-grid.ts:34-37` (forwarding), `core/src/public_api.ts` + `react/src/public_api.ts` (exports), `react/src/use-pretable.ts` (`PretableSurfaceState.filters` :76, controlled apply `grid.replaceFilters(state.filters)` ~:230). +- Website: `apps/website/app/components/heroGrid/filters.ts` (`buildFilters` returns `Record`), consumed in `HeroGrid.tsx` (`filterMap` → `state={{ filters: filterMap }}`). + +--- + +## File Structure + +- `packages/grid-core/src/types.ts` — new filter types; column fields; snapshot + engine retype. +- `packages/grid-core/src/evaluate-filter.ts` — **new**: `evaluateFilter` (pure) + helpers. +- `packages/grid-core/src/derived-rows.ts` — `resolveFilters`/`matchesFilters` use `evaluateFilter`; `deriveVisibleRows` filters param retyped. +- `packages/grid-core/src/create-grid-core.ts` — state/methods/snapshot retype; `setColumnFilter`; `distinctColumnValues`; structural `filtersEqual`. +- `packages/core/src/pretable-grid.ts`, `create-grid.ts`, `public_api.ts` — public interface + forwarding + exports. +- `packages/react/src/use-pretable.ts`, `public_api.ts` — `PretableSurfaceState.filters` retype + controlled apply; re-export types. +- `apps/website/app/components/heroGrid/filters.ts` (+ its test) — `buildFilters` emits `ColumnFilter`s. +- `*.api.md` — regenerated via `pnpm api`. +- Tests: `packages/grid-core/src/__tests__/evaluate-filter.test.ts` (new), extend `grid-core.test.ts`. + +--- + +## Task 1: Filter types + `evaluateFilter` (pure, fully tested) + +**Files:** +- Modify: `packages/grid-core/src/types.ts` +- Create: `packages/grid-core/src/evaluate-filter.ts` +- Test: `packages/grid-core/src/__tests__/evaluate-filter.test.ts` + +This task is additive (no existing code changes behavior yet), so the repo still compiles. + +- [ ] **Step 1: Add types to `packages/grid-core/src/types.ts`** + +Add near the top-level exports (e.g. just above `PretableColumn`): + +```ts +/** @public */ +export type FilterType = "text" | "number" | "date" | "enum"; + +/** @public */ +export type FilterOperator = + | "contains" | "notContains" | "equals" | "notEquals" | "startsWith" | "endsWith" + | "gt" | "gte" | "lt" | "lte" | "between" + | "isAnyOf" | "isNoneOf" + | "on" | "before" | "after" | "dateBetween" + | "isEmpty" | "isNotEmpty"; + +/** @public */ +export type FilterValue = + | string + | number + | readonly [number, number] + | readonly [string, string] + | readonly string[] + | null; + +/** @public — one column's active filter. `value` is omitted for isEmpty/isNotEmpty. */ +export interface ColumnFilter { + operator: FilterOperator; + value?: FilterValue; +} + +/** @public */ +export interface FilterOption { + value: string; + label?: string; +} +``` + +Add to `PretableColumn` (after the existing `filterable?: boolean;` line): + +```ts + filterType?: FilterType; + filterOptions?: FilterOption[]; +``` + +(Leave `snapshot.filters` and `PretableEngine` unchanged in this task — Task 2 retypes them.) + +- [ ] **Step 2: Write the failing test** `packages/grid-core/src/__tests__/evaluate-filter.test.ts` + +```ts +import { describe, expect, it } from "vitest"; +import { evaluateFilter, isFilterActive } from "../evaluate-filter"; +import type { ColumnFilter } from "../types"; + +const ev = ( + cell: unknown, + filterType: "text" | "number" | "date" | "enum", + f: ColumnFilter, +) => evaluateFilter(cell, filterType, f.operator, f.value); + +describe("evaluateFilter — text", () => { + it("contains / notContains are case-insensitive", () => { + expect(ev("Hello", "text", { operator: "contains", value: "ell" })).toBe(true); + expect(ev("Hello", "text", { operator: "contains", value: "ELL" })).toBe(true); + expect(ev("Hello", "text", { operator: "notContains", value: "xyz" })).toBe(true); + expect(ev("Hello", "text", { operator: "notContains", value: "ell" })).toBe(false); + }); + it("equals / notEquals / startsWith / endsWith", () => { + expect(ev("abc", "text", { operator: "equals", value: "ABC" })).toBe(true); + expect(ev("abc", "text", { operator: "notEquals", value: "abd" })).toBe(true); + expect(ev("abcdef", "text", { operator: "startsWith", value: "ABC" })).toBe(true); + expect(ev("abcdef", "text", { operator: "endsWith", value: "DEF" })).toBe(true); + }); +}); + +describe("evaluateFilter — number", () => { + it("comparisons", () => { + expect(ev(5, "number", { operator: "gt", value: 4 })).toBe(true); + expect(ev(5, "number", { operator: "gte", value: 5 })).toBe(true); + expect(ev(5, "number", { operator: "lt", value: 4 })).toBe(false); + expect(ev(5, "number", { operator: "lte", value: 5 })).toBe(true); + expect(ev(5, "number", { operator: "equals", value: 5 })).toBe(true); + expect(ev(5, "number", { operator: "notEquals", value: 6 })).toBe(true); + }); + it("between is inclusive and tolerates reversed bounds", () => { + expect(ev(5, "number", { operator: "between", value: [1, 10] })).toBe(true); + expect(ev(5, "number", { operator: "between", value: [10, 1] })).toBe(true); + expect(ev(11, "number", { operator: "between", value: [1, 10] })).toBe(false); + }); + it("non-numeric cell fails comparisons (but not isEmpty)", () => { + expect(ev("oops", "number", { operator: "gt", value: 1 })).toBe(false); + expect(ev(null, "number", { operator: "isEmpty" })).toBe(true); + }); +}); + +describe("evaluateFilter — enum", () => { + it("isAnyOf / isNoneOf; empty selection = no constraint", () => { + expect(ev("a", "enum", { operator: "isAnyOf", value: ["a", "b"] })).toBe(true); + expect(ev("c", "enum", { operator: "isAnyOf", value: ["a", "b"] })).toBe(false); + expect(ev("c", "enum", { operator: "isNoneOf", value: ["a", "b"] })).toBe(true); + expect(ev("a", "enum", { operator: "isAnyOf", value: [] })).toBe(true); + }); +}); + +describe("evaluateFilter — date", () => { + it("on / before / after / dateBetween (inclusive)", () => { + expect(ev("2026-06-18", "date", { operator: "on", value: "2026-06-18" })).toBe(true); + expect(ev("2026-06-18", "date", { operator: "before", value: "2026-06-19" })).toBe(true); + expect(ev("2026-06-18", "date", { operator: "after", value: "2026-06-17" })).toBe(true); + expect(ev("2026-06-18", "date", { operator: "dateBetween", value: ["2026-06-01", "2026-06-30"] })).toBe(true); + expect(ev("2026-07-01", "date", { operator: "dateBetween", value: ["2026-06-01", "2026-06-30"] })).toBe(false); + }); + it("unparseable cell fails (but not isEmpty)", () => { + expect(ev("not-a-date", "date", { operator: "before", value: "2026-06-19" })).toBe(false); + expect(ev("", "date", { operator: "isEmpty" })).toBe(true); + }); +}); + +describe("evaluateFilter — shared empty semantics", () => { + it("isEmpty / isNotEmpty across types", () => { + expect(ev(null, "text", { operator: "isEmpty" })).toBe(true); + expect(ev("", "text", { operator: "isEmpty" })).toBe(true); + expect(ev(" ", "text", { operator: "isEmpty" })).toBe(true); + expect(ev("x", "text", { operator: "isNotEmpty" })).toBe(true); + expect(ev(undefined, "number", { operator: "isEmpty" })).toBe(true); + expect(ev(Number.NaN, "number", { operator: "isEmpty" })).toBe(true); + }); +}); + +describe("isFilterActive", () => { + it("blank values are inactive (no constraint)", () => { + expect(isFilterActive({ operator: "contains", value: "" })).toBe(false); + expect(isFilterActive({ operator: "isAnyOf", value: [] })).toBe(false); + expect(isFilterActive({ operator: "gt", value: undefined })).toBe(false); + expect(isFilterActive({ operator: "between", value: [1, 2] })).toBe(true); + expect(isFilterActive({ operator: "isEmpty" })).toBe(true); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `pnpm --filter @pretable/grid-core test -- evaluate-filter` +Expected: FAIL — `Cannot find module '../evaluate-filter'`. + +- [ ] **Step 4: Write `packages/grid-core/src/evaluate-filter.ts`** + +```ts +import type { ColumnFilter, FilterOperator, FilterType, FilterValue } from "./types"; + +const NO_OPERAND: ReadonlySet = new Set(["isEmpty", "isNotEmpty"]); + +/** Is this filter active (has a usable operand)? Blank/empty operands are inactive. */ +export function isFilterActive(filter: ColumnFilter): boolean { + const { operator, value } = filter; + if (NO_OPERAND.has(operator)) return true; + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim() !== ""; + if (Array.isArray(value)) return value.length > 0; + return true; // number +} + +function isEmptyCell(cell: unknown): boolean { + if (cell === null || cell === undefined) return true; + if (typeof cell === "number") return Number.isNaN(cell); + return String(cell).trim() === ""; +} + +function toDayMs(input: unknown): number { + // Day-resolution: parse and zero the time so "on"/range compare by calendar day. + const ms = typeof input === "number" ? input : Date.parse(String(input)); + if (Number.isNaN(ms)) return Number.NaN; + const d = new Date(ms); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} + +/** + * Pure per-operator filter match. Evaluation is keyed on `filterType` (not the + * operator name), so `equals` means string-equality for text and numeric-equality + * for number. An operator outside the column's family returns false (no match). + */ +export function evaluateFilter( + cell: unknown, + filterType: FilterType, + operator: FilterOperator, + value: FilterValue | undefined, +): boolean { + if (operator === "isEmpty") return isEmptyCell(cell); + if (operator === "isNotEmpty") return !isEmptyCell(cell); + + switch (filterType) { + case "number": { + const n = typeof cell === "number" ? cell : Number(cell); + if (Number.isNaN(n)) return false; + switch (operator) { + case "equals": return n === Number(value); + case "notEquals": return n !== Number(value); + case "gt": return n > Number(value); + case "gte": return n >= Number(value); + case "lt": return n < Number(value); + case "lte": return n <= Number(value); + case "between": { + if (!Array.isArray(value)) return false; + const a = Number(value[0]); + const b = Number(value[1]); + const lo = Math.min(a, b); + const hi = Math.max(a, b); + return n >= lo && n <= hi; + } + default: return false; + } + } + case "date": { + const c = toDayMs(cell); + if (Number.isNaN(c)) return false; + switch (operator) { + case "on": return c === toDayMs(value); + case "before": return c < toDayMs(value); + case "after": return c > toDayMs(value); + case "dateBetween": { + if (!Array.isArray(value)) return false; + const a = toDayMs(value[0]); + const b = toDayMs(value[1]); + if (Number.isNaN(a) || Number.isNaN(b)) return false; + const lo = Math.min(a, b); + const hi = Math.max(a, b); + return c >= lo && c <= hi; + } + default: return false; + } + } + case "enum": { + const c = String(cell); + const set = Array.isArray(value) ? value.map(String) : []; + if (set.length === 0) return true; // empty selection = no constraint + switch (operator) { + case "isAnyOf": return set.includes(c); + case "isNoneOf": return !set.includes(c); + default: return false; + } + } + case "text": + default: { + const hay = String(cell ?? "").toLowerCase(); + const needle = String(value ?? "").toLowerCase(); + switch (operator) { + case "contains": return hay.includes(needle); + case "notContains": return !hay.includes(needle); + case "equals": return hay === needle; + case "notEquals": return hay !== needle; + case "startsWith": return hay.startsWith(needle); + case "endsWith": return hay.endsWith(needle); + default: return false; + } + } + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `pnpm --filter @pretable/grid-core test -- evaluate-filter` +Expected: PASS (all cases). + +- [ ] **Step 6: Commit** + +```bash +git add packages/grid-core/src/types.ts packages/grid-core/src/evaluate-filter.ts packages/grid-core/src/__tests__/evaluate-filter.test.ts +git commit -m "feat(grid-core): typed filter operators + pure evaluateFilter" +``` + +--- + +## Task 2: Wire operators into the engine (grid-core) + +**Files:** +- Modify: `packages/grid-core/src/derived-rows.ts` +- Modify: `packages/grid-core/src/create-grid-core.ts` +- Modify: `packages/grid-core/src/types.ts` (retype snapshot + engine) +- Test: `packages/grid-core/src/__tests__/grid-core.test.ts` (extend) + +- [ ] **Step 1: Retype snapshot + engine in `types.ts`** + +- `PretableGridSnapshot.filters`: `Record` → `Record`. +- In `PretableEngine`, replace: + ```ts + setFilter(columnId: string, value: string): void; + clearFilters(): void; + replaceFilters(nextFilters: Record): void; + ``` + with: + ```ts + setColumnFilter(columnId: string, filter: ColumnFilter | null): void; + clearFilters(): void; + replaceFilters(nextFilters: Record): void; + distinctColumnValues(columnId: string): string[]; + ``` + +- [ ] **Step 2: Rewrite filter logic in `derived-rows.ts`** + +Replace the `import` to add `ColumnFilter`, and replace `ResolvedFilter`, `resolveFilters`, `matchesFilters`, and the `deriveVisibleRows` filters type: + +```ts +import type { + ColumnFilter, + PretableColumn, + PretableGridOptions, + PretableRow, + PretableVisibleRow, + PretableSortState, +} from "./types"; +import { evaluateFilter, isFilterActive } from "./evaluate-filter"; +``` + +```ts +export function deriveVisibleRows(input: { + columns: PretableColumn[]; + filters: Record; + rows: SourceRow[]; + sort: PretableSortState; +}): PretableVisibleRow[] { + const resolvedFilters = resolveFilters(input.columns, input.filters); + const filtered = input.rows.filter((entry) => + matchesFilters(entry.row, resolvedFilters), + ); + const sorted = sortRows(filtered, input.columns, input.sort); + return sorted.map(({ id, row, sourceIndex }) => ({ id, row, sourceIndex })); +} + +interface ResolvedFilter { + column: PretableColumn; + filter: ColumnFilter; +} + +function resolveFilters( + columns: PretableColumn[], + filters: Record, +): ResolvedFilter[] { + const columnMap = new Map(columns.map((c) => [c.id, c])); + const resolved: ResolvedFilter[] = []; + for (const [columnId, filter] of Object.entries(filters)) { + if (!filter || !isFilterActive(filter)) continue; + const column = columnMap.get(columnId); + if (!column || column.filterable === false) continue; + resolved.push({ column, filter }); + } + return resolved; +} + +function matchesFilters( + row: TRow, + resolvedFilters: ResolvedFilter[], +): boolean { + for (const { column, filter } of resolvedFilters) { + const cell = readCellValue(row, column); + if ( + !evaluateFilter(cell, column.filterType ?? "text", filter.operator, filter.value) + ) { + return false; + } + } + return true; +} +``` + +(Keep `sortRows`, `readCellValue`, `collator` unchanged.) + +- [ ] **Step 3: Update `create-grid-core.ts`** + +1. State + cache types: + ```ts + let cachedDerivedFilters: Record | null = null; + let filters: Record = {}; + ``` + (Add `ColumnFilter` to the existing type import from `./types`.) + +2. Replace `setFilter` (the whole method, `:121-143`) with: + ```ts + setColumnFilter(columnId: string, filter: ColumnFilter | null) { + const current = filters[columnId]; + if (filter && isFilterActive(filter)) { + if (current && columnFilterEqual(current, filter)) return; + filters = { ...filters, [columnId]: filter }; + } else { + if (current === undefined) return; + const next = { ...filters }; + delete next[columnId]; + filters = next; + } + emit(); + }, + ``` + +3. Replace `replaceFilters` body to normalize via `isFilterActive` and compare with the new `filtersEqual`: + ```ts + replaceFilters(nextFilters: Record) { + const normalized: Record = {}; + for (const [columnId, filter] of Object.entries(nextFilters)) { + if (filter && isFilterActive(filter)) normalized[columnId] = filter; + } + if (filtersEqual(filters, normalized)) return; + filters = normalized; + emit(); + }, + ``` + +4. `clearFilters` stays as-is (already type-agnostic). + +5. Add `distinctColumnValues` to the `store` object (it has `sourceRows` + `options` in closure scope): + ```ts + distinctColumnValues(columnId: string): string[] { + const column = options.columns.find((c) => c.id === columnId); + if (!column) return []; + const seen = new Set(); + for (const entry of sourceRows) { + const raw = column.value ? column.value(entry.row) : entry.row[columnId]; + if (raw === null || raw === undefined) continue; + const s = String(raw); + if (s.trim() === "") continue; + seen.add(s); + } + return [...seen].sort((a, b) => a.localeCompare(b)); + }, + ``` + +6. Snapshot: `filters: { ...filters },` stays (shallow copy of the record; `ColumnFilter` values are treated as immutable). + +7. Replace `filtersEqual` (`:990`) with a structural version + add `columnFilterEqual`: + ```ts + function columnFilterEqual(a: ColumnFilter, b: ColumnFilter): boolean { + if (a.operator !== b.operator) return false; + const av = a.value; + const bv = b.value; + if (Array.isArray(av) && Array.isArray(bv)) { + return av.length === bv.length && av.every((v, i) => v === bv[i]); + } + return av === bv; + } + + function filtersEqual( + a: Record, + b: Record, + ): boolean { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + const av = a[key]; + const bv = b[key]; + if (!av || !bv || !columnFilterEqual(av, bv)) return false; + } + return true; + } + ``` + +8. Add `isFilterActive` to the imports from `./evaluate-filter` at the top of the file. + +- [ ] **Step 4: Extend `grid-core.test.ts`** + +Add tests (use the file's existing grid-construction helpers; mirror its style): + +```ts +it("setColumnFilter applies an operator and AND-combines across columns", () => { + const grid = makeGrid(); // however the file builds a grid with rows + grid.setColumnFilter("status", { operator: "equals", value: "open" }); + // expect only matching rows in snapshot.visibleRows ... + grid.setColumnFilter("priority", { operator: "gt", value: 2 }); + // expect rows matching BOTH ... + grid.setColumnFilter("status", null); // removes + // ... +}); + +it("replaceFilters drops inactive filters and is change-guarded", () => { + const grid = makeGrid(); + grid.replaceFilters({ status: { operator: "contains", value: "" } }); // inactive + expect(Object.keys(grid.getSnapshot().filters)).toHaveLength(0); +}); + +it("distinctColumnValues returns sorted de-duped non-empty values", () => { + const grid = makeGrid(); + expect(grid.distinctColumnValues("status")).toEqual([/* sorted distinct */]); +}); +``` + +Adapt assertions to the file's actual fixture data. Verify `snapshot.filters` is typed/usable as `Record`. + +- [ ] **Step 5: Run grid-core tests + typecheck the package** + +Run: `pnpm --filter @pretable/grid-core test` +Run: `pnpm --filter @pretable/grid-core typecheck` (or `pnpm -r typecheck` — note `core`/`react`/website will still fail until Task 3; that's expected at this step). +Expected: grid-core tests PASS; grid-core typechecks. + +- [ ] **Step 6: Commit** + +```bash +git add packages/grid-core +git commit -m "feat(grid-core): operator-based filter engine (setColumnFilter, distinctColumnValues)" +``` + +--- + +## Task 3: Propagate to public API (`core` + `react`) + +**Files:** +- Modify: `packages/core/src/pretable-grid.ts`, `create-grid.ts`, `public_api.ts` +- Modify: `packages/react/src/use-pretable.ts`, `public_api.ts` + +- [ ] **Step 1: `core/src/pretable-grid.ts`** — in the `PretableGrid` interface, replace the three filter method signatures (`:39-41`) with: +```ts + setColumnFilter(columnId: string, filter: ColumnFilter | null): void; + clearFilters(): void; + replaceFilters(nextFilters: Record): void; + distinctColumnValues(columnId: string): string[]; +``` +Add `ColumnFilter` to the type import from `@pretable-internal/grid-core` (match how this file imports other engine types). + +- [ ] **Step 2: `core/src/create-grid.ts`** — update forwarding (`:34-37`): +```ts + setSort: engine.setSort, + setColumnFilter: engine.setColumnFilter, + clearFilters: engine.clearFilters, + replaceFilters: engine.replaceFilters, + distinctColumnValues: engine.distinctColumnValues, +``` +(Remove the old `setFilter` line.) If any JSDoc example in this file uses `setFilter(...)`, update it to `setColumnFilter("age", { operator: "gt", value: 30 })`. + +- [ ] **Step 3: `core/src/public_api.ts`** — export the new types. Add to the existing `export type { ... } from "@pretable-internal/grid-core"` block (or wherever types are re-exported): +```ts + ColumnFilter, + FilterOperator, + FilterType, + FilterValue, + FilterOption, +``` + +- [ ] **Step 4: `react/src/use-pretable.ts`** — retype the controlled slice: +- Import `ColumnFilter` from `@pretable/core`. +- `PretableSurfaceState.filters?: Record` → `Record` (`:76`). +- The controlled-apply call `grid.replaceFilters(state.filters)` (~:230) is unchanged in shape — it now passes the new type. Confirm it compiles. + +- [ ] **Step 5: `react/src/public_api.ts`** — re-export the new types (mirror Step 3's list) so consumers can import them from `@pretable/react`. + +- [ ] **Step 6: Typecheck the packages** + +Run: `pnpm --filter @pretable/core typecheck && pnpm --filter @pretable/react typecheck` +Expected: PASS. (Website still pending Task 4.) + +- [ ] **Step 7: Commit** + +```bash +git add packages/core packages/react +git commit -m "feat(core,react): expose operator filter API (setColumnFilter, filter types)" +``` + +--- + +## Task 4: Migrate website + regenerate API reports + full validation + +**Files:** +- Modify: `apps/website/app/components/heroGrid/filters.ts` (+ `__tests__/filters.test.ts`) +- Modify: `*.api.md` (generated) + +- [ ] **Step 1: Migrate `buildFilters`** in `apps/website/app/components/heroGrid/filters.ts` + +Change the return type to `Record` (import from `@pretable/core`) and emit operator filters: +- search term → `{ symbol: { operator: "contains", value: search } }` (only when non-empty) +- sector (when not "All") → `{ sector: { operator: "isAnyOf", value: [sector] } }` + +Keep the same "omit when empty / All" behavior. Update `filters.test.ts` expectations to the new shape (e.g. `expect(buildFilters({search:"nv",sector:"All"})).toEqual({ symbol: { operator: "contains", value: "nv" } })`). + +`HeroGrid.tsx` passes the result straight into `state={{ filters: filterMap }}`; with `PretableSurfaceState.filters` retyped this compiles unchanged. If the hero declares an explicit type for `filterMap`, update it. + +Optionally (nice, not required): set `filterType: "enum"` on the hero's `sector` column and `filterType:"text"` on `symbol` in `positionColumns.tsx` — harmless and exercises the new field. Keep minimal if it risks scope creep. + +- [ ] **Step 2: Regenerate API reports** + +Run: `pnpm api` +This rewrites `packages/core/.../core.api.md` and `packages/react/.../react.api.md` (and any others). Review the diff: it should show the new `ColumnFilter`/`FilterOperator`/`FilterType`/`FilterValue`/`FilterOption` exports and the changed `setColumnFilter`/`distinctColumnValues`/retyped `filters`. Commit the updated reports. + +- [ ] **Step 3: Full validation sweep (repo + website)** + +Run: +```bash +pnpm -r typecheck +pnpm -r lint +pnpm -r test +pnpm format +pnpm --filter @pretable/app-website build +pnpm api # second run must be a no-op (clean) — proves reports are committed/fresh +``` +Expected: all green; the second `pnpm api` reports no changes. Fix anything red (run `pnpm format:write` if format check fails). + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "feat(website): migrate buildFilters to operator model; refresh API reports" +``` + +--- + +## Self-Review notes (for the executor) + +- **Spec coverage:** operators text/number/enum/date + shared empty (Task 1) ✓; per-column `filterType`/`filterable`/`filterOptions` (Task 1 types; `filterable`/`filterType` honored in Task 2) ✓; AND-combination + non-existent/`filterable:false` ignored + inactive-passes (Task 2) ✓; `setColumnFilter`/`replaceFilters`/`clearFilters`/`distinctColumnValues`/snapshot retype (Tasks 2–3) ✓; public exports (Task 3) ✓; website migration + api refresh (Task 4) ✓. +- **No backcompat:** `setFilter` is removed, not aliased. Grep the repo for any other `setFilter(` / `.filters` string usages before finishing Task 4 and migrate them. +- **Type consistency:** `ColumnFilter`, `FilterOperator`, `FilterType`, `FilterValue`, `FilterOption`, `evaluateFilter`, `isFilterActive`, `setColumnFilter`, `distinctColumnValues`, `columnFilterEqual`, `filtersEqual` are used identically across tasks. +- **`filterOptions` consumer:** only the engine field + auto-derive helper (`distinctColumnValues`) exist here; the menu that reads them is sub-project 2. That's intended — `filterOptions`/`distinctColumnValues` are dormant-but-tested in this sub-project. +- **api gate:** Task 4 Step 2/3 are load-bearing — the "API Extractor — report freshness" check is required on main. From 4eb8681dd722d61fb017d819b8621d27f7b80a61 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 20:40:33 -0700 Subject: [PATCH 3/9] feat(grid-core): typed filter operators + pure evaluateFilter --- .../src/__tests__/evaluate-filter.test.ts | 88 ++++++++++++++ packages/grid-core/src/evaluate-filter.ts | 109 ++++++++++++++++++ packages/grid-core/src/types.ts | 48 ++++++++ 3 files changed, 245 insertions(+) create mode 100644 packages/grid-core/src/__tests__/evaluate-filter.test.ts create mode 100644 packages/grid-core/src/evaluate-filter.ts diff --git a/packages/grid-core/src/__tests__/evaluate-filter.test.ts b/packages/grid-core/src/__tests__/evaluate-filter.test.ts new file mode 100644 index 0000000..a5c93f8 --- /dev/null +++ b/packages/grid-core/src/__tests__/evaluate-filter.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { evaluateFilter, isFilterActive } from "../evaluate-filter"; +import type { ColumnFilter } from "../types"; + +const ev = ( + cell: unknown, + filterType: "text" | "number" | "date" | "enum", + f: ColumnFilter, +) => evaluateFilter(cell, filterType, f.operator, f.value); + +describe("evaluateFilter — text", () => { + it("contains / notContains are case-insensitive", () => { + expect(ev("Hello", "text", { operator: "contains", value: "ell" })).toBe(true); + expect(ev("Hello", "text", { operator: "contains", value: "ELL" })).toBe(true); + expect(ev("Hello", "text", { operator: "notContains", value: "xyz" })).toBe(true); + expect(ev("Hello", "text", { operator: "notContains", value: "ell" })).toBe(false); + }); + it("equals / notEquals / startsWith / endsWith", () => { + expect(ev("abc", "text", { operator: "equals", value: "ABC" })).toBe(true); + expect(ev("abc", "text", { operator: "notEquals", value: "abd" })).toBe(true); + expect(ev("abcdef", "text", { operator: "startsWith", value: "ABC" })).toBe(true); + expect(ev("abcdef", "text", { operator: "endsWith", value: "DEF" })).toBe(true); + }); +}); + +describe("evaluateFilter — number", () => { + it("comparisons", () => { + expect(ev(5, "number", { operator: "gt", value: 4 })).toBe(true); + expect(ev(5, "number", { operator: "gte", value: 5 })).toBe(true); + expect(ev(5, "number", { operator: "lt", value: 4 })).toBe(false); + expect(ev(5, "number", { operator: "lte", value: 5 })).toBe(true); + expect(ev(5, "number", { operator: "equals", value: 5 })).toBe(true); + expect(ev(5, "number", { operator: "notEquals", value: 6 })).toBe(true); + }); + it("between is inclusive and tolerates reversed bounds", () => { + expect(ev(5, "number", { operator: "between", value: [1, 10] })).toBe(true); + expect(ev(5, "number", { operator: "between", value: [10, 1] })).toBe(true); + expect(ev(11, "number", { operator: "between", value: [1, 10] })).toBe(false); + }); + it("non-numeric cell fails comparisons (but not isEmpty)", () => { + expect(ev("oops", "number", { operator: "gt", value: 1 })).toBe(false); + expect(ev(null, "number", { operator: "isEmpty" })).toBe(true); + }); +}); + +describe("evaluateFilter — enum", () => { + it("isAnyOf / isNoneOf; empty selection = no constraint", () => { + expect(ev("a", "enum", { operator: "isAnyOf", value: ["a", "b"] })).toBe(true); + expect(ev("c", "enum", { operator: "isAnyOf", value: ["a", "b"] })).toBe(false); + expect(ev("c", "enum", { operator: "isNoneOf", value: ["a", "b"] })).toBe(true); + expect(ev("a", "enum", { operator: "isAnyOf", value: [] })).toBe(true); + }); +}); + +describe("evaluateFilter — date", () => { + it("on / before / after / dateBetween (inclusive)", () => { + expect(ev("2026-06-18", "date", { operator: "on", value: "2026-06-18" })).toBe(true); + expect(ev("2026-06-18", "date", { operator: "before", value: "2026-06-19" })).toBe(true); + expect(ev("2026-06-18", "date", { operator: "after", value: "2026-06-17" })).toBe(true); + expect(ev("2026-06-18", "date", { operator: "dateBetween", value: ["2026-06-01", "2026-06-30"] })).toBe(true); + expect(ev("2026-07-01", "date", { operator: "dateBetween", value: ["2026-06-01", "2026-06-30"] })).toBe(false); + }); + it("unparseable cell fails (but not isEmpty)", () => { + expect(ev("not-a-date", "date", { operator: "before", value: "2026-06-19" })).toBe(false); + expect(ev("", "date", { operator: "isEmpty" })).toBe(true); + }); +}); + +describe("evaluateFilter — shared empty semantics", () => { + it("isEmpty / isNotEmpty across types", () => { + expect(ev(null, "text", { operator: "isEmpty" })).toBe(true); + expect(ev("", "text", { operator: "isEmpty" })).toBe(true); + expect(ev(" ", "text", { operator: "isEmpty" })).toBe(true); + expect(ev("x", "text", { operator: "isNotEmpty" })).toBe(true); + expect(ev(undefined, "number", { operator: "isEmpty" })).toBe(true); + expect(ev(Number.NaN, "number", { operator: "isEmpty" })).toBe(true); + }); +}); + +describe("isFilterActive", () => { + it("blank values are inactive (no constraint)", () => { + expect(isFilterActive({ operator: "contains", value: "" })).toBe(false); + expect(isFilterActive({ operator: "isAnyOf", value: [] })).toBe(false); + expect(isFilterActive({ operator: "gt", value: undefined })).toBe(false); + expect(isFilterActive({ operator: "between", value: [1, 2] })).toBe(true); + expect(isFilterActive({ operator: "isEmpty" })).toBe(true); + }); +}); diff --git a/packages/grid-core/src/evaluate-filter.ts b/packages/grid-core/src/evaluate-filter.ts new file mode 100644 index 0000000..b5a22c3 --- /dev/null +++ b/packages/grid-core/src/evaluate-filter.ts @@ -0,0 +1,109 @@ +import type { ColumnFilter, FilterOperator, FilterType, FilterValue } from "./types"; + +const NO_OPERAND: ReadonlySet = new Set(["isEmpty", "isNotEmpty"]); + +/** Is this filter active (has a usable operand)? Blank/empty operands are inactive. */ +export function isFilterActive(filter: ColumnFilter): boolean { + const { operator, value } = filter; + if (NO_OPERAND.has(operator)) return true; + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim() !== ""; + if (Array.isArray(value)) return value.length > 0; + return true; // number +} + +function isEmptyCell(cell: unknown): boolean { + if (cell === null || cell === undefined) return true; + if (typeof cell === "number") return Number.isNaN(cell); + return String(cell).trim() === ""; +} + +function toDayMs(input: unknown): number { + // Day-resolution: parse and zero the time so "on"/range compare by calendar day. + const ms = typeof input === "number" ? input : Date.parse(String(input)); + if (Number.isNaN(ms)) return Number.NaN; + const d = new Date(ms); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} + +/** + * Pure per-operator filter match. Evaluation is keyed on `filterType` (not the + * operator name), so `equals` means string-equality for text and numeric-equality + * for number. An operator outside the column's family returns false (no match). + */ +export function evaluateFilter( + cell: unknown, + filterType: FilterType, + operator: FilterOperator, + value: FilterValue | undefined, +): boolean { + if (operator === "isEmpty") return isEmptyCell(cell); + if (operator === "isNotEmpty") return !isEmptyCell(cell); + + switch (filterType) { + case "number": { + const n = typeof cell === "number" ? cell : Number(cell); + if (Number.isNaN(n)) return false; + switch (operator) { + case "equals": return n === Number(value); + case "notEquals": return n !== Number(value); + case "gt": return n > Number(value); + case "gte": return n >= Number(value); + case "lt": return n < Number(value); + case "lte": return n <= Number(value); + case "between": { + if (!Array.isArray(value)) return false; + const a = Number(value[0]); + const b = Number(value[1]); + const lo = Math.min(a, b); + const hi = Math.max(a, b); + return n >= lo && n <= hi; + } + default: return false; + } + } + case "date": { + const c = toDayMs(cell); + if (Number.isNaN(c)) return false; + switch (operator) { + case "on": return c === toDayMs(value); + case "before": return c < toDayMs(value); + case "after": return c > toDayMs(value); + case "dateBetween": { + if (!Array.isArray(value)) return false; + const a = toDayMs(value[0]); + const b = toDayMs(value[1]); + if (Number.isNaN(a) || Number.isNaN(b)) return false; + const lo = Math.min(a, b); + const hi = Math.max(a, b); + return c >= lo && c <= hi; + } + default: return false; + } + } + case "enum": { + const c = String(cell); + const set = Array.isArray(value) ? value.map(String) : []; + if (set.length === 0) return true; // empty selection = no constraint + switch (operator) { + case "isAnyOf": return set.includes(c); + case "isNoneOf": return !set.includes(c); + default: return false; + } + } + case "text": + default: { + const hay = String(cell ?? "").toLowerCase(); + const needle = String(value ?? "").toLowerCase(); + switch (operator) { + case "contains": return hay.includes(needle); + case "notContains": return !hay.includes(needle); + case "equals": return hay === needle; + case "notEquals": return hay !== needle; + case "startsWith": return hay.startsWith(needle); + case "endsWith": return hay.endsWith(needle); + default: return false; + } + } + } +} diff --git a/packages/grid-core/src/types.ts b/packages/grid-core/src/types.ts index 336bea1..f26cd45 100644 --- a/packages/grid-core/src/types.ts +++ b/packages/grid-core/src/types.ts @@ -58,6 +58,52 @@ export interface PretableEditState { error?: string; } +/** @public */ +export type FilterType = "text" | "number" | "date" | "enum"; + +/** @public */ +export type FilterOperator = + | "contains" + | "notContains" + | "equals" + | "notEquals" + | "startsWith" + | "endsWith" + | "gt" + | "gte" + | "lt" + | "lte" + | "between" + | "isAnyOf" + | "isNoneOf" + | "on" + | "before" + | "after" + | "dateBetween" + | "isEmpty" + | "isNotEmpty"; + +/** @public */ +export type FilterValue = + | string + | number + | readonly [number, number] + | readonly [string, string] + | readonly string[] + | null; + +/** @public — one column's active filter. `value` is omitted for isEmpty/isNotEmpty. */ +export interface ColumnFilter { + operator: FilterOperator; + value?: FilterValue; +} + +/** @public */ +export interface FilterOption { + value: string; + label?: string; +} + /** * Engine-level column definition. `@pretable/react` extends this with React-specific render fields. * @@ -71,6 +117,8 @@ export interface PretableColumn { pinned?: "left"; sortable?: boolean; filterable?: boolean; + filterType?: FilterType; + filterOptions?: FilterOption[]; value?: (row: TRow) => unknown; format?: (input: PretableFormatInput) => string; // new in sub-project C: From 2177ce578f3b26239f6b33c23cd24462d07f5561 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 20:44:24 -0700 Subject: [PATCH 4/9] feat(grid-core): operator-based filter engine (setColumnFilter, distinctColumnValues) --- .../src/__tests__/emit-behavior.test.ts | 10 +- .../grid-core/src/__tests__/grid-core.test.ts | 127 +++++++++++++++++- .../src/__tests__/selection-state.test.ts | 4 +- packages/grid-core/src/create-grid-core.ts | 82 ++++++++--- packages/grid-core/src/derived-rows.ts | 37 ++--- packages/grid-core/src/index.ts | 6 + packages/grid-core/src/types.ts | 7 +- 7 files changed, 224 insertions(+), 49 deletions(-) diff --git a/packages/grid-core/src/__tests__/emit-behavior.test.ts b/packages/grid-core/src/__tests__/emit-behavior.test.ts index 75df33f..923d4e0 100644 --- a/packages/grid-core/src/__tests__/emit-behavior.test.ts +++ b/packages/grid-core/src/__tests__/emit-behavior.test.ts @@ -64,7 +64,7 @@ describe("grid-core derivation caching", () => { getRowId: (row) => row.id, }); - grid.setFilter("status", "open"); + grid.setColumnFilter("status", { operator: "contains", value: "open" }); const before = grid.getSnapshot().visibleRows; grid.toggleRowSelection("a"); @@ -109,7 +109,7 @@ describe("grid-core derivation caching", () => { const before = grid.getSnapshot().visibleRows; - grid.setFilter("status", "open"); + grid.setColumnFilter("status", { operator: "contains", value: "open" }); expect(grid.getSnapshot().visibleRows).not.toBe(before); }); @@ -138,13 +138,13 @@ describe("grid-core emit behavior", () => { expect(instrumented.emits).toBe(1); }); - test("setFilter with identical value does not emit", () => { + test("setColumnFilter with identical value does not emit", () => { const instrumented = createInstrumentedGrid(); - instrumented.grid.setFilter("status", "open"); + instrumented.grid.setColumnFilter("status", { operator: "contains", value: "open" }); instrumented.reset(); - instrumented.grid.setFilter("status", "open"); + instrumented.grid.setColumnFilter("status", { operator: "contains", value: "open" }); expect(instrumented.emits).toBe(0); }); diff --git a/packages/grid-core/src/__tests__/grid-core.test.ts b/packages/grid-core/src/__tests__/grid-core.test.ts index dd0c4c2..8f20cf6 100644 --- a/packages/grid-core/src/__tests__/grid-core.test.ts +++ b/packages/grid-core/src/__tests__/grid-core.test.ts @@ -31,7 +31,7 @@ describe("grid-core", () => { grid.toggleRowSelection("b"); grid.setSort("name", "asc"); - grid.setFilter("status", "open"); + grid.setColumnFilter("status", { operator: "contains", value: "open" }); const snapshot = grid.getSnapshot(); @@ -54,7 +54,7 @@ describe("grid-core", () => { }); grid.toggleRowSelection("c"); - grid.setFilter("message", "error"); + grid.setColumnFilter("message", { operator: "contains", value: "error" }); const snapshot = grid.getSnapshot(); @@ -438,3 +438,126 @@ describe("grid-core", () => { }); }); }); + +interface OpRow { + id: string; + status: string; + priority: number; +} + +const opColumns = [ + { id: "status", header: "Status", filterType: "enum" as const }, + { id: "priority", header: "Priority", filterType: "number" as const }, +] as const; + +const opRows: OpRow[] = [ + { id: "a", status: "open", priority: 1 }, + { id: "b", status: "open", priority: 3 }, + { id: "c", status: "closed", priority: 5 }, + { id: "d", status: "closed", priority: 2 }, +]; + +const makeOpGrid = () => + createGridCore({ + columns: [...opColumns], + rows: opRows, + getRowId: (row) => row.id, + }); + +describe("grid-core — filter operators", () => { + test("setColumnFilter applies an operator and AND-combines across columns", () => { + const grid = makeOpGrid(); + + grid.setColumnFilter("status", { operator: "isAnyOf", value: ["open"] }); + expect(grid.getSnapshot().visibleRows.map((r) => r.id)).toEqual(["a", "b"]); + + grid.setColumnFilter("priority", { operator: "gt", value: 2 }); + expect(grid.getSnapshot().visibleRows.map((r) => r.id)).toEqual(["b"]); + + grid.setColumnFilter("status", null); + expect(grid.getSnapshot().visibleRows.map((r) => r.id)).toEqual(["b", "c"]); + }); + + test("snapshot.filters carries the typed ColumnFilter record", () => { + const grid = makeOpGrid(); + + grid.setColumnFilter("priority", { operator: "between", value: [2, 4] }); + + expect(grid.getSnapshot().filters).toEqual({ + priority: { operator: "between", value: [2, 4] }, + }); + expect(grid.getSnapshot().visibleRows.map((r) => r.id)).toEqual(["b", "d"]); + }); + + test("replaceFilters drops inactive filters and is change-guarded", () => { + const grid = makeOpGrid(); + + grid.replaceFilters({ status: { operator: "contains", value: "" } }); + expect(Object.keys(grid.getSnapshot().filters)).toHaveLength(0); + + grid.replaceFilters({ + status: { operator: "isAnyOf", value: ["closed"] }, + }); + const snapshot = grid.getSnapshot(); + expect(snapshot.visibleRows.map((r) => r.id)).toEqual(["c", "d"]); + + // change-guard: replacing with an equal record keeps the same snapshot ref. + grid.replaceFilters({ + status: { operator: "isAnyOf", value: ["closed"] }, + }); + expect(grid.getSnapshot()).toBe(snapshot); + }); + + test("setColumnFilter is emit-guarded for equal filters", () => { + const grid = makeOpGrid(); + let notifications = 0; + grid.subscribe(() => { + notifications += 1; + }); + + grid.setColumnFilter("priority", { operator: "gte", value: 3 }); + grid.setColumnFilter("priority", { operator: "gte", value: 3 }); + + expect(notifications).toBe(1); + }); + + test("isEmpty / isNotEmpty operate without an operand", () => { + const grid = createGridCore({ + columns: [{ id: "note", header: "Note", filterType: "text" as const }], + rows: [ + { id: "a", note: "hi" }, + { id: "b", note: "" }, + { id: "c", note: "yo" }, + ], + getRowId: (row) => row.id as string, + }); + + grid.setColumnFilter("note", { operator: "isNotEmpty" }); + expect(grid.getSnapshot().visibleRows.map((r) => r.id)).toEqual(["a", "c"]); + }); + + test("filterable:false columns ignore their filter entry", () => { + const grid = createGridCore({ + columns: [ + { id: "status", header: "Status", filterable: false }, + ], + rows: opRows, + getRowId: (row) => row.id, + }); + + grid.setColumnFilter("status", { operator: "equals", value: "open" }); + expect(grid.getSnapshot().visibleRows.map((r) => r.id)).toEqual([ + "a", + "b", + "c", + "d", + ]); + }); + + test("distinctColumnValues returns sorted de-duped non-empty values", () => { + const grid = makeOpGrid(); + expect(grid.distinctColumnValues("status")).toEqual(["closed", "open"]); + expect(grid.distinctColumnValues("priority")).toEqual(["1", "2", "3", "5"]); + expect(grid.distinctColumnValues("missing")).toEqual([]); + }); +}); diff --git a/packages/grid-core/src/__tests__/selection-state.test.ts b/packages/grid-core/src/__tests__/selection-state.test.ts index 7372f9d..12fd385 100644 --- a/packages/grid-core/src/__tests__/selection-state.test.ts +++ b/packages/grid-core/src/__tests__/selection-state.test.ts @@ -175,7 +175,7 @@ describe("selection state", () => { test("setSelectAllVisible(true) creates one full-row range per visible row", () => { const grid = makeGrid(); - grid.setFilter("status", "open"); + grid.setColumnFilter("status", { operator: "contains", value: "open" }); grid.setSelectAllVisible(true); @@ -235,7 +235,7 @@ describe("selection state", () => { anchor: { rowId: "c", columnId: "name" }, }); - grid.setFilter("status", "open"); + grid.setColumnFilter("status", { operator: "contains", value: "open" }); expect(grid.getSnapshot().selection.ranges).toEqual([ { diff --git a/packages/grid-core/src/create-grid-core.ts b/packages/grid-core/src/create-grid-core.ts index b0140c7..67effde 100644 --- a/packages/grid-core/src/create-grid-core.ts +++ b/packages/grid-core/src/create-grid-core.ts @@ -5,7 +5,9 @@ import { deriveVisibleRows, type SourceRow, } from "./derived-rows"; +import { isFilterActive } from "./evaluate-filter"; import type { + ColumnFilter, PretableCellAddress, PretableCellRange, PretableColumn, @@ -85,9 +87,9 @@ export function createGridCore( let cachedSnapshot: PretableGridSnapshot | null = null; let cachedVisibleRows: PretableVisibleRow[] | null = null; let cachedDerivedSort: PretableSortState | null = null; - let cachedDerivedFilters: Record | null = null; + let cachedDerivedFilters: Record | null = null; let sort: PretableSortState = { columnId: null, direction: null }; - let filters: Record = {}; + let filters: Record = {}; let selection: PretableSelectionState = { ranges: [], anchor: null }; let focus: PretableFocusState = { rowId: null, columnId: null }; let editing: PretableEditState | null = null; @@ -118,18 +120,17 @@ export function createGridCore( sort = { columnId, direction }; emit(); }, - setFilter(columnId: string, value: string) { - const trimmed = value.trim(); - const currentValue = filters[columnId]; + setColumnFilter(columnId: string, filter: ColumnFilter | null) { + const current = filters[columnId]; - if (trimmed) { - if (currentValue === trimmed) { + if (filter && isFilterActive(filter)) { + if (current && columnFilterEqual(current, filter)) { return; } - filters = { ...filters, [columnId]: trimmed }; + filters = { ...filters, [columnId]: filter }; } else { - if (currentValue === undefined) { + if (current === undefined) { return; } @@ -149,14 +150,12 @@ export function createGridCore( filters = {}; emit(); }, - replaceFilters(nextFilters: Record) { - const normalized: Record = {}; + replaceFilters(nextFilters: Record) { + const normalized: Record = {}; - for (const [columnId, value] of Object.entries(nextFilters)) { - const trimmed = value.trim(); - - if (trimmed) { - normalized[columnId] = trimmed; + for (const [columnId, filter] of Object.entries(nextFilters)) { + if (filter && isFilterActive(filter)) { + normalized[columnId] = filter; } } @@ -167,6 +166,33 @@ export function createGridCore( filters = normalized; emit(); }, + distinctColumnValues(columnId: string): string[] { + const column = options.columns.find((c) => c.id === columnId); + + if (!column) { + return []; + } + + const seen = new Set(); + + for (const entry of sourceRows) { + const raw = column.value ? column.value(entry.row) : entry.row[columnId]; + + if (raw === null || raw === undefined) { + continue; + } + + const s = String(raw); + + if (s.trim() === "") { + continue; + } + + seen.add(s); + } + + return [...seen].sort((a, b) => a.localeCompare(b)); + }, setSelection(next: PretableSelectionState) { if (selectionsEqual(selection, next)) { return; @@ -987,9 +1013,24 @@ function selectionsEqual( ); } +function columnFilterEqual(a: ColumnFilter, b: ColumnFilter): boolean { + if (a.operator !== b.operator) { + return false; + } + + const av = a.value; + const bv = b.value; + + if (Array.isArray(av) && Array.isArray(bv)) { + return av.length === bv.length && av.every((v, i) => v === bv[i]); + } + + return av === bv; +} + function filtersEqual( - a: Record, - b: Record, + a: Record, + b: Record, ): boolean { const aKeys = Object.keys(a); const bKeys = Object.keys(b); @@ -999,7 +1040,10 @@ function filtersEqual( } for (const key of aKeys) { - if (a[key] !== b[key]) { + const av = a[key]; + const bv = b[key]; + + if (!av || !bv || !columnFilterEqual(av, bv)) { return false; } } diff --git a/packages/grid-core/src/derived-rows.ts b/packages/grid-core/src/derived-rows.ts index 44b7395..fe6d45d 100644 --- a/packages/grid-core/src/derived-rows.ts +++ b/packages/grid-core/src/derived-rows.ts @@ -1,10 +1,12 @@ import type { + ColumnFilter, PretableColumn, PretableGridOptions, PretableRow, PretableVisibleRow, PretableSortState, } from "./types"; +import { evaluateFilter, isFilterActive } from "./evaluate-filter"; export interface SourceRow { id: string; @@ -24,7 +26,7 @@ export function createSourceRows( export function deriveVisibleRows(input: { columns: PretableColumn[]; - filters: Record; + filters: Record; rows: SourceRow[]; sort: PretableSortState; }): PretableVisibleRow[] { @@ -43,28 +45,21 @@ export function deriveVisibleRows(input: { interface ResolvedFilter { column: PretableColumn; - needle: string; + filter: ColumnFilter; } function resolveFilters( columns: PretableColumn[], - filters: Record, + filters: Record, ): ResolvedFilter[] { const columnMap = new Map(columns.map((c) => [c.id, c])); const resolved: ResolvedFilter[] = []; - for (const [columnId, rawNeedle] of Object.entries(filters)) { - if (!rawNeedle) { - continue; - } - + for (const [columnId, filter] of Object.entries(filters)) { + if (!filter || !isFilterActive(filter)) continue; const column = columnMap.get(columnId); - - if (!column) { - continue; - } - - resolved.push({ column, needle: rawNeedle.toLowerCase() }); + if (!column || column.filterable === false) continue; + resolved.push({ column, filter }); } return resolved; @@ -74,10 +69,16 @@ function matchesFilters( row: TRow, resolvedFilters: ResolvedFilter[], ): boolean { - for (const { column, needle } of resolvedFilters) { - const haystack = String(readCellValue(row, column)).toLowerCase(); - - if (!haystack.includes(needle)) { + for (const { column, filter } of resolvedFilters) { + const cell = readCellValue(row, column); + if ( + !evaluateFilter( + cell, + column.filterType ?? "text", + filter.operator, + filter.value, + ) + ) { return false; } } diff --git a/packages/grid-core/src/index.ts b/packages/grid-core/src/index.ts index e5ef4f2..eb4f110 100644 --- a/packages/grid-core/src/index.ts +++ b/packages/grid-core/src/index.ts @@ -4,7 +4,13 @@ export { rangeContainsCell, type PretableRowSelectionTriState, } from "./derived-selection"; +export { evaluateFilter, isFilterActive } from "./evaluate-filter"; export type { + ColumnFilter, + FilterOperator, + FilterOption, + FilterType, + FilterValue, PretableCellAddress, PretableCellRange, PretableColumn, diff --git a/packages/grid-core/src/types.ts b/packages/grid-core/src/types.ts index f26cd45..9a8b99c 100644 --- a/packages/grid-core/src/types.ts +++ b/packages/grid-core/src/types.ts @@ -255,7 +255,7 @@ export interface PretableVisibleRow { export interface PretableGridSnapshot { viewport: PretableViewportState; sort: PretableSortState; - filters: Record; + filters: Record; selection: PretableSelectionState; focus: PretableFocusState; totalRowCount: number; @@ -270,9 +270,10 @@ export interface PretableEngine { subscribe(listener: () => void): () => void; getSnapshot(): PretableGridSnapshot; setSort(columnId: string | null, direction: PretableSortDirection): void; - setFilter(columnId: string, value: string): void; + setColumnFilter(columnId: string, filter: ColumnFilter | null): void; clearFilters(): void; - replaceFilters(nextFilters: Record): void; + replaceFilters(nextFilters: Record): void; + distinctColumnValues(columnId: string): string[]; // selection actions setSelection(state: PretableSelectionState): void; selectAll(): void; From a251fa78ae8af323c2cde5829b2b670c0b977f6b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 20:49:08 -0700 Subject: [PATCH 5/9] feat(core,react): expose operator filter API (setColumnFilter, filter types) --- packages/core/src/create-grid.ts | 3 ++- packages/core/src/pretable-grid.ts | 6 ++++-- packages/core/src/public_api.ts | 5 +++++ packages/core/src/types.ts | 5 +++++ .../src/__tests__/labeled-grid-surface.test.tsx | 2 +- .../react/src/__tests__/pretable-surface.test.tsx | 6 +++++- packages/react/src/labeled-grid-surface.tsx | 15 ++++++++++++++- packages/react/src/public_api.ts | 5 +++++ packages/react/src/use-pretable.ts | 3 ++- 9 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/core/src/create-grid.ts b/packages/core/src/create-grid.ts index 3bcf5ae..9a6b1b3 100644 --- a/packages/core/src/create-grid.ts +++ b/packages/core/src/create-grid.ts @@ -32,9 +32,10 @@ export function createGrid( subscribe: engine.subscribe, getSnapshot: engine.getSnapshot, setSort: engine.setSort, - setFilter: engine.setFilter, + setColumnFilter: engine.setColumnFilter, clearFilters: engine.clearFilters, replaceFilters: engine.replaceFilters, + distinctColumnValues: engine.distinctColumnValues, setSelection: engine.setSelection, selectAll: engine.selectAll, clearSelection: engine.clearSelection, diff --git a/packages/core/src/pretable-grid.ts b/packages/core/src/pretable-grid.ts index 0be4946..e76ca27 100644 --- a/packages/core/src/pretable-grid.ts +++ b/packages/core/src/pretable-grid.ts @@ -1,5 +1,6 @@ import type { AutosizeOptions, + ColumnFilter, PretableCellAddress, PretableCellRange, PretableColumn, @@ -36,9 +37,10 @@ export interface PretableGrid { // sort / filter setSort(columnId: string | null, direction: PretableSortDirection): void; - setFilter(columnId: string, value: string): void; + setColumnFilter(columnId: string, filter: ColumnFilter | null): void; clearFilters(): void; - replaceFilters(nextFilters: Record): void; + replaceFilters(nextFilters: Record): void; + distinctColumnValues(columnId: string): string[]; // selection setSelection(state: PretableSelectionState): void; diff --git a/packages/core/src/public_api.ts b/packages/core/src/public_api.ts index 4bf6bc0..df6b47f 100644 --- a/packages/core/src/public_api.ts +++ b/packages/core/src/public_api.ts @@ -11,6 +11,11 @@ export type { PretableGrid } from "./pretable-grid"; export type { AutosizeOptions, + ColumnFilter, + FilterOperator, + FilterOption, + FilterType, + FilterValue, PretableCellAddress, PretableCellRange, PretableColumn, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0545f5f..9047f69 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,5 +1,10 @@ export type { AutosizeOptions, + ColumnFilter, + FilterOperator, + FilterOption, + FilterType, + FilterValue, PretableCellAddress, PretableCellRange, PretableColumn, diff --git a/packages/react/src/__tests__/labeled-grid-surface.test.tsx b/packages/react/src/__tests__/labeled-grid-surface.test.tsx index 5775a36..2cdd4a5 100644 --- a/packages/react/src/__tests__/labeled-grid-surface.test.tsx +++ b/packages/react/src/__tests__/labeled-grid-surface.test.tsx @@ -199,7 +199,7 @@ describe("LabeledGridSurface", () => { headerCellClassName="inspection-header-cell" state={{ sort: null, - filters: { severity: "error" }, + filters: { severity: { operator: "contains", value: "error" } }, }} overscan={0} rows={rows} diff --git a/packages/react/src/__tests__/pretable-surface.test.tsx b/packages/react/src/__tests__/pretable-surface.test.tsx index 6e63dbc..7f02750 100644 --- a/packages/react/src/__tests__/pretable-surface.test.tsx +++ b/packages/react/src/__tests__/pretable-surface.test.tsx @@ -1499,7 +1499,11 @@ describe("controlled-mode round-trips", () => { getRowId: getGridRowId, overscan: 0, rows: gridRows, - state: { filters: query ? { a: query } : {} }, + state: { + filters: query + ? { a: { operator: "contains" as const, value: query } } + : {}, + }, viewportHeight: 300, }); diff --git a/packages/react/src/labeled-grid-surface.tsx b/packages/react/src/labeled-grid-surface.tsx index 938e7e2..d2f7ce9 100644 --- a/packages/react/src/labeled-grid-surface.tsx +++ b/packages/react/src/labeled-grid-surface.tsx @@ -1,4 +1,5 @@ import type { + ColumnFilter, PretableGridOptions, PretableRow, PretableSortDirection, @@ -9,6 +10,18 @@ import type { PretableTelemetry } from "./use-pretable"; import { type PretableSurfaceProps, PretableSurface } from "./pretable-surface"; import type { PretableColumn } from "./types"; +const NO_OPERAND_OPERATORS = new Set(["isEmpty", "isNotEmpty"]); + +/** Mirrors `isFilterActive` from the engine: a filter with a usable operand. */ +function isColumnFilterActive(filter: ColumnFilter): boolean { + const { operator, value } = filter; + if (NO_OPERAND_OPERATORS.has(operator)) return true; + if (value === null || value === undefined) return false; + if (typeof value === "string") return value.trim() !== ""; + if (Array.isArray(value)) return value.length > 0; + return true; // number +} + /** * Input passed to a {@link LabeledGridSurface} format function. * @@ -110,7 +123,7 @@ export function LabeledGridSurface({ column.pinned === "left" && pinnedClassName ? pinnedClassName : undefined; const activeFilterColumns = new Set( Object.entries(state?.filters ?? {}) - .filter(([, value]) => value.trim() !== "") + .filter(([, filter]) => isColumnFilterActive(filter)) .map(([columnId]) => columnId), ); const getFormattedValue = ({ diff --git a/packages/react/src/public_api.ts b/packages/react/src/public_api.ts index ec12d7b..aad77cf 100644 --- a/packages/react/src/public_api.ts +++ b/packages/react/src/public_api.ts @@ -58,6 +58,11 @@ export type { DensityHeights } from "@pretable/ui"; // Re-exports from @pretable/core (the engine types react users typically // touch — full headless surface lives in @pretable/core) export type { + ColumnFilter, + FilterOperator, + FilterOption, + FilterType, + FilterValue, PretableEditInput, PretableEditState, PretableEditStatus, diff --git a/packages/react/src/use-pretable.ts b/packages/react/src/use-pretable.ts index 90c0f66..039629b 100644 --- a/packages/react/src/use-pretable.ts +++ b/packages/react/src/use-pretable.ts @@ -1,5 +1,6 @@ import { type AutosizeOptions, + type ColumnFilter, createGrid, type PretableFocusState, type PretableGrid, @@ -73,7 +74,7 @@ export interface PretableTelemetry { * @public */ export interface PretableSurfaceState { - filters?: Record; + filters?: Record; focus?: PretableFocusState; selection?: PretableSelectionState; sort?: PretableSortState | null; From ebe65f25ee988d6261a5d2c292852b9cc4f83735 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 20:58:42 -0700 Subject: [PATCH 6/9] chore(api): regenerate reports for operator filter model Reflects the new ColumnFilter/FilterOperator/FilterType/FilterValue/ FilterOption exports, setColumnFilter/distinctColumnValues, and the retyped filters maps in core + react. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/core.api.md | 39 +++++++++++++++++++++++++++++++++---- packages/react/react.api.md | 12 ++++++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/core/core.api.md b/packages/core/core.api.md index 37616fa..468ba08 100644 --- a/packages/core/core.api.md +++ b/packages/core/core.api.md @@ -16,9 +16,34 @@ export interface AutosizeOptions { minWidthPx?: number; } +// @public +export interface ColumnFilter { + // (undocumented) + operator: FilterOperator; + // (undocumented) + value?: FilterValue; +} + // @public export function createGrid(options: PretableGridOptions): PretableGrid; +// @public (undocumented) +export type FilterOperator = "contains" | "notContains" | "equals" | "notEquals" | "startsWith" | "endsWith" | "gt" | "gte" | "lt" | "lte" | "between" | "isAnyOf" | "isNoneOf" | "on" | "before" | "after" | "dateBetween" | "isEmpty" | "isNotEmpty"; + +// @public (undocumented) +export interface FilterOption { + // (undocumented) + label?: string; + // (undocumented) + value: string; +} + +// @public (undocumented) +export type FilterType = "text" | "number" | "date" | "enum"; + +// @public (undocumented) +export type FilterValue = string | number | readonly [number, number] | readonly [string, string] | readonly string[] | null; + // @public export interface PretableCellAddress { // (undocumented) @@ -46,6 +71,10 @@ export interface PretableColumn { // (undocumented) filterable?: boolean; // (undocumented) + filterOptions?: FilterOption[]; + // (undocumented) + filterType?: FilterType; + // (undocumented) format?: (input: PretableFormatInput) => string; // (undocumented) formatEditValue?: (value: unknown, input: PretableEditInput) => string; @@ -153,6 +182,8 @@ export interface PretableGrid { // (undocumented) commitEditSucceeded(): void; // (undocumented) + distinctColumnValues(columnId: string): string[]; + // (undocumented) extendRangeFromAnchor(addr: PretableCellAddress): void; getSnapshot(): PretableGridSnapshot; readonly kind: "pretable-grid"; @@ -174,20 +205,20 @@ export interface PretableGrid { moveFocus(direction: PretableFocusDirection, options?: PretableMoveFocusOptions): void; readonly options: PretableGridOptions; // (undocumented) - replaceFilters(nextFilters: Record): void; + replaceFilters(nextFilters: Record): void; // (undocumented) resetColumnLayout(): void; // (undocumented) selectAll(): void; // (undocumented) + setColumnFilter(columnId: string, filter: ColumnFilter | null): void; + // (undocumented) setColumnPinned(columnId: string, pinned: "left" | null): void; // (undocumented) setColumnWidth(columnId: string, width: number): void; // (undocumented) setEditDraft(value: unknown): void; // (undocumented) - setFilter(columnId: string, value: string): void; - // (undocumented) setFocus(addr: PretableCellAddress | null): void; setRows(rows: TRow[]): void; // (undocumented) @@ -220,7 +251,7 @@ export interface PretableGridSnapshot { // (undocumented) editing: PretableEditState | null; // (undocumented) - filters: Record; + filters: Record; // (undocumented) focus: PretableFocusState; // (undocumented) diff --git a/packages/react/react.api.md b/packages/react/react.api.md index da0c413..4ba0c1f 100644 --- a/packages/react/react.api.md +++ b/packages/react/react.api.md @@ -281,6 +281,8 @@ export interface PretableGrid { clearSelection(): void; // (undocumented) commitEditSucceeded(): void; + // (undocumented) + distinctColumnValues(columnId: string): string[]; // Warning: (ae-forgotten-export) The symbol "PretableCellAddress" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -307,11 +309,15 @@ export interface PretableGrid { moveFocus(direction: PretableFocusDirection, options?: PretableMoveFocusOptions): void; readonly options: PretableGridOptions; // (undocumented) - replaceFilters(nextFilters: Record): void; + replaceFilters(nextFilters: Record): void; // (undocumented) resetColumnLayout(): void; // (undocumented) selectAll(): void; + // Warning: (ae-forgotten-export) The symbol "ColumnFilter" needs to be exported by the entry point index.d.ts + // + // (undocumented) + setColumnFilter(columnId: string, filter: ColumnFilter | null): void; // (undocumented) setColumnPinned(columnId: string, pinned: "left" | null): void; // (undocumented) @@ -319,8 +325,6 @@ export interface PretableGrid { // (undocumented) setEditDraft(value: unknown): void; // (undocumented) - setFilter(columnId: string, value: string): void; - // (undocumented) setFocus(addr: PretableCellAddress | null): void; setRows(rows: TRow[]): void; // (undocumented) @@ -357,7 +361,7 @@ export interface PretableGridSnapshot { // (undocumented) editing: PretableEditState | null; // (undocumented) - filters: Record; + filters: Record; // Warning: (ae-forgotten-export) The symbol "PretableFocusState" needs to be exported by the entry point index.d.ts // // (undocumented) From 45165aa6df8943af2b5fea2afbda817833a2101a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 20:58:49 -0700 Subject: [PATCH 7/9] feat(website): migrate buildFilters to operator model; refresh API reports Migrate buildFilters to emit Record (search -> symbol contains; sector -> sector isAnyOf), annotate hero columns with filterType, and update the headless docs/examples + bench adapters to the setColumnFilter/ColumnFilter API. No setFilter/Record filter usages remain. Apply pending prettier formatting. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/ag-grid-adapter.test.tsx | 6 +- apps/bench/src/__tests__/mui-adapter.test.tsx | 6 +- .../src/__tests__/tanstack-adapter.test.tsx | 6 +- apps/bench/src/ag-grid-adapter.tsx | 4 +- apps/bench/src/interaction-plan.ts | 13 +- apps/bench/src/mui-adapter.tsx | 4 +- apps/bench/src/tanstack-adapter.tsx | 2 +- .../heroGrid/__tests__/filters.test.ts | 12 +- .../app/components/heroGrid/filters.ts | 12 +- .../components/heroGrid/positionColumns.tsx | 2 + .../content/docs/grid/api-reference.mdx | 5 +- .../content/docs/grid/custom-rendering.mdx | 4 +- .../content/docs/grid/pretable-component.mdx | 2 +- .../content/docs/headless/getting-started.mdx | 2 +- .../content/docs/headless/mutations.mdx | 8 +- .../HeadlessTable.tsx | 9 +- .../plans/2026-06-18-filter-engine-model.md | 206 ++++++++++++++---- .../2026-06-18-filter-engine-model-design.md | 48 ++-- .../src/__tests__/emit-behavior.test.ts | 10 +- .../src/__tests__/evaluate-filter.test.ts | 74 +++++-- .../grid-core/src/__tests__/grid-core.test.ts | 4 +- packages/grid-core/src/create-grid-core.ts | 4 +- packages/grid-core/src/evaluate-filter.ts | 75 +++++-- 23 files changed, 376 insertions(+), 142 deletions(-) diff --git a/apps/bench/src/__tests__/ag-grid-adapter.test.tsx b/apps/bench/src/__tests__/ag-grid-adapter.test.tsx index 7591e91..1d074e1 100644 --- a/apps/bench/src/__tests__/ag-grid-adapter.test.tsx +++ b/apps/bench/src/__tests__/ag-grid-adapter.test.tsx @@ -30,7 +30,7 @@ const statusDataset = { function filterPlan( mode: "filter-metadata" | "filter-text", - filters: Record, + filters: BenchInteractionPlan["filters"], ): BenchInteractionPlan { return { focusedRowId: null, @@ -81,7 +81,9 @@ describe("AgGridAdapter", () => { dataset={statusDataset as never} runKey={0} scriptName="filter-metadata" - interactionPlan={filterPlan("filter-metadata", { status: "running" })} + interactionPlan={filterPlan("filter-metadata", { + status: { operator: "contains", value: "running" }, + })} />, ); diff --git a/apps/bench/src/__tests__/mui-adapter.test.tsx b/apps/bench/src/__tests__/mui-adapter.test.tsx index 0239e4f..8e1d13f 100644 --- a/apps/bench/src/__tests__/mui-adapter.test.tsx +++ b/apps/bench/src/__tests__/mui-adapter.test.tsx @@ -30,7 +30,7 @@ const statusDataset = { function filterPlan( mode: "filter-metadata" | "filter-text", - filters: Record, + filters: BenchInteractionPlan["filters"], ): BenchInteractionPlan { return { focusedRowId: null, @@ -82,7 +82,9 @@ describe("MuiAdapter", () => { dataset={statusDataset as never} runKey={0} scriptName="filter-metadata" - interactionPlan={filterPlan("filter-metadata", { status: "running" })} + interactionPlan={filterPlan("filter-metadata", { + status: { operator: "contains", value: "running" }, + })} />, ); diff --git a/apps/bench/src/__tests__/tanstack-adapter.test.tsx b/apps/bench/src/__tests__/tanstack-adapter.test.tsx index c011b3b..75597a2 100644 --- a/apps/bench/src/__tests__/tanstack-adapter.test.tsx +++ b/apps/bench/src/__tests__/tanstack-adapter.test.tsx @@ -30,7 +30,7 @@ const statusDataset = { function filterPlan( mode: "filter-metadata" | "filter-text", - filters: Record, + filters: BenchInteractionPlan["filters"], ): BenchInteractionPlan { return { focusedRowId: null, @@ -118,7 +118,9 @@ describe("TanstackAdapter", () => { dataset={statusDataset as never} runKey={0} scriptName="filter-metadata" - interactionPlan={filterPlan("filter-metadata", { status: "running" })} + interactionPlan={filterPlan("filter-metadata", { + status: { operator: "contains", value: "running" }, + })} />, ); diff --git a/apps/bench/src/ag-grid-adapter.tsx b/apps/bench/src/ag-grid-adapter.tsx index 838547e..ad047ff 100644 --- a/apps/bench/src/ag-grid-adapter.tsx +++ b/apps/bench/src/ag-grid-adapter.tsx @@ -141,12 +141,12 @@ export function AgGridAdapter({ interactionPlan.mode === "filter-text" ) { const model: Record = {}; - for (const [colId, value] of Object.entries(interactionPlan.filters)) { + for (const [colId, filter] of Object.entries(interactionPlan.filters)) { model[colId] = { filterType: "text", type: interactionPlan.mode === "filter-metadata" ? "equals" : "contains", - filter: value, + filter: filter.value, }; } api.setFilterModel(model); diff --git a/apps/bench/src/interaction-plan.ts b/apps/bench/src/interaction-plan.ts index 00fe011..d5604ee 100644 --- a/apps/bench/src/interaction-plan.ts +++ b/apps/bench/src/interaction-plan.ts @@ -1,3 +1,4 @@ +import type { ColumnFilter } from "@pretable/react"; import type { ScenarioDataset, ScenarioRow, @@ -7,7 +8,7 @@ import type { BenchQueryState } from "./bench-types"; export interface BenchInteractionPlan { focusedRowId: string | null; - filters: Record; + filters: Record; mode: Exclude; probeColumnId: string; resultRowCount: number; @@ -65,7 +66,10 @@ export function createBenchInteractionPlan( return { focusedRowId: probeRowId, filters: { - [METADATA_FILTER.columnId]: METADATA_FILTER.value, + [METADATA_FILTER.columnId]: { + operator: "contains", + value: METADATA_FILTER.value, + }, }, mode: "filter-metadata", probeColumnId: METADATA_FILTER.columnId, @@ -88,7 +92,10 @@ export function createBenchInteractionPlan( return { focusedRowId: probeRowId, filters: { - [TEXT_FILTER.columnId]: TEXT_FILTER.value, + [TEXT_FILTER.columnId]: { + operator: "contains", + value: TEXT_FILTER.value, + }, }, mode: "filter-text", probeColumnId: TEXT_FILTER.columnId, diff --git a/apps/bench/src/mui-adapter.tsx b/apps/bench/src/mui-adapter.tsx index dc0637c..196028d 100644 --- a/apps/bench/src/mui-adapter.tsx +++ b/apps/bench/src/mui-adapter.tsx @@ -153,11 +153,11 @@ export function MuiAdapter({ interactionPlan.mode === "filter-text" ) { const items = Object.entries(interactionPlan.filters).map( - ([field, value]) => ({ + ([field, filter]) => ({ field, operator: interactionPlan.mode === "filter-metadata" ? "equals" : "contains", - value, + value: filter.value, }), ); api.setFilterModel({ items }); diff --git a/apps/bench/src/tanstack-adapter.tsx b/apps/bench/src/tanstack-adapter.tsx index 20c510a..12af25b 100644 --- a/apps/bench/src/tanstack-adapter.tsx +++ b/apps/bench/src/tanstack-adapter.tsx @@ -145,7 +145,7 @@ export function TanstackAdapter({ interactionPlan.mode === "filter-text" ) { const filters = Object.entries(interactionPlan.filters).map( - ([id, value]) => ({ id, value }), + ([id, filter]) => ({ id, value: filter.value }), ); t.setColumnFilters(filters); } diff --git a/apps/website/app/components/heroGrid/__tests__/filters.test.ts b/apps/website/app/components/heroGrid/__tests__/filters.test.ts index ad84cdb..6bf8ebf 100644 --- a/apps/website/app/components/heroGrid/__tests__/filters.test.ts +++ b/apps/website/app/components/heroGrid/__tests__/filters.test.ts @@ -5,20 +5,20 @@ describe("buildFilters", () => { it("is empty for the default state", () => { expect(buildFilters({ search: "", sector: null })).toEqual({}); }); - it("maps search to the symbol column", () => { + it("maps search to a contains filter on the symbol column", () => { expect(buildFilters({ search: "nvda", sector: null })).toEqual({ - symbol: "nvda", + symbol: { operator: "contains", value: "nvda" }, }); }); - it("maps a sector chip to the sector column", () => { + it("maps a sector chip to an isAnyOf filter on the sector column", () => { expect(buildFilters({ search: "", sector: "Energy" })).toEqual({ - sector: "Energy", + sector: { operator: "isAnyOf", value: ["Energy"] }, }); }); it("composes both (AND)", () => { expect(buildFilters({ search: "x", sector: "Technology" })).toEqual({ - symbol: "x", - sector: "Technology", + symbol: { operator: "contains", value: "x" }, + sector: { operator: "isAnyOf", value: ["Technology"] }, }); }); it("trims whitespace-only search to empty", () => { diff --git a/apps/website/app/components/heroGrid/filters.ts b/apps/website/app/components/heroGrid/filters.ts index bf3482e..8beaa90 100644 --- a/apps/website/app/components/heroGrid/filters.ts +++ b/apps/website/app/components/heroGrid/filters.ts @@ -1,3 +1,5 @@ +import type { ColumnFilter } from "@pretable/core"; + export const SECTORS = [ "All", "Technology", @@ -12,10 +14,12 @@ export interface FilterState { sector: string | null; // null or "All" → no sector filter } -export function buildFilters(state: FilterState): Record { - const out: Record = {}; +export function buildFilters(state: FilterState): Record { + const out: Record = {}; const search = state.search.trim(); - if (search) out.symbol = search; - if (state.sector && state.sector !== "All") out.sector = state.sector; + if (search) out.symbol = { operator: "contains", value: search }; + if (state.sector && state.sector !== "All") { + out.sector = { operator: "isAnyOf", value: [state.sector] }; + } return out; } diff --git a/apps/website/app/components/heroGrid/positionColumns.tsx b/apps/website/app/components/heroGrid/positionColumns.tsx index 3d4b283..53f7a1a 100644 --- a/apps/website/app/components/heroGrid/positionColumns.tsx +++ b/apps/website/app/components/heroGrid/positionColumns.tsx @@ -30,6 +30,7 @@ export function makePositionColumns( header: "Symbol", widthPx: 150, pinned: "left", + filterType: "text", value: (row) => `${row.symbol} ${row.name}`, render: ({ row }) => ( @@ -42,6 +43,7 @@ export function makePositionColumns( id: "sector", header: "Sector", widthPx: 110, + filterType: "enum", value: (row) => row.sector, }, { diff --git a/apps/website/content/docs/grid/api-reference.mdx b/apps/website/content/docs/grid/api-reference.mdx index 15afe69..df93700 100644 --- a/apps/website/content/docs/grid/api-reference.mdx +++ b/apps/website/content/docs/grid/api-reference.mdx @@ -181,9 +181,10 @@ The `grid` returned by `usePretable`. | `getSnapshot(): PretableGridSnapshot` | Read the current state. | | `subscribe(listener: () => void): () => void` | Subscribe to state changes. Returns an unsubscribe fn. | | `setSort(columnId: string \| null, direction: "asc" \| "desc" \| null): void` | Set the sort state. Pass `null` to clear. | -| `setFilter(columnId: string, value: string): void` | Set a filter value for one column. | +| `setColumnFilter(columnId: string, filter: ColumnFilter \| null): void` | Set one column's filter. Pass `null` to remove it. | | `clearFilters(): void` | Remove all filters. | -| `replaceFilters(map: Record): void` | Replace all filters with a new map. | +| `replaceFilters(map: Record): void` | Replace all filters with a new map. | +| `distinctColumnValues(columnId: string): string[]` | Sorted, de-duped non-empty cell values for a column (for enum filter options). | | `setSelection(state): void` | Replace the full selection state ({ ranges, anchor }). | | `selectAll(): void` | Select every visible cell as a single full-grid range. | | `clearSelection(): void` | Collapse selection to the focused cell (or empty if no focus). | diff --git a/apps/website/content/docs/grid/custom-rendering.mdx b/apps/website/content/docs/grid/custom-rendering.mdx index 8f1bf18..7a276a2 100644 --- a/apps/website/content/docs/grid/custom-rendering.mdx +++ b/apps/website/content/docs/grid/custom-rendering.mdx @@ -39,7 +39,7 @@ const { grid, snapshot, renderSnapshot, telemetry } = usePretable({ What you get back: -- **`grid`** — the grid model. Methods: `setSort(columnId, direction)`, `setFilter(columnId, value)`, `clearFilters()`, `replaceFilters(map)`, `setSelection(state)`, `selectAll()`, `clearSelection()`, `addRange(range)`, `extendRangeFromAnchor(addr)`, `toggleRowSelection(id)`, `setSelectAllVisible(checked)`, `setFocus({rowId, columnId} | null)`, `moveFocus(direction, options?)`, `setViewport({scrollTop, scrollLeft, height, width})`, `applyTransaction({add, update, remove})`. +- **`grid`** — the grid model. Methods: `setSort(columnId, direction)`, `setColumnFilter(columnId, filter)`, `clearFilters()`, `replaceFilters(map)`, `distinctColumnValues(columnId)`, `setSelection(state)`, `selectAll()`, `clearSelection()`, `addRange(range)`, `extendRangeFromAnchor(addr)`, `toggleRowSelection(id)`, `setSelectAllVisible(checked)`, `setFocus({rowId, columnId} | null)`, `moveFocus(direction, options?)`, `setViewport({scrollTop, scrollLeft, height, width})`, `applyTransaction({add, update, remove})`. - **`snapshot`** — current state. Shape: `{viewport, sort, filters, selection, focus, totalRowCount, visibleRows, visibleRange}`. - **`renderSnapshot`** — what to render right now. Shape: `{columns: PlannedColumn[], rows: PretableRenderRow[], nodeCount, totalHeight, totalWidth}`. Each `PretableRenderRow` has `{id, row, rowIndex, top, height}`. - **`telemetry`** — `{focusedRowId, rowModelRowCount, renderedRowCount, selectedRowId, totalRowCount, ...}`. @@ -223,7 +223,7 @@ The minimal example above is a starting point. For production grids, you'll like - **Keyboard navigation** — listen for `ArrowUp` / `ArrowDown` on the viewport and call `grid.moveFocus("up")` / `grid.moveFocus("down")`. The full keyboard contract (shift+arrow extend, Cmd/Ctrl+arrow jump, Tab wrap, Cmd+A, Esc) is wired by `` automatically. - **Pinned columns sticky positioning** — apply `position: sticky; left: ${pinnedOffset}px` to pinned cells. The bench's adapter computes pinned offsets via the same algorithm `` uses internally; see `packages/react-surface/src/rendering.ts` (function `getPinnedLeftOffsets`) for the canonical implementation. - **Per-row measured heights** — use `useLayoutEffect` to measure rendered row heights and pass `measuredHeights: Record` to `usePretable` for content-aware sizing. -- **Filter inputs** — render a row of `` elements above the body, debounce changes, and call `grid.setFilter(columnId, value)` per change. +- **Filter inputs** — render a row of `` elements above the body, debounce changes, and call `grid.setColumnFilter(columnId, { operator: "contains", value })` per change (pass `null` to clear). - **Telemetry instrumentation** — use the `telemetry` return value to track visible row counts, frame budget overruns, etc. For the full reference implementation, see `packages/react-surface/src/pretable-surface.tsx` in the repository — that's what the bench's pretable adapter and the website's playground use internally. It's marked private (lives at `@pretable-internal/react-surface`), but reading its source is the canonical reference for the patterns above. diff --git a/apps/website/content/docs/grid/pretable-component.mdx b/apps/website/content/docs/grid/pretable-component.mdx index db3f81b..13561c7 100644 --- a/apps/website/content/docs/grid/pretable-component.mdx +++ b/apps/website/content/docs/grid/pretable-component.mdx @@ -69,7 +69,7 @@ The grid renders with Excel's surface tones, gridlines, accent. Toggle `data-den `` is the simplest possible surface. It is intentionally thin. For: - **Sort UI** — handle clicks on header buttons to call `grid.setSort(id, direction)`. Use [custom rendering](/docs/grid/custom-rendering). -- **Filter UI** — render input fields and call `grid.replaceFilters(...)` or `grid.setFilter(id, value)`. +- **Filter UI** — render input fields and call `grid.replaceFilters(...)` or `grid.setColumnFilter(id, { operator, value })`. - **Selection state in your component tree** — wire `grid.toggleRowSelection(id)` and derive selected rows from `snapshot.selection.ranges` (use `deriveSelectedRows` for the three-state row helper). - **Focus / keyboard navigation** — wire `grid.setFocus({ rowId, columnId })` and `grid.moveFocus("up" | "down" | "left" | "right", options?)` to keyboard handlers. - **Custom cell components** — replace the default label/value layout. Custom rendering lets you render any React tree per cell. diff --git a/apps/website/content/docs/headless/getting-started.mdx b/apps/website/content/docs/headless/getting-started.mdx index 0179315..3a7d8de 100644 --- a/apps/website/content/docs/headless/getting-started.mdx +++ b/apps/website/content/docs/headless/getting-started.mdx @@ -62,7 +62,7 @@ Call methods on the engine; the snapshot updates and React re-renders: ```ts grid.setSort("latencyMs", "asc"); // sort ascending by a column -grid.setFilter("team", "payments"); // filter a column by substring +grid.setColumnFilter("team", { operator: "contains", value: "payments" }); // filter a column grid.toggleRowSelection(rowId); // toggle a row in the selection ``` diff --git a/apps/website/content/docs/headless/mutations.mdx b/apps/website/content/docs/headless/mutations.mdx index 14c05f7..e969897 100644 --- a/apps/website/content/docs/headless/mutations.mdx +++ b/apps/website/content/docs/headless/mutations.mdx @@ -13,8 +13,12 @@ Every change to the grid goes through a method on the engine handle. Each one up grid.setSort("latencyMs", "asc"); // sort a column "asc" | "desc" grid.setSort(null, null); // clear the sort -grid.setFilter("team", "payments"); // filter one column -grid.replaceFilters({ team: "search", status: "down" }); // replace all filters at once +grid.setColumnFilter("team", { operator: "contains", value: "payments" }); // filter one column +grid.setColumnFilter("team", null); // remove that column's filter +grid.replaceFilters({ + team: { operator: "contains", value: "search" }, + status: { operator: "isAnyOf", value: ["down"] }, +}); // replace all filters at once grid.clearFilters(); // remove every filter ``` diff --git a/apps/website/content/examples/headless-custom-renderer/HeadlessTable.tsx b/apps/website/content/examples/headless-custom-renderer/HeadlessTable.tsx index ce6495c..bc9b882 100644 --- a/apps/website/content/examples/headless-custom-renderer/HeadlessTable.tsx +++ b/apps/website/content/examples/headless-custom-renderer/HeadlessTable.tsx @@ -49,7 +49,14 @@ export function HeadlessTable() { grid.setFilter("team", e.target.value)} + onChange={(e) => + grid.setColumnFilter( + "team", + e.target.value + ? { operator: "contains", value: e.target.value } + : null, + ) + } /> diff --git a/docs/superpowers/plans/2026-06-18-filter-engine-model.md b/docs/superpowers/plans/2026-06-18-filter-engine-model.md index 992075f..88a217b 100644 --- a/docs/superpowers/plans/2026-06-18-filter-engine-model.md +++ b/docs/superpowers/plans/2026-06-18-filter-engine-model.md @@ -9,6 +9,7 @@ **Tech Stack:** TypeScript, Vitest, api-extractor (required "report freshness" CI gate). `packages/*` is vanilla (no UI here at all). Commands: `pnpm -r typecheck`, `pnpm -r lint`, `pnpm -r test`, `pnpm format` (check; `format:write` fixes), `pnpm api` (regen api reports), `pnpm --filter @pretable/grid-core test`. **Key facts (verified against code):** + - Filter state: `create-grid-core.ts:90` `let filters: Record = {}`; methods at `:121` (`setFilter`), `:144` (`clearFilters`), `:152` (`replaceFilters`); `filtersEqual` at `:990`; snapshot build `:868-907` (`filters: { ...filters }`, `cachedDerivedFilters`). - Evaluation: `derived-rows.ts` — `deriveVisibleRows` (filters param `Record`), `resolveFilters`, `matchesFilters`, `readCellValue` (uses `column.value` then `row[id]`). - Types: `grid-core/src/types.ts` — `PretableColumn` (:66, has `filterable?`, `value?`), `PretableGridSnapshot.filters` (:210), `PretableEngine` (:220, has `setFilter/clearFilters/replaceFilters`). @@ -34,6 +35,7 @@ ## Task 1: Filter types + `evaluateFilter` (pure, fully tested) **Files:** + - Modify: `packages/grid-core/src/types.ts` - Create: `packages/grid-core/src/evaluate-filter.ts` - Test: `packages/grid-core/src/__tests__/evaluate-filter.test.ts` @@ -50,11 +52,25 @@ export type FilterType = "text" | "number" | "date" | "enum"; /** @public */ export type FilterOperator = - | "contains" | "notContains" | "equals" | "notEquals" | "startsWith" | "endsWith" - | "gt" | "gte" | "lt" | "lte" | "between" - | "isAnyOf" | "isNoneOf" - | "on" | "before" | "after" | "dateBetween" - | "isEmpty" | "isNotEmpty"; + | "contains" + | "notContains" + | "equals" + | "notEquals" + | "startsWith" + | "endsWith" + | "gt" + | "gte" + | "lt" + | "lte" + | "between" + | "isAnyOf" + | "isNoneOf" + | "on" + | "before" + | "after" + | "dateBetween" + | "isEmpty" + | "isNotEmpty"; /** @public */ export type FilterValue = @@ -102,16 +118,30 @@ const ev = ( describe("evaluateFilter — text", () => { it("contains / notContains are case-insensitive", () => { - expect(ev("Hello", "text", { operator: "contains", value: "ell" })).toBe(true); - expect(ev("Hello", "text", { operator: "contains", value: "ELL" })).toBe(true); - expect(ev("Hello", "text", { operator: "notContains", value: "xyz" })).toBe(true); - expect(ev("Hello", "text", { operator: "notContains", value: "ell" })).toBe(false); + expect(ev("Hello", "text", { operator: "contains", value: "ell" })).toBe( + true, + ); + expect(ev("Hello", "text", { operator: "contains", value: "ELL" })).toBe( + true, + ); + expect(ev("Hello", "text", { operator: "notContains", value: "xyz" })).toBe( + true, + ); + expect(ev("Hello", "text", { operator: "notContains", value: "ell" })).toBe( + false, + ); }); it("equals / notEquals / startsWith / endsWith", () => { expect(ev("abc", "text", { operator: "equals", value: "ABC" })).toBe(true); - expect(ev("abc", "text", { operator: "notEquals", value: "abd" })).toBe(true); - expect(ev("abcdef", "text", { operator: "startsWith", value: "ABC" })).toBe(true); - expect(ev("abcdef", "text", { operator: "endsWith", value: "DEF" })).toBe(true); + expect(ev("abc", "text", { operator: "notEquals", value: "abd" })).toBe( + true, + ); + expect(ev("abcdef", "text", { operator: "startsWith", value: "ABC" })).toBe( + true, + ); + expect(ev("abcdef", "text", { operator: "endsWith", value: "DEF" })).toBe( + true, + ); }); }); @@ -127,7 +157,9 @@ describe("evaluateFilter — number", () => { it("between is inclusive and tolerates reversed bounds", () => { expect(ev(5, "number", { operator: "between", value: [1, 10] })).toBe(true); expect(ev(5, "number", { operator: "between", value: [10, 1] })).toBe(true); - expect(ev(11, "number", { operator: "between", value: [1, 10] })).toBe(false); + expect(ev(11, "number", { operator: "between", value: [1, 10] })).toBe( + false, + ); }); it("non-numeric cell fails comparisons (but not isEmpty)", () => { expect(ev("oops", "number", { operator: "gt", value: 1 })).toBe(false); @@ -137,23 +169,47 @@ describe("evaluateFilter — number", () => { describe("evaluateFilter — enum", () => { it("isAnyOf / isNoneOf; empty selection = no constraint", () => { - expect(ev("a", "enum", { operator: "isAnyOf", value: ["a", "b"] })).toBe(true); - expect(ev("c", "enum", { operator: "isAnyOf", value: ["a", "b"] })).toBe(false); - expect(ev("c", "enum", { operator: "isNoneOf", value: ["a", "b"] })).toBe(true); + expect(ev("a", "enum", { operator: "isAnyOf", value: ["a", "b"] })).toBe( + true, + ); + expect(ev("c", "enum", { operator: "isAnyOf", value: ["a", "b"] })).toBe( + false, + ); + expect(ev("c", "enum", { operator: "isNoneOf", value: ["a", "b"] })).toBe( + true, + ); expect(ev("a", "enum", { operator: "isAnyOf", value: [] })).toBe(true); }); }); describe("evaluateFilter — date", () => { it("on / before / after / dateBetween (inclusive)", () => { - expect(ev("2026-06-18", "date", { operator: "on", value: "2026-06-18" })).toBe(true); - expect(ev("2026-06-18", "date", { operator: "before", value: "2026-06-19" })).toBe(true); - expect(ev("2026-06-18", "date", { operator: "after", value: "2026-06-17" })).toBe(true); - expect(ev("2026-06-18", "date", { operator: "dateBetween", value: ["2026-06-01", "2026-06-30"] })).toBe(true); - expect(ev("2026-07-01", "date", { operator: "dateBetween", value: ["2026-06-01", "2026-06-30"] })).toBe(false); + expect( + ev("2026-06-18", "date", { operator: "on", value: "2026-06-18" }), + ).toBe(true); + expect( + ev("2026-06-18", "date", { operator: "before", value: "2026-06-19" }), + ).toBe(true); + expect( + ev("2026-06-18", "date", { operator: "after", value: "2026-06-17" }), + ).toBe(true); + expect( + ev("2026-06-18", "date", { + operator: "dateBetween", + value: ["2026-06-01", "2026-06-30"], + }), + ).toBe(true); + expect( + ev("2026-07-01", "date", { + operator: "dateBetween", + value: ["2026-06-01", "2026-06-30"], + }), + ).toBe(false); }); it("unparseable cell fails (but not isEmpty)", () => { - expect(ev("not-a-date", "date", { operator: "before", value: "2026-06-19" })).toBe(false); + expect( + ev("not-a-date", "date", { operator: "before", value: "2026-06-19" }), + ).toBe(false); expect(ev("", "date", { operator: "isEmpty" })).toBe(true); }); }); @@ -188,9 +244,17 @@ Expected: FAIL — `Cannot find module '../evaluate-filter'`. - [ ] **Step 4: Write `packages/grid-core/src/evaluate-filter.ts`** ```ts -import type { ColumnFilter, FilterOperator, FilterType, FilterValue } from "./types"; +import type { + ColumnFilter, + FilterOperator, + FilterType, + FilterValue, +} from "./types"; -const NO_OPERAND: ReadonlySet = new Set(["isEmpty", "isNotEmpty"]); +const NO_OPERAND: ReadonlySet = new Set([ + "isEmpty", + "isNotEmpty", +]); /** Is this filter active (has a usable operand)? Blank/empty operands are inactive. */ export function isFilterActive(filter: ColumnFilter): boolean { @@ -235,12 +299,18 @@ export function evaluateFilter( const n = typeof cell === "number" ? cell : Number(cell); if (Number.isNaN(n)) return false; switch (operator) { - case "equals": return n === Number(value); - case "notEquals": return n !== Number(value); - case "gt": return n > Number(value); - case "gte": return n >= Number(value); - case "lt": return n < Number(value); - case "lte": return n <= Number(value); + case "equals": + return n === Number(value); + case "notEquals": + return n !== Number(value); + case "gt": + return n > Number(value); + case "gte": + return n >= Number(value); + case "lt": + return n < Number(value); + case "lte": + return n <= Number(value); case "between": { if (!Array.isArray(value)) return false; const a = Number(value[0]); @@ -249,16 +319,20 @@ export function evaluateFilter( const hi = Math.max(a, b); return n >= lo && n <= hi; } - default: return false; + default: + return false; } } case "date": { const c = toDayMs(cell); if (Number.isNaN(c)) return false; switch (operator) { - case "on": return c === toDayMs(value); - case "before": return c < toDayMs(value); - case "after": return c > toDayMs(value); + case "on": + return c === toDayMs(value); + case "before": + return c < toDayMs(value); + case "after": + return c > toDayMs(value); case "dateBetween": { if (!Array.isArray(value)) return false; const a = toDayMs(value[0]); @@ -268,7 +342,8 @@ export function evaluateFilter( const hi = Math.max(a, b); return c >= lo && c <= hi; } - default: return false; + default: + return false; } } case "enum": { @@ -276,9 +351,12 @@ export function evaluateFilter( const set = Array.isArray(value) ? value.map(String) : []; if (set.length === 0) return true; // empty selection = no constraint switch (operator) { - case "isAnyOf": return set.includes(c); - case "isNoneOf": return !set.includes(c); - default: return false; + case "isAnyOf": + return set.includes(c); + case "isNoneOf": + return !set.includes(c); + default: + return false; } } case "text": @@ -286,13 +364,20 @@ export function evaluateFilter( const hay = String(cell ?? "").toLowerCase(); const needle = String(value ?? "").toLowerCase(); switch (operator) { - case "contains": return hay.includes(needle); - case "notContains": return !hay.includes(needle); - case "equals": return hay === needle; - case "notEquals": return hay !== needle; - case "startsWith": return hay.startsWith(needle); - case "endsWith": return hay.endsWith(needle); - default: return false; + case "contains": + return hay.includes(needle); + case "notContains": + return !hay.includes(needle); + case "equals": + return hay === needle; + case "notEquals": + return hay !== needle; + case "startsWith": + return hay.startsWith(needle); + case "endsWith": + return hay.endsWith(needle); + default: + return false; } } } @@ -316,6 +401,7 @@ git commit -m "feat(grid-core): typed filter operators + pure evaluateFilter" ## Task 2: Wire operators into the engine (grid-core) **Files:** + - Modify: `packages/grid-core/src/derived-rows.ts` - Modify: `packages/grid-core/src/create-grid-core.ts` - Modify: `packages/grid-core/src/types.ts` (retype snapshot + engine) @@ -325,12 +411,15 @@ git commit -m "feat(grid-core): typed filter operators + pure evaluateFilter" - `PretableGridSnapshot.filters`: `Record` → `Record`. - In `PretableEngine`, replace: + ```ts setFilter(columnId: string, value: string): void; clearFilters(): void; replaceFilters(nextFilters: Record): void; ``` + with: + ```ts setColumnFilter(columnId: string, filter: ColumnFilter | null): void; clearFilters(): void; @@ -396,7 +485,12 @@ function matchesFilters( for (const { column, filter } of resolvedFilters) { const cell = readCellValue(row, column); if ( - !evaluateFilter(cell, column.filterType ?? "text", filter.operator, filter.value) + !evaluateFilter( + cell, + column.filterType ?? "text", + filter.operator, + filter.value, + ) ) { return false; } @@ -410,13 +504,16 @@ function matchesFilters( - [ ] **Step 3: Update `create-grid-core.ts`** 1. State + cache types: + ```ts let cachedDerivedFilters: Record | null = null; let filters: Record = {}; ``` + (Add `ColumnFilter` to the existing type import from `./types`.) 2. Replace `setFilter` (the whole method, `:121-143`) with: + ```ts setColumnFilter(columnId: string, filter: ColumnFilter | null) { const current = filters[columnId]; @@ -434,6 +531,7 @@ function matchesFilters( ``` 3. Replace `replaceFilters` body to normalize via `isFilterActive` and compare with the new `filtersEqual`: + ```ts replaceFilters(nextFilters: Record) { const normalized: Record = {}; @@ -449,6 +547,7 @@ function matchesFilters( 4. `clearFilters` stays as-is (already type-agnostic). 5. Add `distinctColumnValues` to the `store` object (it has `sourceRows` + `options` in closure scope): + ```ts distinctColumnValues(columnId: string): string[] { const column = options.columns.find((c) => c.id === columnId); @@ -468,6 +567,7 @@ function matchesFilters( 6. Snapshot: `filters: { ...filters },` stays (shallow copy of the record; `ColumnFilter` values are treated as immutable). 7. Replace `filtersEqual` (`:990`) with a structural version + add `columnFilterEqual`: + ```ts function columnFilterEqual(a: ColumnFilter, b: ColumnFilter): boolean { if (a.operator !== b.operator) return false; @@ -520,7 +620,9 @@ it("replaceFilters drops inactive filters and is change-guarded", () => { it("distinctColumnValues returns sorted de-duped non-empty values", () => { const grid = makeGrid(); - expect(grid.distinctColumnValues("status")).toEqual([/* sorted distinct */]); + expect(grid.distinctColumnValues("status")).toEqual([ + /* sorted distinct */ + ]); }); ``` @@ -544,19 +646,23 @@ git commit -m "feat(grid-core): operator-based filter engine (setColumnFilter, d ## Task 3: Propagate to public API (`core` + `react`) **Files:** + - Modify: `packages/core/src/pretable-grid.ts`, `create-grid.ts`, `public_api.ts` - Modify: `packages/react/src/use-pretable.ts`, `public_api.ts` - [ ] **Step 1: `core/src/pretable-grid.ts`** — in the `PretableGrid` interface, replace the three filter method signatures (`:39-41`) with: + ```ts setColumnFilter(columnId: string, filter: ColumnFilter | null): void; clearFilters(): void; replaceFilters(nextFilters: Record): void; distinctColumnValues(columnId: string): string[]; ``` + Add `ColumnFilter` to the type import from `@pretable-internal/grid-core` (match how this file imports other engine types). - [ ] **Step 2: `core/src/create-grid.ts`** — update forwarding (`:34-37`): + ```ts setSort: engine.setSort, setColumnFilter: engine.setColumnFilter, @@ -564,9 +670,11 @@ Add `ColumnFilter` to the type import from `@pretable-internal/grid-core` (match replaceFilters: engine.replaceFilters, distinctColumnValues: engine.distinctColumnValues, ``` + (Remove the old `setFilter` line.) If any JSDoc example in this file uses `setFilter(...)`, update it to `setColumnFilter("age", { operator: "gt", value: 30 })`. - [ ] **Step 3: `core/src/public_api.ts`** — export the new types. Add to the existing `export type { ... } from "@pretable-internal/grid-core"` block (or wherever types are re-exported): + ```ts ColumnFilter, FilterOperator, @@ -599,12 +707,14 @@ git commit -m "feat(core,react): expose operator filter API (setColumnFilter, fi ## Task 4: Migrate website + regenerate API reports + full validation **Files:** + - Modify: `apps/website/app/components/heroGrid/filters.ts` (+ `__tests__/filters.test.ts`) - Modify: `*.api.md` (generated) - [ ] **Step 1: Migrate `buildFilters`** in `apps/website/app/components/heroGrid/filters.ts` Change the return type to `Record` (import from `@pretable/core`) and emit operator filters: + - search term → `{ symbol: { operator: "contains", value: search } }` (only when non-empty) - sector (when not "All") → `{ sector: { operator: "isAnyOf", value: [sector] } }` @@ -622,6 +732,7 @@ This rewrites `packages/core/.../core.api.md` and `packages/react/.../react.api. - [ ] **Step 3: Full validation sweep (repo + website)** Run: + ```bash pnpm -r typecheck pnpm -r lint @@ -630,6 +741,7 @@ pnpm format pnpm --filter @pretable/app-website build pnpm api # second run must be a no-op (clean) — proves reports are committed/fresh ``` + Expected: all green; the second `pnpm api` reports no changes. Fix anything red (run `pnpm format:write` if format check fails). - [ ] **Step 4: Commit** diff --git a/docs/superpowers/specs/2026-06-18-filter-engine-model-design.md b/docs/superpowers/specs/2026-06-18-filter-engine-model-design.md index 051347f..c2d0ef5 100644 --- a/docs/superpowers/specs/2026-06-18-filter-engine-model-design.md +++ b/docs/superpowers/specs/2026-06-18-filter-engine-model-design.md @@ -41,24 +41,38 @@ no React, no DOM, no UI in this sub-project. /** @public */ export type FilterOperator = // text - | "contains" | "notContains" | "equals" | "notEquals" | "startsWith" | "endsWith" + | "contains" + | "notContains" + | "equals" + | "notEquals" + | "startsWith" + | "endsWith" // number - | "gt" | "gte" | "lt" | "lte" | "between" + | "gt" + | "gte" + | "lt" + | "lte" + | "between" // enum / set - | "isAnyOf" | "isNoneOf" + | "isAnyOf" + | "isNoneOf" // date - | "on" | "before" | "after" | "dateBetween" + | "on" + | "before" + | "after" + | "dateBetween" // shared (any type) - | "isEmpty" | "isNotEmpty"; + | "isEmpty" + | "isNotEmpty"; /** @public */ export type FilterValue = - | string // text ops, single date (ISO yyyy-mm-dd) for on/before/after - | number // number comparisons - | [number, number] // between - | [string, string] // dateBetween (ISO start, ISO end) - | string[] // isAnyOf / isNoneOf - | null; // isEmpty / isNotEmpty (no operand) + | string // text ops, single date (ISO yyyy-mm-dd) for on/before/after + | number // number comparisons + | [number, number] // between + | [string, string] // dateBetween (ISO start, ISO end) + | string[] // isAnyOf / isNoneOf + | null; // isEmpty / isNotEmpty (no operand) /** @public — one column's filter. `value` omitted for isEmpty/isNotEmpty. */ export interface ColumnFilter { @@ -70,6 +84,7 @@ export interface ColumnFilter { ``` Notes: + - `equals`/`notEquals` live in the **text** family (case-insensitive string equality). Numeric equality uses the number family's `equals` semantics via the column's `filterType` (the operator string `equals` is shared text+number; evaluation branches @@ -88,11 +103,11 @@ export type FilterType = "text" | "number" | "date" | "enum"; interface PretableColumn { // ...existing... - filterType?: FilterType; // default "text" - filterable?: boolean; // EXISTING — keep; default true (a false value means - // the engine ignores any filter targeting this column) + filterType?: FilterType; // default "text" + filterable?: boolean; // EXISTING — keep; default true (a false value means + // the engine ignores any filter targeting this column) filterOptions?: { value: string; label?: string }[]; // enum only; if omitted, the - // distinct values are auto-derived from the rows. + // distinct values are auto-derived from the rows. } ``` @@ -165,12 +180,13 @@ In `packages/grid-core/src/create-grid-core.ts` and the public `PretableGrid` - `apps/website/app/components/heroGrid/filters.ts` + `HeroGrid.tsx` — migrate `buildFilters` to emit `ColumnFilter`s (e.g. `{operator:"contains",value}` for search, `{operator:"isAnyOf",value:[sector]}` for sector) so the website compiles. The hero's - *UX* redesign is sub-project 3; this is the minimal keep-it-building change. + _UX_ redesign is sub-project 3; this is the minimal keep-it-building change. - `*.api.md` (api-extractor reports) for `core` and `react` — regenerated via `pnpm api`. ## Testing `packages/grid-core/src/__tests__/` (new `filter-operators.test.ts` + extend existing): + - **text:** contains/notContains/equals/notEquals/startsWith/endsWith, case-insensitive. - **number:** gt/gte/lt/lte/equals/notEquals; between inclusive + reversed-bounds; NaN cell fails. - **enum:** isAnyOf/isNoneOf; empty selection = no constraint. diff --git a/packages/grid-core/src/__tests__/emit-behavior.test.ts b/packages/grid-core/src/__tests__/emit-behavior.test.ts index 923d4e0..b54e506 100644 --- a/packages/grid-core/src/__tests__/emit-behavior.test.ts +++ b/packages/grid-core/src/__tests__/emit-behavior.test.ts @@ -141,10 +141,16 @@ describe("grid-core emit behavior", () => { test("setColumnFilter with identical value does not emit", () => { const instrumented = createInstrumentedGrid(); - instrumented.grid.setColumnFilter("status", { operator: "contains", value: "open" }); + instrumented.grid.setColumnFilter("status", { + operator: "contains", + value: "open", + }); instrumented.reset(); - instrumented.grid.setColumnFilter("status", { operator: "contains", value: "open" }); + instrumented.grid.setColumnFilter("status", { + operator: "contains", + value: "open", + }); expect(instrumented.emits).toBe(0); }); diff --git a/packages/grid-core/src/__tests__/evaluate-filter.test.ts b/packages/grid-core/src/__tests__/evaluate-filter.test.ts index a5c93f8..dc9c4f6 100644 --- a/packages/grid-core/src/__tests__/evaluate-filter.test.ts +++ b/packages/grid-core/src/__tests__/evaluate-filter.test.ts @@ -10,16 +10,30 @@ const ev = ( describe("evaluateFilter — text", () => { it("contains / notContains are case-insensitive", () => { - expect(ev("Hello", "text", { operator: "contains", value: "ell" })).toBe(true); - expect(ev("Hello", "text", { operator: "contains", value: "ELL" })).toBe(true); - expect(ev("Hello", "text", { operator: "notContains", value: "xyz" })).toBe(true); - expect(ev("Hello", "text", { operator: "notContains", value: "ell" })).toBe(false); + expect(ev("Hello", "text", { operator: "contains", value: "ell" })).toBe( + true, + ); + expect(ev("Hello", "text", { operator: "contains", value: "ELL" })).toBe( + true, + ); + expect(ev("Hello", "text", { operator: "notContains", value: "xyz" })).toBe( + true, + ); + expect(ev("Hello", "text", { operator: "notContains", value: "ell" })).toBe( + false, + ); }); it("equals / notEquals / startsWith / endsWith", () => { expect(ev("abc", "text", { operator: "equals", value: "ABC" })).toBe(true); - expect(ev("abc", "text", { operator: "notEquals", value: "abd" })).toBe(true); - expect(ev("abcdef", "text", { operator: "startsWith", value: "ABC" })).toBe(true); - expect(ev("abcdef", "text", { operator: "endsWith", value: "DEF" })).toBe(true); + expect(ev("abc", "text", { operator: "notEquals", value: "abd" })).toBe( + true, + ); + expect(ev("abcdef", "text", { operator: "startsWith", value: "ABC" })).toBe( + true, + ); + expect(ev("abcdef", "text", { operator: "endsWith", value: "DEF" })).toBe( + true, + ); }); }); @@ -35,7 +49,9 @@ describe("evaluateFilter — number", () => { it("between is inclusive and tolerates reversed bounds", () => { expect(ev(5, "number", { operator: "between", value: [1, 10] })).toBe(true); expect(ev(5, "number", { operator: "between", value: [10, 1] })).toBe(true); - expect(ev(11, "number", { operator: "between", value: [1, 10] })).toBe(false); + expect(ev(11, "number", { operator: "between", value: [1, 10] })).toBe( + false, + ); }); it("non-numeric cell fails comparisons (but not isEmpty)", () => { expect(ev("oops", "number", { operator: "gt", value: 1 })).toBe(false); @@ -45,23 +61,47 @@ describe("evaluateFilter — number", () => { describe("evaluateFilter — enum", () => { it("isAnyOf / isNoneOf; empty selection = no constraint", () => { - expect(ev("a", "enum", { operator: "isAnyOf", value: ["a", "b"] })).toBe(true); - expect(ev("c", "enum", { operator: "isAnyOf", value: ["a", "b"] })).toBe(false); - expect(ev("c", "enum", { operator: "isNoneOf", value: ["a", "b"] })).toBe(true); + expect(ev("a", "enum", { operator: "isAnyOf", value: ["a", "b"] })).toBe( + true, + ); + expect(ev("c", "enum", { operator: "isAnyOf", value: ["a", "b"] })).toBe( + false, + ); + expect(ev("c", "enum", { operator: "isNoneOf", value: ["a", "b"] })).toBe( + true, + ); expect(ev("a", "enum", { operator: "isAnyOf", value: [] })).toBe(true); }); }); describe("evaluateFilter — date", () => { it("on / before / after / dateBetween (inclusive)", () => { - expect(ev("2026-06-18", "date", { operator: "on", value: "2026-06-18" })).toBe(true); - expect(ev("2026-06-18", "date", { operator: "before", value: "2026-06-19" })).toBe(true); - expect(ev("2026-06-18", "date", { operator: "after", value: "2026-06-17" })).toBe(true); - expect(ev("2026-06-18", "date", { operator: "dateBetween", value: ["2026-06-01", "2026-06-30"] })).toBe(true); - expect(ev("2026-07-01", "date", { operator: "dateBetween", value: ["2026-06-01", "2026-06-30"] })).toBe(false); + expect( + ev("2026-06-18", "date", { operator: "on", value: "2026-06-18" }), + ).toBe(true); + expect( + ev("2026-06-18", "date", { operator: "before", value: "2026-06-19" }), + ).toBe(true); + expect( + ev("2026-06-18", "date", { operator: "after", value: "2026-06-17" }), + ).toBe(true); + expect( + ev("2026-06-18", "date", { + operator: "dateBetween", + value: ["2026-06-01", "2026-06-30"], + }), + ).toBe(true); + expect( + ev("2026-07-01", "date", { + operator: "dateBetween", + value: ["2026-06-01", "2026-06-30"], + }), + ).toBe(false); }); it("unparseable cell fails (but not isEmpty)", () => { - expect(ev("not-a-date", "date", { operator: "before", value: "2026-06-19" })).toBe(false); + expect( + ev("not-a-date", "date", { operator: "before", value: "2026-06-19" }), + ).toBe(false); expect(ev("", "date", { operator: "isEmpty" })).toBe(true); }); }); diff --git a/packages/grid-core/src/__tests__/grid-core.test.ts b/packages/grid-core/src/__tests__/grid-core.test.ts index 8f20cf6..913afa4 100644 --- a/packages/grid-core/src/__tests__/grid-core.test.ts +++ b/packages/grid-core/src/__tests__/grid-core.test.ts @@ -538,9 +538,7 @@ describe("grid-core — filter operators", () => { test("filterable:false columns ignore their filter entry", () => { const grid = createGridCore({ - columns: [ - { id: "status", header: "Status", filterable: false }, - ], + columns: [{ id: "status", header: "Status", filterable: false }], rows: opRows, getRowId: (row) => row.id, }); diff --git a/packages/grid-core/src/create-grid-core.ts b/packages/grid-core/src/create-grid-core.ts index 67effde..abe2760 100644 --- a/packages/grid-core/src/create-grid-core.ts +++ b/packages/grid-core/src/create-grid-core.ts @@ -176,7 +176,9 @@ export function createGridCore( const seen = new Set(); for (const entry of sourceRows) { - const raw = column.value ? column.value(entry.row) : entry.row[columnId]; + const raw = column.value + ? column.value(entry.row) + : entry.row[columnId]; if (raw === null || raw === undefined) { continue; diff --git a/packages/grid-core/src/evaluate-filter.ts b/packages/grid-core/src/evaluate-filter.ts index b5a22c3..3d16695 100644 --- a/packages/grid-core/src/evaluate-filter.ts +++ b/packages/grid-core/src/evaluate-filter.ts @@ -1,6 +1,14 @@ -import type { ColumnFilter, FilterOperator, FilterType, FilterValue } from "./types"; +import type { + ColumnFilter, + FilterOperator, + FilterType, + FilterValue, +} from "./types"; -const NO_OPERAND: ReadonlySet = new Set(["isEmpty", "isNotEmpty"]); +const NO_OPERAND: ReadonlySet = new Set([ + "isEmpty", + "isNotEmpty", +]); /** Is this filter active (has a usable operand)? Blank/empty operands are inactive. */ export function isFilterActive(filter: ColumnFilter): boolean { @@ -45,12 +53,18 @@ export function evaluateFilter( const n = typeof cell === "number" ? cell : Number(cell); if (Number.isNaN(n)) return false; switch (operator) { - case "equals": return n === Number(value); - case "notEquals": return n !== Number(value); - case "gt": return n > Number(value); - case "gte": return n >= Number(value); - case "lt": return n < Number(value); - case "lte": return n <= Number(value); + case "equals": + return n === Number(value); + case "notEquals": + return n !== Number(value); + case "gt": + return n > Number(value); + case "gte": + return n >= Number(value); + case "lt": + return n < Number(value); + case "lte": + return n <= Number(value); case "between": { if (!Array.isArray(value)) return false; const a = Number(value[0]); @@ -59,16 +73,20 @@ export function evaluateFilter( const hi = Math.max(a, b); return n >= lo && n <= hi; } - default: return false; + default: + return false; } } case "date": { const c = toDayMs(cell); if (Number.isNaN(c)) return false; switch (operator) { - case "on": return c === toDayMs(value); - case "before": return c < toDayMs(value); - case "after": return c > toDayMs(value); + case "on": + return c === toDayMs(value); + case "before": + return c < toDayMs(value); + case "after": + return c > toDayMs(value); case "dateBetween": { if (!Array.isArray(value)) return false; const a = toDayMs(value[0]); @@ -78,7 +96,8 @@ export function evaluateFilter( const hi = Math.max(a, b); return c >= lo && c <= hi; } - default: return false; + default: + return false; } } case "enum": { @@ -86,9 +105,12 @@ export function evaluateFilter( const set = Array.isArray(value) ? value.map(String) : []; if (set.length === 0) return true; // empty selection = no constraint switch (operator) { - case "isAnyOf": return set.includes(c); - case "isNoneOf": return !set.includes(c); - default: return false; + case "isAnyOf": + return set.includes(c); + case "isNoneOf": + return !set.includes(c); + default: + return false; } } case "text": @@ -96,13 +118,20 @@ export function evaluateFilter( const hay = String(cell ?? "").toLowerCase(); const needle = String(value ?? "").toLowerCase(); switch (operator) { - case "contains": return hay.includes(needle); - case "notContains": return !hay.includes(needle); - case "equals": return hay === needle; - case "notEquals": return hay !== needle; - case "startsWith": return hay.startsWith(needle); - case "endsWith": return hay.endsWith(needle); - default: return false; + case "contains": + return hay.includes(needle); + case "notContains": + return !hay.includes(needle); + case "equals": + return hay === needle; + case "notEquals": + return hay !== needle; + case "startsWith": + return hay.startsWith(needle); + case "endsWith": + return hay.endsWith(needle); + default: + return false; } } } From da6b955c296e7098bb4ed5117ce535d0be9edfba Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 21:03:11 -0700 Subject: [PATCH 8/9] chore(api): refresh react report from clean build The prior regen ran against a stale react dist and missed the top-level filter type re-exports and the retyped PretableSurfaceState.filters. This regen is from a clean package build. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react/react.api.md | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/react/react.api.md b/packages/react/react.api.md index 4ba0c1f..b301aa0 100644 --- a/packages/react/react.api.md +++ b/packages/react/react.api.md @@ -9,6 +9,14 @@ import { HTMLAttributes } from 'react'; import * as react from 'react'; import { ReactNode } from 'react'; +// @public +export interface ColumnFilter { + // (undocumented) + operator: FilterOperator; + // (undocumented) + value?: FilterValue; +} + // @public export interface CopyPayload { // (undocumented) @@ -28,6 +36,23 @@ export interface DensityHeights { rowHeight: number; } +// @public (undocumented) +export type FilterOperator = "contains" | "notContains" | "equals" | "notEquals" | "startsWith" | "endsWith" | "gt" | "gte" | "lt" | "lte" | "between" | "isAnyOf" | "isNoneOf" | "on" | "before" | "after" | "dateBetween" | "isEmpty" | "isNotEmpty"; + +// @public (undocumented) +export interface FilterOption { + // (undocumented) + label?: string; + // (undocumented) + value: string; +} + +// @public (undocumented) +export type FilterType = "text" | "number" | "date" | "enum"; + +// @public (undocumented) +export type FilterValue = string | number | readonly [number, number] | readonly [string, string] | readonly string[] | null; + // @beta export function InspectionGrid(input: InspectionGridProps): react.JSX.Element; @@ -314,8 +339,6 @@ export interface PretableGrid { resetColumnLayout(): void; // (undocumented) selectAll(): void; - // Warning: (ae-forgotten-export) The symbol "ColumnFilter" needs to be exported by the entry point index.d.ts - // // (undocumented) setColumnFilter(columnId: string, filter: ColumnFilter | null): void; // (undocumented) @@ -591,7 +614,7 @@ export interface PretableSurfaceState { // (undocumented) columnWidths?: Record; // (undocumented) - filters?: Record; + filters?: Record; // (undocumented) focus?: PretableFocusState; // (undocumented) From 8f66d70609e1522a8ea3427ff61ead74a0ef3729 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 18 Jun 2026 21:09:42 -0700 Subject: [PATCH 9/9] docs,test: refresh headless API reference for operator filters; cover value-accessor distinct Final-review nits: the headless api-reference still documented the removed setFilter + Record filters and omitted the new filter types; add a distinctColumnValues test exercising the column value accessor. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../content/docs/grid/api-reference.mdx | 6 +- .../content/docs/headless/api-reference.mdx | 67 +++++++++++++++++-- .../grid-core/src/__tests__/grid-core.test.ts | 21 ++++++ 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/apps/website/content/docs/grid/api-reference.mdx b/apps/website/content/docs/grid/api-reference.mdx index df93700..9da18f4 100644 --- a/apps/website/content/docs/grid/api-reference.mdx +++ b/apps/website/content/docs/grid/api-reference.mdx @@ -119,7 +119,7 @@ interface PretableHeaderRenderInput { ```ts interface PretableSurfaceState { sort?: PretableSortState; - filters?: Record; + filters?: Record; selection?: PretableSelectionState; focus?: PretableFocusState; columnWidths?: Record; @@ -220,7 +220,7 @@ interface PretableGridSnapshot { width: number; }; sort: { columnId: string | null; direction: "asc" | "desc" | null }; - filters: Record; + filters: Record; selection: { ranges: Array<{ startRowId: string; @@ -267,7 +267,7 @@ interface UsePretableOptions extends UsePretableOptions { interface PretableSurfaceState { sort?: PretableSortState; - filters?: Record; + filters?: Record; selection?: PretableSelectionState; focus?: PretableFocusState; columnWidths?: Record; diff --git a/apps/website/content/docs/headless/api-reference.mdx b/apps/website/content/docs/headless/api-reference.mdx index d4871df..1739980 100644 --- a/apps/website/content/docs/headless/api-reference.mdx +++ b/apps/website/content/docs/headless/api-reference.mdx @@ -44,12 +44,13 @@ readonly options: PretableGridOptions; ### Sort & filter -| Method | Signature | -| ---------------- | ---------------------------------------------------------------------- | -| `setSort` | `(columnId: string \| null, direction: PretableSortDirection) => void` | -| `setFilter` | `(columnId: string, value: string) => void` | -| `replaceFilters` | `(nextFilters: Record) => void` | -| `clearFilters` | `() => void` | +| Method | Signature | +| ---------------------- | ---------------------------------------------------------------------- | +| `setSort` | `(columnId: string \| null, direction: PretableSortDirection) => void` | +| `setColumnFilter` | `(columnId: string, filter: ColumnFilter \| null) => void` | +| `replaceFilters` | `(nextFilters: Record) => void` | +| `clearFilters` | `() => void` | +| `distinctColumnValues` | `(columnId: string) => string[]` | ### Selection & ranges @@ -101,6 +102,8 @@ interface PretableColumn { format?: (input: PretableFormatInput) => string; sortable?: boolean; filterable?: boolean; + filterType?: FilterType; + filterOptions?: FilterOption[]; reorderable?: boolean; resizable?: boolean; pinned?: "left"; @@ -111,6 +114,56 @@ interface PretableColumn { } ``` +### Filter types + +```ts +type FilterType = "text" | "number" | "date" | "enum"; + +type FilterOperator = + | "contains" + | "notContains" + | "equals" + | "notEquals" + | "startsWith" + | "endsWith" + | "gt" + | "gte" + | "lt" + | "lte" + | "between" + | "isAnyOf" + | "isNoneOf" + | "on" + | "before" + | "after" + | "dateBetween" + | "isEmpty" + | "isNotEmpty"; + +type FilterValue = + | string + | number + | readonly [number, number] + | readonly [string, string] + | readonly string[] + | null; + +interface ColumnFilter { + operator: FilterOperator; + value?: FilterValue; +} + +interface FilterOption { + value: string; + label?: string; +} +``` + +Each column's `filterType` (default `"text"`) selects which operators apply and how +values are compared. Columns are AND-combined. `filterOptions` supplies the choices for +an `enum` column; when omitted, call `distinctColumnValues(columnId)` to derive them from +the data. + ### `PretableRow` ```ts @@ -125,7 +178,7 @@ interface PretableGridSnapshot { visibleRange: PretableRowRange; totalRowCount: number; sort: PretableSortState; - filters: Record; + filters: Record; selection: PretableSelectionState; focus: PretableFocusState; viewport: PretableViewportState; diff --git a/packages/grid-core/src/__tests__/grid-core.test.ts b/packages/grid-core/src/__tests__/grid-core.test.ts index 913afa4..d4b322c 100644 --- a/packages/grid-core/src/__tests__/grid-core.test.ts +++ b/packages/grid-core/src/__tests__/grid-core.test.ts @@ -558,4 +558,25 @@ describe("grid-core — filter operators", () => { expect(grid.distinctColumnValues("priority")).toEqual(["1", "2", "3", "5"]); expect(grid.distinctColumnValues("missing")).toEqual([]); }); + + test("distinctColumnValues reads through the column value accessor", () => { + const grid = createGridCore({ + columns: [ + { + id: "team", + header: "Team", + filterType: "enum" as const, + value: (row) => `${row.org}/${row.squad}`, + }, + ], + rows: [ + { id: "a", org: "eng", squad: "core" }, + { id: "b", org: "eng", squad: "core" }, + { id: "c", org: "eng", squad: "web" }, + ], + getRowId: (row) => row.id as string, + }); + + expect(grid.distinctColumnValues("team")).toEqual(["eng/core", "eng/web"]); + }); });