@lobb-js/studio 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,27 @@
1
1
  <script lang="ts">
2
- // Airtable-style flat filter editor.
2
+ // Airtable-style filter editor.
3
3
  //
4
- // Conditions are an internal list of {field, operator, value} triples
5
- // joined by AND. They serialise to the backend's Mongo-style filter
6
- // shape:
7
- // [{ name, $icontains, "abc" }, { age, $gte, 18 }]
8
- // → { name: {$icontains: "abc"}, age: {$gte: 18} }
9
- // and parse back on init so an existing filter object can be edited.
4
+ // Internal tree:
5
+ // topConjunction : "$and" | "$or" joins top-level items
6
+ // topChildren : (Rule | Group)[] — mixed rules and groups
10
7
  //
11
- // Any sub-shape we can't model as a flat condition (nested $and / $or
12
- // groups left over from the previous tree editor) is preserved
13
- // verbatim under a `__unmanaged` carrier so we don't silently destroy
14
- // the user's existing filter. A small banner surfaces that case.
8
+ // Rule = { kind: "rule", id, field, operator, value }
9
+ // Group = { kind: "group", id, conjunction: "$and" | "$or", children: Rule[] }
10
+ //
11
+ // Nesting is capped at one level (groups hold rules, not other groups)
12
+ // because the Airtable-style UI gets unreadable deeper than that and
13
+ // ~all real-world filters fit. The output filter object still uses
14
+ // the unrestricted Mongo-style $and / $or arrays, so the backend
15
+ // contract is unchanged.
16
+ //
17
+ // Serialisation tries to keep the cheapest legal shape:
18
+ // • top-level AND of simple rules with no field-key collisions →
19
+ // flat object: { field1: {...}, field2: {...} }
20
+ // • anything else → explicit $and/$or arrays.
15
21
 
16
22
  import * as Select from "../ui/select/index.js";
17
23
  import Button from "../ui/button/button.svelte";
18
- import { Plus, X, AlertCircle } from "lucide-svelte";
24
+ import { Plus, X, Boxes, Settings2 } from "lucide-svelte";
19
25
  import { getStudioContext } from "../../context";
20
26
  import { getFieldIcon } from "./utils";
21
27
  import { getFieldRelationTarget } from "../../relations";
@@ -25,28 +31,42 @@
25
31
  interface Props {
26
32
  filter: Record<string, any>;
27
33
  collectionName: string;
34
+ // Bindable signal back to the parent so it can shrink the
35
+ // popover when no rules are applied — the empty state looks
36
+ // hollow at full width.
37
+ isEmpty?: boolean;
28
38
  }
29
39
 
30
- let { filter = $bindable({}), collectionName }: Props = $props();
40
+ let {
41
+ filter = $bindable({}),
42
+ collectionName,
43
+ isEmpty = $bindable(true),
44
+ }: Props = $props();
31
45
 
32
46
  type OperatorDef = {
33
47
  value: string;
34
48
  label: string;
35
- // single: one input. range: two inputs. list: comma-separated. none: no input (reserved).
36
49
  valueType: "single" | "range" | "list" | "none";
37
50
  };
38
51
 
39
- type Condition = {
52
+ type Rule = {
53
+ kind: "rule";
40
54
  id: string;
41
55
  field: string;
42
56
  operator: string;
43
57
  value: any;
44
58
  };
45
59
 
60
+ type Group = {
61
+ kind: "group";
62
+ id: string;
63
+ conjunction: "$and" | "$or";
64
+ children: Rule[];
65
+ };
66
+
67
+ type Item = Rule | Group;
68
+
46
69
  // ── Operator catalogues ─────────────────────────────────────────────
47
- // Curated per category. We don't expose every operator the backend
48
- // accepts (regex, instarts_with, etc.) — Airtable's strength is the
49
- // restraint here.
50
70
  const STRING_OPS: OperatorDef[] = [
51
71
  { value: "$icontains", label: "contains", valueType: "single" },
52
72
  { value: "$incontains", label: "does not contain", valueType: "single" },
@@ -120,9 +140,6 @@
120
140
  return (field?.enum ?? []).map((e: any) => String(e.value ?? e));
121
141
  }
122
142
 
123
- // ── Field listing ──────────────────────────────────────────────────
124
- // Every schema field becomes a candidate. Hidden / id is allowed —
125
- // an Airtable-style filter on id is sometimes useful.
126
143
  const allFieldNames = Object.keys(ctx.meta.collections[collectionName].fields);
127
144
 
128
145
  function nextId() {
@@ -135,95 +152,109 @@
135
152
  return "";
136
153
  }
137
154
 
138
- // ── Initial parse: filter → conditions ─────────────────────────────
139
- // Walk the top-level entries. A field key whose value is a plain
140
- // operator object becomes one or more conditions (one per operator).
141
- // Anything else ($and / $or / nested groups / fields with primitive
142
- // values) is stashed into `unmanaged` so we don't destroy it.
143
- type ParseResult = { conditions: Condition[]; unmanaged: Record<string, any> };
144
-
145
- function isOperatorObject(value: any): boolean {
146
- if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
147
- const keys = Object.keys(value);
148
- return keys.length > 0 && keys.every((k) => k.startsWith("$"));
155
+ function makeRule(field: string): Rule {
156
+ const op = getOperatorsForField(field)[0];
157
+ return { kind: "rule", id: nextId(), field, operator: op.value, value: defaultValueFor(op) };
149
158
  }
150
159
 
151
- function parseFilter(f: Record<string, any>): ParseResult {
152
- const conditions: Condition[] = [];
153
- const unmanaged: Record<string, any> = {};
154
- for (const [key, value] of Object.entries(f ?? {})) {
155
- if (key.startsWith("$")) {
156
- // top-level $and / $or — leave alone
157
- unmanaged[key] = value;
158
- continue;
159
- }
160
- if (!allFieldNames.includes(key)) {
161
- unmanaged[key] = value;
162
- continue;
163
- }
164
- if (!isOperatorObject(value)) {
165
- // bare primitive value → treat as $eq for round-tripping
166
- conditions.push({
167
- id: nextId(),
168
- field: key,
169
- operator: "$eq",
170
- value: value,
171
- });
172
- continue;
173
- }
174
- for (const [op, opVal] of Object.entries(value as Record<string, any>)) {
175
- conditions.push({
160
+ function makeGroup(): Group {
161
+ return {
162
+ kind: "group",
163
+ id: nextId(),
164
+ conjunction: "$and",
165
+ children: allFieldNames.length ? [makeRule(allFieldNames[0])] : [],
166
+ };
167
+ }
168
+
169
+ // ── Parsing: filter object → tree ──────────────────────────────────
170
+ function parseRulesForField(field: string, value: any): Rule[] {
171
+ if (value == null) return [];
172
+ if (typeof value !== "object" || Array.isArray(value)) {
173
+ return [{ kind: "rule", id: nextId(), field, operator: "$eq", value }];
174
+ }
175
+ return Object.entries(value).map(([op, opVal]) => ({
176
+ kind: "rule" as const,
177
+ id: nextId(),
178
+ field,
179
+ operator: op,
180
+ value: opVal,
181
+ }));
182
+ }
183
+
184
+ // Parse a single sub-object that lives inside a $and / $or array:
185
+ // it may itself be a rule (field-keyed) or a nested group.
186
+ function parseSubObject(sub: any): Item[] {
187
+ const items: Item[] = [];
188
+ for (const [key, value] of Object.entries(sub ?? {})) {
189
+ if (key === "$and" || key === "$or") {
190
+ items.push({
191
+ kind: "group",
176
192
  id: nextId(),
177
- field: key,
178
- operator: op,
179
- value: opVal,
193
+ conjunction: key,
194
+ // Only keep rules inside a group (one-level cap). A
195
+ // nested-nested group would be a flattening — its
196
+ // children get spliced in instead.
197
+ children: (Array.isArray(value) ? value : []).flatMap((c: any) =>
198
+ parseSubObject(c).flatMap((it) =>
199
+ it.kind === "rule" ? [it] : it.children,
200
+ ),
201
+ ),
180
202
  });
203
+ } else if (allFieldNames.includes(key)) {
204
+ items.push(...parseRulesForField(key, value));
181
205
  }
206
+ // unknown keys are silently dropped; the tree round-trips
207
+ // through the editor and re-emits a clean filter.
182
208
  }
183
- return { conditions, unmanaged };
209
+ return items;
184
210
  }
185
211
 
186
- const initial = parseFilter(filter);
187
- let conditions = $state<Condition[]>(initial.conditions);
188
- let unmanaged = $state<Record<string, any>>(initial.unmanaged);
189
-
190
- // ── Sync: conditions filter ──────────────────────────────────────
191
- // Re-emit the filter prop with a fresh object identity so the
192
- // dataTable header's watcher picks it up (same lesson as sort.svelte).
193
- function commit() {
194
- const next: Record<string, any> = { ...unmanaged };
195
- for (const c of conditions) {
196
- if (!c.field || !c.operator) continue;
197
- // Skip conditions whose value isn't usable yet so the table
198
- // doesn't refetch with a half-typed filter.
199
- if (!isValueReady(c)) continue;
200
- const existing = next[c.field];
201
- if (existing && typeof existing === "object" && !Array.isArray(existing)) {
202
- next[c.field] = { ...existing, [c.operator]: serialiseValue(c) };
203
- } else {
204
- next[c.field] = { [c.operator]: serialiseValue(c) };
212
+ function parseFilter(f: Record<string, any>): {
213
+ topConjunction: "$and" | "$or";
214
+ topChildren: Item[];
215
+ } {
216
+ const entries = Object.entries(f ?? {});
217
+ // {$or: [...]} only top is OR; everything else flows through as AND.
218
+ if (entries.length === 1) {
219
+ const [k, v] = entries[0];
220
+ if ((k === "$or" || k === "$and") && Array.isArray(v)) {
221
+ return {
222
+ topConjunction: k,
223
+ topChildren: v.flatMap((sub) => parseSubObject(sub)),
224
+ };
205
225
  }
206
226
  }
207
- filter = next;
227
+ // Mixed top: every entry becomes a top-level item, joined by AND.
228
+ return { topConjunction: "$and", topChildren: parseSubObject(f) };
208
229
  }
209
230
 
210
- function isValueReady(c: Condition): boolean {
211
- const op = getOperatorsForField(c.field).find((o) => o.value === c.operator);
231
+ const initial = parseFilter(filter);
232
+ let topConjunction = $state<"$and" | "$or">(initial.topConjunction);
233
+ let topChildren = $state<Item[]>(initial.topChildren);
234
+
235
+ // Keep the parent in sync so it can size the popover accordingly.
236
+ $effect(() => {
237
+ isEmpty = topChildren.length === 0;
238
+ });
239
+
240
+ // ── Serialisation: tree → filter object ────────────────────────────
241
+ function isRuleReady(r: Rule): boolean {
242
+ const op = getOperatorsForField(r.field).find((o) => o.value === r.operator);
212
243
  if (!op) return false;
213
244
  if (op.valueType === "range") {
214
- const v = Array.isArray(c.value) ? c.value : ["", ""];
245
+ const v = Array.isArray(r.value) ? r.value : ["", ""];
215
246
  return v[0] !== "" && v[0] != null && v[1] !== "" && v[1] != null;
216
247
  }
217
248
  if (op.valueType === "list") {
218
- return Array.isArray(c.value) && c.value.length > 0;
249
+ return Array.isArray(r.value) && r.value.length > 0;
219
250
  }
220
- if (getFieldKind(c.field) === "bool") return typeof c.value === "boolean";
221
- return c.value !== "" && c.value != null;
251
+ if (getFieldKind(r.field) === "bool") return typeof r.value === "boolean";
252
+ return r.value !== "" && r.value != null;
222
253
  }
223
254
 
224
- function serialiseValue(c: Condition): any {
225
- const kind = getFieldKind(c.field);
226
- const op = getOperatorsForField(c.field).find((o) => o.value === c.operator);
255
+ function serialiseValue(r: Rule): any {
256
+ const kind = getFieldKind(r.field);
257
+ const op = getOperatorsForField(r.field).find((o) => o.value === r.operator);
227
258
  const coerce = (v: any) => {
228
259
  if (kind === "number" || kind === "fk") {
229
260
  const n = typeof v === "number" ? v : parseFloat(v);
@@ -233,269 +264,412 @@
233
264
  return v;
234
265
  };
235
266
  if (op?.valueType === "range") {
236
- const [a, b] = Array.isArray(c.value) ? c.value : ["", ""];
267
+ const [a, b] = Array.isArray(r.value) ? r.value : ["", ""];
237
268
  return [coerce(a), coerce(b)];
238
269
  }
239
270
  if (op?.valueType === "list") {
240
- return (Array.isArray(c.value) ? c.value : []).map(coerce);
271
+ return (Array.isArray(r.value) ? r.value : []).map(coerce);
272
+ }
273
+ return coerce(r.value);
274
+ }
275
+
276
+ function serialiseRule(r: Rule): Record<string, any> {
277
+ return { [r.field]: { [r.operator]: serialiseValue(r) } };
278
+ }
279
+
280
+ function serialiseGroup(g: Group): Record<string, any> | null {
281
+ const ready = g.children.filter(isRuleReady);
282
+ if (!ready.length) return null;
283
+ if (g.conjunction === "$or") {
284
+ return { $or: ready.map(serialiseRule) };
285
+ }
286
+ // AND group: try the flat shape, fall back to $and on collision.
287
+ return tryFlattenAnd(ready.map(serialiseRule)) ?? {
288
+ $and: ready.map(serialiseRule),
289
+ };
290
+ }
291
+
292
+ // Merge a list of `{field: {...}}` objects into a single flat object,
293
+ // collapsing different operators on the same field into one operator
294
+ // object. Returns null if any single field has the same operator
295
+ // twice (an unresolvable collision — caller falls back to $and).
296
+ function tryFlattenAnd(parts: Record<string, any>[]): Record<string, any> | null {
297
+ const out: Record<string, any> = {};
298
+ for (const part of parts) {
299
+ for (const [field, opsObj] of Object.entries(part)) {
300
+ if (field.startsWith("$")) return null; // groups can't be flattened
301
+ if (!(field in out)) {
302
+ out[field] = { ...(opsObj as Record<string, any>) };
303
+ continue;
304
+ }
305
+ const existing = out[field];
306
+ if (!existing || typeof existing !== "object") return null;
307
+ for (const [op, val] of Object.entries(opsObj as Record<string, any>)) {
308
+ if (op in existing) return null;
309
+ existing[op] = val;
310
+ }
311
+ }
312
+ }
313
+ return out;
314
+ }
315
+
316
+ function commit() {
317
+ // Serialise each top-level item; drop empties.
318
+ const parts: Record<string, any>[] = [];
319
+ for (const item of topChildren) {
320
+ if (item.kind === "rule") {
321
+ if (isRuleReady(item)) parts.push(serialiseRule(item));
322
+ } else {
323
+ const g = serialiseGroup(item);
324
+ if (g) parts.push(g);
325
+ }
326
+ }
327
+ if (!parts.length) {
328
+ filter = {};
329
+ return;
330
+ }
331
+ if (topConjunction === "$or") {
332
+ filter = { $or: parts };
333
+ return;
241
334
  }
242
- return coerce(c.value);
335
+ // Top is AND: prefer flat shape, fall back to $and array.
336
+ filter = tryFlattenAnd(parts) ?? { $and: parts };
243
337
  }
244
338
 
245
339
  // ── Mutators ───────────────────────────────────────────────────────
246
- function addCondition() {
247
- const firstField = allFieldNames[0];
248
- if (!firstField) return;
249
- const firstOp = getOperatorsForField(firstField)[0];
250
- conditions = [
251
- ...conditions,
252
- {
253
- id: nextId(),
254
- field: firstField,
255
- operator: firstOp.value,
256
- value: defaultValueFor(firstOp),
257
- },
258
- ];
259
- // Don't commit yet — user hasn't entered a value.
340
+ function addTopRule() {
341
+ const f = allFieldNames[0];
342
+ if (!f) return;
343
+ topChildren = [...topChildren, makeRule(f)];
260
344
  }
261
345
 
262
- function removeCondition(id: string) {
263
- conditions = conditions.filter((c) => c.id !== id);
346
+ function addTopGroup() {
347
+ if (!allFieldNames.length) return;
348
+ topChildren = [...topChildren, makeGroup()];
264
349
  commit();
265
350
  }
266
351
 
267
- function setField(id: string, newField: string) {
268
- conditions = conditions.map((c) => {
269
- if (c.id !== id) return c;
270
- // Reset operator + value if the new field uses a different
271
- // operator set so we never end up with e.g. $contains on a
272
- // number field.
273
- const ops = getOperatorsForField(newField);
274
- const stillValid = ops.find((o) => o.value === c.operator);
275
- const newOp = stillValid ?? ops[0];
276
- return {
277
- ...c,
278
- field: newField,
279
- operator: newOp.value,
280
- value: stillValid ? c.value : defaultValueFor(newOp),
281
- };
282
- });
352
+ function removeTopItem(id: string) {
353
+ topChildren = topChildren.filter((it) => it.id !== id);
283
354
  commit();
284
355
  }
285
356
 
286
- function setOperator(id: string, newOpValue: string) {
287
- conditions = conditions.map((c) => {
288
- if (c.id !== id) return c;
289
- const op = getOperatorsForField(c.field).find((o) => o.value === newOpValue);
290
- if (!op) return c;
291
- // Preserve value when the shape doesn't change; reset otherwise.
292
- const prevOp = getOperatorsForField(c.field).find((o) => o.value === c.operator);
293
- const sameShape = prevOp?.valueType === op.valueType;
294
- return {
295
- ...c,
296
- operator: newOpValue,
297
- value: sameShape ? c.value : defaultValueFor(op),
298
- };
299
- });
357
+ function addGroupRule(groupId: string) {
358
+ const f = allFieldNames[0];
359
+ if (!f) return;
360
+ topChildren = topChildren.map((it) =>
361
+ it.kind === "group" && it.id === groupId
362
+ ? { ...it, children: [...it.children, makeRule(f)] }
363
+ : it,
364
+ );
365
+ }
366
+
367
+ function removeGroupRule(groupId: string, ruleId: string) {
368
+ topChildren = topChildren.map((it) =>
369
+ it.kind === "group" && it.id === groupId
370
+ ? { ...it, children: it.children.filter((r) => r.id !== ruleId) }
371
+ : it,
372
+ );
300
373
  commit();
301
374
  }
302
375
 
303
- function setValue(id: string, newValue: any) {
304
- conditions = conditions.map((c) => (c.id === id ? { ...c, value: newValue } : c));
376
+ function setTopConjunction(c: "$and" | "$or") {
377
+ topConjunction = c;
305
378
  commit();
306
379
  }
307
380
 
308
- function setRangeValue(id: string, index: 0 | 1, newValue: any) {
309
- conditions = conditions.map((c) => {
310
- if (c.id !== id) return c;
311
- const arr = Array.isArray(c.value) ? [...c.value] : ["", ""];
312
- arr[index] = newValue;
313
- return { ...c, value: arr };
314
- });
381
+ function setGroupConjunction(groupId: string, c: "$and" | "$or") {
382
+ topChildren = topChildren.map((it) =>
383
+ it.kind === "group" && it.id === groupId ? { ...it, conjunction: c } : it,
384
+ );
315
385
  commit();
316
386
  }
317
387
 
318
- function setListValue(id: string, raw: string) {
319
- // Comma-separated input array of trimmed strings. Empty items
320
- // dropped so trailing commas don't produce empty list entries.
321
- const arr = raw
322
- .split(",")
323
- .map((s) => s.trim())
324
- .filter((s) => s.length > 0);
325
- setValue(id, arr);
388
+ function patchRuleInPlace(ruleId: string, patch: Partial<Rule>) {
389
+ const apply = (r: Rule): Rule => {
390
+ if (r.id !== ruleId) return r;
391
+ const next = { ...r, ...patch };
392
+ // If field changed, re-pick operator + reset value when the
393
+ // type's operator set no longer contains the previous one.
394
+ if (patch.field && patch.field !== r.field) {
395
+ const ops = getOperatorsForField(next.field);
396
+ const keep = ops.find((o) => o.value === next.operator);
397
+ const newOp = keep ?? ops[0];
398
+ next.operator = newOp.value;
399
+ next.value = keep ? next.value : defaultValueFor(newOp);
400
+ }
401
+ // If operator changed, reset value when the value shape
402
+ // (single vs range vs list) doesn't match.
403
+ if (patch.operator && patch.operator !== r.operator) {
404
+ const prev = getOperatorsForField(r.field).find((o) => o.value === r.operator);
405
+ const op = getOperatorsForField(next.field).find((o) => o.value === next.operator);
406
+ if (prev?.valueType !== op?.valueType) {
407
+ next.value = op ? defaultValueFor(op) : "";
408
+ }
409
+ }
410
+ return next;
411
+ };
412
+ topChildren = topChildren.map((it) => {
413
+ if (it.kind === "rule") return apply(it);
414
+ return { ...it, children: it.children.map(apply) };
415
+ });
416
+ commit();
326
417
  }
327
418
 
328
- function clearUnmanaged() {
329
- unmanaged = {};
419
+ function setRangeValue(ruleId: string, index: 0 | 1, newValue: any) {
420
+ const apply = (r: Rule): Rule => {
421
+ if (r.id !== ruleId) return r;
422
+ const arr = Array.isArray(r.value) ? [...r.value] : ["", ""];
423
+ arr[index] = newValue;
424
+ return { ...r, value: arr };
425
+ };
426
+ topChildren = topChildren.map((it) => {
427
+ if (it.kind === "rule") return apply(it);
428
+ return { ...it, children: it.children.map(apply) };
429
+ });
330
430
  commit();
331
431
  }
332
432
 
433
+ function setListValue(ruleId: string, raw: string) {
434
+ const arr = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
435
+ patchRuleInPlace(ruleId, { value: arr });
436
+ }
437
+
333
438
  // ── Display helpers ────────────────────────────────────────────────
334
- const hasUnmanaged = $derived(Object.keys(unmanaged).length > 0);
439
+ // The conjunction "word" shown before each rule from row 2 onwards.
440
+ function conjLabel(c: "$and" | "$or") {
441
+ return c === "$and" ? "and" : "or";
442
+ }
335
443
  </script>
336
444
 
337
- <div class="flex flex-col p-3">
338
- {#if hasUnmanaged}
339
- <!-- Existing filter contained shapes we can't surface as flat
340
- conditions (nested $and / $or from the legacy editor). It
341
- still ships with the request, but we let the user wipe it
342
- so they can rebuild from scratch in this UI. -->
343
- <div class="mb-2 flex items-start gap-2 rounded-md border border-dashed border-muted-foreground/40 bg-muted/40 px-3 py-2">
344
- <AlertCircle size="14" class="mt-0.5 shrink-0 text-muted-foreground" />
345
- <div class="flex-1 text-xs text-muted-foreground">
346
- <div>This view has an advanced filter that can't be edited here.</div>
347
- <button
348
- type="button"
349
- onclick={clearUnmanaged}
350
- class="mt-1 underline underline-offset-2 hover:text-foreground"
351
- >
352
- Clear advanced filter
353
- </button>
445
+ {#snippet conjunctionSlot(index: number, conjunction: "$and" | "$or", onChange: (c: "$and" | "$or") => void)}
446
+ <!-- Row leader: "Where" on the first row, a clickable AND/OR
447
+ selector on the second row, and a static word matching the
448
+ selector on row 3+. The selector lives in row 2 only so the
449
+ user sees the conjunction is shared, not per-row. -->
450
+ <div class="w-14 shrink-0 text-right text-xs text-muted-foreground">
451
+ {#if index === 0}
452
+ Where
453
+ {:else if index === 1}
454
+ <Select.Root type="single" value={conjunction} onValueChange={(v) => v && onChange(v as "$and" | "$or")}>
455
+ <Select.Trigger class="h-7 w-14 bg-muted text-xs">
456
+ {conjLabel(conjunction)}
457
+ </Select.Trigger>
458
+ <Select.Content>
459
+ <Select.Item value="$and">and</Select.Item>
460
+ <Select.Item value="$or">or</Select.Item>
461
+ </Select.Content>
462
+ </Select.Root>
463
+ {:else}
464
+ {conjLabel(conjunction)}
465
+ {/if}
466
+ </div>
467
+ {/snippet}
468
+
469
+ {#snippet ruleRow(rule: Rule, onRemove: () => void)}
470
+ {@const ops = getOperatorsForField(rule.field)}
471
+ {@const currentOp = ops.find((o) => o.value === rule.operator) ?? ops[0]}
472
+ {@const kind = getFieldKind(rule.field)}
473
+ {@const FieldIcon = getFieldIcon(ctx, rule.field, collectionName)}
474
+ <!-- Field picker -->
475
+ <Select.Root
476
+ type="single"
477
+ value={rule.field}
478
+ onValueChange={(v) => v && patchRuleInPlace(rule.id, { field: v })}
479
+ >
480
+ <Select.Trigger class="bg-muted h-7 w-36 text-xs">
481
+ <div class="inline-flex items-center gap-1.5">
482
+ <FieldIcon size="13" />
483
+ {rule.field}
484
+ </div>
485
+ </Select.Trigger>
486
+ <Select.Content>
487
+ {#each allFieldNames as fname}
488
+ {@const OptionIcon = getFieldIcon(ctx, fname, collectionName)}
489
+ <Select.Item value={fname}>
490
+ <div class="inline-flex items-center gap-1.5">
491
+ <OptionIcon size="13" />
492
+ {fname}
493
+ </div>
494
+ </Select.Item>
495
+ {/each}
496
+ </Select.Content>
497
+ </Select.Root>
498
+
499
+ <!-- Operator picker -->
500
+ <Select.Root
501
+ type="single"
502
+ value={rule.operator}
503
+ onValueChange={(v) => v && patchRuleInPlace(rule.id, { operator: v })}
504
+ >
505
+ <Select.Trigger class="bg-muted h-7 w-32 text-xs">
506
+ {currentOp.label}
507
+ </Select.Trigger>
508
+ <Select.Content>
509
+ {#each ops as op}
510
+ <Select.Item value={op.value}>{op.label}</Select.Item>
511
+ {/each}
512
+ </Select.Content>
513
+ </Select.Root>
514
+
515
+ <!-- Value input — type-aware -->
516
+ <div class="flex flex-1 items-center gap-1.5">
517
+ {#if currentOp.valueType === "range"}
518
+ {@const range = Array.isArray(rule.value) ? rule.value : ["", ""]}
519
+ <input
520
+ class="h-7 w-full rounded-md border bg-muted px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
521
+ type={kind === "date" ? "date" : "number"}
522
+ value={range[0] ?? ""}
523
+ oninput={(e) => setRangeValue(rule.id, 0, (e.currentTarget as HTMLInputElement).value)}
524
+ />
525
+ <span class="text-muted-foreground">–</span>
526
+ <input
527
+ class="h-7 w-full rounded-md border bg-muted px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
528
+ type={kind === "date" ? "date" : "number"}
529
+ value={range[1] ?? ""}
530
+ oninput={(e) => setRangeValue(rule.id, 1, (e.currentTarget as HTMLInputElement).value)}
531
+ />
532
+ {:else if currentOp.valueType === "list"}
533
+ <input
534
+ class="h-7 w-full rounded-md border bg-muted px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
535
+ type="text"
536
+ placeholder="Comma separated"
537
+ value={(Array.isArray(rule.value) ? rule.value : []).join(", ")}
538
+ oninput={(e) => setListValue(rule.id, (e.currentTarget as HTMLInputElement).value)}
539
+ />
540
+ {:else if kind === "bool"}
541
+ <Select.Root
542
+ type="single"
543
+ value={rule.value === true ? "true" : rule.value === false ? "false" : ""}
544
+ onValueChange={(v) => v && patchRuleInPlace(rule.id, { value: v === "true" })}
545
+ >
546
+ <Select.Trigger class="bg-muted h-7 w-full text-xs">
547
+ {rule.value === true ? "true" : rule.value === false ? "false" : "—"}
548
+ </Select.Trigger>
549
+ <Select.Content>
550
+ <Select.Item value="true">true</Select.Item>
551
+ <Select.Item value="false">false</Select.Item>
552
+ </Select.Content>
553
+ </Select.Root>
554
+ {:else if kind === "enum"}
555
+ <Select.Root
556
+ type="single"
557
+ value={rule.value == null ? "" : String(rule.value)}
558
+ onValueChange={(v) => v && patchRuleInPlace(rule.id, { value: v })}
559
+ >
560
+ <Select.Trigger class="bg-muted h-7 w-full text-xs">
561
+ {rule.value == null || rule.value === "" ? "—" : String(rule.value)}
562
+ </Select.Trigger>
563
+ <Select.Content>
564
+ {#each getEnumValues(rule.field) as ev}
565
+ <Select.Item value={ev}>{ev}</Select.Item>
566
+ {/each}
567
+ </Select.Content>
568
+ </Select.Root>
569
+ {:else}
570
+ <input
571
+ class="h-7 w-full rounded-md border bg-muted px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
572
+ type={kind === "date" ? "date" : kind === "number" || kind === "fk" ? "number" : "text"}
573
+ value={rule.value ?? ""}
574
+ oninput={(e) => patchRuleInPlace(rule.id, { value: (e.currentTarget as HTMLInputElement).value })}
575
+ />
576
+ {/if}
577
+ </div>
578
+
579
+ <Button
580
+ onclick={onRemove}
581
+ class="h-7 w-7 text-muted-foreground hover:bg-transparent"
582
+ variant="ghost"
583
+ size="icon"
584
+ Icon={X}
585
+ ></Button>
586
+ {/snippet}
587
+
588
+ <div class="flex flex-col gap-2 p-3">
589
+ {#if topChildren.length === 0}
590
+ <!-- Empty state: centred icon + two-line message so the popover
591
+ doesn't feel hollow. Width stays consistent with the loaded
592
+ state so adding the first rule doesn't snap the popover. -->
593
+ <div class="flex flex-col items-center justify-center gap-2 py-8 text-center">
594
+ <Settings2 size="20" class="text-muted-foreground/60" />
595
+ <div class="flex flex-col gap-0.5">
596
+ <div class="text-xs text-foreground">No filter rules are applied</div>
597
+ <div class="text-xs text-muted-foreground">Add a rule below to filter the view</div>
354
598
  </div>
355
- </div>
356
- {/if}
357
-
358
- {#if conditions.length === 0}
359
- <div class="px-1 py-2 text-xs text-muted-foreground">
360
- No filter conditions are applied
361
599
  </div>
362
600
  {:else}
363
601
  <div class="flex flex-col gap-1.5">
364
- {#each conditions as cond, index (cond.id)}
365
- {@const ops = getOperatorsForField(cond.field)}
366
- {@const currentOp = ops.find((o) => o.value === cond.operator) ?? ops[0]}
367
- {@const kind = getFieldKind(cond.field)}
368
- {@const FieldIcon = getFieldIcon(ctx, cond.field, collectionName)}
369
- <div class="flex items-center gap-1.5 text-xs">
370
- <!-- Conjunction label: first row reads "Where", the rest
371
- "and" so the row reads as a sentence. -->
372
- <div class="w-10 shrink-0 text-right text-muted-foreground">
373
- {index === 0 ? "Where" : "and"}
602
+ {#each topChildren as item, index (item.id)}
603
+ {#if item.kind === "rule"}
604
+ <div class="flex items-center gap-1.5">
605
+ {@render conjunctionSlot(index, topConjunction, setTopConjunction)}
606
+ {@render ruleRow(item, () => removeTopItem(item.id))}
374
607
  </div>
375
-
376
- <!-- Field picker -->
377
- <Select.Root
378
- type="single"
379
- value={cond.field}
380
- onValueChange={(v) => v && setField(cond.id, v)}
381
- >
382
- <Select.Trigger class="bg-muted h-7 w-36 text-xs">
383
- <div class="inline-flex items-center gap-1.5">
384
- <FieldIcon size="13" />
385
- {cond.field}
608
+ {:else}
609
+ <!-- Group container: bordered card with its own
610
+ conjunction toggle and its own +Add filter rule
611
+ button. Capped at one level — no nested groups. -->
612
+ <div class="flex items-start gap-1.5">
613
+ {@render conjunctionSlot(index, topConjunction, setTopConjunction)}
614
+ <div class="flex-1 rounded-md border bg-muted/30 p-2">
615
+ <div class="mb-2 flex items-center justify-between">
616
+ <div class="flex items-center gap-1.5 text-xs text-muted-foreground">
617
+ <Boxes size="13" />
618
+ <span>Group</span>
619
+ </div>
620
+ <Button
621
+ onclick={() => removeTopItem(item.id)}
622
+ class="h-6 w-6 text-muted-foreground hover:bg-transparent"
623
+ variant="ghost"
624
+ size="icon"
625
+ Icon={X}
626
+ ></Button>
386
627
  </div>
387
- </Select.Trigger>
388
- <Select.Content>
389
- {#each allFieldNames as fname}
390
- {@const OptionIcon = getFieldIcon(ctx, fname, collectionName)}
391
- <Select.Item value={fname}>
392
- <div class="inline-flex items-center gap-1.5">
393
- <OptionIcon size="13" />
394
- {fname}
395
- </div>
396
- </Select.Item>
397
- {/each}
398
- </Select.Content>
399
- </Select.Root>
400
-
401
- <!-- Operator picker -->
402
- <Select.Root
403
- type="single"
404
- value={cond.operator}
405
- onValueChange={(v) => v && setOperator(cond.id, v)}
406
- >
407
- <Select.Trigger class="bg-muted h-7 w-32 text-xs">
408
- {currentOp.label}
409
- </Select.Trigger>
410
- <Select.Content>
411
- {#each ops as op}
412
- <Select.Item value={op.value}>{op.label}</Select.Item>
413
- {/each}
414
- </Select.Content>
415
- </Select.Root>
416
-
417
- <!-- Value input — type-aware -->
418
- <div class="flex flex-1 items-center gap-1.5">
419
- {#if currentOp.valueType === "range"}
420
- {@const range = Array.isArray(cond.value) ? cond.value : ["", ""]}
421
- <input
422
- class="h-7 w-full rounded-md border bg-muted px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
423
- type={kind === "date" ? "date" : "number"}
424
- value={range[0] ?? ""}
425
- oninput={(e) => setRangeValue(cond.id, 0, (e.currentTarget as HTMLInputElement).value)}
426
- />
427
- <span class="text-muted-foreground">–</span>
428
- <input
429
- class="h-7 w-full rounded-md border bg-muted px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
430
- type={kind === "date" ? "date" : "number"}
431
- value={range[1] ?? ""}
432
- oninput={(e) => setRangeValue(cond.id, 1, (e.currentTarget as HTMLInputElement).value)}
433
- />
434
- {:else if currentOp.valueType === "list"}
435
- <input
436
- class="h-7 w-full rounded-md border bg-muted px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
437
- type="text"
438
- placeholder="Comma separated"
439
- value={(Array.isArray(cond.value) ? cond.value : []).join(", ")}
440
- oninput={(e) => setListValue(cond.id, (e.currentTarget as HTMLInputElement).value)}
441
- />
442
- {:else if kind === "bool"}
443
- <Select.Root
444
- type="single"
445
- value={cond.value === true ? "true" : cond.value === false ? "false" : ""}
446
- onValueChange={(v) => v && setValue(cond.id, v === "true")}
447
- >
448
- <Select.Trigger class="bg-muted h-7 w-full text-xs">
449
- {cond.value === true ? "true" : cond.value === false ? "false" : "—"}
450
- </Select.Trigger>
451
- <Select.Content>
452
- <Select.Item value="true">true</Select.Item>
453
- <Select.Item value="false">false</Select.Item>
454
- </Select.Content>
455
- </Select.Root>
456
- {:else if kind === "enum"}
457
- <Select.Root
458
- type="single"
459
- value={cond.value == null ? "" : String(cond.value)}
460
- onValueChange={(v) => v && setValue(cond.id, v)}
461
- >
462
- <Select.Trigger class="bg-muted h-7 w-full text-xs">
463
- {cond.value == null || cond.value === "" ? "—" : String(cond.value)}
464
- </Select.Trigger>
465
- <Select.Content>
466
- {#each getEnumValues(cond.field) as ev}
467
- <Select.Item value={ev}>{ev}</Select.Item>
628
+ {#if item.children.length === 0}
629
+ <div class="px-1 py-1 text-xs text-muted-foreground">
630
+ Empty group
631
+ </div>
632
+ {:else}
633
+ <div class="flex flex-col gap-1.5">
634
+ {#each item.children as childRule, childIndex (childRule.id)}
635
+ <div class="flex items-center gap-1.5">
636
+ {@render conjunctionSlot(childIndex, item.conjunction, (c) => setGroupConjunction(item.id, c))}
637
+ {@render ruleRow(childRule, () => removeGroupRule(item.id, childRule.id))}
638
+ </div>
468
639
  {/each}
469
- </Select.Content>
470
- </Select.Root>
471
- {:else}
472
- <input
473
- class="h-7 w-full rounded-md border bg-muted px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
474
- type={kind === "date" ? "date" : kind === "number" || kind === "fk" ? "number" : "text"}
475
- value={cond.value ?? ""}
476
- oninput={(e) => setValue(cond.id, (e.currentTarget as HTMLInputElement).value)}
477
- />
478
- {/if}
640
+ </div>
641
+ {/if}
642
+ <button
643
+ type="button"
644
+ onclick={() => addGroupRule(item.id)}
645
+ class="mt-2 inline-flex w-fit items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
646
+ >
647
+ <Plus size="14" />
648
+ Add filter rule
649
+ </button>
650
+ </div>
479
651
  </div>
480
-
481
- <Button
482
- onclick={() => removeCondition(cond.id)}
483
- class="h-7 w-7 text-muted-foreground hover:bg-transparent"
484
- variant="ghost"
485
- size="icon"
486
- Icon={X}
487
- ></Button>
488
- </div>
652
+ {/if}
489
653
  {/each}
490
654
  </div>
491
655
  {/if}
492
656
 
493
- <button
494
- type="button"
495
- onclick={addCondition}
496
- class="mt-2 inline-flex w-fit items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
497
- >
498
- <Plus size="14" />
499
- Add condition
500
- </button>
657
+ <div class="flex items-center gap-2">
658
+ <button
659
+ type="button"
660
+ onclick={addTopRule}
661
+ class="inline-flex w-fit items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
662
+ >
663
+ <Plus size="14" />
664
+ Add filter rule
665
+ </button>
666
+ <button
667
+ type="button"
668
+ onclick={addTopGroup}
669
+ class="inline-flex w-fit items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
670
+ >
671
+ <Plus size="14" />
672
+ Add filter group
673
+ </button>
674
+ </div>
501
675
  </div>