---
title: Add column header quick filter to react-table and league data views
type: task
status: completed
description: >-
  Add a header-anchored quick filter popover offering min, max and both-bound range entry for NUMBER
  and DATE columns plus data-type-appropriate controls for TEXT, SELECT and BOOLEAN, built on a
  reusable where-clause lens and expressed entirely as ordinary where rows with no new operator.
base_uri: user:task/react-table/add-column-header-quick-filter.md
created_at: '2026-09-22T03:00:33.225Z'
entity_id: 42f58d9b-f629-4a12-a0a6-378fd60c3319
finished_at: '2026-10-10T16:01:05.220Z'
observations:
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
  - ████████████████████████████████████████████████
owner_identity_uri: █████████████████████████
public_read: true
relations:
  - relates [[user:task/league/data-views-quick-filter-sort-implementation-plan.md]]
  - relates [[user:task/league/resolve-generated-option-sets-on-column-values.md]]
  - precedes [[user:task/league/retype-low-cardinality-text-columns-to-select.md]]
started_at: '2026-09-22T04:55:02.289Z'
tags:
  - user:tag/base-project.md
  - user:tag/league-xo-football.md
updated_at: '2026-10-10T16:46:32.550Z'
visibility_analyzed_at: '2026-11-05T23:50:40.754Z'
---

# Implementation Plan: Add Column Header Quick Filter

## Overview

- Add a header-anchored popover to the data view so a filter can be set from the column you are looking at, without a trip to the side panel
- NUMBER and DATE columns get min-only, max-only, or both-bounds entry; TEXT, SELECT and BOOLEAN get data-type-appropriate quick controls
- Express every filter as ordinary `where` rows using operators that already validate server-side — no new operator, no new table-state field, no parallel filter store, no server change
- Ship the projection of a flat `where` array onto one column instance as a pure, separately-tested lens, because index stability across four `where`-index consumers is the failure mode most likely to regress silently

**Impact.** Setting a numeric bound currently costs a trip to the side panel, a column search, an operator dropdown, a value, and an Apply click — repeated for the second bound of a range. After this, it is a header click and a number. The beneficiary is anyone building a view on xo.football; the columns most often filtered are numeric, which is exactly the case with the worst current ergonomics.

**Ordering.** Supersedes `user:task/league/data-views-quick-filter-sort-implementation-plan.md` (Abandoned), whose parallel `quick_filters` state this design rejects. Nothing is blocked on it. It touches `src/table-header/table-header.js` and `src/table/table.js`, so it should not run concurrently with other work in those files.

## Background

**Repo wiring.** react-table is a GitHub-pinned npm dependency of league (`league/package.json:199`, pin `7b3fb0ee`), not a submodule or workspace link. League imports deep source paths (`react-table/src/constants.mjs`), so changes ship via a pin bump plus `yarn install`. Iteration uses the node_modules symlink path in [[user:guideline/software/develop-react-table.md]].

**The header already exists and already has a Filter entry.** `src/table-header/table-header.js` opens a single MUI Popper menu carrying remove-column, sort ascending/descending, multi-sort, Filter, scatter X/Y, and bar chart. Its Filter item (`:598-610`, handler `:213-243`) only appends a blank `where` row and opens the side panel on a 10ms timeout. That is the hook point. File length measured with `wc -l src/table-header/table-header.js` (707).

**Where-clause shape is settled and flat.** `schema/state/where-item.json` gives `{ column_id, operator, value, params?, column_index?, column_name?, id? }`. The `where` array is a flat AND list with no grouping or conjunction concept, so a range is genuinely two rows.

**No `BETWEEN` exists in any of five allowlists.** Client `src/constants.mjs:69`, `schema/base/table-operators.json`, `src/validators/security-patterns.mjs:59`, server `league/libs-server/validators.mjs:242` (authoritative), plus the narrower lists at `league/libs-server/data-views/generation/execute-generated-sql.mjs:50` and `league/libs-server/plays-view/get-plays-view-results.mjs:474`, and two Swagger mirrors. Verified with `grep -rn "'BETWEEN'" libs-server/ app/` in league (zero hits) and `grep -rn BETWEEN src/ schema/` in react-table (zero hits). `src/validators/table-state-validator.mjs:169` additionally forbids array values on comparison operators, so `BETWEEN` would need a new operator class. A range as `>=` plus `<=` needs none of this and round-trips through the share-link URL today.

**Per-data-type operator allowlists** (`src/constants.mjs:103-159`). NUMBER and DATE get `=`, `!=`, the four inequalities, and both null checks. TEXT gets `=`, `!=`, `LIKE`, `NOT LIKE`, and both null checks. SELECT gets `=`, `!=`, `IN`, `NOT IN`, and both null checks. BOOLEAN gets `=`, `!=`, and both null checks. `OBJECT_PRESET` gets `IN` only, and `RANGE` has **no entry at all**. The panel reads these rather than inventing its own vocabulary, so each body must offer the equality operators alongside its characteristic ones.

**Commit path.** `src/table/table.js:252-316` `on_table_state_change` strips row-axis-disabled params, drops any where row with an empty value unless the operator is a null check, then calls the consumer's `on_view_change`. League handles it at `app/views/pages/data-views/data-views.js:308-313`, then `data_view_changed`, reducer merge, saga, websocket.

**Four consumers read a `where` index, not two.** `src/utils/build-where-column-definitions.js` is index-aligned (stated in its own header comment); `src/table-filter-controls/table-filter-controls.js:461` bulk-removes by `selected_where_indexes`; league's `app/core/data-views/data-view-notices.mjs:81,96,107,144` carries `filter_index`, and `app/views/components/data-view-notices/data-view-notices.js:28,35` persists dismissals as `` `${code}:${filter_index}` ``. A shifted index therefore silently resurrects a dismissed notice or pre-dismisses a new one.

**Multiple where rows per column are reachable.** `src/table-filter-controls/table-filter-controls.js:43` reads `enable_duplicate_column_ids` from context; when set, clicking a column row appends another filter instead of removing. `handle_operator_change` then allows two rows to carry the same operator. Filters the panel cannot represent are therefore a real producible state, not a hypothetical.

**The toolbar already has a quick filter.** `src/table/table.js:758-778` renders `TableQuickFilter` per `selected_view.view_filters`, and it matches rows by `column_id` alone (`src/table-quick-filter/table-quick-filter.js:15-17`). Left alone, it and the new panel would disagree about which row a duplicated column owns.

### Assumptions

- **`column_index` is a safe matching key for a header-written row.** Unchecked as live data; source of truth is `schema/state/where-item.json`, which documents it as the disambiguator for duplicate `column_id`s. No UI sets it today, so every existing row is treated as index 0.
- **`remove_disabled_params` mutates params on commit.** Checked: read `src/table/table.js:262-281`, which deletes `item.params[param_name]` in place for every `where` row when `row_axes` is non-empty. This is why `params` is excluded from the matching key.
- **`column_values` reaches a body via `all_columns[column_id]`, not `column.columnDef`.** Unchecked; inferred from `src/table/table.js:764` feeding `TableQuickFilter`. Verify at the first SELECT body render rather than assuming.
- **League's notice infrastructure surfaces a server-rejected filter value.** Unchecked end to end; `app/core/data-views/data-view-notices.mjs` exists and carries filter-scoped codes. If it does not cover a 400 from `where_value_pattern`, the TEXT body needs its own guard and this plan grows a step.
- **Each `on_table_state_change` triggers a league refetch.** Checked: `app/core/data-views/sagas.js:405-428` calls `handle_data_view_request` on `view_state_changed && !is_display_only_change`. This is what makes the debounce interval load-bearing rather than cosmetic.
- **jsdom is an adequate oracle here.** These are text inputs and checkboxes, not SVG geometry; the chart-overlay caveat in `repository/active/react-table/CLAUDE.md` does not transfer.

## Design

Two layers, and the split is the whole design.

**A pure lens** projects the flat `where` array onto one column instance and reduces edits back into it. No React, no MUI, separately unit-testable. It has one consumer in this change; it is nonetheless its own module because the index-stability contract above is the part most likely to break silently, and testing it through a rendered popover would test it worst.

`resolve_column_filter_state({ where, column_id, column_index })` returns:

```js
{
  lower_bound: { operator: '>=' | '>', value, where_index } | null,
  upper_bound: { operator: '<=' | '<', value, where_index } | null,
  exact_value: { operator: '=' | '!=', value, where_index } | null,
  text_match:  { operator: 'LIKE' | 'NOT LIKE', value, where_index } | null,
  value_set:   { operator: 'IN' | 'NOT IN', value: [...], where_index } | null,
  null_check:  { operator: 'IS NULL' | 'IS NOT NULL', where_index } | null,
  unrepresentable_where_indexes: [7, 11]
}
```

`apply_column_filter_state({ where, column_id, column_index, params, next_state })` returns a new `where` array preserving every untouched index.

**The matching key is `column_id` plus `column_index` only — deliberately not `params`.** `remove_disabled_params` rewrites `item.params` in place on commit whenever `row_axes` is non-empty, so a params-bearing key stops matching the row it just wrote, the panel reads blank, and the next edit appends a duplicate. The panel still *writes* `params` onto rows it creates, so the row targets the right column instance; it just never matches on them. A row with no `column_index` is treated as index 0. When two rows tie on the key and the same descriptor slot, the first is claimed and the rest join `unrepresentable_where_indexes` — reported, never repaired.

**A dispatching panel** renders inside the header's existing Popper, choosing a body from the column's `data_type`, following `src/parameters-editor/parameters-editor-item.js:112-125`. That precedent is safe because it dispatches a closed set of `data_type` values to controls that share one `handle_change` contract, and the panel's bodies share the same contract, which is the property that has to transfer. Rendering inside the existing Popper rather than nesting a second one avoids the capture-phase Escape problem documented for chart overlays.

Bodies commit through `on_table_state_change` directly, debounced at **500 ms** on typed input. A quick filter that needs a second click to take effect is not quick, but every commit is a league refetch, so the interval is a stated cost rather than an implementation detail.

Dispatch covers NUMBER, DATE, TEXT, SELECT and BOOLEAN. `JSON`, `BINARY_UUID`, `RANGE`, `OBJECT_PRESET` and `undefined` render the advanced-filter link only. `undefined` is reachable today: `league/app/core/data-views-fields/player-adp-table-fields.js:144` sets `data_type: TABLE_DATA_TYPES.PERCENT`, and `PERCENT` is absent from `TABLE_DATA_TYPES` — so the shell must handle it or that column loses its Filter entry.

File organization:

```
src/utils/column-filter-state.js          # resolve_ and apply_, one module
src/column-quick-filter/
  index.js
  column-quick-filter.js                  # shell, data_type dispatch, unrepresentable notice, all bodies
  column-quick-filter.styl
```

Identifier check against [[user:guideline/write/write-software.md]]: `resolve_column_filter_state`, `apply_column_filter_state`, `lower_bound`, `upper_bound`, `exact_value`, `text_match`, `value_set`, `null_check`, `unrepresentable_where_indexes`, `where_index` — full words, no abbreviations, each greppable and unique in both trees (`grep -rn column_filter_state src/` returns zero before the change).

No new dependencies. No new table-state field, so `schema/state/table-state.json` is untouched and saved views and share links round-trip unchanged against an un-bumped server. `SHARE_LINK_URL_SCHEMA` already carries `where: 'array'` (`src/constants.mjs:26-55`), and `src/table-context.js` already supplies every value the shell reads, so neither needs a change.

**Runtime observability:** not applicable in the flywheel sense — human-in-the-loop UI with no unattended execution path, so there is no pipeline failure to route per [[user:guideline/surface-pipeline-failures.md]].

**Knowledge Base Impact:** `repository/active/react-table/CLAUDE.md` gains a quick-filter section (the lens contract, the four index consumers, the no-repair rule, and why `params` is excluded from the matching key). `repository/active/react-table/ABOUT.md` gains two observations: why `BETWEEN` was refused across five allowlists, and the `LIKE` force-wrap that forecloses starts-with. [[user:guideline/software/develop-react-table.md]] gains the two general development facts that do not belong in a task — the bare-specifier resolution trap and the by-file-path spec import rule. No new `text/` entity: `text/league/data-views/` holds service objectives, the measure contract, param option sets and the source bridge, none of which describes the filter UI. No new tag, no guideline candidate beyond the two additions above.

**Scratch tier:** not needed. Source files and tests only, no multi-file working artifacts. No `scratch/` slug declared.

**Orchestrator shape:** bounded to this single task entity. One feature across two repos with a pin bump — days, not weeks.

## Notes

- **`IS EMPTY` / `IS NOT EMPTY` are a trap.** Present in `schema/base/table-operators.json` but in neither `TABLE_OPERATORS` nor the server enum. `IS NULL` is already labeled "Is empty" at `src/constants.mjs:99` — reuse that copy so the header and side panel do not name the same thing differently.
- **Server value validation is stricter than the client's.** `league/libs-server/validators.mjs:279-287` rejects values containing `<`, `>`, `=`, quotes or SQL keywords and caps strings at 50 characters; react-table's own cap is 200. The server regex is deliberately not duplicated client-side — a copied regex drifts.
- **Starts-with and ends-with are not expressible.** `league/libs-server/data-views/where-string.mjs:95-100` force-wraps `LIKE` values as `'%value%'`, so the caller cannot supply wildcards.
- **Text matching is case-insensitive, by decision.** `src/constants.mjs:69-82` omits `ILIKE` / `NOT ILIKE` while both `league/libs-server/validators.mjs:242` and `src/validators/security-patterns.mjs:59` already accept them, so only the client vocabulary was missing. Note the server asymmetry this walks into: `where-string.mjs:95-100` wraps `ILIKE` as `'%value%'` but `NOT ILIKE` falls through to the generic branch at `:101-106` without the wrapping, so a "does not contain" written as `NOT ILIKE` would compare against the bare value. Use `NOT LIKE` for negation until that is fixed, or fix it in this change.
- **TEXT is the least valuable body, and the panel should say so by its ordering.** Of 38 TEXT-typed league columns, 27 are external-system identifier fields and only `player_name` and `player_hometown` are genuinely free text; the rest are enumerable attributes awaiting `user:task/league/retype-low-cardinality-text-columns-to-select.md`. A substring match against `player_espn_id` is semantically wrong, so the TEXT body leads with Equals and offers Contains second.
- Deferred, explicitly: percentile-threshold presets (need a consumer-supplied resolver to keep NFL knowledge out of react-table); a pin/unpin header toggle for `prefix_columns`; DATE relative presets (no stated requirement); a per-bound inclusive/exclusive toggle in the UI, though the descriptor still reads `>` and `<`; pairing a range into a single filter chip in league, which is cosmetic since `app/views/components/data-view-filter-chips/data-view-filter-chips.js:19-41` renders plain buttons with no per-chip remove and no index contract; and `NOT ILIKE` missing its `%` wrapping at `where-string.mjs:101-106`.

## Plan

### Lens

- [x] **`src/utils/column-filter-state.js`** — implement `resolve_column_filter_state` and `apply_column_filter_state`. Match rows on `column_id` plus `column_index` (absent treated as 0) and never on `params`; classify each matched row into its descriptor slot by operator; send ties and anything unclassifiable to `unrepresentable_where_indexes`. Apply rewrites in place at a held `where_index`, appends only for a genuinely new row, splices on clear, writes `params` onto rows it creates, and never touches an index the descriptor did not claim. Purpose: one tested projection, and a matching key that survives `remove_disabled_params`.
  - Verify: `yarn mocha test/column-filter-state.spec.mjs`

- [x] **`test/column-filter-state.spec.mjs`** — cover min-only, max-only, both bounds, clear, a second column untouched, duplicate `column_id` distinguished by `column_index`, a row whose `params` were stripped by `remove_disabled_params` still matching, two rows tying on the key with the second reported unrepresentable, and an unrepresentable row surviving a round trip byte-identical. Purpose: index stability and the no-repair rule are what regress silently.
  - Verify: `yarn mocha test/column-filter-state.spec.mjs` — all cases green, and the params-stripped case fails against a params-bearing matching key

### Panel

- [x] **`src/constants.mjs` and `schema/base/table-operators.json`** — add `ILIKE` and `NOT ILIKE` to `TABLE_OPERATORS`, `OPERATOR_MENU_OPTIONS` and `DATA_TYPE_OPERATORS[TEXT]`. Both are already accepted by `league/libs-server/validators.mjs:242` and `src/validators/security-patterns.mjs:59`, so this closes a client-side gap rather than widening the server contract. Purpose: text matching should be case-insensitive.
  - Verify: `yarn mocha test/` shows no validator regression; the side panel offers the new operators on a TEXT column

- [x] **`src/column-quick-filter/column-quick-filter.js` and `index.js`** — shell plus all bodies in one file, following the single-file dispatch precedent at `src/parameters-editor/parameters-editor-item.js:112-125`. Shell reads `table_state`, `on_table_state_change`, `all_columns` and `set_filter_controls_open` from `table_context`, dispatches on `data_type`, renders the unrepresentable-row count with a link to the advanced panel, and falls through to that link alone for `JSON`, `BINARY_UUID`, `RANGE`, `OBJECT_PRESET` and `undefined`. Bodies: range (Min, Max, Exactly, Has value / Is empty) for NUMBER and DATE; text (Equals first, then case-insensitive Contains, Does not contain, Has value / Is empty) for TEXT; value set (checkbox list over `column_values` writing `IN` / `NOT IN`) for SELECT; tri-state True / False / Any for BOOLEAN. Each body offers the equality operators its `DATA_TYPE_OPERATORS` entry allows. Commits debounce at 500 ms.
  - Verify: `yarn mocha test/column-quick-filter.spec.jsx`

- [x] **`src/column-quick-filter/column-quick-filter.styl`** — panel styling matching `src/filter-base/filter-base.styl` and `src/table-header/table-header.styl`. Purpose: the panel reads as part of the header menu, not a foreign surface.
  - Verify: `yarn build` succeeds and the panel renders inside the Popper in the browser

- [x] **`src/table-header/table-header.js`** — replace the Filter menu item at `:598-610` with an inline `ColumnQuickFilter` section, and add an "Advanced filter" item preserving today's `handle_open_filter` behaviour at `:213-243`. Purpose: the click target the feature is named for.
  - Verify: `yarn mocha test/table-header-remove-column.spec.jsx` still passes (it locates its button by `textContent`, not by index)

- [x] **`test/column-quick-filter.spec.jsx`** — assert the emitted `where` array for each body: min only, max only, both, clear, null check, value set, boolean, and a bodiless `data_type` rendering only the advanced link. Purpose: assert what is written, not that a component mounted.
  - Verify: `yarn mocha test/column-quick-filter.spec.jsx`

- [x] **`src/table-quick-filter/table-quick-filter.js`** — re-point row matching at `resolve_column_filter_state` so the toolbar widget and the header panel agree on which row a duplicated column owns, replacing the `column_id`-only match at `:15-17`. Purpose: two surfaces writing the same rows must not disagree about ownership.
  - Verify: `yarn mocha test/` — no existing spec regresses; exercise both surfaces on one duplicated column in the browser

- [x] **`src/table-filter-controls/table-filter-controls.js`** — copy before splicing at `:46-51`, which currently mutates the array taken straight from `table_state`. Purpose: the index invariant the lens enforces is worthless beside a neighbour that corrupts `where` in place.
  - Verify: `yarn mocha test/` — no existing spec regresses

### Consumer

- [x] **`league/app/core/data-views-fields/player-adp-table-fields.js`** — replace the non-existent `TABLE_DATA_TYPES.PERCENT` at `:144` with `TABLE_DATA_TYPES.NUMBER`. Purpose: that column's `data_type` is `undefined` today, so it has no operator menu at all; the shell tolerates `undefined` but this column should not be producing it.
  - Verify: `grep -rn "TABLE_DATA_TYPES.PERCENT" app/` returns zero; the column offers a filter menu in the browser

- [x] **`league/package.json`** — bump the `react-table` pin to the full 40-character sha per [[user:guideline/software/develop-react-table.md]], then `yarn install`. Purpose: ship the library change.
  - Verify: `grep react-table package.json` shows the new sha and `yarn install` completes

- [x] **Browser verification** — with `yarn dev:live` per [[███████████████████████████████-█████████]], exercise a numeric column (min only, max only, both), a TEXT column, a SELECT column and a BOOLEAN column against a live view; confirm a saved view reloads with the filter intact, that a filter written while a row axis is active still reads back into the panel, and that dismissing a notice then adding a header filter does not resurrect it.
  - Verify: manual observation of each case; the row-axis case is the regression the matching-key fix exists for

### Knowledge Base Updates

- [x] **`repository/active/react-table/CLAUDE.md`** — add the quick-filter section: the lens contract, the four `where`-index consumers, the no-repair rule, and why `params` is excluded from the matching key.
  - Verify: `grep -n "column_filter_state" CLAUDE.md` returns the new section

- [x] **`repository/active/react-table/ABOUT.md`** — add two observations via `base entity observe`: why `BETWEEN` was refused across five allowlists, and the `LIKE` force-wrap that forecloses starts-with.
  - Verify: `base entity get user:repository/active/react-table/ABOUT.md --json` shows both

- [x] **`user:guideline/software/develop-react-table.md`** — add the bare-specifier resolution trap (a harness importing `react-table` gets the pinned install, not the working tree) and the by-file-path spec import rule. Purpose: both are general development facts, and a task archives and takes its rules with it.
  - Verify: `base entity get user:guideline/software/develop-react-table.md --body` shows both, and this task cites the guideline rather than restating them
