@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.
- package/dist/actions.d.ts +1 -0
- package/dist/components/dataTable/dataTable.svelte +3 -0
- package/dist/components/dataTable/dataTable.svelte.d.ts +1 -0
- package/dist/components/dataTable/filter.svelte +631 -226
- package/dist/components/dataTable/filter.svelte.d.ts +3 -5
- package/dist/components/dataTable/filterButton.svelte +39 -4
- package/dist/components/dataTable/header.svelte +9 -31
- package/dist/components/dataTable/header.svelte.d.ts +1 -0
- package/dist/components/dataTablePopup/dataTablePopup.svelte +7 -0
- package/dist/components/dataTablePopup/dataTablePopup.svelte.d.ts +1 -0
- package/package.json +1 -1
- package/src/lib/actions.ts +1 -0
- package/src/lib/components/dataTable/dataTable.svelte +3 -0
- package/src/lib/components/dataTable/filter.svelte +631 -226
- package/src/lib/components/dataTable/filterButton.svelte +39 -4
- package/src/lib/components/dataTable/header.svelte +9 -31
- package/src/lib/components/dataTablePopup/dataTablePopup.svelte +7 -0
|
@@ -1,270 +1,675 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
30
|
-
deleteFilter,
|
|
43
|
+
isEmpty = $bindable(true),
|
|
31
44
|
}: Props = $props();
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
45
|
-
const
|
|
46
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
373
|
+
commit();
|
|
62
374
|
}
|
|
63
375
|
|
|
64
|
-
function
|
|
65
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
</
|
|
93
|
-
</
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
<
|
|
106
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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={
|
|
217
|
-
onclick={() => {
|
|
218
|
-
delete filter[key];
|
|
219
|
-
}}
|
|
625
|
+
Icon={X}
|
|
220
626
|
></Button>
|
|
221
627
|
</div>
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
<
|
|
255
|
-
|
|
256
|
-
</
|
|
257
|
-
|
|
647
|
+
<Plus size="14" />
|
|
648
|
+
Add filter rule
|
|
649
|
+
</button>
|
|
650
|
+
</div>
|
|
258
651
|
</div>
|
|
259
652
|
{/if}
|
|
260
653
|
{/each}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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>
|