@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.
- 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/fieldPicker.svelte +61 -0
- package/dist/components/dataTable/fieldPicker.svelte.d.ts +9 -0
- package/dist/components/dataTable/filter.svelte +469 -238
- package/dist/components/dataTable/filter.svelte.d.ts +1 -4
- package/dist/components/dataTable/filterButton.svelte +24 -6
- package/dist/components/dataTable/header.svelte +9 -31
- package/dist/components/dataTable/header.svelte.d.ts +1 -0
- package/dist/components/dataTable/sort.svelte +169 -104
- package/dist/components/dataTable/sortButton.svelte +33 -7
- package/dist/components/dataTable/table.svelte +2 -1
- package/dist/components/dataTable/table.svelte.d.ts +1 -0
- package/dist/components/dataTablePopup/dataTablePopup.svelte +7 -0
- package/dist/components/dataTablePopup/dataTablePopup.svelte.d.ts +1 -0
- package/dist/components/importButton.svelte +154 -31
- package/package.json +4 -3
- package/src/lib/actions.ts +1 -0
- package/src/lib/components/dataTable/dataTable.svelte +3 -0
- package/src/lib/components/dataTable/fieldPicker.svelte +61 -0
- package/src/lib/components/dataTable/filter.svelte +469 -238
- package/src/lib/components/dataTable/filterButton.svelte +24 -6
- package/src/lib/components/dataTable/header.svelte +9 -31
- package/src/lib/components/dataTable/sort.svelte +169 -104
- package/src/lib/components/dataTable/sortButton.svelte +33 -7
- package/src/lib/components/dataTable/table.svelte +2 -1
- package/src/lib/components/dataTablePopup/dataTablePopup.svelte +7 -0
- package/src/lib/components/importButton.svelte +154 -31
|
@@ -1,270 +1,501 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
352
|
+
Clear advanced filter
|
|
90
353
|
</button>
|
|
91
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
</
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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>
|