@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.
- package/dist/components/dataTable/filter.svelte +485 -311
- package/dist/components/dataTable/filter.svelte.d.ts +2 -1
- package/dist/components/dataTable/filterButton.svelte +26 -8
- package/package.json +1 -1
- package/src/lib/components/dataTable/filter.svelte +485 -311
- package/src/lib/components/dataTable/filterButton.svelte +26 -8
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
// Airtable-style
|
|
2
|
+
// Airtable-style filter editor.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
209
|
+
return items;
|
|
184
210
|
}
|
|
185
211
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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(
|
|
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(
|
|
249
|
+
return Array.isArray(r.value) && r.value.length > 0;
|
|
219
250
|
}
|
|
220
|
-
if (getFieldKind(
|
|
221
|
-
return
|
|
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(
|
|
225
|
-
const kind = getFieldKind(
|
|
226
|
-
const op = getOperatorsForField(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
247
|
-
const
|
|
248
|
-
if (!
|
|
249
|
-
|
|
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
|
|
263
|
-
|
|
346
|
+
function addTopGroup() {
|
|
347
|
+
if (!allFieldNames.length) return;
|
|
348
|
+
topChildren = [...topChildren, makeGroup()];
|
|
264
349
|
commit();
|
|
265
350
|
}
|
|
266
351
|
|
|
267
|
-
function
|
|
268
|
-
|
|
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
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
304
|
-
|
|
376
|
+
function setTopConjunction(c: "$and" | "$or") {
|
|
377
|
+
topConjunction = c;
|
|
305
378
|
commit();
|
|
306
379
|
}
|
|
307
380
|
|
|
308
|
-
function
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
.
|
|
325
|
-
|
|
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
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
<
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
</
|
|
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
|
|
365
|
-
{
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
<!--
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
</
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
<
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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>
|