fix(filter-builder): id cells showed raw ids; OR mode starved the value menu
Two user-reported builder defects:
1. Id-kind value cells (library/collection/feed/added_by) rendered the
stored UUIDs in the input box. The menu offered names (s.display)
and picking stored the id correctly — but the cell bound
rule.values.join(',') raw. The cell now renders through
valueCellText: idLabels give the NAMES; typing drafts a name filter
in idDrafts (the stored values stay ids — the clause matches ids —
and are updated only by picking). Text/number/days cells are
unchanged.
2. The value menu ignored the connective: fetchSuggestions always sent
the sibling rules as the qf constraint, so with "Author contains
Lewis Carroll" present, flipping to OR still starved the author
menu down to Lewis Carroll. Under OR the siblings are independent
alternatives — a new rule's candidates must not be narrowed by
them. pathUnderAll now checks the edited rule's group path: under
AND all the way up, siblings constrain as before (helpful
narrowing); under any OR on the path, NO tree rides and the menu
browses the whole scope.
Server contract unchanged — the suggest endpoint already treats a
missing qf as an unconstrained browse; the fix is client-side
mode-awareness. Pins: 6 new vitest cases (AND sends siblings, OR sends
none, id label display, draft typing never touches stored ids, pick
toggles + relabels, text passthrough).
This commit is contained in:
1 parent
7c7e282e79
commit
28efaf3fe9
4 files changed
+213
-9
No files matched your search
@@ -60,7 +60,7 @@ templ FilterBuilderRows(countNoun string) {
|
||||
</template>
|
||||
<template x-if="hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')">
|
||||
<span class="fb-value-cell relative flex items-center gap-1">
|
||||
<input type="text" class="input fb-value" :value="row.rule.values.join(',')" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="fieldIsDays(row.rule.field) ? 'days' : 'value'"/>
|
||||
<input type="text" class="input fb-value" :value="valueCellText(row.path)" @input.debounce.250ms="onValueCellInput(row.path, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="fieldIsDays(row.rule.field) ? 'days' : 'value'"/>
|
||||
<template x-if="fieldIsDays(row.rule.field)">
|
||||
<span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="'days'"></span>
|
||||
</template>
|
||||
|
||||
@@ -35,14 +35,14 @@ func FilterBuilderRows(countNoun string) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"'margin-left:' + (row.depth * 14) + 'px'\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"'margin-left:' + ((row.depth + 1) * 14) + 'px'\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"toggleConnective(row)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldIsDays(row.rule.field) ? 'days' : 'value'\"><template x-if=\"fieldIsDays(row.rule.field)\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware value menu: server JSON (the other\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trules bound), or the field's fixed options.\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tClicks anywhere inside the value cell never close\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tit — the opening focus-click must not race the\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toutside handler. --><div class=\"fb-menu fb-menu-value\" x-show=\"suggestionShown(row.key)\" @click.outside=\"dismissSuggestions($event)\"><template x-for=\"s in (suggestions[row.key] || [])\" :key=\"s.value\"><button type=\"button\" class=\"fb-menu-item\" @click=\"pickSuggestion(row.key, rLine truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"'margin-left:' + (row.depth * 14) + 'px'\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"'margin-left:' + ((row.depth + 1) * 14) + 'px'\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"toggleConnective(row)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"valueCellText(row.path)\" @input.debounce.250ms=\"onValueCellInput(row.path, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldIsDays(row.rule.field) ? 'days' : 'value'\"><template x-if=\"fieldIsDays(row.rule.field)\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware value menu: server JSON (the other\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trules bound), or the field's fixed options.\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tClicks anywhere inside the value cell never close\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tit — the opening focus-click must not race the\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\toutside handler. --><div class=\"fb-menu fb-menu-value\" x-show=\"suggestionShown(row.key)\" @click.outside=\"dismissSuggestions($event)\"><template x-for=\"s in (suggestions[row.key] || [])\" :key=\"s.value\"><button type=\"button\" class=\"fb-menu-item\" @click=\"pickSuggestion(row.key, roLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("matchCountLoading ? 'counting…' : (matchCount === 1 ? '1 " + countNoun + " matches' : matchCount + ' " + countNoun + "s match')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_builder.templ`, Line: 101, Col: 238}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `filter_builder.templ`, Line: 101, Col: 238}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
filterBuilderData,
|
||||
ingestFilterFields,
|
||||
isFlatable,
|
||||
isRule,
|
||||
@@ -50,7 +51,8 @@ const rule = (field: string, op: string, values: string[] = []): FilterRuleState
|
||||
field,
|
||||
op,
|
||||
values,
|
||||
});const group = (
|
||||
});
|
||||
const group = (
|
||||
mode: "all" | "any",
|
||||
children: (FilterRuleState | FilterGroupState)[],
|
||||
implicit = false,
|
||||
@@ -225,3 +227,135 @@ describe("opsForField", () => {
|
||||
expect(opsForField("nonexistent_field")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2026-10-31 user reports ─────────────────────────────────────────
|
||||
// 1. id-kind value cells (library/collection/…) showed the raw ids in
|
||||
// the input box; 2. the value menu stayed narrowed by sibling rules
|
||||
// after flipping the connective to OR. Both fixed in the component;
|
||||
// these pins hold them.
|
||||
|
||||
const builderFields = [
|
||||
{
|
||||
key: "library",
|
||||
label: "Library",
|
||||
group: "Holding",
|
||||
valueKind: "id" as const,
|
||||
defaultOp: "in",
|
||||
suggest: true,
|
||||
ops: [{ key: "in", label: "in", arity: "many" }],
|
||||
},
|
||||
{
|
||||
key: "author",
|
||||
label: "Author",
|
||||
group: "People",
|
||||
valueKind: "text" as const,
|
||||
defaultOp: "contains",
|
||||
suggest: true,
|
||||
ops: [{ key: "contains", label: "contains", arity: "many" }],
|
||||
},
|
||||
];
|
||||
|
||||
describe("mode-aware value suggestions", () => {
|
||||
// ingest REPLACES the registry — scoped to this describe so the
|
||||
// earlier contract pins keep their vocabulary.
|
||||
beforeEach(() => {
|
||||
ingestFilterFields(builderFields);
|
||||
});
|
||||
|
||||
let captured = "";
|
||||
beforeEach(() => {
|
||||
captured = "";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string | URL) => {
|
||||
captured = String(url);
|
||||
return { ok: true, json: async () => ({ results: [] }) };
|
||||
}),
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("constrains the menu by siblings under AND", async () => {
|
||||
const c = filterBuilderData();
|
||||
c.filterTree = {
|
||||
mode: "all",
|
||||
children: [
|
||||
{ field: "author", op: "contains", values: ["Lewis Carroll"] },
|
||||
{ field: "library", op: "in", values: [""] },
|
||||
],
|
||||
};
|
||||
await c.fetchSuggestions("1", [1], { field: "library", op: "in", values: [""] });
|
||||
expect(captured).toContain("qf=");
|
||||
expect(decodeURIComponent(captured.replace(/\+/g, " "))).toContain("Lewis Carroll");
|
||||
});
|
||||
|
||||
it("sends NO sibling tree under OR — the menu browses the scope", async () => {
|
||||
const c = filterBuilderData();
|
||||
c.filterTree = {
|
||||
mode: "any",
|
||||
children: [
|
||||
{ field: "author", op: "contains", values: ["Lewis Carroll"] },
|
||||
{ field: "library", op: "in", values: [""] },
|
||||
],
|
||||
};
|
||||
await c.fetchSuggestions("1", [1], { field: "library", op: "in", values: [""] });
|
||||
expect(captured).not.toContain("qf=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("id-kind value cells", () => {
|
||||
beforeEach(() => {
|
||||
ingestFilterFields(builderFields);
|
||||
});
|
||||
|
||||
it("displays labels while the rule stores ids", () => {
|
||||
const c = filterBuilderData();
|
||||
c.filterTree = {
|
||||
mode: "all",
|
||||
children: [{ field: "library", op: "in", values: ["lib-1"] }],
|
||||
};
|
||||
c.idLabels["lib-1"] = "Books";
|
||||
expect(c.valueCellText([0])).toBe("Books");
|
||||
});
|
||||
|
||||
it("typing drafts a NAME filter and never writes into the rule's ids", () => {
|
||||
const c = filterBuilderData();
|
||||
c.filterTree = {
|
||||
mode: "all",
|
||||
children: [{ field: "library", op: "in", values: ["lib-1"] }],
|
||||
};
|
||||
c.idLabels["lib-1"] = "Books";
|
||||
c.onValueCellInput([0], "boo");
|
||||
expect(c.filterTree.children[0] as FilterRuleState).toEqual({
|
||||
field: "library",
|
||||
op: "in",
|
||||
values: ["lib-1"],
|
||||
});
|
||||
expect(c.valueCellText([0])).toBe("boo");
|
||||
});
|
||||
|
||||
it("picking toggles the id and flips the cell back to labels", () => {
|
||||
const c = filterBuilderData();
|
||||
c.filterTree = {
|
||||
mode: "all",
|
||||
children: [{ field: "library", op: "in", values: ["lib-1"] }],
|
||||
};
|
||||
c.idLabels["lib-1"] = "Books";
|
||||
c.onValueCellInput([0], "com");
|
||||
c.pickSuggestion("0", [0], { value: "lib-2", display: "Comics", count: 3 });
|
||||
expect((c.filterTree.children[0] as FilterRuleState).values).toEqual(["lib-1,lib-2"]);
|
||||
expect(c.valueCellText([0])).toBe("Books, Comics");
|
||||
});
|
||||
|
||||
it("text cells keep writing straight through", () => {
|
||||
const c = filterBuilderData();
|
||||
c.filterTree = {
|
||||
mode: "all",
|
||||
children: [{ field: "author", op: "contains", values: [""] }],
|
||||
};
|
||||
c.onValueCellInput([0], "lewis");
|
||||
expect((c.filterTree.children[0] as FilterRuleState).values).toEqual(["lewis"]);
|
||||
});
|
||||
});
|
||||
@@ -371,7 +371,7 @@ interface Suggestion {
|
||||
display?: string;
|
||||
}
|
||||
|
||||
function filterBuilderData() {
|
||||
export function filterBuilderData() {
|
||||
return {
|
||||
// Lifecycle: the Go registry renders this page's vocabulary (the
|
||||
// data-filter-fields payload); ingest once so the menus are born
|
||||
@@ -434,6 +434,7 @@ function filterBuilderData() {
|
||||
suggestionsOpen: "" as string, // rowKey whose value menu is open
|
||||
suggestionsLoading: "" as string, // rowKey currently fetching
|
||||
idLabels: {} as Record<string, string>, // id → display, from suggestion fetches
|
||||
idDrafts: {} as Record<string, string>, // id-kind rowKey → the NAME text being typed (display draft; stored values stay ids)
|
||||
_suggestTimer: null as number | null,
|
||||
_countTimer: null as number | null,
|
||||
|
||||
@@ -625,6 +626,66 @@ function filterBuilderData() {
|
||||
this.openMenu = "";
|
||||
this._touch();
|
||||
},
|
||||
// isIdValueRow — the value cell stores IDS (library/collection/
|
||||
// feed/added_by): display needs the label map, typing filters NAMES.
|
||||
isIdValueRow(path: number[]): boolean {
|
||||
const node = this.nodeAt(path);
|
||||
if (!node || !isRule(node)) return false;
|
||||
return FIELD_BY_KEY.get(node.field)?.valueKind === "id";
|
||||
},
|
||||
|
||||
// pathUnderAll — is every group from the root down to the edited
|
||||
// rule's parent an AND ("all") group? Under any OR on the path the
|
||||
// siblings cannot constrain the rule being edited.
|
||||
pathUnderAll(path: number[]): boolean {
|
||||
if (this.filterTree.mode !== "all") return false;
|
||||
let node: FilterRuleState | FilterGroupState = this.filterTree;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
if (isRule(node)) return false;
|
||||
const child = (node as FilterGroupState).children[path[i]];
|
||||
if (!child || isRule(child)) return false;
|
||||
node = child;
|
||||
if (node.mode !== "all") return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
// valueCellText — what the value INPUT displays. Id-kind rules keep
|
||||
// the ids in rule.values (the clause matches ids); the cell shows
|
||||
// their LABELS, or the name text being typed.
|
||||
valueCellText(path: number[]): string {
|
||||
const node = this.nodeAt(path);
|
||||
if (!node || !isRule(node)) return "";
|
||||
if (this.isIdValueRow(path)) {
|
||||
const draft = this.idDrafts[path.join("-")];
|
||||
if (draft !== undefined) return draft;
|
||||
return node.values
|
||||
.join(",")
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.map((v) => this.idLabels[v] ?? v)
|
||||
.join(", ");
|
||||
}
|
||||
return node.values.join(",");
|
||||
},
|
||||
|
||||
// onValueCellInput — id-kind cells type a NAME filter (the draft);
|
||||
// the stored values stay ids, updated only by picking from the
|
||||
// menu. Every other kind writes straight through to the rule.
|
||||
onValueCellInput(path: number[], value: string): void {
|
||||
const node = this.nodeAt(path);
|
||||
if (!node || !isRule(node)) return;
|
||||
if (this.isIdValueRow(path)) {
|
||||
const key = path.join("-");
|
||||
if (value === "") delete this.idDrafts[key];
|
||||
else this.idDrafts[key] = value;
|
||||
this.scheduleSuggestions(key, path, node);
|
||||
this._touch();
|
||||
return;
|
||||
}
|
||||
this.setRuleValue(path, 0, value);
|
||||
},
|
||||
|
||||
setRuleValue(path: number[], idx: number, value: string): void {
|
||||
const node = this.nodeAt(path);
|
||||
if (!node || !isRule(node)) return;
|
||||
@@ -639,6 +700,7 @@ function filterBuilderData() {
|
||||
if (!node || !isRule(node)) return;
|
||||
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") {
|
||||
// id-valued: toggle the id in the comma list.
|
||||
const ids = node.values.join(",").split(",").filter(Boolean);
|
||||
@@ -715,7 +777,9 @@ function filterBuilderData() {
|
||||
if (!meta || !meta.suggest) return;
|
||||
// Fixed-option fields never hit the server.
|
||||
if (meta.valueKind === "fixed") return;
|
||||
const typed = (rule.values[0] ?? "").trim();
|
||||
const typed = (
|
||||
this.isIdValueRow(path) ? (this.idDrafts[path.join("-")] ?? "") : (rule.values[0] ?? "")
|
||||
).trim();
|
||||
// 1 char is too little to filter on; EMPTY means BROWSE — the
|
||||
// user clicked the box to see the field's top values under the
|
||||
// other rules (the server answers the blank query with the
|
||||
@@ -728,8 +792,14 @@ function filterBuilderData() {
|
||||
this.suggestionsOpen = rowKey;
|
||||
// Symmetry: the tree sent EXCLUDES the rule being edited — the
|
||||
// menu is computed under every OTHER COMPLETED rule (incomplete
|
||||
// rules are not filters; sending them would be a 400).
|
||||
const others = this.completedTree(path);
|
||||
// rules are not filters; sending them would be a 400). Under an
|
||||
// OR connective on the edited rule's path (root mode "any" or an
|
||||
// "any" ancestor group), siblings are INDEPENDENT alternatives —
|
||||
// a new rule's candidates must not be narrowed by them — so no
|
||||
// tree rides at all (2026-10-31 user report: with "Author
|
||||
// contains Lewis Carroll" present, flipping to OR still starved
|
||||
// the author menu down to Lewis Carroll).
|
||||
const others = this.pathUnderAll(path) ? this.completedTree(path) : null;
|
||||
const params = new URLSearchParams();
|
||||
params.set(rule.field, typed || " ");
|
||||
if (others) params.set("qf", JSON.stringify(others));
|
||||
|
||||
Reference in new issue
Block a user