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:
1 parent
4ee24e7152
commit
04b36f0563
6 files changed
+134
-23
No files matched your search
+81
-10
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user