@lobb-js/studio 0.37.1 → 0.39.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.
Files changed (29) hide show
  1. package/dist/actions.d.ts +1 -0
  2. package/dist/components/dataTable/dataTable.svelte +3 -0
  3. package/dist/components/dataTable/dataTable.svelte.d.ts +1 -0
  4. package/dist/components/dataTable/fieldPicker.svelte +61 -0
  5. package/dist/components/dataTable/fieldPicker.svelte.d.ts +9 -0
  6. package/dist/components/dataTable/filter.svelte +469 -238
  7. package/dist/components/dataTable/filter.svelte.d.ts +1 -4
  8. package/dist/components/dataTable/filterButton.svelte +24 -6
  9. package/dist/components/dataTable/header.svelte +9 -31
  10. package/dist/components/dataTable/header.svelte.d.ts +1 -0
  11. package/dist/components/dataTable/sort.svelte +169 -104
  12. package/dist/components/dataTable/sortButton.svelte +33 -7
  13. package/dist/components/dataTable/table.svelte +2 -1
  14. package/dist/components/dataTable/table.svelte.d.ts +1 -0
  15. package/dist/components/dataTablePopup/dataTablePopup.svelte +7 -0
  16. package/dist/components/dataTablePopup/dataTablePopup.svelte.d.ts +1 -0
  17. package/dist/components/importButton.svelte +154 -31
  18. package/package.json +4 -3
  19. package/src/lib/actions.ts +1 -0
  20. package/src/lib/components/dataTable/dataTable.svelte +3 -0
  21. package/src/lib/components/dataTable/fieldPicker.svelte +61 -0
  22. package/src/lib/components/dataTable/filter.svelte +469 -238
  23. package/src/lib/components/dataTable/filterButton.svelte +24 -6
  24. package/src/lib/components/dataTable/header.svelte +9 -31
  25. package/src/lib/components/dataTable/sort.svelte +169 -104
  26. package/src/lib/components/dataTable/sortButton.svelte +33 -7
  27. package/src/lib/components/dataTable/table.svelte +2 -1
  28. package/src/lib/components/dataTablePopup/dataTablePopup.svelte +7 -0
  29. package/src/lib/components/importButton.svelte +154 -31
@@ -1,270 +1,501 @@
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 flat filter editor.
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.
10
+ //
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.
15
+
16
+ import * as Select from "../ui/select/index.js";
15
17
  import Button from "../ui/button/button.svelte";
18
+ import { Plus, X, AlertCircle } from "lucide-svelte";
19
+ import { getStudioContext } from "../../context";
20
+ import { getFieldIcon } from "./utils";
21
+ import { getFieldRelationTarget } from "../../relations";
16
22
 
17
23
  const { ctx } = getStudioContext();
18
24
 
19
25
  interface Props {
20
- filter: any;
26
+ filter: Record<string, any>;
21
27
  collectionName: string;
22
- isFirst?: boolean;
23
- deleteFilter?: () => void;
24
28
  }
25
29
 
26
- let {
27
- filter = $bindable({}),
28
- collectionName,
29
- isFirst = false,
30
- deleteFilter,
31
- }: Props = $props();
32
-
33
- let firstPopover = $state(false);
34
- let secondPopover = $state(false);
35
-
36
- function groupAddingHandler(filter: any, key: string) {
37
- if (key === "$and" || key === "$or") {
38
- filter[key] = [];
39
- } else {
40
- filter[key] = {};
30
+ let { filter = $bindable({}), collectionName }: Props = $props();
31
+
32
+ type OperatorDef = {
33
+ value: string;
34
+ label: string;
35
+ // single: one input. range: two inputs. list: comma-separated. none: no input (reserved).
36
+ valueType: "single" | "range" | "list" | "none";
37
+ };
38
+
39
+ type Condition = {
40
+ id: string;
41
+ field: string;
42
+ operator: string;
43
+ value: any;
44
+ };
45
+
46
+ // ── 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
+ const STRING_OPS: OperatorDef[] = [
51
+ { value: "$icontains", label: "contains", valueType: "single" },
52
+ { value: "$incontains", label: "does not contain", valueType: "single" },
53
+ { value: "$eq", label: "is", valueType: "single" },
54
+ { value: "$ne", label: "is not", valueType: "single" },
55
+ { value: "$istarts_with",label: "starts with", valueType: "single" },
56
+ { value: "$iends_with", label: "ends with", valueType: "single" },
57
+ ];
58
+ const NUMBER_OPS: OperatorDef[] = [
59
+ { value: "$eq", label: "=", valueType: "single" },
60
+ { value: "$ne", label: "≠", valueType: "single" },
61
+ { value: "$gt", label: ">", valueType: "single" },
62
+ { value: "$gte", label: "≥", valueType: "single" },
63
+ { value: "$lt", label: "<", valueType: "single" },
64
+ { value: "$lte", label: "≤", valueType: "single" },
65
+ { value: "$between", label: "between", valueType: "range" },
66
+ ];
67
+ const DATE_OPS: OperatorDef[] = [
68
+ { value: "$eq", label: "on", valueType: "single" },
69
+ { value: "$lt", label: "before", valueType: "single" },
70
+ { value: "$gt", label: "after", valueType: "single" },
71
+ { value: "$between", label: "between", valueType: "range" },
72
+ ];
73
+ const BOOL_OPS: OperatorDef[] = [
74
+ { value: "$eq", label: "is", valueType: "single" },
75
+ { value: "$ne", label: "is not", valueType: "single" },
76
+ ];
77
+ const ENUM_OPS: OperatorDef[] = [
78
+ { value: "$eq", label: "is", valueType: "single" },
79
+ { value: "$ne", label: "is not", valueType: "single" },
80
+ { value: "$in", label: "is any of", valueType: "list" },
81
+ { value: "$nin", label: "is none of", valueType: "list" },
82
+ ];
83
+ const FK_OPS: OperatorDef[] = [
84
+ { value: "$eq", label: "is", valueType: "single" },
85
+ { value: "$ne", label: "is not", valueType: "single" },
86
+ { value: "$in", label: "is any of", valueType: "list" },
87
+ ];
88
+
89
+ type FieldKind = "string" | "number" | "date" | "bool" | "enum" | "fk";
90
+
91
+ function getFieldKind(fieldName: string): FieldKind {
92
+ const field: any = ctx.meta.collections[collectionName].fields[fieldName];
93
+ if (!field) return "string";
94
+ if (Array.isArray(field.enum) && field.enum.length) return "enum";
95
+ if (getFieldRelationTarget(ctx, collectionName, fieldName)) return "fk";
96
+ if (field.type === "bool") return "bool";
97
+ if (field.type === "date" || field.type === "datetime") return "date";
98
+ if (
99
+ field.type === "integer" ||
100
+ field.type === "decimal" ||
101
+ field.type === "float" ||
102
+ field.type === "long"
103
+ ) return "number";
104
+ return "string";
105
+ }
106
+
107
+ function getOperatorsForField(fieldName: string): OperatorDef[] {
108
+ switch (getFieldKind(fieldName)) {
109
+ case "enum": return ENUM_OPS;
110
+ case "fk": return FK_OPS;
111
+ case "bool": return BOOL_OPS;
112
+ case "date": return DATE_OPS;
113
+ case "number": return NUMBER_OPS;
114
+ default: return STRING_OPS;
115
+ }
116
+ }
117
+
118
+ function getEnumValues(fieldName: string): string[] {
119
+ const field: any = ctx.meta.collections[collectionName].fields[fieldName];
120
+ return (field?.enum ?? []).map((e: any) => String(e.value ?? e));
121
+ }
122
+
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
+ const allFieldNames = Object.keys(ctx.meta.collections[collectionName].fields);
127
+
128
+ function nextId() {
129
+ return Math.random().toString(36).slice(2, 9);
130
+ }
131
+
132
+ function defaultValueFor(op: OperatorDef): any {
133
+ if (op.valueType === "range") return ["", ""];
134
+ if (op.valueType === "list") return [];
135
+ return "";
136
+ }
137
+
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("$"));
149
+ }
150
+
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({
176
+ id: nextId(),
177
+ field: key,
178
+ operator: op,
179
+ value: opVal,
180
+ });
181
+ }
182
+ }
183
+ return { conditions, unmanaged };
184
+ }
185
+
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) };
205
+ }
41
206
  }
207
+ filter = next;
208
+ }
209
+
210
+ function isValueReady(c: Condition): boolean {
211
+ const op = getOperatorsForField(c.field).find((o) => o.value === c.operator);
212
+ if (!op) return false;
213
+ if (op.valueType === "range") {
214
+ const v = Array.isArray(c.value) ? c.value : ["", ""];
215
+ return v[0] !== "" && v[0] != null && v[1] !== "" && v[1] != null;
216
+ }
217
+ if (op.valueType === "list") {
218
+ return Array.isArray(c.value) && c.value.length > 0;
219
+ }
220
+ if (getFieldKind(c.field) === "bool") return typeof c.value === "boolean";
221
+ return c.value !== "" && c.value != null;
222
+ }
223
+
224
+ function serialiseValue(c: Condition): any {
225
+ const kind = getFieldKind(c.field);
226
+ const op = getOperatorsForField(c.field).find((o) => o.value === c.operator);
227
+ const coerce = (v: any) => {
228
+ if (kind === "number" || kind === "fk") {
229
+ const n = typeof v === "number" ? v : parseFloat(v);
230
+ return Number.isFinite(n) ? n : v;
231
+ }
232
+ if (kind === "bool") return Boolean(v);
233
+ return v;
234
+ };
235
+ if (op?.valueType === "range") {
236
+ const [a, b] = Array.isArray(c.value) ? c.value : ["", ""];
237
+ return [coerce(a), coerce(b)];
238
+ }
239
+ if (op?.valueType === "list") {
240
+ return (Array.isArray(c.value) ? c.value : []).map(coerce);
241
+ }
242
+ return coerce(c.value);
243
+ }
244
+
245
+ // ── 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.
260
+ }
261
+
262
+ function removeCondition(id: string) {
263
+ conditions = conditions.filter((c) => c.id !== id);
264
+ commit();
265
+ }
266
+
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
+ });
283
+ commit();
284
+ }
285
+
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
+ });
300
+ commit();
301
+ }
302
+
303
+ function setValue(id: string, newValue: any) {
304
+ conditions = conditions.map((c) => (c.id === id ? { ...c, value: newValue } : c));
305
+ commit();
42
306
  }
43
307
 
44
- function getGroupOptions(filter: any) {
45
- const collectionFieldNames = Object.keys(
46
- ctx.meta.collections[collectionName].fields,
47
- );
48
- const options = ["$and", "$or", ...collectionFieldNames];
49
- const existingPropertiesNames = Object.keys(filter);
50
- const filteredOptions = _.difference(options, existingPropertiesNames);
51
- return filteredOptions;
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
+ });
315
+ commit();
52
316
  }
53
317
 
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,
60
- );
61
- return filteredOptions;
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);
62
326
  }
63
327
 
64
- function operatorAddingHandler(filter: any, key: string) {
65
- filter[key] = "";
328
+ function clearUnmanaged() {
329
+ unmanaged = {};
330
+ commit();
66
331
  }
332
+
333
+ // ── Display helpers ────────────────────────────────────────────────
334
+ const hasUnmanaged = $derived(Object.keys(unmanaged).length > 0);
67
335
  </script>
68
336
 
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}
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>
82
347
  <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"
348
+ type="button"
349
+ onclick={clearUnmanaged}
350
+ class="mt-1 underline underline-offset-2 hover:text-foreground"
88
351
  >
89
- <div>{fieldName}</div>
352
+ Clear advanced filter
90
353
  </button>
91
- {/each}
92
- </Popover.Content>
93
- </Popover.Root>
94
- {/snippet}
95
-
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>
354
+ </div>
101
355
  </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}
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
113
361
  </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}
362
+ {:else}
363
+ <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"}
173
374
  </div>
174
- {: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>
212
- <Button
213
- class="text-muted-foreground px-2 h-7"
214
- variant="ghost"
215
- size="icon"
216
- Icon={Trash}
217
- onclick={() => {
218
- delete filter[key];
219
- }}
220
- ></Button>
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}
221
386
  </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}
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}
233
395
  </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"
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")}
253
447
  >
254
- <CircleOff size="17.5" />
255
- No rules defined
256
- </div>
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>
468
+ {/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
+ />
257
478
  {/if}
258
479
  </div>
259
- {/if}
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>
260
489
  {/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}
269
- </div>
490
+ </div>
491
+ {/if}
492
+
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>
270
501
  </div>