feat(filters): per-row AND/OR (Excel-style chips) + browse-on-click value menus (user report)

- Per-rule connectives: the chip before each rule flips that rule
  between AND and OR — OR-joined neighbors merge into an invisible
  OR-block, AND pulls them back out. The tree stays the single source
  of truth (implicit blocks are a client marker the server ignores),
  so deep links, saved filters, and the composed engine are untouched.
  Group-mode pills are gone; explicit groups remain as pure structure
  (+ group). Compile limit, same as Excel: flat chips express
  AND-of-OR-groups; a top-level OR of AND-blocks still needs nesting.
- Browse-on-click: clicking an empty value box now fetches the field's
  top values UNDER the other rules (blank typed = browse, not a
  similarity filter) — 'assign an author, click Publisher, see their
  publishers' without typing. Server: the composed suggestion engine
  trims the typed query; client: the >=2-char guard no longer blocks
  the empty query, browse limit 15 vs 8 while typing.
- Tests: the browse case pinned (blank sentinel + rules → the field's
  values under those rules, zero-match values absent).
This commit is contained in:
John O'Keefe committed 2026-10-20 15:25:57 -04:00
1 parent 6f98ca4c22
commit c747f0a76c
7 files changed
+138 -36

No files matched your search

+111 -26
View File
@@ -224,6 +224,10 @@ export interface FilterRuleState {
export interface FilterGroupState {
mode: "all" | "any";
children: (FilterRuleState | FilterGroupState)[];
// Client-side marker: an OR-block built by the per-row connective
// chips (Excel-style rows). Invisible in the UI — its children render
// as one flat list; the server ignores the extra JSON key.
implicit?: boolean;
}
export const isRule = (n: FilterRuleState | FilterGroupState): n is FilterRuleState =>
@@ -319,7 +323,9 @@ function pruneIncomplete(
children.push({ field: child.field, op: child.op, values: [kept.join(",")] });
});
if (children.length === 0) return null;
return { mode: group.mode, children };
const out: FilterGroupState = { mode: group.mode, children };
if (group.implicit) out.implicit = true;
return out;
}
// ─── row projection (flat rendering of the nested tree) ─────────────
@@ -329,7 +335,11 @@ export interface BuilderRow {
path: number[];
type: "rule" | "groupOpen" | "groupClose";
depth: number;
connective: "AND" | "OR";
// How this row joins the row above: AND | OR. Clickable on rules
// where toggling restructures the tree (Excel-style per-row
// connective); static on group boundaries and first rows.
chip: "AND" | "OR";
chipClickable: boolean;
isFirst: boolean;
rule?: FilterRuleState;
group?: FilterGroupState;
@@ -344,34 +354,68 @@ function projectRows(
group.children.forEach((child, idx) => {
const childPath = [...path, idx];
const key = childPath.join("-");
// The slot chip: how this child joins the row above it.
const slotChip: "AND" | "OR" =
idx === 0 || group.mode === "all" ? "AND" : "OR";
if (!isRule(child)) {
const g = child as FilterGroupState;
if (g.implicit) {
// Invisible OR-block: its children continue the flat list —
// the first inherits this slot's chip, the rest are ORs.
g.children.forEach((sub, j) => {
const subPath = [...childPath, j];
const subKey = subPath.join("-");
if (!isRule(sub)) {
return; // implicit blocks hold rules only
}
out.push({
key: subKey,
path: subPath,
type: "rule",
depth,
chip: j === 0 ? slotChip : "OR",
chipClickable: j > 0,
isFirst: idx === 0 && j === 0,
rule: sub,
});
});
return;
}
out.push({
key,
path: childPath,
type: "groupOpen",
depth,
connective: group.mode === "all" ? "AND" : "OR",
chip: slotChip,
chipClickable: false,
isFirst: idx === 0,
group: child,
group: g,
});
projectRows(child, childPath, depth + 1, out);
projectRows(g, childPath, depth + 1, out);
out.push({
key: key + "-close",
path: childPath,
type: "groupClose",
depth,
connective: group.mode === "all" ? "AND" : "OR",
chip: slotChip,
chipClickable: false,
isFirst: idx === 0,
group: child,
group: g,
});
return;
}
const prev = group.children[idx - 1];
const clickable =
idx > 0 &&
!!prev &&
(isRule(prev) || !!(prev as FilterGroupState).implicit);
out.push({
key,
path: childPath,
type: "rule",
depth,
connective: group.mode === "all" ? "AND" : "OR",
chip: slotChip,
chipClickable: clickable,
isFirst: idx === 0,
rule: child,
});
@@ -471,7 +515,7 @@ export function filterBuilderData() {
const parent = this.nodeAt(path);
if (!parent || !("children" in parent)) return;
(parent as FilterGroupState).children.push({
mode: "any",
mode: "all",
children: [{ field: "author", op: "contains", values: [] }],
});
this._touch();
@@ -486,19 +530,54 @@ export function filterBuilderData() {
if (this.openMenu.startsWith(path.join("-") + ":")) this.openMenu = "";
this._touch();
},
setGroupMode(path: number[], mode: "all" | "any"): void {
const node = this.nodeAt(path);
if (!node || !("children" in node)) return;
(node as FilterGroupState).mode = mode;
this._touch();
},
// toggleGroupMode flips a group between AND and OR (the clickable
// connective between rules targets its parent group).
toggleGroupMode(parentPath: number[]): void {
const node = this.nodeAt(parentPath);
if (!node || !("children" in node)) return;
const group = node as FilterGroupState;
group.mode = group.mode === "all" ? "any" : "all";
// toggleRuleConnective — Excel-style per-row AND/OR. Clicking the
// chip before a rule flips how THAT rule joins the row above:
// AND → OR moves it into an implicit OR-block with the previous
// rule(s); OR → AND pulls it back out to the top level. The tree
// stays the single source of truth; nothing else changes.
toggleRuleConnective(rulePath: number[]): void {
const parentPath = rulePath.slice(0, -1);
const parent = this.nodeAt(parentPath);
if (!parent || !("children" in parent)) return;
const list = parent as FilterGroupState;
const idx = rulePath[rulePath.length - 1];
const rule = list.children[idx];
if (!rule || !isRule(rule)) return;
if (list.implicit) {
// OR → AND: pull the rule out of the OR-block, re-inserting it
// right after the block at the parent level.
const gpPath = parentPath.slice(0, -1);
const gp = this.nodeAt(gpPath);
if (!gp || !("children" in gp)) return;
const gpList = gp as FilterGroupState;
const blockIdx = parentPath[parentPath.length - 1];
list.children.splice(idx, 1);
gpList.children.splice(blockIdx + 1, 0, rule);
if (list.children.length === 1) {
gpList.children.splice(blockIdx, 1, list.children[0]);
} else if (list.children.length === 0) {
gpList.children.splice(blockIdx, 1);
}
} else {
// AND → OR: join the previous sibling rule / OR-block.
if (idx === 0) return;
const prev = list.children[idx - 1];
if (!prev) return;
if (isRule(prev)) {
const block: FilterGroupState = {
mode: "any",
implicit: true,
children: [prev, rule],
};
list.children.splice(idx - 1, 2, block);
} else if ((prev as FilterGroupState).implicit) {
(prev as FilterGroupState).children.push(rule);
list.children.splice(idx, 1);
} else {
return; // previous is an explicit group: no row-level toggle
}
}
this._touch();
},
setRuleField(path: number[], key: string): void {
@@ -613,8 +692,12 @@ export function filterBuilderData() {
if (!meta || !meta.suggest) return;
// Fixed-option fields never hit the server.
if (meta.valueKind === "fixed") return;
const typed = rule.values[0] ?? "";
if (typed.trim().length < 2 && meta.valueKind !== "id") return;
const typed = (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
// un-typed top list).
if (typed.length === 1) return;
const token = localStorage.getItem("token");
if (!token) return;
// Symmetry: the tree sent EXCLUDES the rule being edited — the
@@ -622,13 +705,15 @@ export function filterBuilderData() {
// rules are not filters; sending them would be a 400).
const others = this.completedTree(path);
const params = new URLSearchParams();
params.set(rule.field, typed.trim() || " ");
params.set(rule.field, typed || " ");
if (others) params.set("qf", JSON.stringify(others));
const currentLibraryId = (
document.getElementById("library-select") as HTMLSelectElement
)?.value;
if (currentLibraryId) params.set("library_id", currentLibraryId);
params.set("limit", "8");
// Browse (no typing) shows a slightly wider window than filter-
// as-you-type.
params.set("limit", typed ? "8" : "15");
try {
const response = await fetch(`/api/media-items/search?${params.toString()}`, {
headers: { Authorization: `Bearer ${token}` },