Skip to content

Structured filter query in core - #2258

Open
borisno2 wants to merge 4 commits into
mainfrom
claude/issue-2248-filter-query
Open

borisno2 wants to merge 4 commits into
mainfrom
claude/issue-2248-filter-query

Conversation

@borisno2

@borisno2 borisno2 commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Implements #2248
Part of #2246

Summary

This is a prefactor and does not change what any query means. Core now owns one structured filter query with a parser and a serialiser. The parser stays on today's grammar, and the current Filter builder form sits on top of the new shape, so an admin sees no change.

Owner's decisions

  1. The parser stays on today's grammar. A leading - and a , are ordinary characters of a value. Negation and comma lists come in Filter conditions: is not, any of, is empty #2250, together with what the engine does with them. The serialiser still quotes a value containing , and a free-text word starting with -. Quoting means nothing different today, and it keeps canonical URLs the same once Filter conditions: is not, any of, is empty #2250 lands.
  2. The \\ escape stays alongside \" inside quotes. ADR-0083's grammar table now has a row for it, and the UI reference lists both escapes.

The query tree (later tickets build on this)

interface FilterToken {
  kind: 'token'
  field: string | null          // null = free-text word
  operator: FilterOperator      // 'eq' | 'gt' | 'gte' | 'lt' | 'lte'
  negated: boolean              // ADR-0083 `-field:value`; the parser always writes false
  values: string[]              // ADR-0083 `field:a,b`; the parser always writes exactly one
  quoted: boolean               // written in double quotes, so it is literal
}
type FilterTerm = FilterToken   // the OR group joins this union under its own `kind` (#2251)
interface FilterQuery { terms: FilterTerm[] }   // an AND of terms
  • parseFilterQuery(string): FilterQuery never throws. Every token it returns has negated: false and one value. -x, a,b, status:draft,review and , parse the same way they do on main.
  • serializeFilterQuery(FilterQuery): string. For every q the parser returns, parse(serialize(q)) is q, except that quoted is set on any value the serialiser had to quote (a ,, or free text starting with -). serialize ∘ parse is the identity on serialised strings.
  • buildFilterWhere(FilterQuery, specs) takes the structured query and searches each parsed token exactly as written. It also accepts negated or multi-value tokens, which the parser never produces. It searches the literal word they serialise to, or the comma-joined values, and never drops or negates them.
  • isEmptyFilterToken is true only for a token written with no characters (values.length === 1 && values[0] === ''). So ?search=, and role:, search for the literal ,, as they do on main.
  • FilterToken loses value and raw. FilterTerm and FilterQuery are new exports, along with isEmptyFilterToken and isFieldConditionToken.

Defects fixed

  • An embedded quote now round-trips. Inside quotes, \" is a literal quote and \\ a literal backslash. Any other backslash stays as it is, so a value like "C:\Users me" reads the same as before.
  • A quoted "status:draft" stays a free-text word. The serialiser also quotes any value that would otherwise read back as different syntax: a field-shaped word, a leading ", an eq value starting with >, < or //, and a gt/lt value starting with =.
  • Every whitespace character (/\s/) separates tokens and is quoted, so a trailing NBSP survives trim().

UI

queryToState, stateToQuery, FilterRow and FilterBuilderState are removed, along with lib/filterBuilderState.ts. FilterBuilder keeps its own row state over FilterTokens and calls core's parseFilterQuery and serializeFilterQuery directly. Its props and slots are unchanged.

  • status:draft,review hydrates as a structured row, the same as on main.
  • An unchanged hydrated query is applied in the order it was written.
  • Empty rows and empty tokens are dropped on Apply.

Tests

  • Property tests (serialize.test.ts) use a seeded PRNG with 2,000 runs each and add no new dependency. The generators produce only parser-shaped tokens (single value, not negated). They check:

    • the parser never throws, and parse ∘ serialize ∘ parse equals parse, with quoted raised exactly where , or a leading - forces quotes
    • serialize ∘ parse is the identity on serialised queries
    • a structured query reads back with the same fields, operators and values
    • a separate property checks that a structured multi-value or negated token serialises to a different string than the one literal value it would join to

    I removed the , quoting rule to check the tests can fail. Six tests failed, including both properties.

  • Parser table tests check that -x, a,b, , and role:, read as literal values. buildFilterWhere tests check that ?search=, and role:, search for ,, and that structured negated and multi-value tokens are neither dropped nor negated.

  • engine.test.ts runs ?search= through a real Test context and checks the rows that come back, including the escaped-quote cases.

  • FilterBuilder tests check that title:draft,review hydrates as one row, that unknown fields, unsupported operators and quoted field-shaped words go to the free-text box, that an unchanged query is applied as written, and that empty rows are dropped.

Verification

  • pnpm build passes.
  • core: tsc --noEmit passes. vitest runs 138 files: 2,499 tests pass and 29 are skipped (the escape-only suites).
  • ui: pnpm typecheck passes. vitest runs 80 files and all 1,119 tests pass.
  • pnpm lint reports 0 errors and 1 warning that was already there before this PR. pnpm format and pnpm manypkg fix are clean.

Generated by Claude Code

…iser

Core owns one FilterQuery (an AND of terms; a term is a FilterToken carrying
kind, negated, values and quoted). parseFilterQuery returns it and never
throws; serializeFilterQuery is its inverse, proven by a property test. An
embedded quote is escaped as \" and a quoted "status:draft" stays free text.
buildFilterWhere takes the structured query. The UI's queryToState and
stateToQuery are removed: FilterBuilder builds on core's parser and serialiser.

Implements #2248. Part of #2246.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cg5CfRyy3n2WVUNY9PWtt
@vercel

vercel Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
stack-docs Ready Ready Preview Nov 7, 2026 11:50pm UTC

@changeset-bot

changeset-bot Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 48e7e7e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@opensaas/stack-core Minor
@opensaas/stack-ui Minor
create-opensaas-app Patch
@opensaas/stack-auth Minor
@opensaas/stack-cli Minor
@opensaas/stack-rag Minor
@opensaas/stack-storage Minor
@opensaas/stack-tiptap Minor
@opensaas/stack-storage-s3 Minor
@opensaas/stack-storage-vercel Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Comment thread packages/core/src/filter/parse.ts Resolved
Comment thread packages/core/src/filter/serialize.ts Resolved
Comment thread packages/core/src/filter/serialize.ts Resolved
Comment thread packages/core/src/filter/map.ts Resolved
Comment thread packages/core/src/filter/serialize.ts Resolved
Comment thread packages/core/src/filter/parse.ts Resolved
Comment thread packages/core/src/filter/types.ts Resolved
@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for Core Package Coverage (./packages/core)

Status Category Percentage Covered / Total
🟢 Lines 95.45% (🎯 81%) 4576 / 4794
🟢 Statements 93.46% (🎯 76%) 5224 / 5589
🟢 Functions 96.21% (🎯 78%) 1041 / 1082
🟢 Branches 89.44% (🎯 71%) 3661 / 4093
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/filter/map.ts 97.5% 93.54% 100% 100% 41
packages/core/src/filter/parse.ts 100% 100% 100% 100%
packages/core/src/filter/serialize.ts 100% 100% 100% 100%
Generated in workflow #3708 for commit 48e7e7e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for UI Package Coverage (./packages/ui)

Status Category Percentage Covered / Total
🔵 Lines 58.23% 799 / 1372
🔵 Statements 55.71% 848 / 1522
🔵 Functions 56.28% 224 / 398
🔵 Branches 46.02% 677 / 1471
File CoverageNo changed files found.
Generated in workflow #3708 for commit 48e7e7e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for CLI Package Coverage (./packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 82.29% 2366 / 2875
🔵 Statements 82.16% 2571 / 3129
🔵 Functions 88.75% 426 / 480
🔵 Branches 77.56% 1355 / 1747
File CoverageNo changed files found.
Generated in workflow #3708 for commit 48e7e7e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for Auth Package Coverage (./packages/auth)

Status Category Percentage Covered / Total
🔵 Lines 85.12% 452 / 531
🔵 Statements 83.84% 514 / 613
🔵 Functions 88.46% 115 / 130
🔵 Branches 79.06% 423 / 535
File CoverageNo changed files found.
Generated in workflow #3708 for commit 48e7e7e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Package Coverage (./packages/storage)

Status Category Percentage Covered / Total
🔵 Lines 96.77% 570 / 589
🔵 Statements 95.7% 646 / 675
🔵 Functions 98.8% 166 / 168
🔵 Branches 91.18% 579 / 635
File CoverageNo changed files found.
Generated in workflow #3708 for commit 48e7e7e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for RAG Package Coverage (./packages/rag)

Status Category Percentage Covered / Total
🔵 Lines 89.14% 665 / 746
🔵 Statements 88.64% 726 / 819
🔵 Functions 95.55% 129 / 135
🔵 Branches 86.36% 494 / 572
File CoverageNo changed files found.
Generated in workflow #3708 for commit 48e7e7e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)

Status Category Percentage Covered / Total
🔵 Lines 100% 50 / 50
🔵 Statements 100% 51 / 51
🔵 Functions 100% 11 / 11
🔵 Branches 97.05% 33 / 34
File CoverageNo changed files found.
Generated in workflow #3708 for commit 48e7e7e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)

Status Category Percentage Covered / Total
🔵 Lines 100% 76 / 76
🔵 Statements 100% 80 / 80
🔵 Functions 100% 17 / 17
🔵 Branches 96.55% 56 / 58
File CoverageNo changed files found.
Generated in workflow #3708 for commit 48e7e7e by the Vitest Coverage Report Action

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES

The core design is sound. I checked parseFilterQuery and it cannot throw: every branch only advances an index or builds a string, and an unterminated quote runs to end of input. For every value the parser can currently produce (one value, no negation), serializeFilterQuery is a true inverse, including the escaping. A run of backslashes, a trailing backslash, a leading ", and an eq value starting with >, < or // all round-trip. Nothing left in the monorepo imports the removed filterBuilderState module or the queryToState/stateToQuery/FilterRow/FilterBuilderState names, including examples, docs and claude-plugins. I found no any and no casts outside one as HTMLInputElement in a test. CI is green.

Several findings are inline. These block the merge:

  1. parse.ts:81: the claim that existing URLs keep their meaning is false. Inside quotes, \" and \\ are now escapes. A URL written by the old serializer with a backslash in a quoted value now reads differently: name:"my dir\" beta used to give name=my dir\ plus beta, and now gives one unterminated value. Either change the changeset's "Every other existing ?search= URL means what it meant" to say this, or keep the old reading where it is unambiguous.
  2. serialize.ts:13: the quoting test narrowed from /\s/ to four ASCII whitespace characters. A row value with a leading or trailing NBSP or U+2028 is no longer quoted, and applyQuery's trim() then strips it. This is a behaviour regression from the old builder.
  3. FilterBuilder.tsx:69: applying a hydrated query without editing it can reorder the terms. When free text comes before field terms, the search key changes, so row selection resets and a history entry is pushed. That contradicts "props and behaviour are unchanged".

Fix these before #2250 makes them live:

  • serialize.ts:37: values joined with an unquoted , will be ambiguous once field:a,b means any-of.
  • map.ts:58: a negated empty token narrows the results where an empty token is meant to be a no-op.
  • serialize.ts:61: the public serializer now writes empty-value field: tokens and "", which it used to drop, and the changeset doesn't mention it.

Cleanups:

  • FilterBuilder.tsx:32 re-implements emptiness and mappability rules that core owns (ADR-0083 says the UI carries no grammar), and joins with '' where it should use values[0].
  • parse.ts:60: query.slice(i) on every token makes parsing quadratic.
  • types.ts:23: TSDoc saying "does not produce them yet" and "a parsed token carries exactly one" describes another file's current behaviour and will go stale without any test failing (repo comment rule).

Also, removing four public UI exports and reshaping FilterToken is a breaking change shipped as minor. That's acceptable while the package is on the 0.x alpha, but say "breaking" explicitly in the changeset.


Generated by Claude Code

Quote values containing a comma or any whitespace and free-text words
starting with `-`, reading negation and comma lists in the parser so
parse and serialise stay inverses. Treat a negated empty token as inert,
export isEmptyFilterToken and isFieldConditionToken for the Filter
builder, keep hydrated term order, and match field prefixes with a
sticky regex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cg5CfRyy3n2WVUNY9PWtt
Comment thread packages/core/src/filter/map.ts Resolved
Comment thread packages/core/src/filter/map.ts Resolved
Comment thread docs/content/reference/ui.md Resolved
Comment thread packages/core/src/filter/types.ts Resolved
Comment thread packages/core/src/filter/map.ts Resolved

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES (re-review at 268b9cd; posted as a comment because GitHub does not let the PR author request changes)

Previous round. I checked all 9 earlier threads against the current code, and each fix is in place. I resolved all 9:

  • Escape semantics: the changeset now documents the change.
  • Values containing a comma are quoted per value, and the parser splits on commas.
  • Whitespace uses /\s/ in both the parser and the serialiser.
  • A negated empty token is inert, because isEmptyFilterToken is checked first.
  • The changeset and TSDoc state that empty tokens are now written, and FilterBuilder filters them out.
  • Builder order is kept through slots.
  • rowValue reads values[0], and the shared predicates are exported from core.
  • The prefix regex is sticky and no longer slices.
  • The stale TSDoc is removed.

Parser and serialiser. I traced the quoting rules against the parser for these cases: a lone or leading -, negated "", ,a, a,, a " at the start of a non-first value, = after > or <, // after field:, a backslash before a quote or a backslash or at the end of a value, and an unterminated quote. Parse → serialise → parse was stable in every case, and the parser has no throwing path. The inverse claim holds.

Blocking: meaning changes the changeset does not cover

  1. map.ts:89: a search made only of commas (?search=,, role:,) used to search the literal ,. It now parses to all-empty values and filters nothing. That widens the result set, against #1356.
  2. FilterBuilder.tsx:48: status:draft,review used to hydrate as a structured row. It now goes to the free-text box, while the engine still applies it as a field condition. The PR says builder behaviour is unchanged.
  3. map.ts:92/96: negated and multi-value tokens are searched as their canonical serialisation, not as written. For example, -x,"y" now searches -x,y, and a,"b" now searches a,b. Also, -"foo bar" used to be two words (-"foo AND bar") and is now one negated quoted word. Either fix the code, or correct the changeset's "the literal word it was written as" and list these cases.

Non-blocking

  • docs/content/reference/ui.md:287 still says "an empty value — degrades to free text". The engine actually skips an empty token, which means it filters nothing.
  • types.ts:35: quoted only covers the first value. A per-value { text, quoted } would remove the special case for the first value in serializeValues, and this matters once #2250 gives field:a,b its meaning.
  • map.ts:103: the second specFor and the spec ? … : null branch are dead code.
  • FilterBuilder.tsx:42: hydrate repeats the suggestionByField lookup with a linear find.
  • The PR description still says the parser does not yet produce negated or multi-value tokens. It does now.

Generated by Claude Code

A leading `-` and a `,` are part of a value again, so a parsed token is
never negated and carries one value. The serialiser still quotes commas
and leading-`-` free text, and the `\\` escape stays alongside `\"`.
An empty token is one written with no characters at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017cg5CfRyy3n2WVUNY9PWtt
const order: QuerySlot[] = []
for (const token of parseFilterQuery(query).terms) {
if (isEmptyFilterToken(token)) continue
if (isFieldConditionToken(token, operatorsFor)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-value tokens become single-value rows and lose values on edit. isFieldConditionToken doesn't look at values.length, so hydrate turns a token like { field: 'status', values: ['draft','review'] } into a row. That row then shows only values[0] (rowValue, line 37), and changeValue replaces values with [value]. Once #2250 makes the parser emit status:draft,review as two values, the builder will show draft, and any edit to that row will silently drop review from the applied query. The row model needs to handle multi-value tokens, or hydrate should only make rows from values.length === 1 tokens and send the rest to the free-text box.


Generated by Claude Code


if (token.field === null) {
freeTextWords.push(token.value)
if (token.negated) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A structured negated or multi-value token searches something different from its serialised URL. For a negated token this pushes serializeFilterToken(token) as one free-text word. For { negated: true, values: ['a b'] } that is contains '-"a b"', but the URL it serialises to (-"a b") parses today as two ANDed words, -"a and b". Multi-value has the same problem: { field: 'title', values: ['a b','c'] } maps to title contains 'a b,c' (line 96), but it serialises to title:"a b",c, which re-parses as title:"a b" AND a free-text ,c. So buildFilterWhere(q) and buildFilterWhere(parse(serialize(q))) return different rows. That breaks the 'searches the literal word they serialise to' contract in the docblock.


Generated by Claude Code

* Whether a {@link FilterToken} was written with no characters at all (`role:`,
* `""`). Such a token is incomplete input and filters nothing (#1509).
*/
export function isEmptyFilterToken(token: FilterToken): boolean {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

values: [] is not treated as empty. isEmptyFilterToken only matches values.length === 1 && values[0] === ''. A structured token with values: [] (which the public FilterToken type allows) falls through to values.join(',') === '', and that empty string is pushed as a free-text word. On a list with no free-text fields, or where every free-text mapping rejects '', it narrows to NEVER_MATCHES, so an empty token returns zero rows instead of being a no-op. serializeFilterQuery also writes it as an empty term ({terms:[{values:[]},{values:['x']}]} becomes ' x'), which round-trips to one term. Consider values.every(v => v === ''), or normalising [].


Generated by Claude Code

return { rows, freeText: serializeFilterQuery({ terms: freeTerms }), order }
}

function toQuery(state: BuilderState): string {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplification: BuilderState keeps rows plus a parallel order: QuerySlot[]. toQuery then rebuilds the ordering with a rowsById map, freeTerms.shift(), leftover appends, and stale slots from removed rows. One ordered list of terms, each either a row token or a free-text marker, would hold the same information with no reconciliation pass and no stale order entries after removeRow or addRow.


Generated by Claude Code

}

function mustQuoteValue(value: string): boolean {
return value.startsWith('"') || value.includes(',') || [...value].some(isFilterWhitespace)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: [...value].some(isFilterWhitespace) builds an array of code points and runs the regex once per character. /\s/.test(value) (the same WHITESPACE_RE, non-global) gives the same answer in one pass with no allocation. The parser's hot loop also calls isFilterWhitespace (WHITESPACE_RE.test(ch)) once per character.


Generated by Claude Code

freeTextWords.push(token.value)
const value = token.values.join(',')
const spec = token.field === null ? undefined : specFor(token.field)
if (!spec || !isFieldConditionToken(token, () => spec.operators)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplification: specFor(token.field) runs, then isFieldConditionToken(token, () => spec.operators) repeats the field === null, negated and empty checks already handled above it and ignores its field argument. isFieldConditionToken(token, (f) => specFor(f)?.operators) followed by specs[token.field] would make the shared predicate the single gate. Separately, COMPARISON_PREFIXES (parse.ts) and OPERATOR_PREFIX (serialize.ts) encode the same operator-to-prefix mapping twice, so one could be derived from the other to keep them from drifting.


Generated by Claude Code


Core owns one structured filter query, with a parser and serialiser that are true inverses (ADR-0083)

Breaking: `FilterToken` drops `value` and `raw` for `kind`, `negated`, `values` and `quoted`, and `parseFilterQuery`, `serializeFilterQuery` and `buildFilterWhere` now take or return a `FilterQuery`. `@opensaas/stack-ui` removes `queryToState`, `stateToQuery`, `FilterRow` and `FilterBuilderState`.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conventions: root CLAUDE.md says "major: Breaking changes (only when user explicitly requests, include migration guide)". This changeset says "Breaking:" (it removes FilterToken.value/raw, the queryToState/stateToQuery exports, and changes the parseFilterQuery/buildFilterWhere signatures) but is versioned minor with no migration snippet for the removed UI helpers. If minor is intentional for the alpha prerelease, a short before/after for queryToState → parseFilterQuery + isFieldConditionToken would still help consumers.


Generated by Claude Code

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES (one small fix to a repo rule)

GitHub does not let this account request changes on its own PR, so I am posting this as a comment. The verdict is still REQUEST CHANGES.

Reviewed at 48e7e7e. I took the owner's two decisions as settled: the parser stays on today's grammar, and \\ stays documented in ADR-0083. Per-value quoting is left to #2250.

Earlier threads

All 16 threads from the first two rounds are resolved, and I checked each one against the head commit. The fixes are in place. ?search=, and role:, search for the literal , again. status:draft,review hydrates as a single row. The UI docs now say an empty value filters nothing. The second specFor lookup is gone, and hydrate uses suggestionByField. I found no regression against main in the parser, the serialiser or buildFilterWhere for any query the parser produces. Fifteen edge-case inputs survive a round trip, changing only the quoted flag where the serialiser added quotes.

Blocking

  1. The test adds type casts. Root CLAUDE.md says "do not use type casting". The PR adds three new as HTMLInputElement casts in packages/ui/tests/components/FilterBuilder.test.tsx, at lines 140, 152 and 153. jest-dom is already loaded through tests/setup.ts, and BigIntField.test.tsx in the same folder uses it. So the fix is a direct replacement:
    expect(screen.getByLabelText('Filter value')).toHaveValue('draft,review')
    expect(screen.getByLabelText('Search')).toHaveValue('')
    The cast at line 130 was already on main, so it would be good to change it in the same edit.

Non-blocking

These are the 7 inline comments from this round. None of them changes behaviour against main.

  • map.ts:27: a values: [] token is not counted as empty. The parser never produces one.
  • map.ts:91: a structured negated or multi-value token searches different text than its serialised URL reads back as. The parser never produces these tokens either.
  • FilterBuilder.tsx:51: a multi-value token becomes a single-value row. This only matters once #2250 lands, so it should be handled there.
  • FilterBuilder.tsx:63: rows and order hold the same information in parallel. Simplification only.
  • map.ts:98: the gate check is repeated, and two operator-prefix tables duplicate each other. Simplification only.
  • serialize.ts:13: the whitespace check runs once per character. A nit.
  • .changeset/quiet-filters-invert.md:8: the changeset says "Breaking" but the bump is minor. Under the versioning rule, minor is correct unless a major is explicitly requested, so this is only a request for a short before/after example for queryToState.

I am leaving those 7 threads open for the author.

The comma re-apply case: title:draft,review becomes title:"draft,review"

This does not matter. The meaning is identical: the parser reads both strings as the single value draft,review, and buildFilterWhere builds the same Where from each. The history entry is not new. applyQuery calls router.push on every Apply, so main already adds a history entry for an unchanged query and resets to page 1. The only new effect is that row selection resets, because the ?search= key changes.

That is also not new in kind. main's serialiser already rewrites queries on Apply. For example, it drops unneeded quotes, so title:"draft" becomes title:draft, which changes the key in the same way. This PR's quoting extends that rewriting to commas, which is what keeps these URLs meaning the same once #2250 makes , mean any-of. The effect only shows up when a user selects rows and then clicks Apply without changing anything, on a value that contains a comma. After that one Apply the URL is canonical and stays put. I don't think it needs action.

Once the three casts are replaced, I would approve this.


Generated by Claude Code

This branch was successfully deployed

1 active deployment
Preview — 48e7e7e8 Deployed Nov 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants