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:
1 parent
68701d6b94
commit
43987262a6
3 files changed
+123
-20
No files matched your search
+26
-20
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user