fix(filters): builder UX round one — incomplete rules, group removal, field menu (user report)

- Root bug: incomplete rules (the ready rule, every new group's first
  rule) were sent to the server — the live-count fetch toasted a 400
  and every suggestion fetch failed silently (why author typing showed
  no dropdown). Fix: completedTree() prunes incomplete rules and empty
  groups; the count fetch skips when nothing is complete, suggestion
  fetches send only the other COMPLETED rules, and serialization wires
  only completed rules (an unfinished rule was never a filter).
- Group remove button was x-show'd away on depth-0 groups — exactly
  where user groups live. Always visible now.
- Field menu: filter input at the top (Enter prevented), no-match
  state; group headers restyled as flush-left tinted sticky bands with
  options tabbed in beneath (user's layout pick).
- toast.ts: filter-count fetches join the suggestion-fetch exemption
  (a failed background hint never interrupts).
This commit is contained in:
John O'Keefe committed 2026-10-20 15:04:19 -04:00
1 parent 4ee24e7152
commit 04b36f0563
6 files changed
+134 -23

No files matched your search

+81 -10
View File
@@ -284,6 +284,44 @@ function isFlatable(tree: FilterGroupState): boolean {
return true;
}
// pruneIncomplete deep-clones the tree keeping only COMPLETE rules (an
// operator that takes values must have trimmed, non-empty ones), drops
// groups left empty, and can exclude the rule being edited (suggestion
// symmetry). Returns null when nothing complete remains — callers then
// send NO tree at all (an empty group is a server-side 400).
function pruneIncomplete(
group: FilterGroupState,
excludePath: number[] | null,
path: number[] = [],
): FilterGroupState | null {
const children: (FilterRuleState | FilterGroupState)[] = [];
group.children.forEach((child, idx) => {
const childPath = [...path, idx];
if (excludePath && childPath.join("-") === excludePath.join("-")) return;
if (!isRule(child)) {
const pruned = pruneIncomplete(child, excludePath, childPath);
if (pruned && pruned.children.length > 0) children.push(pruned);
return;
}
const arity = OPS[child.op]?.arity ?? "many";
const vals = (child.values ?? []).map((v) => (v ?? "").trim());
if (arity === "none") {
children.push({ field: child.field, op: child.op, values: [] });
return;
}
const kept = vals.filter((v) => v !== "");
if (kept.length === 0) return;
if (arity === "two") {
if (kept.length < 2) return;
children.push({ field: child.field, op: child.op, values: vals });
return;
}
children.push({ field: child.field, op: child.op, values: [kept.join(",")] });
});
if (children.length === 0) return null;
return { mode: group.mode, children };
}
// ─── row projection (flat rendering of the nested tree) ─────────────
export interface BuilderRow {
@@ -356,6 +394,7 @@ export function filterBuilderData() {
matchCount: null as number | null,
matchCountLoading: false,
openMenu: "" as string, // "<rowKey>:field" | "<rowKey>:op" | "<rowKey>:value" | ""
fieldFilter: "" as string, // the field menu's filter input
suggestions: {} as Record<string, Suggestion[]>,
suggestionsOpen: "" as string, // rowKey whose value menu is open
idLabels: {} as Record<string, string>, // id → display, from suggestion fetches
@@ -530,11 +569,29 @@ export function filterBuilderData() {
// ── menus ───────────────────────────────────────────────────────
toggleMenu(menuKey: string): void {
this.openMenu = this.openMenu === menuKey ? "" : menuKey;
if (this.openMenu.endsWith(":field")) {
this.fieldFilter = "";
}
if (this.openMenu.endsWith(":value")) {
this.suggestionsOpen = this.openMenu.split(":")[0];
}
},
// visibleFieldGroups — the field menu's groups, filtered by the
// menu's filter input (label or key substring, case-insensitive);
// groups with no matches hide.
get visibleFieldGroups(): { group: string; fields: FieldMeta[] }[] {
const q = this.fieldFilter.trim().toLowerCase();
if (!q) return FIELD_GROUPS;
return FIELD_GROUPS.map((g) => ({
group: g.group,
fields: g.fields.filter(
(f) =>
f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q),
),
})).filter((g) => g.fields.length > 0);
},
// ── suggestions (field-aware, under the OTHER rules) ────────────
scheduleSuggestions(rowKey: string, path: number[], rule: FilterRuleState): void {
if (this._suggestTimer !== null) window.clearTimeout(this._suggestTimer);
@@ -552,10 +609,11 @@ export function filterBuilderData() {
const token = localStorage.getItem("token");
if (!token) return;
// Symmetry: the tree sent EXCLUDES the rule being edited — the
// menu is computed under every OTHER completed rule.
const others = this.treeWithout(path);
// menu is computed under every OTHER COMPLETED rule (incomplete
// rules are not filters; sending them would be a 400).
const others = this.completedTree(path);
const params = new URLSearchParams();
params.set(meta.valueKind === "id" ? rule.field : rule.field, typed.trim() || " ");
params.set(rule.field, typed.trim() || " ");
if (others) params.set("qf", JSON.stringify(others));
const currentLibraryId = (
document.getElementById("library-select") as HTMLSelectElement
@@ -611,12 +669,15 @@ export function filterBuilderData() {
async fetchMatchCount(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
if (this.filterTree.children.length === 0) {
// Incomplete rules are NOT filters yet — the count runs on the
// completed tree only (an incomplete rule would be a 400).
const completed = this.completedTree(null);
if (!completed) {
this.matchCount = null;
return;
}
const params = new URLSearchParams();
params.set("qf", JSON.stringify(this.filterTree));
params.set("qf", JSON.stringify(completed));
const currentLibraryId = (
document.getElementById("library-select") as HTMLSelectElement
)?.value;
@@ -658,18 +719,28 @@ export function filterBuilderData() {
serializeFilters():
| { mode: "flat"; params: Record<string, string> }
| { mode: "qf"; qf: string } {
if (this.filterTree.children.length === 0) {
// The COMPLETED tree is what the wire sees — an incomplete rule
// was never a filter, so applying must not send it (the server
// would 400 and the grid would break).
const completed = this.completedTree(null);
if (!completed) {
return { mode: "flat", params: {} };
}
if (isFlatable(this.filterTree as FilterGroupState)) {
if (isFlatable(completed)) {
const params: Record<string, string> = {};
for (const child of this.filterTree.children) {
for (const child of completed.children) {
if (!isRule(child)) continue;
Object.assign(params, ruleToFlatParam(child));
const rule = child as FilterRuleState;
Object.assign(params, ruleToFlatParam(rule));
}
return { mode: "flat", params };
}
return { mode: "qf", qf: JSON.stringify(this.filterTree) };
return { mode: "qf", qf: JSON.stringify(completed) };
},
// completedTree — the pruned clone (see pruneIncomplete).
completedTree(excludePath: number[] | null): FilterGroupState | null {
return pruneIncomplete(this.filterTree as FilterGroupState, excludePath);
},
syncFormInputs(): void {
const form = document.getElementById("filter-form") as HTMLFormElement | null;
+6 -1
View File
@@ -186,7 +186,12 @@ const suggestionFieldRe =
/[?&](author|narrator|contributor|publisher|imprint|edition|genre|tags|story_arc|age_rating|title|subtitle|language|format_group|mime_type|manga_type|reading_direction|series|note_text|highlight_text|collection|library|added_by)=/;
function isSuggestionFetch(input: Parameters<typeof fetch>[0]): boolean {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (!url.includes("/api/media-items/search?")) return false;
// The builder's live-count endpoint is a background hint too — a
// failed count must never interrupt with a toast.
if (!url.includes("/api/media-items/search?") && !url.includes("/api/media-items/filter-count?")) {
return false;
}
if (url.includes("/api/media-items/filter-count?")) return true;
return suggestionFieldRe.test(url) && !/[?&](q|sort)=/.test(url);
}