fix(filters): operator table sent labels as wire values — every non-self-named operator was broken (user report)

The client's operator map passed the display string as BOTH the map
key and the OpMeta.key, so the menu clicks transmitted 'doesn't
contain' / 'isn't' / 'is empty' / 'at least' / … literally. The server
rightly rejected them (unknown operator → 400), the count fetch's
catch hid the counter, and flipping away from such an operator never
came back. Only self-named ops (contains, is, has, between…) worked.

- Map keys now DERIVE from meta.key (Object.fromEntries over the op
  list) — wire value and label structurally cannot diverge again.
- New scripts/filter_builder_harness.js: a playwright harness that
  drives the real drawer in headless Chromium and sweeps every author
  operator, capturing filter-count responses and console errors — the
  first UI-level regression net for the builder (the server tests
  could never see a client-side mis-key).
- Verified live: contains 8 → doesn't contain 60 → is 8 → isn't 60 →
  is empty 2 → refill 8; zero console errors across the sweep.
This commit is contained in:
John O'Keefe committed 2026-10-20 17:14:26 -04:00
1 parent 68701d6b94
commit 43987262a6
3 files changed
+123 -20

No files matched your search

+26 -20
View File
@@ -30,26 +30,32 @@ const op = (
arity: OpMeta["arity"],
): OpMeta => ({ key, label, negated, arity });
export const OPS: Record<string, OpMeta> = {
contains: op("contains", "contains", false, "many"),
not_contains: op("doesn't contain", "doesn't contain", true, "many"),
is: op("is", "is", false, "many"),
is_not: op("isn't", "isn't", true, "many"),
empty: op("is empty", "is empty", false, "none"),
is_true: op("is true", "is true", false, "none"),
is_false: op("is false", "is false", false, "none"),
between: op("between", "between", false, "two"),
at_least: op("at least", "at least", false, "one"),
at_most: op("at most", "at most", false, "one"),
within_days: op("within the last", "within the last", false, "one"),
has: op("has", "has", false, "none"),
missing: op("missing", "missing", false, "none"),
unrated: op("unrated", "unrated", false, "none"),
in: op("in", "in", false, "many"),
not_in: op("isn't in", "isn't in", true, "many"),
in_any: op("in any", "in any", false, "none"),
in_none: op("in none", "in none", false, "none"),
};
// The map key DERIVES from meta.key — the wire value and the display
// label cannot drift apart again (a mis-keyed entry threw every
// non-self-named operator out of the allow-list: "doesn't contain" was
// being sent literally, and the server rightly refused it).
export const OPS: Record<string, OpMeta> = Object.fromEntries(
[
op("contains", "contains", false, "many"),
op("not_contains", "doesn't contain", true, "many"),
op("is", "is", false, "many"),
op("is_not", "isn't", true, "many"),
op("empty", "is empty", false, "none"),
op("is_true", "is true", false, "none"),
op("is_false", "is false", false, "none"),
op("between", "between", false, "two"),
op("at_least", "at least", false, "one"),
op("at_most", "at most", false, "one"),
op("within_days", "within the last", false, "one"),
op("has", "has", false, "none"),
op("missing", "missing", false, "none"),
op("unrated", "unrated", false, "none"),
op("in", "in", false, "many"),
op("not_in", "isn't in", true, "many"),
op("in_any", "in any", false, "none"),
op("in_none", "in none", false, "none"),
].map((m) => [m.key, m]),
);
export interface FieldMeta {
key: string;