@lobb-js/studio 0.38.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,270 +1,675 @@
1
1
  <script lang="ts">
2
- import * as Popover from "../ui/popover/index.js";
3
- import Filter from "./filter.svelte";
4
- import {
5
- Plus,
6
- Boxes,
7
- CircleOff,
8
- ListFilter,
9
- Diamond,
10
- Trash,
11
- } from "lucide-svelte";
12
- import { buttonVariants } from "../ui/button";
13
- import * as _ from "lodash-es";
14
- import { getStudioContext } from "../../context";
2
+ // Airtable-style filter editor.
3
+ //
4
+ // Internal tree:
5
+ // topConjunction : "$and" | "$or" — joins top-level items
6
+ // topChildren : (Rule | Group)[] — mixed rules and groups
7
+ //
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.
21
+
22
+ import * as Select from "../ui/select/index.js";
15
23
  import Button from "../ui/button/button.svelte";
24
+ import { Plus, X, Boxes, Settings2 } from "lucide-svelte";
25
+ import { getStudioContext } from "../../context";
26
+ import { getFieldIcon } from "./utils";
27
+ import { getFieldRelationTarget } from "../../relations";
16
28
 
17
29
  const { ctx } = getStudioContext();
18
30
 
19
31
  interface Props {
20
- filter: any;
32
+ filter: Record<string, any>;
21
33
  collectionName: string;
22
- isFirst?: boolean;
23
- deleteFilter?: () => void;
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;
24
38
  }
25
39
 
26
40
  let {
27
41
  filter = $bindable({}),
28
42
  collectionName,
29
- isFirst = false,
30
- deleteFilter,
43
+ isEmpty = $bindable(true),
31
44
  }: Props = $props();
32
45
 
33
- let firstPopover = $state(false);
34
- let secondPopover = $state(false);
46
+ type OperatorDef = {
47
+ value: string;
48
+ label: string;
49
+ valueType: "single" | "range" | "list" | "none";
50
+ };
51
+
52
+ type Rule = {
53
+ kind: "rule";
54
+ id: string;
55
+ field: string;
56
+ operator: string;
57
+ value: any;
58
+ };
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
+
69
+ // ── Operator catalogues ─────────────────────────────────────────────
70
+ const STRING_OPS: OperatorDef[] = [
71
+ { value: "$icontains", label: "contains", valueType: "single" },
72
+ { value: "$incontains", label: "does not contain", valueType: "single" },
73
+ { value: "$eq", label: "is", valueType: "single" },
74
+ { value: "$ne", label: "is not", valueType: "single" },
75
+ { value: "$istarts_with",label: "starts with", valueType: "single" },
76
+ { value: "$iends_with", label: "ends with", valueType: "single" },
77
+ ];
78
+ const NUMBER_OPS: OperatorDef[] = [
79
+ { value: "$eq", label: "=", valueType: "single" },
80
+ { value: "$ne", label: "≠", valueType: "single" },
81
+ { value: "$gt", label: ">", valueType: "single" },
82
+ { value: "$gte", label: "≥", valueType: "single" },
83
+ { value: "$lt", label: "<", valueType: "single" },
84
+ { value: "$lte", label: "≤", valueType: "single" },
85
+ { value: "$between", label: "between", valueType: "range" },
86
+ ];
87
+ const DATE_OPS: OperatorDef[] = [
88
+ { value: "$eq", label: "on", valueType: "single" },
89
+ { value: "$lt", label: "before", valueType: "single" },
90
+ { value: "$gt", label: "after", valueType: "single" },
91
+ { value: "$between", label: "between", valueType: "range" },
92
+ ];
93
+ const BOOL_OPS: OperatorDef[] = [
94
+ { value: "$eq", label: "is", valueType: "single" },
95
+ { value: "$ne", label: "is not", valueType: "single" },
96
+ ];
97
+ const ENUM_OPS: OperatorDef[] = [
98
+ { value: "$eq", label: "is", valueType: "single" },
99
+ { value: "$ne", label: "is not", valueType: "single" },
100
+ { value: "$in", label: "is any of", valueType: "list" },
101
+ { value: "$nin", label: "is none of", valueType: "list" },
102
+ ];
103
+ const FK_OPS: OperatorDef[] = [
104
+ { value: "$eq", label: "is", valueType: "single" },
105
+ { value: "$ne", label: "is not", valueType: "single" },
106
+ { value: "$in", label: "is any of", valueType: "list" },
107
+ ];
108
+
109
+ type FieldKind = "string" | "number" | "date" | "bool" | "enum" | "fk";
35
110
 
36
- function groupAddingHandler(filter: any, key: string) {
37
- if (key === "$and" || key === "$or") {
38
- filter[key] = [];
39
- } else {
40
- filter[key] = {};
111
+ function getFieldKind(fieldName: string): FieldKind {
112
+ const field: any = ctx.meta.collections[collectionName].fields[fieldName];
113
+ if (!field) return "string";
114
+ if (Array.isArray(field.enum) && field.enum.length) return "enum";
115
+ if (getFieldRelationTarget(ctx, collectionName, fieldName)) return "fk";
116
+ if (field.type === "bool") return "bool";
117
+ if (field.type === "date" || field.type === "datetime") return "date";
118
+ if (
119
+ field.type === "integer" ||
120
+ field.type === "decimal" ||
121
+ field.type === "float" ||
122
+ field.type === "long"
123
+ ) return "number";
124
+ return "string";
125
+ }
126
+
127
+ function getOperatorsForField(fieldName: string): OperatorDef[] {
128
+ switch (getFieldKind(fieldName)) {
129
+ case "enum": return ENUM_OPS;
130
+ case "fk": return FK_OPS;
131
+ case "bool": return BOOL_OPS;
132
+ case "date": return DATE_OPS;
133
+ case "number": return NUMBER_OPS;
134
+ default: return STRING_OPS;
41
135
  }
42
136
  }
43
137
 
44
- function getGroupOptions(filter: any) {
45
- const collectionFieldNames = Object.keys(
46
- ctx.meta.collections[collectionName].fields,
138
+ function getEnumValues(fieldName: string): string[] {
139
+ const field: any = ctx.meta.collections[collectionName].fields[fieldName];
140
+ return (field?.enum ?? []).map((e: any) => String(e.value ?? e));
141
+ }
142
+
143
+ const allFieldNames = Object.keys(ctx.meta.collections[collectionName].fields);
144
+
145
+ function nextId() {
146
+ return Math.random().toString(36).slice(2, 9);
147
+ }
148
+
149
+ function defaultValueFor(op: OperatorDef): any {
150
+ if (op.valueType === "range") return ["", ""];
151
+ if (op.valueType === "list") return [];
152
+ return "";
153
+ }
154
+
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) };
158
+ }
159
+
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",
192
+ id: nextId(),
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
+ ),
202
+ });
203
+ } else if (allFieldNames.includes(key)) {
204
+ items.push(...parseRulesForField(key, value));
205
+ }
206
+ // unknown keys are silently dropped; the tree round-trips
207
+ // through the editor and re-emits a clean filter.
208
+ }
209
+ return items;
210
+ }
211
+
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
+ };
225
+ }
226
+ }
227
+ // Mixed top: every entry becomes a top-level item, joined by AND.
228
+ return { topConjunction: "$and", topChildren: parseSubObject(f) };
229
+ }
230
+
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);
243
+ if (!op) return false;
244
+ if (op.valueType === "range") {
245
+ const v = Array.isArray(r.value) ? r.value : ["", ""];
246
+ return v[0] !== "" && v[0] != null && v[1] !== "" && v[1] != null;
247
+ }
248
+ if (op.valueType === "list") {
249
+ return Array.isArray(r.value) && r.value.length > 0;
250
+ }
251
+ if (getFieldKind(r.field) === "bool") return typeof r.value === "boolean";
252
+ return r.value !== "" && r.value != null;
253
+ }
254
+
255
+ function serialiseValue(r: Rule): any {
256
+ const kind = getFieldKind(r.field);
257
+ const op = getOperatorsForField(r.field).find((o) => o.value === r.operator);
258
+ const coerce = (v: any) => {
259
+ if (kind === "number" || kind === "fk") {
260
+ const n = typeof v === "number" ? v : parseFloat(v);
261
+ return Number.isFinite(n) ? n : v;
262
+ }
263
+ if (kind === "bool") return Boolean(v);
264
+ return v;
265
+ };
266
+ if (op?.valueType === "range") {
267
+ const [a, b] = Array.isArray(r.value) ? r.value : ["", ""];
268
+ return [coerce(a), coerce(b)];
269
+ }
270
+ if (op?.valueType === "list") {
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;
334
+ }
335
+ // Top is AND: prefer flat shape, fall back to $and array.
336
+ filter = tryFlattenAnd(parts) ?? { $and: parts };
337
+ }
338
+
339
+ // ── Mutators ───────────────────────────────────────────────────────
340
+ function addTopRule() {
341
+ const f = allFieldNames[0];
342
+ if (!f) return;
343
+ topChildren = [...topChildren, makeRule(f)];
344
+ }
345
+
346
+ function addTopGroup() {
347
+ if (!allFieldNames.length) return;
348
+ topChildren = [...topChildren, makeGroup()];
349
+ commit();
350
+ }
351
+
352
+ function removeTopItem(id: string) {
353
+ topChildren = topChildren.filter((it) => it.id !== id);
354
+ commit();
355
+ }
356
+
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,
47
364
  );
48
- const options = ["$and", "$or", ...collectionFieldNames];
49
- const existingPropertiesNames = Object.keys(filter);
50
- const filteredOptions = _.difference(options, existingPropertiesNames);
51
- return filteredOptions;
52
365
  }
53
366
 
54
- function getOperatorOptions(filter: any) {
55
- const operators = ctx.meta.filter.operators;
56
- const existingPropertiesNames = Object.keys(filter);
57
- const filteredOptions = _.difference(
58
- operators,
59
- existingPropertiesNames,
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,
60
372
  );
61
- return filteredOptions;
373
+ commit();
62
374
  }
63
375
 
64
- function operatorAddingHandler(filter: any, key: string) {
65
- filter[key] = "";
376
+ function setTopConjunction(c: "$and" | "$or") {
377
+ topConjunction = c;
378
+ commit();
379
+ }
380
+
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
+ );
385
+ commit();
386
+ }
387
+
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();
417
+ }
418
+
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
+ });
430
+ commit();
431
+ }
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
+
438
+ // ── Display helpers ────────────────────────────────────────────────
439
+ // The conjunction "word" shown before each rule from row 2 onwards.
440
+ function conjLabel(c: "$and" | "$or") {
441
+ return c === "$and" ? "and" : "or";
66
442
  }
67
443
  </script>
68
444
 
69
- {#snippet filterAddButton(filter: any)}
70
- <Popover.Root bind:open={firstPopover}>
71
- <Popover.Trigger
72
- class={buttonVariants({
73
- variant: "ghost",
74
- size: "sm",
75
- class: "text-muted-foreground",
76
- })}
77
- >
78
- <Plus />
79
- </Popover.Trigger>
80
- <Popover.Content class="flex w-48 flex-col p-2 max-h-60 overflow-auto">
81
- {#each getGroupOptions(filter) as fieldName}
82
- <button
83
- onclick={() => {
84
- groupAddingHandler(filter, fieldName);
85
- firstPopover = false;
86
- }}
87
- class="flex cursor-pointer items-center gap-2 rounded-md p-2 px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
88
- >
89
- <div>{fieldName}</div>
90
- </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>
91
511
  {/each}
92
- </Popover.Content>
93
- </Popover.Root>
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>
94
586
  {/snippet}
95
587
 
96
- <div class="flex flex-col rounded-md {isFirst ? '' : 'border'}">
97
- <div class="flex justify-between items-center gap-2 border-b p-2 h-10">
98
- <div class="flex text-xs font-semibold text-muted-foreground gap-2">
99
- <ListFilter size="17.5" />
100
- <div>Filter</div>
101
- </div>
102
- <div>
103
- {@render filterAddButton(filter)}
104
- {#if deleteFilter}
105
- <Button
106
- class="text-muted-foreground px-2 h-7"
107
- variant="ghost"
108
- size="icon"
109
- Icon={Trash}
110
- onclick={deleteFilter}
111
- ></Button>
112
- {/if}
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>
598
+ </div>
113
599
  </div>
114
- </div>
115
- <div
116
- class="flex flex-col gap-2 p-2 {isFirst
117
- ? 'max-h-100 overflow-auto'
118
- : ''}"
119
- >
120
- {#if Object.entries(filter).length}
121
- {#each Object.entries(filter) as [key, value]}
122
- {@const collectionFields =
123
- ctx.meta.collections[collectionName].fields}
124
- {#if key === "$and" || key === "$or"}
125
- <div class="flex flex-col rounded-md border">
126
- <div
127
- class="flex justify-between items-center gap-2 text-xs font-semibold text-muted-foreground p-2 border-b h-10"
128
- >
129
- <div class="flex gap-2">
130
- <Boxes size="17.5" />
131
- {key === "$and" ? "AND" : "OR"}
132
- </div>
133
- <div>
134
- <Button
135
- class="text-muted-foreground px-2 h-7"
136
- variant="ghost"
137
- size="icon"
138
- Icon={Plus}
139
- onclick={() =>
140
- (filter[key] = [...filter[key], {}])}
141
- ></Button>
142
- <Button
143
- class="text-muted-foreground px-2 h-7"
144
- variant="ghost"
145
- size="icon"
146
- Icon={Trash}
147
- onclick={() => {
148
- delete filter[key];
149
- }}
150
- ></Button>
151
- </div>
152
- </div>
153
- {#if Object.keys(filter[key]).length}
154
- <div class="flex flex-col gap-2 p-2">
155
- {#each filter[key] as _, index}
156
- <Filter
157
- bind:filter={filter[key][index]}
158
- {collectionName}
159
- deleteFilter={() => {
160
- filter[key].splice(index, 1);
161
- }}
162
- />
163
- {/each}
164
- </div>
165
- {:else}
166
- <div
167
- class="flex justify-center gap-2 text-xs text-muted-foreground text-center p-4"
168
- >
169
- <CircleOff size="17.5" />
170
- No rules defined
171
- </div>
172
- {/if}
600
+ {:else}
601
+ <div class="flex flex-col gap-1.5">
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))}
173
607
  </div>
174
608
  {:else}
175
- <div class="flex flex-col gap-2 rounded-md border">
176
- <div
177
- class="flex gap-2 justify-between items-center text-xs font-semibold text-muted-foreground p-2 border-b h-10"
178
- >
179
- <div class="flex gap-2">
180
- <Diamond size="17.5" />
181
- {key}
182
- </div>
183
- <div>
184
- <Popover.Root bind:open={secondPopover}>
185
- <Popover.Trigger
186
- class={buttonVariants({
187
- variant: "ghost",
188
- size: "sm",
189
- })}
190
- >
191
- <Plus />
192
- </Popover.Trigger>
193
- <Popover.Content
194
- class="flex w-48 flex-col p-2 max-h-60 overflow-auto"
195
- >
196
- {#each getOperatorOptions(filter[key]) as fieldName}
197
- <button
198
- onclick={() => {
199
- operatorAddingHandler(
200
- filter[key],
201
- fieldName,
202
- );
203
- secondPopover = false;
204
- }}
205
- class="flex cursor-pointer items-center gap-2 rounded-md p-2 px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
206
- >
207
- <div>{fieldName}</div>
208
- </button>
209
- {/each}
210
- </Popover.Content>
211
- </Popover.Root>
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>
212
620
  <Button
213
- class="text-muted-foreground px-2 h-7"
621
+ onclick={() => removeTopItem(item.id)}
622
+ class="h-6 w-6 text-muted-foreground hover:bg-transparent"
214
623
  variant="ghost"
215
624
  size="icon"
216
- Icon={Trash}
217
- onclick={() => {
218
- delete filter[key];
219
- }}
625
+ Icon={X}
220
626
  ></Button>
221
627
  </div>
222
- </div>
223
- {#if Object.entries(value as any).length}
224
- <div
225
- class="gap-2 p-2"
226
- style="display: grid; grid-template-columns: auto 1fr auto;"
227
- >
228
- {#each Object.entries(value as any) as [ruleKey, ruleValue]}
229
- <div
230
- class="rounded-md bg-muted border text-xs text-muted-foreground py-1 px-2"
231
- >
232
- {ruleKey}
233
- </div>
234
- <input
235
- class="w-full rounded-md bg-muted border text-xs text-muted-foreground py-1 px-2"
236
- type="text"
237
- bind:value={filter[key][ruleKey]}
238
- />
239
- <Button
240
- class="text-muted-foreground px-2 h-7"
241
- variant="ghost"
242
- size="icon"
243
- Icon={Trash}
244
- onclick={() => {
245
- delete filter[key][ruleKey];
246
- }}
247
- ></Button>
248
- {/each}
249
- </div>
250
- {:else}
251
- <div
252
- class="flex justify-center gap-2 text-xs text-muted-foreground text-center rounded-md p-2"
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>
639
+ {/each}
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"
253
646
  >
254
- <CircleOff size="17.5" />
255
- No rules defined
256
- </div>
257
- {/if}
647
+ <Plus size="14" />
648
+ Add filter rule
649
+ </button>
650
+ </div>
258
651
  </div>
259
652
  {/if}
260
653
  {/each}
261
- {:else}
262
- <div
263
- class="flex justify-center gap-2 text-xs text-muted-foreground text-center rounded-md p-2"
264
- >
265
- <CircleOff size="17.5" />
266
- No rules defined
267
- </div>
268
- {/if}
654
+ </div>
655
+ {/if}
656
+
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>
269
674
  </div>
270
675
  </div>