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:
1 parent
6f98ca4c22
commit
c747f0a76c
7 files changed
+138
-36
No files matched your search
+111
-26
@@ -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}` },
|
||||
|
||||
Reference in new issue
Block a user