fix(filter-builder): one operator name per field (Option A); drawer ids resolve on refresh

The is/in question, settled per user decision: they were exact
synonyms (feed's clause compiles is/is_not and in/not_in to the same
ANY), so the vocabulary collapses instead of being explained.

- Registry: feed's offered ops are in/isn't-in (+has/missing);
  legacyFieldOps keeps is/is_not ACCEPTED at parse forever — every
  stored tree and saved filter from before the collapse parses and
  evaluates unchanged. Library keeps is/isn't (its only names);
  collection's genuinely distinct operators are untouched.
- Client: id-kind cells are uniform — every operator on an id field
  accepts a set, so all id cells say "pick one or more" and accumulate
  picks. The per-operator placeholder/pick branching is gone (it also
  never fired: the registry types is as Values:"many").
- The drawer-refresh id fix rides along: hydrateFromForm now calls
  resolveIdLabels, so a page refresh with an active saved filter
  resolves stored ids to names in the bookshelf drawer (the collection
  rules page already did at init).

Pins: feed payload omits the synonyms while legacy feed is/is_not
trees parse (Go unit); uniform accumulate + placeholder (vitest);
menu-open regression and hydrate-resolution pins kept. Web 183/183;
services, filter + episode-tree + search/collections contract slices
green (full suite skipped per user — vocabulary + UI-layer change,
engine paths pinned by the targeted slices).
This commit is contained in:
John O'Keefe committed 2026-11-01 01:16:20 -04:00
1 parent 81e54f89b2
commit c448fa438f
5 files changed
+88 -14

No files matched your search

+8 -5
View File
@@ -463,23 +463,26 @@ describe("id-kind value cells", () => {
vi.unstubAllGlobals();
});
it("a one-arity op (is) picks ONE value — replace, not toggle", () => {
it("is and in behave identically on id cells — both accumulate (Option A)", () => {
const c = filterBuilderData();
c.filterTree = {
mode: "all",
children: [{ field: "library", op: "is", values: ["lib-1"] }],
};
c.idLabels["lib-1"] = "Books";
c.pickSuggestion("0", [0], { value: "lib-2", display: "Comics", count: 2 });
expect((c.filterTree.children[0] as FilterRuleState).values).toEqual(["lib-2"]);
expect(c.valueCellText([0])).toBe("Comics");
expect((c.filterTree.children[0] as FilterRuleState).values).toEqual(["lib-1,lib-2"]);
expect(c.valueCellText([0])).toBe("Books, Comics");
});
it("the placeholder follows the op's arity", () => {
it("the id-cell placeholder is uniform — one name per field, no per-op distinction", () => {
const c = filterBuilderData();
expect(c.valuePlaceholderFor({ field: "library", op: "in", values: [] })).toBe(
"pick one or more",
);
expect(c.valuePlaceholderFor({ field: "library", op: "is", values: [] })).toBe("pick one");
expect(c.valuePlaceholderFor({ field: "library", op: "is", values: [] })).toBe(
"pick one or more",
);
expect(c.valuePlaceholderFor({ field: "author", op: "contains", values: [] })).toBe("value");
});
+11 -7
View File
@@ -727,14 +727,14 @@ export function filterBuilderData() {
this.filterTree.children.forEach((child, idx) => visit(child, [idx]));
},
// valuePlaceholderFor — the value cell's hint, ARITY-AWARE: the op
// decides how many values the cell takes ("in" = several, "is" =
// one), and the hint follows the op as it changes.
// valuePlaceholderFor — the value cell's hint. Id-kind cells are
// the MULTI-VALUE boxes (pick several; the menu keeps toggling
// them in) — every operator on an id field accepts a set, so the
// hint is uniform (2026-11-01, Option A: one name per field, no
// per-operator distinction — "is" and "in" were synonyms).
valuePlaceholderFor(rule: FilterRuleState): string {
if (this.fieldIsDays(rule.field)) return "days";
if (FIELD_BY_KEY.get(rule.field)?.valueKind === "id") {
return this.valueArity(rule) === "many" ? "pick one or more" : "pick one";
}
if (FIELD_BY_KEY.get(rule.field)?.valueKind === "id") return "pick one or more";
return "value";
},
@@ -797,7 +797,7 @@ export function filterBuilderData() {
const meta = FIELD_BY_KEY.get(node.field);
this.idLabels[s.value] = s.display ?? s.value;
delete this.idDrafts[path.join("-")]; // the cell shows labels again
if (meta?.valueKind === "id" && this.valueArity(node) === "many") {
if (meta?.valueKind === "id") {
// id-valued MULTI: toggle the id in the comma list.
const ids = node.values.join(",").split(",").filter(Boolean);
const at = ids.indexOf(s.value);
@@ -1084,6 +1084,10 @@ export function filterBuilderData() {
const tree = JSON.parse(qfInput.value) as FilterGroupState;
if (tree && Array.isArray(tree.children)) {
this.filterTree = tree;
// Hydrated trees carry bare ids with no labels in sight
// (page refresh with an active saved filter) — resolve
// them now (2026-11-01: the cells showed raw uuids).
this.resolveIdLabels();
return true;
}
} catch {